From 161e08fe1fadb1163c14b00d5ddd0f31d46214d1 Mon Sep 17 00:00:00 2001 From: Tom Date: Tue, 6 Jun 2023 18:26:53 +0200 Subject: [PATCH] WIPL tokens --- API.md | 1 + internal/dis/component/auth/api/api.go | 54 +++++++ internal/dis/component/auth/auth.go | 10 +- internal/dis/component/auth/next/next.go | 4 +- internal/dis/component/auth/panel/panel.go | 29 +++- internal/dis/component/auth/panel/password.go | 2 +- internal/dis/component/auth/panel/ssh.go | 9 +- .../auth/panel/templates/tokens.html | 56 +++++++ .../auth/panel/templates/tokens_add.html | 9 ++ internal/dis/component/auth/panel/tokens.go | 153 ++++++++++++++++++ internal/dis/component/auth/panel/totp.go | 14 +- internal/dis/component/auth/panel/user.go | 3 +- internal/dis/component/auth/protect.go | 21 ++- internal/dis/component/auth/scope.go | 60 ++++--- internal/dis/component/auth/scopes/admin.go | 2 +- internal/dis/component/auth/scopes/user.go | 2 +- internal/dis/component/auth/session.go | 49 +++++- internal/dis/component/auth/tokens/check.go | 90 +++++++++++ internal/dis/component/auth/tokens/tokens.go | 148 +++++++++++++++++ internal/dis/component/auth/user.go | 2 +- internal/dis/component/server/admin/admin.go | 2 +- internal/dis/component/server/admin/users.go | 2 +- internal/dis/component/server/list/list.go | 2 +- internal/dis/distillery.go | 5 + internal/models/token.go | 50 ++++++ 25 files changed, 716 insertions(+), 63 deletions(-) create mode 100644 internal/dis/component/auth/api/api.go create mode 100644 internal/dis/component/auth/panel/templates/tokens.html create mode 100644 internal/dis/component/auth/panel/templates/tokens_add.html create mode 100644 internal/dis/component/auth/panel/tokens.go create mode 100644 internal/dis/component/auth/tokens/check.go create mode 100644 internal/dis/component/auth/tokens/tokens.go create mode 100644 internal/models/token.go diff --git a/API.md b/API.md index 58d85f9..e61d4db 100644 --- a/API.md +++ b/API.md @@ -4,5 +4,6 @@ The distillery comes with an API served under `/api/`. It is still a work in progress, and will be polished and properly implemented at a later point. The API is currently disabled by default, and needs to be enabled in `distillery.yaml`. +- `/api/v1/auth`: Returns user information - `/api/v1/systems`: Returns a (publically visible) list of systems - `/api/v1/news`: Returns JSON containing all news items \ No newline at end of file diff --git a/internal/dis/component/auth/api/api.go b/internal/dis/component/auth/api/api.go new file mode 100644 index 0000000..3342b10 --- /dev/null +++ b/internal/dis/component/auth/api/api.go @@ -0,0 +1,54 @@ +package api + +import ( + "context" + "net/http" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" +) + +type API struct { + component.Base + Dependencies struct { + Auth *auth.Auth + } +} + +var ( + _ component.Routeable = (*API)(nil) +) + +func (api *API) Routes() component.Routes { + return component.Routes{ + Prefix: "/api/v1/auth/", + Exact: true, + } +} + +type AuthInfo struct { + // User returns the authenticated user. + // If there is no user, contains the empty string. + User string + + // Token indicates if the user is authenticated with a token. + Token bool +} + +func (a *API) HandleRoute(ctx context.Context, path string) (http.Handler, error) { + return &Handler[AuthInfo]{ + Config: a.Config, + Auth: a.Dependencies.Auth, + + Methods: []string{"GET"}, + + Handler: func(s string, r *http.Request) (ai AuthInfo, err error) { + var user *auth.AuthUser + user, ai.Token, err = a.Dependencies.Auth.UserOf(r) + if user != nil { + ai.User = user.User.User + } + return + }, + }, nil +} diff --git a/internal/dis/component/auth/auth.go b/internal/dis/component/auth/auth.go index 99712a3..34cff52 100644 --- a/internal/dis/component/auth/auth.go +++ b/internal/dis/component/auth/auth.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/tokens" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templating" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql" "github.com/gorilla/sessions" @@ -19,12 +20,17 @@ type Auth struct { UserDeleteHooks []component.UserDeleteHook Templating *templating.Templating ScopeProviders []component.ScopeProvider + Tokens *tokens.Tokens } store lazy.Lazy[sessions.Store] - scopeInfos lazy.Lazy[[]component.ScopeInfo] - scopeIndex lazy.Lazy[map[component.Scope]int] + scopeMap lazy.Lazy[map[component.Scope]scopeMapEntry] +} + +type scopeMapEntry struct { + Provider component.ScopeProvider + Info component.ScopeInfo } var ( diff --git a/internal/dis/component/auth/next/next.go b/internal/dis/component/auth/next/next.go index 9c85c56..0ff7bd5 100644 --- a/internal/dis/component/auth/next/next.go +++ b/internal/dis/component/auth/next/next.go @@ -31,7 +31,7 @@ var ( func (next *Next) Routes() component.Routes { return component.Routes{ Prefix: "/next/", - Decorator: next.Dependencies.Auth.Require(scopes.ScopeUserLoggedIn, nil), + Decorator: next.Dependencies.Auth.Require(true, scopes.ScopeUserLoggedIn, nil), } } @@ -80,7 +80,7 @@ func (next *Next) HandleRoute(ctx context.Context, path string) (http.Handler, e } // get the user - user, err := next.Dependencies.Auth.UserOf(r) + user, _, err := next.Dependencies.Auth.UserOf(r) if err != nil { return "", 0, err } diff --git a/internal/dis/component/auth/panel/panel.go b/internal/dis/component/auth/panel/panel.go index f686c91..14a37d9 100644 --- a/internal/dis/component/auth/panel/panel.go +++ b/internal/dis/component/auth/panel/panel.go @@ -9,6 +9,7 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/next" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/policy" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/tokens" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/instances" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templating" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2" @@ -24,6 +25,7 @@ type UserPanel struct { Auth *auth.Auth Templating *templating.Templating Policy *policy.Policy + Tokens *tokens.Tokens Instances *instances.Instances Next *next.Next Keys *sshkeys.SSHKeys @@ -40,14 +42,14 @@ func (panel *UserPanel) Routes() component.Routes { return component.Routes{ Prefix: "/user/", CSRF: true, - Decorator: panel.Dependencies.Auth.Require(scopes.ScopeUserLoggedIn, nil), + Decorator: panel.Dependencies.Auth.Require(false, scopes.ScopeUserLoggedIn, nil), } } func (panel *UserPanel) Menu(r *http.Request) []component.MenuItem { title := "Login" - user, err := panel.Dependencies.Auth.UserOf(r) + user, err := panel.Dependencies.Auth.UserOfSession(r) if user != nil && err == nil { title = user.User.User } @@ -62,6 +64,9 @@ var ( menuSSH = component.MenuItem{Title: "SSH Keys", Path: "/user/ssh/"} menuSSHAdd = component.MenuItem{Title: "Add New Key", Path: "/user/ssh/add/"} + menuTokens = component.MenuItem{Title: "Tokens", Path: "/user/tokens/"} + menuTokensAdd = component.MenuItem{Title: "Add New Token", Path: "/user/tokens/add/"} + menuTOTPAction = component.DummyMenuItem() menuTOTPDisable = component.MenuItem{Title: "Disable Passcode (TOTP)", Path: "/user/totp/disable/"} menuTOTPEnable = component.MenuItem{Title: "Enable Passcode (TOTP)", Path: "/user/totp/enable/"} @@ -115,8 +120,24 @@ func (panel *UserPanel) HandleRoute(ctx context.Context, route string) (http.Han router.Handler(http.MethodPost, route+"ssh/delete", delete) } + { + tokens := panel.tokensRoute(ctx) + router.Handler(http.MethodGet, route+"tokens", tokens) + } + + { + add := panel.tokensAddRoute(ctx) + router.Handler(http.MethodGet, route+"tokens/add", add) + router.Handler(http.MethodPost, route+"tokens/add", add) + } + + { + delete := panel.tokensDeleteRoute(ctx) + router.Handler(http.MethodPost, route+"tokens/delete", delete) + } + // ensure that the user is logged in! - return panel.Dependencies.Auth.Protect(router, scopes.ScopeUserLoggedIn, nil), nil + return panel.Dependencies.Auth.Protect(router, false, scopes.ScopeUserLoggedIn, nil), nil } type userFormContext struct { @@ -137,7 +158,7 @@ func (panel *UserPanel) UserFormContext(tpl *templating.Template[userFormContext return func(ctx httpx.FormContext, r *http.Request) any { uctx := userFormContext{FormContext: ctx} - if user, err := panel.Dependencies.Auth.UserOf(r); err == nil { + if user, err := panel.Dependencies.Auth.UserOfSession(r); err == nil { uctx.User = &user.User } return tpl.Context(r, uctx, funcs...) diff --git a/internal/dis/component/auth/panel/password.go b/internal/dis/component/auth/panel/password.go index f199575..523bb47 100644 --- a/internal/dis/component/auth/panel/password.go +++ b/internal/dis/component/auth/panel/password.go @@ -53,7 +53,7 @@ func (panel *UserPanel) routePassword(ctx context.Context) http.Handler { return struct{}{}, errPasswordsNotIdentical } - user, err := panel.Dependencies.Auth.UserOf(r) + user, err := panel.Dependencies.Auth.UserOfSession(r) if err != nil { return struct{}{}, err } diff --git a/internal/dis/component/auth/panel/ssh.go b/internal/dis/component/auth/panel/ssh.go index 1ea3e7a..4b1cbcc 100644 --- a/internal/dis/component/auth/panel/ssh.go +++ b/internal/dis/component/auth/panel/ssh.go @@ -57,7 +57,7 @@ func (panel *UserPanel) sshRoute(ctx context.Context) http.Handler { ) return tpl.HTMLHandler(func(r *http.Request) (sc SSHTemplateContext, err error) { - user, err := panel.Dependencies.Auth.UserOf(r) + user, err := panel.Dependencies.Auth.UserOfSession(r) if err != nil { return sc, err } @@ -89,6 +89,7 @@ var ( errInvalidUser = errors.New("invalid user") errKeyParse = errors.New("unable to parse ssh key") errAddKey = errors.New("unable to add key") + errAddToken = errors.New("unable to add token") ) func (panel *UserPanel) sshDeleteRoute(ctx context.Context) http.Handler { @@ -99,7 +100,7 @@ func (panel *UserPanel) sshDeleteRoute(ctx context.Context) http.Handler { httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) return } - user, err := panel.Dependencies.Auth.UserOf(r) + user, err := panel.Dependencies.Auth.UserOfSession(r) if err != nil { logger.Err(err).Str("action", "delete ssh key").Msg("failed to get current user") httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) @@ -119,7 +120,7 @@ func (panel *UserPanel) sshDeleteRoute(ctx context.Context) http.Handler { return } - http.Redirect(w, r, "/user/ssh/", http.StatusSeeOther) + http.Redirect(w, r, string(menuSSH.Path), http.StatusSeeOther) }) } @@ -158,7 +159,7 @@ func (panel *UserPanel) sshAddRoute(ctx context.Context) http.Handler { RenderTemplateContext: templating.FormTemplateContext(tpl), Validate: func(r *http.Request, values map[string]string) (ak addKeyResult, err error) { - ak.User, err = panel.Dependencies.Auth.UserOf(r) + ak.User, err = panel.Dependencies.Auth.UserOfSession(r) if err != nil || ak.User == nil { return ak, errInvalidUser } diff --git a/internal/dis/component/auth/panel/templates/tokens.html b/internal/dis/component/auth/panel/templates/tokens.html new file mode 100644 index 0000000..6c4172c --- /dev/null +++ b/internal/dis/component/auth/panel/templates/tokens.html @@ -0,0 +1,56 @@ +
+

+ This page allows you to add, view and remove tokens from your distillery account. +

+
+ +
+

My Tokens

+

+ This table shows tokens currently associated with your account. + Tokens can be used to access the API programatically. +

+
+
+ + + + + + + + + + + {{ $csrf := .CSRF }} + {{ range .Tokens }} + + + + + + {{ end }} + +
+ Token + + Description + + Actions +
+ {{ .Token }} + + {{ .Description }} + +
+
+ + + {{ $csrf }} +
+
+
+
+
+
+ diff --git a/internal/dis/component/auth/panel/templates/tokens_add.html b/internal/dis/component/auth/panel/templates/tokens_add.html new file mode 100644 index 0000000..ffd028e --- /dev/null +++ b/internal/dis/component/auth/panel/templates/tokens_add.html @@ -0,0 +1,9 @@ +{{ template "form.html" . }} +{{ define "form/button" }}Add{{ end }} +{{ define "form/inside" }} +
+

+ Use this form to add a new Token to your account. +

+
+{{ end }} \ No newline at end of file diff --git a/internal/dis/component/auth/panel/tokens.go b/internal/dis/component/auth/panel/tokens.go new file mode 100644 index 0000000..e32f0c5 --- /dev/null +++ b/internal/dis/component/auth/panel/tokens.go @@ -0,0 +1,153 @@ +package panel + +import ( + "context" + "net/http" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/assets" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templating" + "github.com/FAU-CDI/wisski-distillery/internal/models" + "github.com/rs/zerolog" + "github.com/tkw1536/pkglib/httpx" + "github.com/tkw1536/pkglib/httpx/field" + + _ "embed" +) + +//go:embed "templates/tokens.html" +var tokensHTML []byte +var tokensTemplate = templating.Parse[TokenTemplateContext]( + "tokens.html", tokensHTML, nil, + + templating.Title("Tokens"), + templating.Assets(assets.AssetsUser), +) + +type TokenTemplateContext struct { + templating.RuntimeFlags + + Tokens []models.Token +} + +func (panel *UserPanel) tokensRoute(ctx context.Context) http.Handler { + tpl := tokensTemplate.Prepare( + panel.Dependencies.Templating, + templating.Crumbs( + menuUser, + menuTokens, + ), + templating.Actions( + menuTokensAdd, + ), + ) + + return tpl.HTMLHandler(func(r *http.Request) (tc TokenTemplateContext, err error) { + // list the user + user, err := panel.Dependencies.Auth.UserOfSession(r) + if err != nil || user == nil { + return tc, err + } + + // get the tokens + tc.Tokens, err = panel.Dependencies.Tokens.Tokens(r.Context(), user.User.User) + return tc, err + }) +} + +func (panel *UserPanel) tokensDeleteRoute(ctx context.Context) 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", "delete token").Msg("failed to parse form") + httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) + return + } + user, err := panel.Dependencies.Auth.UserOfSession(r) + if err != nil { + logger.Err(err).Str("action", "delete token").Msg("failed to get current user") + httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) + return + } + + token := r.PostFormValue("token") + if token == "" { + logger.Err(err).Str("action", "delete token").Msg("failed to get token") + httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) + return + } + + if err := panel.Dependencies.Tokens.Remove(r.Context(), user.User.User, token); err != nil { + logger.Err(err).Str("action", "delete token").Msg("failed to delete token") + httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) + return + } + + http.Redirect(w, r, string(menuTokens.Path), http.StatusSeeOther) + }) +} + +//go:embed "templates/tokens_add.html" +var tokensAddHTML []byte +var tokensAddTemplate = templating.ParseForm( + "tokens_add.html", tokensAddHTML, httpx.FormTemplate, + templating.Title("Add Token"), + templating.Assets(assets.AssetsUser), +) + +type addTokenResult struct { + User *auth.AuthUser + Description string + Scopes []string +} + +func (panel *UserPanel) tokensAddRoute(ctx context.Context) http.Handler { + tpl := tokensAddTemplate.Prepare( + panel.Dependencies.Templating, + templating.Crumbs( + menuUser, + menuTokens, + menuTokensAdd, + ), + ) + + return &httpx.Form[addTokenResult]{ + Fields: []field.Field{ + {Name: "description", Type: field.Text, Label: "Description"}, + }, + FieldTemplate: field.PureCSSFieldTemplate, + + RenderTemplate: tpl.Template(), + RenderTemplateContext: templating.FormTemplateContext(tpl), + + Validate: func(r *http.Request, values map[string]string) (at addTokenResult, err error) { + at.User, err = panel.Dependencies.Auth.UserOfSession(r) + if err != nil || at.User == nil { + return at, errInvalidUser + } + + at.Description = values["description"] + if at.Description == "" { + at.Description = "API Key" + } + + at.Scopes = nil + + return at, nil + }, + + RenderSuccess: func(at addTokenResult, values map[string]string, w http.ResponseWriter, r *http.Request) error { + // add the key to the user + _, err := panel.Dependencies.Tokens.Add(r.Context(), at.User.User.User, at.Description, at.Scopes) + if err != nil { + return err + } + if err != nil { + return errAddToken + } + // everything went fine, redirect the user back to the user page! + http.Redirect(w, r, string(menuTokens.Path), http.StatusSeeOther) + return nil + }, + } +} diff --git a/internal/dis/component/auth/panel/totp.go b/internal/dis/component/auth/panel/totp.go index c8b14fd..ab09fac 100644 --- a/internal/dis/component/auth/panel/totp.go +++ b/internal/dis/component/auth/panel/totp.go @@ -33,7 +33,7 @@ func (panel *UserPanel) routeTOTPEnable(ctx context.Context) http.Handler { FieldTemplate: field.PureCSSFieldTemplate, SkipForm: func(r *http.Request) (data struct{}, skip bool) { - user, err := panel.Dependencies.Auth.UserOf(r) + user, err := panel.Dependencies.Auth.UserOfSession(r) return struct{}{}, err == nil && user != nil && user.IsTOTPEnabled() }, @@ -43,7 +43,7 @@ func (panel *UserPanel) routeTOTPEnable(ctx context.Context) http.Handler { Validate: func(r *http.Request, values map[string]string) (struct{}, error) { password := values["password"] - user, err := panel.Dependencies.Auth.UserOf(r) + user, err := panel.Dependencies.Auth.UserOfSession(r) if err != nil { return struct{}{}, err } @@ -105,11 +105,11 @@ func (panel *UserPanel) routeTOTPEnroll(ctx context.Context) http.Handler { FieldTemplate: field.PureCSSFieldTemplate, SkipForm: func(r *http.Request) (data struct{}, skip bool) { - user, err := panel.Dependencies.Auth.UserOf(r) + user, err := panel.Dependencies.Auth.UserOfSession(r) return struct{}{}, err == nil && user != nil && user.IsTOTPEnabled() }, RenderTemplateContext: func(context httpx.FormContext, r *http.Request) any { - user, err := panel.Dependencies.Auth.UserOf(r) + user, err := panel.Dependencies.Auth.UserOfSession(r) ctx := totpEnrollContext{ userFormContext: userFormContext{ @@ -136,7 +136,7 @@ func (panel *UserPanel) routeTOTPEnroll(ctx context.Context) http.Handler { Validate: func(r *http.Request, values map[string]string) (struct{}, error) { password, otp := values["password"], values["otp"] - user, err := panel.Dependencies.Auth.UserOf(r) + user, err := panel.Dependencies.Auth.UserOfSession(r) if err != nil { return struct{}{}, err } @@ -184,7 +184,7 @@ func (panel *UserPanel) routeTOTPDisable(ctx context.Context) http.Handler { FieldTemplate: field.PureCSSFieldTemplate, SkipForm: func(r *http.Request) (data struct{}, skip bool) { - user, err := panel.Dependencies.Auth.UserOf(r) + user, err := panel.Dependencies.Auth.UserOfSession(r) return struct{}{}, err == nil && user != nil && !user.IsTOTPEnabled() }, RenderTemplate: tpl.Template(), @@ -193,7 +193,7 @@ func (panel *UserPanel) routeTOTPDisable(ctx context.Context) http.Handler { Validate: func(r *http.Request, values map[string]string) (struct{}, error) { password, otp := values["password"], values["otp"] - user, err := panel.Dependencies.Auth.UserOf(r) + user, err := panel.Dependencies.Auth.UserOfSession(r) if err != nil { return struct{}{}, err } diff --git a/internal/dis/component/auth/panel/user.go b/internal/dis/component/auth/panel/user.go index c2fe8f0..26f2d1b 100644 --- a/internal/dis/component/auth/panel/user.go +++ b/internal/dis/component/auth/panel/user.go @@ -51,12 +51,13 @@ func (panel *UserPanel) routeUser(ctx context.Context) http.Handler { menuChangePassword, menuTOTPAction, menuSSH, + menuTokens, ), ) return tpl.HTMLHandlerWithFlags(func(r *http.Request) (uc userContext, funcs []templating.FlagFunc, err error) { // find the user - uc.AuthUser, err = panel.Dependencies.Auth.UserOf(r) + uc.AuthUser, err = panel.Dependencies.Auth.UserOfSession(r) if err != nil || uc.AuthUser == nil { return uc, nil, err } diff --git a/internal/dis/component/auth/protect.go b/internal/dis/component/auth/protect.go index 8fd5e06..f80d04a 100644 --- a/internal/dis/component/auth/protect.go +++ b/internal/dis/component/auth/protect.go @@ -9,22 +9,30 @@ import ( "github.com/tkw1536/pkglib/httpx" ) -// Protect returns a new handler which requires a user to be logged in and have the provided scope and +// Protect returns a new handler which requires a user to be logged in and have the provided scope. +// +// AllowToken determines if a token is allowed instead of a user session. // // If an unauthenticated user attempts to access the returned handler, they are redirected to the login endpoint. // If an authenticated user is missing the given scope, a Forbidden response is called. // If an authenticated calls the endpoint, and they have the given permissions, the original handler is called. -func (auth *Auth) Protect(handler http.Handler, scope component.Scope, param func(*http.Request) string) http.Handler { +func (auth *Auth) Protect(handler http.Handler, AllowToken bool, scope component.Scope, param func(*http.Request) string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var forbiddenMessage string var paramValue string // load the user in the session - user, err := auth.UserOf(r) + // TODO: Check if API access is allowed + user, token, err := auth.UserOf(r) if err != nil { goto err } + // token was set, but not allowed! + if token && !AllowToken { + goto forbidden + } + // if there is no user in the session, they need to login first! if user == nil { // we can't redirect anything other than GET @@ -75,9 +83,10 @@ func (auth *Auth) Protect(handler http.Handler, scope component.Scope, param fun }) } -// Require returns a slice containing one decorator that acts like Protect(scope,param) on every request. -func (auth *Auth) Require(scope component.Scope, param func(*http.Request) string) func(http.Handler) http.Handler { +// Require returns a slice containing one decorator that acts like auth.Protect(allowToken,scope,param) on every request. +func (auth *Auth) Require(allowToken bool, scope component.Scope, param func(*http.Request) string) func(http.Handler) http.Handler { + // TODO: Work on this stuff return func(h http.Handler) http.Handler { - return auth.Protect(h, scope, param) + return auth.Protect(h, allowToken, scope, param) } } diff --git a/internal/dis/component/auth/scope.go b/internal/dis/component/auth/scope.go index cbc423e..98900a5 100644 --- a/internal/dis/component/auth/scope.go +++ b/internal/dis/component/auth/scope.go @@ -13,46 +13,62 @@ var ( ErrNoParam = errors.New("scope does not take parameter") ) -// CheckScope checks if the given session has the given scope. -// If the user is denied a scope, the error will be of type AccessDeniedError. -func (auth *Auth) CheckScope(param string, scope component.Scope, r *http.Request) error { - // get all the infos about all of the scopes - infos := auth.scopeInfos.Get(func() []component.ScopeInfo { - infos := make([]component.ScopeInfo, len(auth.Dependencies.ScopeProviders)) - for i, p := range auth.Dependencies.ScopeProviders { - infos[i] = p.Scope() +// Scopes returns a map of all available scopes +func (auth *Auth) Scopes() map[component.Scope]component.ScopeInfo { + scopes := auth.getScopeMap() + mp := make(map[component.Scope]component.ScopeInfo, len(scopes)) + for scope, entry := range scopes { + mp[scope] = entry.Info + } + return mp +} + +// getScopeMap return a (cached version of) all scopes +func (auth *Auth) getScopeMap() map[component.Scope]scopeMapEntry { + return auth.scopeMap.Get(func() map[component.Scope]scopeMapEntry { + mp := make(map[component.Scope]scopeMapEntry, len(auth.Dependencies.ScopeProviders)) + for _, p := range auth.Dependencies.ScopeProviders { + info := p.Scope() + mp[info.Scope] = scopeMapEntry{ + Provider: p, + Info: info, + } } - return infos + return mp }) +} - // find where in teh list of parameters it is! - index, ok := auth.scopeIndex.Get(func() map[component.Scope]int { - m := make(map[component.Scope]int, len(infos)) - for idx, i := range infos { - m[i.Scope] = idx - } - return m - })[scope] +// CheckScope checks if the given request is associated with the given request. +// A request can be one of two types: +// - A signed in user with an implicitly associated set of scopes +// - A session authorized with a token only +// If the request is denied a scope, the error will be of type AccessDeniedError. +func (auth *Auth) CheckScope(param string, scope component.Scope, r *http.Request) error { + // the empty scope is always permitted implicitly + if scope == "" { + return nil + } + entry, ok := auth.getScopeMap()[scope] if !ok { return ErrUnknownScope } // check that we take a parameter - if infos[index].TakesParam && param == "" { + if entry.Info.TakesParam && param == "" { return ErrParamRequired } - if !infos[index].TakesParam && param != "" { + if !entry.Info.TakesParam && param != "" { return ErrNoParam } // call the checker and return an error - ok, err := auth.Dependencies.ScopeProviders[index].HasScope(param, r) + ok, err := entry.Provider.HasScope(param, r) if err != nil { - return infos[index].CheckError(err) + return entry.Info.CheckError(err) } if ok { return nil } - return infos[index].DeniedError() + return entry.Info.DeniedError() } diff --git a/internal/dis/component/auth/scopes/admin.go b/internal/dis/component/auth/scopes/admin.go index 2bf1da1..f95f968 100644 --- a/internal/dis/component/auth/scopes/admin.go +++ b/internal/dis/component/auth/scopes/admin.go @@ -32,6 +32,6 @@ func (*AdminLoggedIn) Scope() component.ScopeInfo { } func (al *AdminLoggedIn) HasScope(param string, r *http.Request) (bool, error) { - user, err := al.Dependencies.Auth.UserOf(r) + user, _, err := al.Dependencies.Auth.UserOf(r) return user != nil && user.IsAdmin() && user.IsTOTPEnabled(), err } diff --git a/internal/dis/component/auth/scopes/user.go b/internal/dis/component/auth/scopes/user.go index 97c62ec..fe22059 100644 --- a/internal/dis/component/auth/scopes/user.go +++ b/internal/dis/component/auth/scopes/user.go @@ -31,6 +31,6 @@ func (*UserLoggedIn) Scope() component.ScopeInfo { } func (iu *UserLoggedIn) HasScope(param string, r *http.Request) (bool, error) { - user, err := iu.Dependencies.Auth.UserOf(r) + user, _, err := iu.Dependencies.Auth.UserOf(r) return user != nil, err } diff --git a/internal/dis/component/auth/session.go b/internal/dis/component/auth/session.go index 8191e86..d1e1672 100644 --- a/internal/dis/component/auth/session.go +++ b/internal/dis/component/auth/session.go @@ -18,12 +18,42 @@ import ( _ "embed" ) -// UserOf returns the user logged into the given request. -// If there is no user associated with the given user, user and error will be nil. +// UserOf returns the user logged into the provided request. +// token indicates if the user used a token to authenticate, or a browser session was used. +// A token takes priority over a user in a session. +// +// If there is no user associated with the given request, user and error are nil, and token is false. +// An invalid session, expired token, or disabled user all result in user = nil. // // When no UserOf exists in the given session returns nil. -// An invalid session (for a UserOf) -func (auth *Auth) UserOf(r *http.Request) (user *AuthUser, err error) { +func (auth *Auth) UserOf(r *http.Request) (user *AuthUser, token bool, err error) { + // check the user from the token first + { + user, err := auth.UserOfToken(r) + if user != nil && err == nil { + return user, true, nil + } + } + + // fallback to using session + { + user, err := auth.UserOfSession(r) + return user, false, err + } +} + +// UserOfToken returns the user associated with the token in request. +func (auth *Auth) UserOfToken(r *http.Request) (user *AuthUser, err error) { + // get the token object + token, err := auth.Dependencies.Tokens.TokenOf(r) + if token == nil { + return nil, err + } + return auth.checkUser(r.Context(), token.User) +} + +// UserOfSession returns the user of the session associated with r. +func (auth *Auth) UserOfSession(r *http.Request) (user *AuthUser, err error) { ctx := r.Context() if user, ok := ctx.Value(ctxUserKey).(*AuthUser); ok && user != nil { return user, nil @@ -44,9 +74,12 @@ func (auth *Auth) UserOf(r *http.Request) (user *AuthUser, err error) { if !ok || nameS == "" { return nil, nil } + return auth.checkUser(ctx, nameS) +} +func (auth *Auth) checkUser(ctx context.Context, name string) (user *AuthUser, err error) { // fetch the user, check if they still exist - user, err = auth.User(ctx, nameS) + user, err = auth.User(ctx, name) if err == ErrUserNotFound { return nil, nil } @@ -55,7 +88,7 @@ func (auth *Auth) UserOf(r *http.Request) (user *AuthUser, err error) { } // user isn't enabled - if !user.IsEnabled() { + if user == nil || !user.IsEnabled() { return nil, nil } @@ -73,7 +106,7 @@ func (auth *Auth) session(r *http.Request) (*sessions.Session, error) { func (auth *Auth) Menu(r *http.Request) []component.MenuItem { - user, err := auth.UserOf(r) + user, err := auth.UserOfSession(r) if user == nil || err != nil { return nil } @@ -176,7 +209,7 @@ func (auth *Auth) authLogin(ctx context.Context) http.Handler { }, SkipForm: func(r *http.Request) (user *AuthUser, skip bool) { - user, err := auth.UserOf(r) + user, err := auth.UserOfSession(r) return user, err == nil && user != nil }, diff --git a/internal/dis/component/auth/tokens/check.go b/internal/dis/component/auth/tokens/check.go new file mode 100644 index 0000000..918dcd2 --- /dev/null +++ b/internal/dis/component/auth/tokens/check.go @@ -0,0 +1,90 @@ +package tokens + +import ( + "errors" + "net/http" + "strings" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/models" + "golang.org/x/exp/slices" +) + +const ( + authHeader = "Authorization" // authorization + authBearer = "Bearer" + " " // Prefix for bearer +) + +// TokenOf returns the token header found in the given request. +// If r is nil, or there is no token, returns nil. +// Error is only set if there is an error accessing the table that stores tokens. +func (tok *Tokens) TokenOf(r *http.Request) (*models.Token, error) { + if r == nil { + return nil, nil + } + + // make sure that the authorization header exists and starts with the bearer + auth := r.Header.Get(authHeader) + if !strings.HasPrefix(auth, authBearer) { + return nil, nil + } + + // get the token + id := strings.TrimSpace(strings.TrimPrefix(auth, authBearer)) + if id == "" { + return nil, nil + } + + table, err := tok.table(r.Context()) + if err != nil { + return nil, err + } + + // take a single object from the tokens + var tokenObj models.Token + res := table.Where(&models.Token{Token: id}).Find(&tokenObj) + + if res.Error != nil { + return nil, errors.Join(ErrNoToken, res.Error) + } + if res.RowsAffected == 0 { + return nil, nil + } + + // and return the token object + return &tokenObj, nil +} + +var ErrNoToken = errors.New("no token") + +// Check checks if there is a token in the given request and if this request has an appropriate token with the appropriate scope. +// +// If the token is found and has the requested token, returns true, nil. +// If there is a token found, but the specific scope is not set, returns false, nil. +// If there is no valid authentication token found, returns false and an error that wraps ErrNoToken. +// In other cases, other errors may be returned. +// +// Note that the scope may require an parameter to be validated. +// This validation should take place in the appropriate ScopeProvider; which should recursively invoke this method. +func (tok *Tokens) Check(r *http.Request, scope component.Scope) (bool, error) { + // get the token object from the request + tokenObj, err := tok.TokenOf(r) + if tokenObj == nil { + if err == nil { + return false, ErrNoToken + } + return false, errors.Join(ErrNoToken, err) + } + + // TODO: Do we need this function? + + // get the scopes + scopes := tokenObj.GetScopes() + if scopes == nil { + // all scopes (implicitly) + return true, nil + } + + // else check if they are contained + return slices.Contains(scopes, string(scope)), nil +} diff --git a/internal/dis/component/auth/tokens/tokens.go b/internal/dis/component/auth/tokens/tokens.go new file mode 100644 index 0000000..b77f8e2 --- /dev/null +++ b/internal/dis/component/auth/tokens/tokens.go @@ -0,0 +1,148 @@ +package tokens + +import ( + "context" + "crypto/rand" + "strings" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql" + "github.com/FAU-CDI/wisski-distillery/internal/models" + "github.com/tkw1536/pkglib/password" + "github.com/tkw1536/pkglib/reflectx" + "gorm.io/gorm" +) + +// Tokens implements Tokens +type Tokens struct { + component.Base + + Dependencies struct { + SQL *sql.SQL + } +} + +var ( + _ component.UserDeleteHook = (*Tokens)(nil) + _ component.Table = (*Tokens)(nil) +) + +func (tok *Tokens) TableInfo() component.TableInfo { + return component.TableInfo{ + Name: models.TokensTable, + Model: reflectx.MakeType[models.Token](), + } +} + +func (tok *Tokens) table(ctx context.Context) (*gorm.DB, error) { + return tok.Dependencies.SQL.QueryTable(ctx, tok) +} + +func (tok *Tokens) OnUserDelete(ctx context.Context, user *models.User) error { + table, err := tok.table(ctx) + if err != nil { + return err + } + return table.Delete(&models.Token{}, &models.Token{User: user.User}).Error +} + +// Tokens returns a list of tokens for the given user +func (tok *Tokens) Tokens(ctx context.Context, user string) ([]models.Token, error) { + // the empty user has no tokens + if user == "" { + return nil, nil + } + + // get the table + table, err := tok.table(ctx) + if err != nil { + return nil, err + } + + var tokens []models.Token + + // make a query to find all keys (in the underlying model) + query := table.Find(&tokens, &models.Token{User: user}) + if query.Error != nil { + return nil, query.Error + } + + return tokens, nil +} + +const ( + tokenGroupLength = 8 + tokenGroupCount = 8 + tokenSeparator = "-" + tokenCharset password.Charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +) + +// NewToken generates a new token +func NewToken() (string, error) { + // generate a new password + token, err := password.Generate(rand.Reader, tokenGroupCount*tokenGroupLength, tokenCharset) + if err != nil { + return "", err + } + + // insert the token group separators + var result strings.Builder + result.Grow(len(token) + (tokenGroupCount-1)*len(tokenSeparator)) + + for i := 0; i < tokenGroupCount; i++ { + if i != 0 { + result.WriteString(tokenSeparator) + } + + start := i * tokenGroupLength + result.WriteString(token[start : start+tokenGroupLength]) + } + + return result.String(), nil + +} + +// Add adds a new token, unless it already exists. +// The token is granted scopes with .SetScopes(scopes). +func (tok *Tokens) Add(ctx context.Context, user string, description string, scopes []string) (*models.Token, error) { + + // create a new token and set the scopes + mk := models.Token{ + User: user, + Description: description, + } + mk.SetScopes(scopes) + + // generate a new random password + var err error + mk.Token, err = NewToken() + if err != nil { + return nil, err + } + + // get the table + table, err := tok.table(ctx) + if err != nil { + return nil, err + } + + // create the token instance + if err := table.Create(&mk).Error; err != nil { + return nil, err + } + + // and return + return &mk, nil +} + +// Remove removes a token with the given token from the user +func (tok *Tokens) Remove(ctx context.Context, user, token string) error { + // get the table + table, err := tok.table(ctx) + if err != nil { + return err + } + + // and do the delete + return table.Where("user = ? AND token = ?", user, token).Delete(&models.Token{}).Error +} diff --git a/internal/dis/component/auth/user.go b/internal/dis/component/auth/user.go index f337b15..11c4dfe 100644 --- a/internal/dis/component/auth/user.go +++ b/internal/dis/component/auth/user.go @@ -25,7 +25,7 @@ var ErrUserNotFound = errors.New("user not found") func (auth *Auth) TableInfo() component.TableInfo { return component.TableInfo{ Name: models.UserTable, - Model: reflectx.TypeOf[models.User](), + Model: reflectx.MakeType[models.User](), } } diff --git a/internal/dis/component/server/admin/admin.go b/internal/dis/component/server/admin/admin.go index ece8512..46ecee4 100644 --- a/internal/dis/component/server/admin/admin.go +++ b/internal/dis/component/server/admin/admin.go @@ -47,7 +47,7 @@ func (admin *Admin) Routes() component.Routes { return component.Routes{ Prefix: "/admin/", CSRF: true, - Decorator: admin.Dependencies.Auth.Require(scopes.ScopeAdminLoggedIn, nil), + Decorator: admin.Dependencies.Auth.Require(false, scopes.ScopeAdminLoggedIn, nil), } } diff --git a/internal/dis/component/server/admin/users.go b/internal/dis/component/server/admin/users.go index 016662f..3c96826 100644 --- a/internal/dis/component/server/admin/users.go +++ b/internal/dis/component/server/admin/users.go @@ -158,7 +158,7 @@ func (admin *Admin) useraction(ctx context.Context, name string, action func(r * return } - me, err := admin.Dependencies.Auth.UserOf(r) + me, err := admin.Dependencies.Auth.UserOfSession(r) if err != nil { logger.Err(err).Str("action", name).Msg("failed to get current user") httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r) diff --git a/internal/dis/component/server/list/list.go b/internal/dis/component/server/list/list.go index 45962b9..571fff2 100644 --- a/internal/dis/component/server/list/list.go +++ b/internal/dis/component/server/list/list.go @@ -42,7 +42,7 @@ func (li *ListInstances) ShouldShowList(r *http.Request) bool { return allowPrivate } - user, _ := li.Dependencies.Auth.UserOf(r) + user, _, _ := li.Dependencies.Auth.UserOf(r) if user == nil { return allowPublic } else { diff --git a/internal/dis/distillery.go b/internal/dis/distillery.go index 72af7ee..0b9e76d 100644 --- a/internal/dis/distillery.go +++ b/internal/dis/distillery.go @@ -8,10 +8,12 @@ import ( "github.com/FAU-CDI/wisski-distillery/internal/dis/component" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/api" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/next" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/panel" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/policy" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/tokens" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/binder" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/docker" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter" @@ -145,6 +147,7 @@ func (dis *Distillery) allComponents() []initFunc { auto[*policy.Policy], auto[*panel.UserPanel], auto[*next.Next], + auto[*tokens.Tokens], //scopes auto[*scopes.UserLoggedIn], @@ -196,6 +199,8 @@ func (dis *Distillery) allComponents() []initFunc { auto[*cron.Cron], // API + auto[*api.API], + auto[*list.API], auto[*list.API], auto[*news.API], } diff --git a/internal/models/token.go b/internal/models/token.go new file mode 100644 index 0000000..9c50b22 --- /dev/null +++ b/internal/models/token.go @@ -0,0 +1,50 @@ +package models + +import ( + "encoding/json" +) + +// TokensTable is the name of the table the 'Token' model is stored in. +const TokensTable = "tokens" + +// Token represents an access token for a specific user +type Token struct { + Pk uint `gorm:"column:pk;primaryKey"` + + Token string `gorm:"column:token;unique:true;not null"` + User string `gorm:"column:user;not null"` // (distillery) username + + Description string `gorm:"column:description"` + + AllScopes bool `gorm:"column:all;not null"` + Scopes []byte `gorm:"column:scopes;not null"` // comma-seperated list of scopes +} + +// GetScopes returns the scopes associated with this Token. +// +// If this token implicitly has all scopes, returns nil. +// If this token has no scopes, returns an empty string slice. +func (token *Token) GetScopes() (scopes []string) { + // all scopes + if token.AllScopes { + return nil + } + + // unmarshal the scopes associated with this token + // and ensure that it is never nil. + err := json.Unmarshal(token.Scopes, &scopes) + if scopes == nil || err != nil { + scopes = []string{} + } + return +} + +// SetScopes sets the scopes associated to this token to scopes. +// It scopes is nil, sets the token to permit all scopes. +func (token *Token) SetScopes(scopes []string) { + token.AllScopes = scopes == nil + if token.AllScopes { + scopes = []string{} + } + token.Scopes, _ = json.Marshal(scopes) +}