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.
+
+
+
+
+
+
+
+ |
+ Token
+ |
+
+ Description
+ |
+
+ Actions
+ |
+
+
+
+ {{ $csrf := .CSRF }}
+ {{ range .Tokens }}
+
+
+ {{ .Token }}
+ |
+
+ {{ .Description }}
+ |
+
+
+
+
+ |
+
+ {{ end }}
+
+
+
+
+
+
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)
+}