Admin: Add user page
This commit is contained in:
parent
bc0e92bdac
commit
d34e85a18f
24 changed files with 456 additions and 77 deletions
|
|
@ -192,8 +192,7 @@ func (du disUser) runCheckPassword(context wisski_distillery.Context) error {
|
|||
context.Println()
|
||||
|
||||
var passcode string
|
||||
if user.TOTPEnabled {
|
||||
context.Printf("Enter passcode for %s:", du.Positionals.User)
|
||||
if user.IsTOTPEnabled() {
|
||||
|
||||
passcode, err = context.IOStream.ReadPassword()
|
||||
if err != nil {
|
||||
|
|
@ -261,9 +260,7 @@ func (du disUser) runMakeAdmin(context wisski_distillery.Context) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
user.Admin = true
|
||||
return user.Save(context.Context)
|
||||
return user.MakeAdmin(context.Context)
|
||||
}
|
||||
|
||||
func (du disUser) runRemoveAdmin(context wisski_distillery.Context) error {
|
||||
|
|
@ -272,6 +269,5 @@ func (du disUser) runRemoveAdmin(context wisski_distillery.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
user.Admin = false
|
||||
return user.Save(context.Context)
|
||||
return user.MakeRegular(context.Context)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package cli
|
||||
|
||||
// ===========================================================================================================
|
||||
// This file was generated automatically at 04-01-2023 11:57:49 using gogenlicense.
|
||||
// This file was generated automatically at 04-01-2023 14:54:26 using gogenlicense.
|
||||
// Do not edit manually, as changes may be overwritten.
|
||||
// ===========================================================================================================
|
||||
|
||||
|
|
@ -2189,7 +2189,7 @@ package cli
|
|||
// # Generation
|
||||
//
|
||||
// This variable and the associated documentation have been automatically generated using the 'gogenlicense' tool.
|
||||
// It was last updated at 04-01-2023 11:57:49.
|
||||
// It was last updated at 04-01-2023 14:54:26.
|
||||
var LegalNotices string
|
||||
|
||||
func init() {
|
||||
|
|
|
|||
|
|
@ -32,17 +32,7 @@ func (auth *Auth) Routes() []string { return []string{"/user/"} }
|
|||
func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
|
||||
router := httprouter.New()
|
||||
|
||||
// setup the csrf handler (if needed)
|
||||
auth.csrf.Get(func() func(http.Handler) http.Handler {
|
||||
var opts []csrf.Option
|
||||
if !auth.Config.HTTPSEnabled() {
|
||||
opts = append(opts, csrf.Secure(false))
|
||||
}
|
||||
opts = append(opts, csrf.Path(route))
|
||||
return csrf.Protect(auth.Config.CSRFSecret(), opts...)
|
||||
})
|
||||
|
||||
router.Handler(http.MethodGet, route, auth.authHome(ctx))
|
||||
router.Handler(http.MethodGet, route, auth.authUser(ctx))
|
||||
|
||||
{
|
||||
login := auth.authLogin(ctx)
|
||||
|
|
@ -78,3 +68,14 @@ func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler,
|
|||
|
||||
return router, nil
|
||||
}
|
||||
|
||||
func (auth *Auth) CSRF() func(http.Handler) http.Handler {
|
||||
// setup the csrf handler (if needed)
|
||||
return auth.csrf.Get(func() func(http.Handler) http.Handler {
|
||||
var opts []csrf.Option
|
||||
if !auth.Config.HTTPSEnabled() {
|
||||
opts = append(opts, csrf.Secure(false))
|
||||
}
|
||||
return csrf.Protect(auth.Config.CSRFSecret(), opts...)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,5 +74,5 @@ func (auth *Auth) Protect(handler http.Handler, perm Permission) http.Handler {
|
|||
|
||||
// Admin represents a permission that checks if a user is an administrator and has totp enabled.
|
||||
var Admin Permission = func(user *AuthUser, r *http.Request) (ok Grant, err error) {
|
||||
return Bool2Grant(user != nil && user.Admin && user.TOTPEnabled, "user needs to have admin permissions and TOTP enabled"), nil
|
||||
return Bool2Grant(user != nil && user.IsAdmin() && user.IsTOTPEnabled(), "user needs to have admin permissions and TOTP enabled"), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,17 +10,17 @@ import (
|
|||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
)
|
||||
|
||||
//go:embed "templates/home.html"
|
||||
var homeHTMLStr string
|
||||
var homeTemplate = static.AssetsHome.MustParseShared(
|
||||
"home.html",
|
||||
homeHTMLStr,
|
||||
//go:embed "templates/user.html"
|
||||
var userHTMLStr string
|
||||
var userTemplate = static.AssetsUser.MustParseShared(
|
||||
"user.html",
|
||||
userHTMLStr,
|
||||
)
|
||||
|
||||
func (auth *Auth) authHome(ctx context.Context) http.Handler {
|
||||
func (auth *Auth) authUser(ctx context.Context) http.Handler {
|
||||
return auth.Protect(&httpx.HTMLHandler[*AuthUser]{
|
||||
Handler: auth.UserOf,
|
||||
Template: homeTemplate,
|
||||
Template: userTemplate,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ func (auth *Auth) authPassword(ctx context.Context) http.Handler {
|
|||
},
|
||||
FieldTemplate: httpx.PureCSSFieldTemplate,
|
||||
|
||||
CSRF: auth.csrf.Get(nil),
|
||||
CSRF: auth.CSRF(),
|
||||
|
||||
RenderTemplate: passwordTemplate,
|
||||
RenderTemplateContext: auth.UserFormContext,
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ func (auth *Auth) UserOf(r *http.Request) (user *AuthUser, err error) {
|
|||
}
|
||||
|
||||
// user isn't enabled
|
||||
if !user.Enabled {
|
||||
if !user.IsEnabled() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
|
@ -122,7 +122,7 @@ func (auth *Auth) authLogin(ctx context.Context) http.Handler {
|
|||
},
|
||||
FieldTemplate: httpx.PureCSSFieldTemplate,
|
||||
|
||||
CSRF: auth.csrf.Get(nil),
|
||||
CSRF: auth.CSRF(),
|
||||
|
||||
RenderForm: func(context httpx.FormContext, w http.ResponseWriter, r *http.Request) {
|
||||
if context.Err != nil {
|
||||
|
|
|
|||
|
|
@ -13,12 +13,12 @@
|
|||
{{ define "content" }}
|
||||
<div class="pure-u-1">
|
||||
<p>
|
||||
{{ if .User.Admin }}
|
||||
{{ if .User.IsAdmin }}
|
||||
You are an administrator.
|
||||
{{ else }}
|
||||
You are a regular user.
|
||||
{{ end }}
|
||||
{{ if .User.TOTPEnabled }}
|
||||
{{ if .User.IsTOTPEnabled }}
|
||||
You have TOTP enabled.
|
||||
{{ else }}
|
||||
You do not have TOTP enabled.
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
</p>
|
||||
<div class="pure-button-group" role="group" role="Actions">
|
||||
<a class="pure-button" href="/user/password/">Change Password</a>
|
||||
{{ if .User.TOTPEnabled }}
|
||||
{{ if .User.IsTOTPEnabled }}
|
||||
<a class="pure-button" href="/user/totp/disable/">Disable TOTP</a>
|
||||
{{ else }}
|
||||
<a class="pure-button" href="/user/totp/enable/">Enable TOTP</a>
|
||||
|
|
@ -35,9 +35,9 @@
|
|||
<hr />
|
||||
</div>
|
||||
|
||||
{{ if .User.Admin }}
|
||||
{{ if .User.IsAdmin }}
|
||||
<div class="pure-u-1">
|
||||
{{ if (not .User.TOTPEnabled) }}
|
||||
{{ if (not .User.IsTOTPEnabled) }}
|
||||
<div>
|
||||
<p class="error-message">
|
||||
TOTP is required to access these.
|
||||
|
|
@ -22,11 +22,11 @@ func (auth *Auth) authTOTPEnable(ctx context.Context) http.Handler {
|
|||
},
|
||||
FieldTemplate: httpx.PureCSSFieldTemplate,
|
||||
|
||||
CSRF: auth.csrf.Get(nil),
|
||||
CSRF: auth.CSRF(),
|
||||
|
||||
SkipForm: func(r *http.Request) (data struct{}, skip bool) {
|
||||
user, err := auth.UserOf(r)
|
||||
return struct{}{}, err == nil && user != nil && user.TOTPEnabled
|
||||
return struct{}{}, err == nil && user != nil && user.IsTOTPEnabled()
|
||||
},
|
||||
|
||||
RenderTemplate: totpEnableTemplate,
|
||||
|
|
@ -81,11 +81,11 @@ func (auth *Auth) authTOTPEnroll(ctx context.Context) http.Handler {
|
|||
},
|
||||
FieldTemplate: httpx.PureCSSFieldTemplate,
|
||||
|
||||
CSRF: auth.csrf.Get(nil),
|
||||
CSRF: auth.CSRF(),
|
||||
|
||||
SkipForm: func(r *http.Request) (data struct{}, skip bool) {
|
||||
user, _ := auth.UserOf(r)
|
||||
return struct{}{}, user != nil && user.TOTPEnabled
|
||||
user, err := auth.UserOf(r)
|
||||
return struct{}{}, err == nil && user != nil && user.IsTOTPEnabled()
|
||||
},
|
||||
RenderForm: func(context httpx.FormContext, w http.ResponseWriter, r *http.Request) {
|
||||
user, err := auth.UserOf(r)
|
||||
|
|
@ -152,11 +152,11 @@ func (auth *Auth) authTOTPDisable(ctx context.Context) http.Handler {
|
|||
},
|
||||
FieldTemplate: httpx.PureCSSFieldTemplate,
|
||||
|
||||
CSRF: auth.csrf.Get(nil),
|
||||
CSRF: auth.CSRF(),
|
||||
|
||||
SkipForm: func(r *http.Request) (data struct{}, skip bool) {
|
||||
user, _ := auth.UserOf(r)
|
||||
return struct{}{}, user != nil && !user.TOTPEnabled
|
||||
user, err := auth.UserOf(r)
|
||||
return struct{}{}, err == nil && user != nil && !user.IsTOTPEnabled()
|
||||
},
|
||||
RenderTemplate: totpDisableTemplate,
|
||||
RenderTemplateContext: auth.UserFormContext,
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import (
|
|||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"net/http"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
|
@ -90,15 +90,17 @@ func (auth *Auth) CreateUser(ctx context.Context, name string) (user *AuthUser,
|
|||
|
||||
user = &AuthUser{
|
||||
User: models.User{
|
||||
User: name,
|
||||
Enabled: false,
|
||||
User: name,
|
||||
},
|
||||
}
|
||||
user.SetAdmin(false)
|
||||
user.SetEnabled(false)
|
||||
user.SetTOTPEnabled(false)
|
||||
|
||||
// do the create statement
|
||||
err = table.Create(&user.User).Error
|
||||
err = table.Select("*").Create(&user.User).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errors.Wrapf(err, "Create")
|
||||
}
|
||||
|
||||
user.auth = auth
|
||||
|
|
@ -116,7 +118,7 @@ func (au *AuthUser) String() string {
|
|||
return "User{nil}"
|
||||
}
|
||||
hasPassword := len(au.PasswordHash) > 0
|
||||
return fmt.Sprintf("User{Name:%q,Enabled:%t,HasPassword:%t,Admin:%t}", au.User.User, au.User.Enabled, hasPassword, au.User.Admin)
|
||||
return fmt.Sprintf("User{Name:%q,Enabled:%t,HasPassword:%t,Admin:%t}", au.User.User, au.User.IsEnabled(), hasPassword, au.User.IsAdmin())
|
||||
}
|
||||
|
||||
var (
|
||||
|
|
@ -140,7 +142,7 @@ func (au *AuthUser) CheckTOTP(passcode string) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if au.TOTPEnabled && !totp.Validate(passcode, secret.Secret()) {
|
||||
if au.IsTOTPEnabled() && !totp.Validate(passcode, secret.Secret()) {
|
||||
return ErrTOTPFailed
|
||||
}
|
||||
return nil
|
||||
|
|
@ -148,7 +150,7 @@ func (au *AuthUser) CheckTOTP(passcode string) error {
|
|||
|
||||
// NewTOTP generates a new TOTP secret, returning a totp key.
|
||||
func (au *AuthUser) NewTOTP(ctx context.Context) (*otp.Key, error) {
|
||||
if au.User.TOTPEnabled {
|
||||
if au.User.IsTOTPEnabled() {
|
||||
return nil, ErrTOTPEnabled
|
||||
}
|
||||
|
||||
|
|
@ -191,14 +193,14 @@ func (au *AuthUser) EnableTOTP(ctx context.Context, passcode string) error {
|
|||
return ErrTOTPFailed
|
||||
}
|
||||
|
||||
au.User.TOTPEnabled = true
|
||||
au.User.SetTOTPEnabled(true)
|
||||
return au.Save(ctx)
|
||||
|
||||
}
|
||||
|
||||
// DisableTOTP disables totp for the given user
|
||||
func (au *AuthUser) DisableTOTP(ctx context.Context) (err error) {
|
||||
au.User.TOTPEnabled = false
|
||||
au.User.SetTOTPEnabled(false)
|
||||
au.User.TOTPURL = ""
|
||||
return au.Save(ctx)
|
||||
}
|
||||
|
|
@ -209,14 +211,14 @@ func (au *AuthUser) SetPassword(ctx context.Context, password []byte) (err error
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
au.User.Enabled = true
|
||||
au.User.SetEnabled(true)
|
||||
return au.Save(ctx)
|
||||
}
|
||||
|
||||
// UnsetPassword removes the password from this user, and disables them
|
||||
func (au *AuthUser) UnsetPassword(ctx context.Context) error {
|
||||
au.User.PasswordHash = nil
|
||||
au.User.Enabled = false
|
||||
au.User.SetEnabled(false)
|
||||
return au.Save(ctx)
|
||||
}
|
||||
|
||||
|
|
@ -230,7 +232,7 @@ func (au *AuthUser) CheckPassword(ctx context.Context, password []byte) error {
|
|||
if au == nil {
|
||||
return ErrNoUser
|
||||
}
|
||||
if !au.User.Enabled {
|
||||
if !au.User.IsEnabled() {
|
||||
return ErrUserDisabled
|
||||
}
|
||||
|
||||
|
|
@ -254,14 +256,14 @@ func (au *AuthUser) CheckCredentials(ctx context.Context, password []byte, passc
|
|||
// MakeAdmin makes this user an admin, and saves the update in the database.
|
||||
// If the user is already an admin, does not return an error.
|
||||
func (au *AuthUser) MakeAdmin(ctx context.Context) error {
|
||||
au.User.Admin = true
|
||||
au.User.SetAdmin(true)
|
||||
return au.Save(ctx)
|
||||
}
|
||||
|
||||
// MakeRegular removes admin rights from this user.
|
||||
// If this user is not an dmin, does not return an error.
|
||||
func (au *AuthUser) MakeRegular(ctx context.Context) error {
|
||||
au.User.Admin = true
|
||||
au.User.SetAdmin(false)
|
||||
return au.Save(ctx)
|
||||
}
|
||||
|
||||
|
|
@ -271,7 +273,7 @@ func (au *AuthUser) Save(ctx context.Context) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return table.Save(&au.User).Error
|
||||
return table.Select("*").Updates(&au.User).Error
|
||||
}
|
||||
|
||||
// Delete deletes the user from the database
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/rs/zerolog"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
|
|
@ -48,6 +49,7 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
|
|||
Handler: admin.serveSocket,
|
||||
}
|
||||
handler = admin.Dependencies.Auth.Protect(socket, auth.Admin)
|
||||
handler = admin.Dependencies.Auth.CSRF()(handler)
|
||||
}
|
||||
|
||||
// handle everything
|
||||
|
|
@ -61,6 +63,26 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
|
|||
Template: indexTemplate,
|
||||
})
|
||||
|
||||
// add a handler for the user page
|
||||
router.Handler(http.MethodGet, route+"users", httpx.HTMLHandler[userContext]{
|
||||
Handler: admin.users,
|
||||
Template: userTemplate,
|
||||
})
|
||||
|
||||
// add a user create form
|
||||
{
|
||||
create := admin.createUser(ctx)
|
||||
router.Handler(http.MethodGet, route+"users/create", create)
|
||||
router.Handler(http.MethodPost, route+"users/create", create)
|
||||
}
|
||||
|
||||
// add all the admin actions
|
||||
router.Handler(http.MethodPost, route+"users/delete", admin.usersDeleteHandler(ctx))
|
||||
router.Handler(http.MethodPost, route+"users/disable", admin.usersDisableHandler(ctx))
|
||||
router.Handler(http.MethodPost, route+"users/disabletotp", admin.usersDisableTOTPHandler(ctx))
|
||||
router.Handler(http.MethodPost, route+"users/password", admin.usersPasswordHandler(ctx))
|
||||
router.Handler(http.MethodPost, route+"users/toggleadmin", admin.usersToggleAdmin(ctx))
|
||||
|
||||
// add a handler for the component page
|
||||
router.Handler(http.MethodGet, route+"components", httpx.HTMLHandler[componentContext]{
|
||||
Handler: admin.components,
|
||||
|
|
@ -79,9 +101,19 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
|
|||
Template: instanceTemplate,
|
||||
})
|
||||
|
||||
router.Handler(http.MethodPost, route+"api/login", httpx.RedirectHandler(func(r *http.Request) (string, int, error) {
|
||||
// add a router for the login page
|
||||
router.Handler(http.MethodPost, route+"login", admin.loginHandler(ctx))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (admin *Admin) loginHandler(ctx context.Context) http.Handler {
|
||||
logger := zerolog.Ctx(ctx)
|
||||
|
||||
return httpx.RedirectHandler(func(r *http.Request) (string, int, error) {
|
||||
// parse the form
|
||||
if err := r.ParseForm(); err != nil {
|
||||
logger.Err(err).Msg("failed to parse admin login")
|
||||
return "", 0, err
|
||||
}
|
||||
|
||||
|
|
@ -93,10 +125,9 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
|
|||
|
||||
target, err := instance.Users().Login(r.Context(), nil, r.PostFormValue("user"))
|
||||
if err != nil {
|
||||
logger.Err(err).Msg("failed to admin login")
|
||||
return "", 0, err
|
||||
}
|
||||
return target.String(), http.StatusSeeOther, err
|
||||
}))
|
||||
|
||||
return
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,10 @@
|
|||
<a class="pure-button pure-button-primary" href="/admin/index">Admin</a>
|
||||
</p>
|
||||
<p>
|
||||
<a class="pure-button" href="/admin/components">Components</a>
|
||||
<div class="pure-button-group" role="group" aria-label="Actions">
|
||||
<a class="pure-button" href="/admin/users">Users</a>
|
||||
<a class="pure-button pure-button-small" href="/admin/components">Components</a>
|
||||
</div>
|
||||
</p>
|
||||
{{ end }}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@
|
|||
<a class="pure-button pure-button-primary" href="/admin/instance/{{ .Info.Slug }}">Instance</a>
|
||||
</p>
|
||||
<p>
|
||||
<a class="pure-button" href="/admin/ingredients/{{ .Info.Slug }}">Ingredients</a>
|
||||
<div class="pure-button-group" role="group" aria-label="Actions">
|
||||
<a class="pure-button pure-button-small" href="/admin/ingredients/{{ .Info.Slug }}">Ingredients</a>
|
||||
</div>
|
||||
</p>
|
||||
{{ end }}
|
||||
|
||||
|
|
@ -216,6 +218,7 @@
|
|||
</thead>
|
||||
<tbody>
|
||||
{{ $slug := .Instance.Slug }}
|
||||
{{ $csrf := .CSRF }}
|
||||
{{ range $index, $user := .Info.Users }}
|
||||
<tr {{ if not $user.Status }}style="color:gray"{{ end }}>
|
||||
<td>
|
||||
|
|
@ -247,10 +250,11 @@
|
|||
<code class="date">{{ $user.Login.Time.Format "2006-01-02T15:04:05Z07:00" }}</code>
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/api/login" method="POST" target="_blank">
|
||||
<form action="/admin/login" method="POST" target="_blank">
|
||||
<input type="hidden" name="slug" value="{{ $slug }}">
|
||||
<input type="hidden" name="user" value="{{ $user.Name }}">
|
||||
<input type="submit" class="pure-button pure-button-action" value="Login in new window">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
|
|
|
|||
11
internal/dis/component/control/admin/html/user_create.html
Normal file
11
internal/dis/component/control/admin/html/user_create.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{{ template "_form.html" . }}
|
||||
{{ define "form/title" }}Distillery Admin - Create User{{ end }}
|
||||
{{ define "form/button" }}Create{{ end }}
|
||||
|
||||
{{ define "header"}}
|
||||
<p>
|
||||
<a class="pure-button" href="/admin/index">Control</a> >
|
||||
<a class="pure-button" href="/admin/users">Users</a> >
|
||||
<a class="pure-button pure-button-primary" href="/admin/users/create">Create</a>
|
||||
</p>
|
||||
{{ end }}
|
||||
95
internal/dis/component/control/admin/html/users.html
Normal file
95
internal/dis/component/control/admin/html/users.html
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
{{ template "_base.html" . }}
|
||||
{{ define "title" }}Distillery Admin - Users{{ end }}
|
||||
|
||||
{{ define "header"}}
|
||||
<p>
|
||||
<a class="pure-button" href="/admin/index">Control</a> >
|
||||
<a class="pure-button pure-button-primary" href="/admin/users">Users</a>
|
||||
</p>
|
||||
<p>
|
||||
<div class="pure-button-group" role="group" aria-label="Actions">
|
||||
<a class="pure-button" href="/admin/users/create">Create New</a>
|
||||
</div>
|
||||
</p>
|
||||
{{ end }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="pure-u-1">
|
||||
<div class="padding">
|
||||
<div class="overflow">
|
||||
<table class="pure-table pure-table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
Username
|
||||
</th>
|
||||
<th>
|
||||
Enabled
|
||||
</th>
|
||||
<th>
|
||||
Admin
|
||||
</th>
|
||||
<th>
|
||||
Passcode (TOTP)
|
||||
</th>
|
||||
<th>
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ $csrf := .CSRF }}
|
||||
{{ range .Users }}
|
||||
<tr {{ if not .User.IsEnabled }}style="color:gray"{{ end }}>
|
||||
<td>
|
||||
{{ .User.User }}
|
||||
</td>
|
||||
<td>
|
||||
{{ .User.IsEnabled }}
|
||||
|
||||
</td>
|
||||
<td>
|
||||
{{ .User.IsAdmin }}
|
||||
</td>
|
||||
<td>
|
||||
{{ .User.IsTOTPEnabled }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="pure-button-group" role="group">
|
||||
<form action="/admin/users/toggleadmin" method="POST" class="pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="submit" class="pure-button" value="{{ if .User.IsAdmin }}Remove Admin{{ else }} Make Admin{{ end }}">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
<form action="/admin/users/password" method="POST" class="pure-form pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="password" name="password">
|
||||
<input type="submit" class="pure-button" value="Update Password">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
<form action="/admin/users/disable" method="POST" class="pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="submit" class="pure-button" {{ if (not .User.IsEnabled) }}disabled{{ end }} value="Disable">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
<form action="/admin/users/disabletotp" method="POST" class="pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="submit" class="pure-button" {{ if (not .User.IsTOTPEnabled) }}disabled{{ end }} value="Remove Passcode">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
<form action="/admin/users/delete" method="POST" class="pure-form-group">
|
||||
<input type="hidden" name="user" value="{{ .User.User }}">
|
||||
<input type="submit" class="pure-button pure-button-danger" value="Delete">
|
||||
{{ $csrf }}
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ end }}
|
||||
|
|
@ -2,6 +2,7 @@ package admin
|
|||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
|
|
@ -10,6 +11,7 @@ import (
|
|||
"github.com/FAU-CDI/wisski-distillery/internal/models"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/status"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
|
|
@ -23,11 +25,14 @@ var instanceTemplate = static.AssetsAdmin.MustParseShared(
|
|||
type instanceContext struct {
|
||||
Time time.Time
|
||||
|
||||
CSRF template.HTML
|
||||
Instance models.Instance
|
||||
Info status.WissKI
|
||||
}
|
||||
|
||||
func (admin *Admin) instance(r *http.Request) (is instanceContext, err error) {
|
||||
is.CSRF = csrf.TemplateField(r)
|
||||
|
||||
// find the instance itself!
|
||||
instance, err := admin.Dependencies.Instances.WissKI(r.Context(), mux.Vars(r)["slug"])
|
||||
if err == instances.ErrWissKINotFound {
|
||||
|
|
|
|||
190
internal/dis/component/control/admin/users.go
Normal file
190
internal/dis/component/control/admin/users.go
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
_ "embed"
|
||||
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth"
|
||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/control/static"
|
||||
"github.com/FAU-CDI/wisski-distillery/pkg/httpx"
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
//go:embed "html/users.html"
|
||||
var userTemplateString string
|
||||
var userTemplate = static.AssetsAdmin.MustParseShared(
|
||||
"users.html",
|
||||
userTemplateString,
|
||||
)
|
||||
|
||||
type userContext struct {
|
||||
Time time.Time
|
||||
|
||||
CSRF template.HTML
|
||||
Users []*auth.AuthUser
|
||||
}
|
||||
|
||||
func (admin *Admin) users(r *http.Request) (uc userContext, err error) {
|
||||
uc.CSRF = csrf.TemplateField(r)
|
||||
uc.Time = time.Now()
|
||||
uc.Users, err = admin.Dependencies.Auth.Users(r.Context())
|
||||
return
|
||||
}
|
||||
|
||||
//go:embed "html/user_create.html"
|
||||
var userCreateTemplateString string
|
||||
var userCreateTemplate = static.AssetsAdmin.MustParseShared(
|
||||
"user_create.html",
|
||||
userCreateTemplateString,
|
||||
)
|
||||
|
||||
var (
|
||||
errCreateInvalidUsername = errors.New("invalid username")
|
||||
errCreateInvalidPassword = errors.New("invalid password")
|
||||
)
|
||||
|
||||
type createUserResult struct {
|
||||
User string
|
||||
Passsword string
|
||||
Admin bool
|
||||
}
|
||||
|
||||
func (admin *Admin) createUser(ctx context.Context) http.Handler {
|
||||
return &httpx.Form[createUserResult]{
|
||||
Fields: []httpx.Field{
|
||||
{Name: "username", Type: httpx.TextField, Label: "Username"},
|
||||
{Name: "password", Type: httpx.PasswordField, Label: "Password"},
|
||||
{Name: "admin", Type: httpx.CheckboxField, Label: "Distillery Administrator"},
|
||||
},
|
||||
FieldTemplate: httpx.PureCSSFieldTemplate,
|
||||
|
||||
CSRF: admin.Dependencies.Auth.CSRF(),
|
||||
|
||||
RenderTemplate: userCreateTemplate,
|
||||
|
||||
Validate: func(r *http.Request, values map[string]string) (cu createUserResult, err error) {
|
||||
cu.User, cu.Passsword, cu.Admin = values["username"], values["password"], values["admin"] == "on"
|
||||
|
||||
if cu.User == "" {
|
||||
return cu, errCreateInvalidUsername
|
||||
}
|
||||
if cu.Passsword == "" {
|
||||
return cu, errCreateInvalidPassword
|
||||
}
|
||||
|
||||
return cu, nil
|
||||
},
|
||||
|
||||
RenderSuccess: func(cu createUserResult, values map[string]string, w http.ResponseWriter, r *http.Request) error {
|
||||
// create the user
|
||||
user, err := admin.Dependencies.Auth.CreateUser(r.Context(), cu.User)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// disable the user and setup the admin flag
|
||||
user.SetAdmin(cu.Admin)
|
||||
if err := user.Save(r.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// set the password!
|
||||
err = user.SetPassword(r.Context(), []byte(cu.Passsword))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// everything went fine, redirect the user back to the user page!
|
||||
http.Redirect(w, r, "/admin/users/", http.StatusSeeOther)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var errNotCurrentUser = httpx.Response{
|
||||
Body: []byte("attempt to modify current user"),
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
|
||||
func (admin *Admin) useraction(ctx context.Context, name string, action func(r *http.Request, user *auth.AuthUser) error) http.Handler {
|
||||
logger := zerolog.Ctx(ctx)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
logger.Err(err).Str("action", name).Msg("failed to parse form")
|
||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
username := r.PostFormValue("user")
|
||||
user, err := admin.Dependencies.Auth.User(r.Context(), username)
|
||||
if err != nil {
|
||||
logger.Err(err).Str("action", name).Msg("failed to get user")
|
||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
me, err := admin.Dependencies.Auth.UserOf(r)
|
||||
if err != nil {
|
||||
logger.Err(err).Str("action", name).Msg("failed to get current user")
|
||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// don't allow the current user
|
||||
if me.User.User == user.User.User {
|
||||
errNotCurrentUser.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
if err := action(r, user); err != nil {
|
||||
logger.Err(err).Str("action", name).Msg("failed to act on user")
|
||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/admin/users/", http.StatusSeeOther)
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersDeleteHandler(ctx context.Context) http.Handler {
|
||||
return admin.useraction(ctx, "delete user", func(r *http.Request, user *auth.AuthUser) error {
|
||||
return user.Delete(r.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersDisableHandler(ctx context.Context) http.Handler {
|
||||
return admin.useraction(ctx, "disable user", func(r *http.Request, user *auth.AuthUser) error {
|
||||
return user.UnsetPassword(r.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersDisableTOTPHandler(ctx context.Context) http.Handler {
|
||||
return admin.useraction(ctx, "disable user totp", func(r *http.Request, user *auth.AuthUser) error {
|
||||
return user.DisableTOTP(r.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersToggleAdmin(ctx context.Context) http.Handler {
|
||||
return admin.useraction(ctx, "toggle admin", func(r *http.Request, user *auth.AuthUser) error {
|
||||
if user.IsAdmin() {
|
||||
return user.MakeRegular(r.Context())
|
||||
}
|
||||
return user.MakeAdmin(r.Context())
|
||||
})
|
||||
}
|
||||
|
||||
func (admin *Admin) usersPasswordHandler(ctx context.Context) http.Handler {
|
||||
return admin.useraction(ctx, "set password", func(r *http.Request, user *auth.AuthUser) error {
|
||||
password := r.PostFormValue("password")
|
||||
if password == "" {
|
||||
return httpx.ErrBadRequest
|
||||
}
|
||||
return user.SetPassword(r.Context(), []byte(password))
|
||||
})
|
||||
}
|
||||
|
|
@ -5,17 +5,17 @@ package static
|
|||
// AssetsHome contains assets for the 'Home' entrypoint.
|
||||
var AssetsHome = Assets{
|
||||
Scripts: `<script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script>`,
|
||||
Styles: `<link rel="stylesheet" href="/static/Home.4ec77c43.css"><link rel="stylesheet" href="/static/Home.2353e048.css">`,
|
||||
Styles: `<link rel="stylesheet" href="/static/Home.9f00501f.css"><link rel="stylesheet" href="/static/Home.2353e048.css">`,
|
||||
}
|
||||
|
||||
// AssetsUser contains assets for the 'User' entrypoint.
|
||||
var AssetsUser = Assets{
|
||||
Scripts: `<script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/User.4197014b.js"></script><script src="/static/User.30d54198.js" nomodule="" defer></script>`,
|
||||
Styles: `<link rel="stylesheet" href="/static/Home.4ec77c43.css"><link rel="stylesheet" href="/static/User.38d394c2.css">`,
|
||||
Styles: `<link rel="stylesheet" href="/static/Home.9f00501f.css"><link rel="stylesheet" href="/static/User.38d394c2.css">`,
|
||||
}
|
||||
|
||||
// AssetsAdmin contains assets for the 'Admin' entrypoint.
|
||||
var AssetsAdmin = Assets{
|
||||
Scripts: `<script nomodule="" defer src="/static/User.30d54198.js"></script><script type="module" src="/static/User.4197014b.js"></script><script type="module" src="/static/Home.38d394c2.js"></script><script src="/static/Home.38d394c2.js" nomodule="" defer></script><script type="module" src="/static/Admin.4ca3cb6f.js"></script><script src="/static/Admin.9750ba9c.js" nomodule="" defer></script>`,
|
||||
Styles: `<link rel="stylesheet" href="/static/Home.4ec77c43.css"><link rel="stylesheet" href="/static/Admin.6d59e220.css"><link rel="stylesheet" href="/static/User.38d394c2.css"><link rel="stylesheet" href="/static/Admin.6d2ae968.css">`,
|
||||
Styles: `<link rel="stylesheet" href="/static/Home.9f00501f.css"><link rel="stylesheet" href="/static/Admin.6d59e220.css"><link rel="stylesheet" href="/static/User.38d394c2.css"><link rel="stylesheet" href="/static/Admin.6d2ae968.css">`,
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -21,7 +21,7 @@ footer {
|
|||
}
|
||||
|
||||
.overflow table td,
|
||||
.overflow table th{
|
||||
.overflow table th {
|
||||
padding: .5em .5em;
|
||||
}
|
||||
|
||||
|
|
@ -41,14 +41,26 @@ footer {
|
|||
display: block;
|
||||
height: 1em;
|
||||
}
|
||||
.pure-form-group {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.pure-button-action {
|
||||
background-color: rgb(66, 184, 221) !important;
|
||||
}
|
||||
|
||||
.pure-button-success {
|
||||
background-color: rgb(28, 184, 65) !important;
|
||||
}
|
||||
|
||||
.pure-button-danger {
|
||||
background: rgb(202, 60, 60) !important;
|
||||
}
|
||||
|
||||
.pure-button-warning {
|
||||
background: rgb(223, 117, 20) !important;
|
||||
}
|
||||
|
||||
.pure-button-xsmall {
|
||||
font-size: 70%;
|
||||
}
|
||||
|
|
@ -70,4 +82,4 @@ footer {
|
|||
border: 1px solid red;
|
||||
padding: 2px;
|
||||
color: red;
|
||||
}
|
||||
}
|
||||
2
internal/models/models.go
Normal file
2
internal/models/models.go
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Package contains all database models
|
||||
package models
|
||||
|
|
@ -10,9 +10,33 @@ type User struct {
|
|||
User string `gorm:"column:user;not null;unique"` // name of the user
|
||||
PasswordHash []byte `gorm:"column:password"` // password of the user, hashed
|
||||
|
||||
TOTPEnabled bool `gorm:"column:totpenabled"` // is totp enabled for the user
|
||||
TOTPEnabled *bool `gorm:"column:totpenabled"` // is totp enabled for the user
|
||||
TOTPURL string `gorm:"column:totp"` // the totp of the user
|
||||
|
||||
Enabled bool `gorm:"enabled;not null"`
|
||||
Admin bool `gorm:"column:admin;not null"`
|
||||
Enabled *bool `gorm:"enabled;not null"`
|
||||
Admin *bool `gorm:"column:admin;not null"`
|
||||
}
|
||||
|
||||
func (user *User) IsAdmin() bool {
|
||||
return user.Admin != nil && *user.Admin
|
||||
}
|
||||
|
||||
func (user *User) SetAdmin(v bool) {
|
||||
user.Admin = &v
|
||||
}
|
||||
|
||||
func (user *User) IsEnabled() bool {
|
||||
return user.Enabled != nil && *user.Enabled
|
||||
}
|
||||
|
||||
func (user *User) SetEnabled(v bool) {
|
||||
user.Enabled = &v
|
||||
}
|
||||
|
||||
func (user *User) IsTOTPEnabled() bool {
|
||||
return user.TOTPEnabled != nil && *user.TOTPEnabled
|
||||
}
|
||||
|
||||
func (user *User) SetTOTPEnabled(v bool) {
|
||||
user.TOTPEnabled = &v
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ func (bk *Bookkeeping) Save(ctx context.Context) error {
|
|||
}
|
||||
|
||||
// Update based on the primary key!
|
||||
return sdb.Where("pk = ?", bk.Instance.Pk).Updates(&bk.Instance).Error
|
||||
return sdb.Select("*").Save(&bk.Instance).Error
|
||||
}
|
||||
|
||||
// Delete deletes this instance from the bookkeeping table
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ func StatusInterceptor(contentType string, body func(code int, text string) ([]b
|
|||
|
||||
return ErrInterceptor{
|
||||
Errors: map[error]Response{
|
||||
ErrBadRequest: makeResponse(http.StatusBadRequest),
|
||||
ErrNotFound: makeResponse(http.StatusNotFound),
|
||||
ErrForbidden: makeResponse(http.StatusForbidden),
|
||||
ErrMethodNotAllowed: makeResponse(http.StatusMethodNotAllowed),
|
||||
|
|
@ -59,6 +60,7 @@ func StatusInterceptor(contentType string, body func(code int, text string) ([]b
|
|||
|
||||
// Common errors accepted by all httpx handlers
|
||||
var (
|
||||
ErrBadRequest = errors.New("httpx: Bad Request")
|
||||
ErrNotFound = errors.New("httpx: Not Found")
|
||||
ErrForbidden = errors.New("httpx: Forbidden")
|
||||
ErrMethodNotAllowed = errors.New("httpx: Method Not Allowed")
|
||||
|
|
|
|||
|
|
@ -224,4 +224,5 @@ type InputType string
|
|||
const (
|
||||
TextField InputType = "text"
|
||||
PasswordField InputType = "password"
|
||||
CheckboxField InputType = "checkbox"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue