Admin: Add user page

This commit is contained in:
Tom Wiesing 2023-01-04 16:10:55 +01:00
parent bc0e92bdac
commit d34e85a18f
No known key found for this signature in database
24 changed files with 456 additions and 77 deletions

View file

@ -192,8 +192,7 @@ func (du disUser) runCheckPassword(context wisski_distillery.Context) error {
context.Println() context.Println()
var passcode string var passcode string
if user.TOTPEnabled { if user.IsTOTPEnabled() {
context.Printf("Enter passcode for %s:", du.Positionals.User)
passcode, err = context.IOStream.ReadPassword() passcode, err = context.IOStream.ReadPassword()
if err != nil { if err != nil {
@ -261,9 +260,7 @@ func (du disUser) runMakeAdmin(context wisski_distillery.Context) error {
if err != nil { if err != nil {
return err return err
} }
return user.MakeAdmin(context.Context)
user.Admin = true
return user.Save(context.Context)
} }
func (du disUser) runRemoveAdmin(context wisski_distillery.Context) error { func (du disUser) runRemoveAdmin(context wisski_distillery.Context) error {
@ -272,6 +269,5 @@ func (du disUser) runRemoveAdmin(context wisski_distillery.Context) error {
return err return err
} }
user.Admin = false return user.MakeRegular(context.Context)
return user.Save(context.Context)
} }

View file

@ -1,7 +1,7 @@
package cli 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. // Do not edit manually, as changes may be overwritten.
// =========================================================================================================== // ===========================================================================================================
@ -2189,7 +2189,7 @@ package cli
// # Generation // # Generation
// //
// This variable and the associated documentation have been automatically generated using the 'gogenlicense' tool. // 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 var LegalNotices string
func init() { func init() {

View file

@ -32,17 +32,7 @@ func (auth *Auth) Routes() []string { return []string{"/user/"} }
func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler, error) { func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler, error) {
router := httprouter.New() router := httprouter.New()
// setup the csrf handler (if needed) router.Handler(http.MethodGet, route, auth.authUser(ctx))
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))
{ {
login := auth.authLogin(ctx) login := auth.authLogin(ctx)
@ -78,3 +68,14 @@ func (auth *Auth) HandleRoute(ctx context.Context, route string) (http.Handler,
return router, nil 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...)
})
}

View file

@ -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. // 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) { 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
} }

View file

@ -10,17 +10,17 @@ import (
"github.com/FAU-CDI/wisski-distillery/pkg/httpx" "github.com/FAU-CDI/wisski-distillery/pkg/httpx"
) )
//go:embed "templates/home.html" //go:embed "templates/user.html"
var homeHTMLStr string var userHTMLStr string
var homeTemplate = static.AssetsHome.MustParseShared( var userTemplate = static.AssetsUser.MustParseShared(
"home.html", "user.html",
homeHTMLStr, 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]{ return auth.Protect(&httpx.HTMLHandler[*AuthUser]{
Handler: auth.UserOf, Handler: auth.UserOf,
Template: homeTemplate, Template: userTemplate,
}, nil) }, nil)
} }
@ -47,7 +47,7 @@ func (auth *Auth) authPassword(ctx context.Context) http.Handler {
}, },
FieldTemplate: httpx.PureCSSFieldTemplate, FieldTemplate: httpx.PureCSSFieldTemplate,
CSRF: auth.csrf.Get(nil), CSRF: auth.CSRF(),
RenderTemplate: passwordTemplate, RenderTemplate: passwordTemplate,
RenderTemplateContext: auth.UserFormContext, RenderTemplateContext: auth.UserFormContext,

View file

@ -49,7 +49,7 @@ func (auth *Auth) UserOf(r *http.Request) (user *AuthUser, err error) {
} }
// user isn't enabled // user isn't enabled
if !user.Enabled { if !user.IsEnabled() {
return nil, nil return nil, nil
} }
@ -122,7 +122,7 @@ func (auth *Auth) authLogin(ctx context.Context) http.Handler {
}, },
FieldTemplate: httpx.PureCSSFieldTemplate, FieldTemplate: httpx.PureCSSFieldTemplate,
CSRF: auth.csrf.Get(nil), CSRF: auth.CSRF(),
RenderForm: func(context httpx.FormContext, w http.ResponseWriter, r *http.Request) { RenderForm: func(context httpx.FormContext, w http.ResponseWriter, r *http.Request) {
if context.Err != nil { if context.Err != nil {

View file

@ -13,12 +13,12 @@
{{ define "content" }} {{ define "content" }}
<div class="pure-u-1"> <div class="pure-u-1">
<p> <p>
{{ if .User.Admin }} {{ if .User.IsAdmin }}
You are an administrator. You are an administrator.
{{ else }} {{ else }}
You are a regular user. You are a regular user.
{{ end }} {{ end }}
{{ if .User.TOTPEnabled }} {{ if .User.IsTOTPEnabled }}
You have TOTP enabled. You have TOTP enabled.
{{ else }} {{ else }}
You do not have TOTP enabled. You do not have TOTP enabled.
@ -26,7 +26,7 @@
</p> </p>
<div class="pure-button-group" role="group" role="Actions"> <div class="pure-button-group" role="group" role="Actions">
<a class="pure-button" href="/user/password/">Change Password</a> <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> <a class="pure-button" href="/user/totp/disable/">Disable TOTP</a>
{{ else }} {{ else }}
<a class="pure-button" href="/user/totp/enable/">Enable TOTP</a> <a class="pure-button" href="/user/totp/enable/">Enable TOTP</a>
@ -35,9 +35,9 @@
<hr /> <hr />
</div> </div>
{{ if .User.Admin }} {{ if .User.IsAdmin }}
<div class="pure-u-1"> <div class="pure-u-1">
{{ if (not .User.TOTPEnabled) }} {{ if (not .User.IsTOTPEnabled) }}
<div> <div>
<p class="error-message"> <p class="error-message">
TOTP is required to access these. TOTP is required to access these.

View file

@ -22,11 +22,11 @@ func (auth *Auth) authTOTPEnable(ctx context.Context) http.Handler {
}, },
FieldTemplate: httpx.PureCSSFieldTemplate, FieldTemplate: httpx.PureCSSFieldTemplate,
CSRF: auth.csrf.Get(nil), CSRF: auth.CSRF(),
SkipForm: func(r *http.Request) (data struct{}, skip bool) { SkipForm: func(r *http.Request) (data struct{}, skip bool) {
user, err := auth.UserOf(r) user, err := auth.UserOf(r)
return struct{}{}, err == nil && user != nil && user.TOTPEnabled return struct{}{}, err == nil && user != nil && user.IsTOTPEnabled()
}, },
RenderTemplate: totpEnableTemplate, RenderTemplate: totpEnableTemplate,
@ -81,11 +81,11 @@ func (auth *Auth) authTOTPEnroll(ctx context.Context) http.Handler {
}, },
FieldTemplate: httpx.PureCSSFieldTemplate, FieldTemplate: httpx.PureCSSFieldTemplate,
CSRF: auth.csrf.Get(nil), CSRF: auth.CSRF(),
SkipForm: func(r *http.Request) (data struct{}, skip bool) { SkipForm: func(r *http.Request) (data struct{}, skip bool) {
user, _ := auth.UserOf(r) user, err := auth.UserOf(r)
return struct{}{}, user != nil && user.TOTPEnabled return struct{}{}, err == nil && user != nil && user.IsTOTPEnabled()
}, },
RenderForm: func(context httpx.FormContext, w http.ResponseWriter, r *http.Request) { RenderForm: func(context httpx.FormContext, w http.ResponseWriter, r *http.Request) {
user, err := auth.UserOf(r) user, err := auth.UserOf(r)
@ -152,11 +152,11 @@ func (auth *Auth) authTOTPDisable(ctx context.Context) http.Handler {
}, },
FieldTemplate: httpx.PureCSSFieldTemplate, FieldTemplate: httpx.PureCSSFieldTemplate,
CSRF: auth.csrf.Get(nil), CSRF: auth.CSRF(),
SkipForm: func(r *http.Request) (data struct{}, skip bool) { SkipForm: func(r *http.Request) (data struct{}, skip bool) {
user, _ := auth.UserOf(r) user, err := auth.UserOf(r)
return struct{}{}, user != nil && !user.TOTPEnabled return struct{}{}, err == nil && user != nil && !user.IsTOTPEnabled()
}, },
RenderTemplate: totpDisableTemplate, RenderTemplate: totpDisableTemplate,
RenderTemplateContext: auth.UserFormContext, RenderTemplateContext: auth.UserFormContext,

View file

@ -4,13 +4,13 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"image/png" "image/png"
"net/http" "net/http"
"github.com/FAU-CDI/wisski-distillery/internal/models" "github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx" "github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/pkg/errors"
"github.com/pquerna/otp" "github.com/pquerna/otp"
"github.com/pquerna/otp/totp" "github.com/pquerna/otp/totp"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
@ -90,15 +90,17 @@ func (auth *Auth) CreateUser(ctx context.Context, name string) (user *AuthUser,
user = &AuthUser{ user = &AuthUser{
User: models.User{ User: models.User{
User: name, User: name,
Enabled: false,
}, },
} }
user.SetAdmin(false)
user.SetEnabled(false)
user.SetTOTPEnabled(false)
// do the create statement // do the create statement
err = table.Create(&user.User).Error err = table.Select("*").Create(&user.User).Error
if err != nil { if err != nil {
return nil, err return nil, errors.Wrapf(err, "Create")
} }
user.auth = auth user.auth = auth
@ -116,7 +118,7 @@ func (au *AuthUser) String() string {
return "User{nil}" return "User{nil}"
} }
hasPassword := len(au.PasswordHash) > 0 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 ( var (
@ -140,7 +142,7 @@ func (au *AuthUser) CheckTOTP(passcode string) error {
return err return err
} }
if au.TOTPEnabled && !totp.Validate(passcode, secret.Secret()) { if au.IsTOTPEnabled() && !totp.Validate(passcode, secret.Secret()) {
return ErrTOTPFailed return ErrTOTPFailed
} }
return nil return nil
@ -148,7 +150,7 @@ func (au *AuthUser) CheckTOTP(passcode string) error {
// NewTOTP generates a new TOTP secret, returning a totp key. // NewTOTP generates a new TOTP secret, returning a totp key.
func (au *AuthUser) NewTOTP(ctx context.Context) (*otp.Key, error) { func (au *AuthUser) NewTOTP(ctx context.Context) (*otp.Key, error) {
if au.User.TOTPEnabled { if au.User.IsTOTPEnabled() {
return nil, ErrTOTPEnabled return nil, ErrTOTPEnabled
} }
@ -191,14 +193,14 @@ func (au *AuthUser) EnableTOTP(ctx context.Context, passcode string) error {
return ErrTOTPFailed return ErrTOTPFailed
} }
au.User.TOTPEnabled = true au.User.SetTOTPEnabled(true)
return au.Save(ctx) return au.Save(ctx)
} }
// DisableTOTP disables totp for the given user // DisableTOTP disables totp for the given user
func (au *AuthUser) DisableTOTP(ctx context.Context) (err error) { func (au *AuthUser) DisableTOTP(ctx context.Context) (err error) {
au.User.TOTPEnabled = false au.User.SetTOTPEnabled(false)
au.User.TOTPURL = "" au.User.TOTPURL = ""
return au.Save(ctx) return au.Save(ctx)
} }
@ -209,14 +211,14 @@ func (au *AuthUser) SetPassword(ctx context.Context, password []byte) (err error
if err != nil { if err != nil {
return err return err
} }
au.User.Enabled = true au.User.SetEnabled(true)
return au.Save(ctx) return au.Save(ctx)
} }
// UnsetPassword removes the password from this user, and disables them // UnsetPassword removes the password from this user, and disables them
func (au *AuthUser) UnsetPassword(ctx context.Context) error { func (au *AuthUser) UnsetPassword(ctx context.Context) error {
au.User.PasswordHash = nil au.User.PasswordHash = nil
au.User.Enabled = false au.User.SetEnabled(false)
return au.Save(ctx) return au.Save(ctx)
} }
@ -230,7 +232,7 @@ func (au *AuthUser) CheckPassword(ctx context.Context, password []byte) error {
if au == nil { if au == nil {
return ErrNoUser return ErrNoUser
} }
if !au.User.Enabled { if !au.User.IsEnabled() {
return ErrUserDisabled 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. // 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. // If the user is already an admin, does not return an error.
func (au *AuthUser) MakeAdmin(ctx context.Context) error { func (au *AuthUser) MakeAdmin(ctx context.Context) error {
au.User.Admin = true au.User.SetAdmin(true)
return au.Save(ctx) return au.Save(ctx)
} }
// MakeRegular removes admin rights from this user. // MakeRegular removes admin rights from this user.
// If this user is not an dmin, does not return an error. // If this user is not an dmin, does not return an error.
func (au *AuthUser) MakeRegular(ctx context.Context) error { func (au *AuthUser) MakeRegular(ctx context.Context) error {
au.User.Admin = true au.User.SetAdmin(false)
return au.Save(ctx) return au.Save(ctx)
} }
@ -271,7 +273,7 @@ func (au *AuthUser) Save(ctx context.Context) error {
if err != nil { if err != nil {
return err return err
} }
return table.Save(&au.User).Error return table.Select("*").Updates(&au.User).Error
} }
// Delete deletes the user from the database // Delete deletes the user from the database

View file

@ -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"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter/logger"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"github.com/rs/zerolog"
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx" "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.serveSocket,
} }
handler = admin.Dependencies.Auth.Protect(socket, auth.Admin) handler = admin.Dependencies.Auth.Protect(socket, auth.Admin)
handler = admin.Dependencies.Auth.CSRF()(handler)
} }
// handle everything // handle everything
@ -61,6 +63,26 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
Template: indexTemplate, 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 // add a handler for the component page
router.Handler(http.MethodGet, route+"components", httpx.HTMLHandler[componentContext]{ router.Handler(http.MethodGet, route+"components", httpx.HTMLHandler[componentContext]{
Handler: admin.components, Handler: admin.components,
@ -79,9 +101,19 @@ func (admin *Admin) HandleRoute(ctx context.Context, route string) (handler http
Template: instanceTemplate, 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 // parse the form
if err := r.ParseForm(); err != nil { if err := r.ParseForm(); err != nil {
logger.Err(err).Msg("failed to parse admin login")
return "", 0, err 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")) target, err := instance.Users().Login(r.Context(), nil, r.PostFormValue("user"))
if err != nil { if err != nil {
logger.Err(err).Msg("failed to admin login")
return "", 0, err return "", 0, err
} }
return target.String(), http.StatusSeeOther, err return target.String(), http.StatusSeeOther, err
})) })
return
} }

View file

@ -6,7 +6,10 @@
<a class="pure-button pure-button-primary" href="/admin/index">Admin</a> <a class="pure-button pure-button-primary" href="/admin/index">Admin</a>
</p> </p>
<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> </p>
{{ end }} {{ end }}

View file

@ -7,7 +7,9 @@
<a class="pure-button pure-button-primary" href="/admin/instance/{{ .Info.Slug }}">Instance</a> <a class="pure-button pure-button-primary" href="/admin/instance/{{ .Info.Slug }}">Instance</a>
</p> </p>
<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> </p>
{{ end }} {{ end }}
@ -216,6 +218,7 @@
</thead> </thead>
<tbody> <tbody>
{{ $slug := .Instance.Slug }} {{ $slug := .Instance.Slug }}
{{ $csrf := .CSRF }}
{{ range $index, $user := .Info.Users }} {{ range $index, $user := .Info.Users }}
<tr {{ if not $user.Status }}style="color:gray"{{ end }}> <tr {{ if not $user.Status }}style="color:gray"{{ end }}>
<td> <td>
@ -247,10 +250,11 @@
<code class="date">{{ $user.Login.Time.Format "2006-01-02T15:04:05Z07:00" }}</code> <code class="date">{{ $user.Login.Time.Format "2006-01-02T15:04:05Z07:00" }}</code>
</td> </td>
<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="slug" value="{{ $slug }}">
<input type="hidden" name="user" value="{{ $user.Name }}"> <input type="hidden" name="user" value="{{ $user.Name }}">
<input type="submit" class="pure-button pure-button-action" value="Login in new window"> <input type="submit" class="pure-button pure-button-action" value="Login in new window">
{{ $csrf }}
</form> </form>
</td> </td>
</tr> </tr>

View 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> &gt;
<a class="pure-button" href="/admin/users">Users</a> &gt;
<a class="pure-button pure-button-primary" href="/admin/users/create">Create</a>
</p>
{{ end }}

View 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> &gt;
<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"> &nbsp;
<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 }}

View file

@ -2,6 +2,7 @@ package admin
import ( import (
_ "embed" _ "embed"
"html/template"
"net/http" "net/http"
"time" "time"
@ -10,6 +11,7 @@ import (
"github.com/FAU-CDI/wisski-distillery/internal/models" "github.com/FAU-CDI/wisski-distillery/internal/models"
"github.com/FAU-CDI/wisski-distillery/internal/status" "github.com/FAU-CDI/wisski-distillery/internal/status"
"github.com/FAU-CDI/wisski-distillery/pkg/httpx" "github.com/FAU-CDI/wisski-distillery/pkg/httpx"
"github.com/gorilla/csrf"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@ -23,11 +25,14 @@ var instanceTemplate = static.AssetsAdmin.MustParseShared(
type instanceContext struct { type instanceContext struct {
Time time.Time Time time.Time
CSRF template.HTML
Instance models.Instance Instance models.Instance
Info status.WissKI Info status.WissKI
} }
func (admin *Admin) instance(r *http.Request) (is instanceContext, err error) { func (admin *Admin) instance(r *http.Request) (is instanceContext, err error) {
is.CSRF = csrf.TemplateField(r)
// find the instance itself! // find the instance itself!
instance, err := admin.Dependencies.Instances.WissKI(r.Context(), mux.Vars(r)["slug"]) instance, err := admin.Dependencies.Instances.WissKI(r.Context(), mux.Vars(r)["slug"])
if err == instances.ErrWissKINotFound { if err == instances.ErrWissKINotFound {

View 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))
})
}

View file

@ -5,17 +5,17 @@ package static
// AssetsHome contains assets for the 'Home' entrypoint. // AssetsHome contains assets for the 'Home' entrypoint.
var AssetsHome = Assets{ 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>`, 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. // AssetsUser contains assets for the 'User' entrypoint.
var AssetsUser = Assets{ 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>`, 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. // AssetsAdmin contains assets for the 'Admin' entrypoint.
var AssetsAdmin = Assets{ 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>`, 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">`,
} }

View file

@ -21,7 +21,7 @@ footer {
} }
.overflow table td, .overflow table td,
.overflow table th{ .overflow table th {
padding: .5em .5em; padding: .5em .5em;
} }
@ -41,14 +41,26 @@ footer {
display: block; display: block;
height: 1em; height: 1em;
} }
.pure-form-group {
display: inline;
}
.pure-button-action { .pure-button-action {
background-color: rgb(66, 184, 221) !important; background-color: rgb(66, 184, 221) !important;
} }
.pure-button-success { .pure-button-success {
background-color: rgb(28, 184, 65) !important; 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 { .pure-button-xsmall {
font-size: 70%; font-size: 70%;
} }
@ -70,4 +82,4 @@ footer {
border: 1px solid red; border: 1px solid red;
padding: 2px; padding: 2px;
color: red; color: red;
} }

View file

@ -0,0 +1,2 @@
// Package contains all database models
package models

View file

@ -10,9 +10,33 @@ type User struct {
User string `gorm:"column:user;not null;unique"` // name of the user User string `gorm:"column:user;not null;unique"` // name of the user
PasswordHash []byte `gorm:"column:password"` // password of the user, hashed 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 TOTPURL string `gorm:"column:totp"` // the totp of the user
Enabled bool `gorm:"enabled;not null"` Enabled *bool `gorm:"enabled;not null"`
Admin bool `gorm:"column:admin;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
} }

View file

@ -25,7 +25,7 @@ func (bk *Bookkeeping) Save(ctx context.Context) error {
} }
// Update based on the primary key! // 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 // Delete deletes this instance from the bookkeeping table

View file

@ -49,6 +49,7 @@ func StatusInterceptor(contentType string, body func(code int, text string) ([]b
return ErrInterceptor{ return ErrInterceptor{
Errors: map[error]Response{ Errors: map[error]Response{
ErrBadRequest: makeResponse(http.StatusBadRequest),
ErrNotFound: makeResponse(http.StatusNotFound), ErrNotFound: makeResponse(http.StatusNotFound),
ErrForbidden: makeResponse(http.StatusForbidden), ErrForbidden: makeResponse(http.StatusForbidden),
ErrMethodNotAllowed: makeResponse(http.StatusMethodNotAllowed), 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 // Common errors accepted by all httpx handlers
var ( var (
ErrBadRequest = errors.New("httpx: Bad Request")
ErrNotFound = errors.New("httpx: Not Found") ErrNotFound = errors.New("httpx: Not Found")
ErrForbidden = errors.New("httpx: Forbidden") ErrForbidden = errors.New("httpx: Forbidden")
ErrMethodNotAllowed = errors.New("httpx: Method Not Allowed") ErrMethodNotAllowed = errors.New("httpx: Method Not Allowed")

View file

@ -224,4 +224,5 @@ type InputType string
const ( const (
TextField InputType = "text" TextField InputType = "text"
PasswordField InputType = "password" PasswordField InputType = "password"
CheckboxField InputType = "checkbox"
) )