WIPL tokens

This commit is contained in:
Tom 2023-06-06 18:26:53 +02:00
parent c09c729157
commit 161e08fe1f
25 changed files with 716 additions and 63 deletions

View file

@ -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
}

View file

@ -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 (

View file

@ -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
}

View file

@ -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...)

View file

@ -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
}

View file

@ -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
}

View file

@ -0,0 +1,56 @@
<div class="pure-u-1">
<p>
This page allows you to add, view and remove tokens from your distillery account.
</p>
</div>
<div class="pure-u-1">
<h2>My Tokens</h2>
<p>
This table shows tokens currently associated with your account.
Tokens can be used to access the API programatically.
</p>
<div class="padding">
<div class="overflow">
<table class="pure-table pure-table-bordered">
<thead>
<tr>
<th>
Token
</th>
<th>
Description
</th>
<th>
Actions
</th>
</tr>
</thead>
<tbody>
{{ $csrf := .CSRF }}
{{ range .Tokens }}
<tr>
<td>
<code class="copy">{{ .Token }}</code>
</td>
<td>
{{ .Description }}
</td>
<td>
<div class="pure-button-group" role="group">
<form action="/user/tokens/delete" method="POST" class="pure-form-group">
<input type="hidden" name="token" value="{{ .Token }}">
<input type="submit" class="pure-button pure-button-danger" value="Delete">
{{ $csrf }}
</form>
</div>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>
</div>
</div>

View file

@ -0,0 +1,9 @@
{{ template "form.html" . }}
{{ define "form/button" }}Add{{ end }}
{{ define "form/inside" }}
<div>
<p>
Use this form to add a new <em>Token</em> to your account.
</p>
</div>
{{ end }}

View file

@ -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
},
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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<tokens>: 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)
}
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
},

View file

@ -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
}

View file

@ -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
}

View file

@ -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](),
}
}

View file

@ -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),
}
}

View file

@ -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)

View file

@ -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 {

View file

@ -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],
}