WIPL tokens
This commit is contained in:
parent
c09c729157
commit
161e08fe1f
25 changed files with 716 additions and 63 deletions
1
API.md
1
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.
|
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`.
|
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/systems`: Returns a (publically visible) list of systems
|
||||||
- `/api/v1/news`: Returns JSON containing all news items
|
- `/api/v1/news`: Returns JSON containing all news items
|
||||||
54
internal/dis/component/auth/api/api.go
Normal file
54
internal/dis/component/auth/api/api.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
"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/server/templating"
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql"
|
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/sql"
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
|
|
@ -19,12 +20,17 @@ type Auth struct {
|
||||||
UserDeleteHooks []component.UserDeleteHook
|
UserDeleteHooks []component.UserDeleteHook
|
||||||
Templating *templating.Templating
|
Templating *templating.Templating
|
||||||
ScopeProviders []component.ScopeProvider
|
ScopeProviders []component.ScopeProvider
|
||||||
|
Tokens *tokens.Tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
store lazy.Lazy[sessions.Store]
|
store lazy.Lazy[sessions.Store]
|
||||||
|
|
||||||
scopeInfos lazy.Lazy[[]component.ScopeInfo]
|
scopeMap lazy.Lazy[map[component.Scope]scopeMapEntry]
|
||||||
scopeIndex lazy.Lazy[map[component.Scope]int]
|
}
|
||||||
|
|
||||||
|
type scopeMapEntry struct {
|
||||||
|
Provider component.ScopeProvider
|
||||||
|
Info component.ScopeInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ var (
|
||||||
func (next *Next) Routes() component.Routes {
|
func (next *Next) Routes() component.Routes {
|
||||||
return component.Routes{
|
return component.Routes{
|
||||||
Prefix: "/next/",
|
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
|
// get the user
|
||||||
user, err := next.Dependencies.Auth.UserOf(r)
|
user, _, err := next.Dependencies.Auth.UserOf(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", 0, err
|
return "", 0, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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/next"
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/policy"
|
"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/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/instances"
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templating"
|
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/server/templating"
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2"
|
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/ssh2"
|
||||||
|
|
@ -24,6 +25,7 @@ type UserPanel struct {
|
||||||
Auth *auth.Auth
|
Auth *auth.Auth
|
||||||
Templating *templating.Templating
|
Templating *templating.Templating
|
||||||
Policy *policy.Policy
|
Policy *policy.Policy
|
||||||
|
Tokens *tokens.Tokens
|
||||||
Instances *instances.Instances
|
Instances *instances.Instances
|
||||||
Next *next.Next
|
Next *next.Next
|
||||||
Keys *sshkeys.SSHKeys
|
Keys *sshkeys.SSHKeys
|
||||||
|
|
@ -40,14 +42,14 @@ func (panel *UserPanel) Routes() component.Routes {
|
||||||
return component.Routes{
|
return component.Routes{
|
||||||
Prefix: "/user/",
|
Prefix: "/user/",
|
||||||
CSRF: true,
|
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 {
|
func (panel *UserPanel) Menu(r *http.Request) []component.MenuItem {
|
||||||
title := "Login"
|
title := "Login"
|
||||||
|
|
||||||
user, err := panel.Dependencies.Auth.UserOf(r)
|
user, err := panel.Dependencies.Auth.UserOfSession(r)
|
||||||
if user != nil && err == nil {
|
if user != nil && err == nil {
|
||||||
title = user.User.User
|
title = user.User.User
|
||||||
}
|
}
|
||||||
|
|
@ -62,6 +64,9 @@ var (
|
||||||
menuSSH = component.MenuItem{Title: "SSH Keys", Path: "/user/ssh/"}
|
menuSSH = component.MenuItem{Title: "SSH Keys", Path: "/user/ssh/"}
|
||||||
menuSSHAdd = component.MenuItem{Title: "Add New Key", Path: "/user/ssh/add/"}
|
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()
|
menuTOTPAction = component.DummyMenuItem()
|
||||||
menuTOTPDisable = component.MenuItem{Title: "Disable Passcode (TOTP)", Path: "/user/totp/disable/"}
|
menuTOTPDisable = component.MenuItem{Title: "Disable Passcode (TOTP)", Path: "/user/totp/disable/"}
|
||||||
menuTOTPEnable = component.MenuItem{Title: "Enable Passcode (TOTP)", Path: "/user/totp/enable/"}
|
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)
|
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!
|
// 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 {
|
type userFormContext struct {
|
||||||
|
|
@ -137,7 +158,7 @@ func (panel *UserPanel) UserFormContext(tpl *templating.Template[userFormContext
|
||||||
|
|
||||||
return func(ctx httpx.FormContext, r *http.Request) any {
|
return func(ctx httpx.FormContext, r *http.Request) any {
|
||||||
uctx := userFormContext{FormContext: ctx}
|
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
|
uctx.User = &user.User
|
||||||
}
|
}
|
||||||
return tpl.Context(r, uctx, funcs...)
|
return tpl.Context(r, uctx, funcs...)
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ func (panel *UserPanel) routePassword(ctx context.Context) http.Handler {
|
||||||
return struct{}{}, errPasswordsNotIdentical
|
return struct{}{}, errPasswordsNotIdentical
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := panel.Dependencies.Auth.UserOf(r)
|
user, err := panel.Dependencies.Auth.UserOfSession(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return struct{}{}, err
|
return struct{}{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ func (panel *UserPanel) sshRoute(ctx context.Context) http.Handler {
|
||||||
)
|
)
|
||||||
|
|
||||||
return tpl.HTMLHandler(func(r *http.Request) (sc SSHTemplateContext, err error) {
|
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 {
|
if err != nil {
|
||||||
return sc, err
|
return sc, err
|
||||||
}
|
}
|
||||||
|
|
@ -89,6 +89,7 @@ var (
|
||||||
errInvalidUser = errors.New("invalid user")
|
errInvalidUser = errors.New("invalid user")
|
||||||
errKeyParse = errors.New("unable to parse ssh key")
|
errKeyParse = errors.New("unable to parse ssh key")
|
||||||
errAddKey = errors.New("unable to add key")
|
errAddKey = errors.New("unable to add key")
|
||||||
|
errAddToken = errors.New("unable to add token")
|
||||||
)
|
)
|
||||||
|
|
||||||
func (panel *UserPanel) sshDeleteRoute(ctx context.Context) http.Handler {
|
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)
|
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user, err := panel.Dependencies.Auth.UserOf(r)
|
user, err := panel.Dependencies.Auth.UserOfSession(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Err(err).Str("action", "delete ssh key").Msg("failed to get current user")
|
logger.Err(err).Str("action", "delete ssh key").Msg("failed to get current user")
|
||||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||||
|
|
@ -119,7 +120,7 @@ func (panel *UserPanel) sshDeleteRoute(ctx context.Context) http.Handler {
|
||||||
return
|
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),
|
RenderTemplateContext: templating.FormTemplateContext(tpl),
|
||||||
|
|
||||||
Validate: func(r *http.Request, values map[string]string) (ak addKeyResult, err error) {
|
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 {
|
if err != nil || ak.User == nil {
|
||||||
return ak, errInvalidUser
|
return ak, errInvalidUser
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
internal/dis/component/auth/panel/templates/tokens.html
Normal file
56
internal/dis/component/auth/panel/templates/tokens.html
Normal 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>
|
||||||
|
|
||||||
|
|
@ -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 }}
|
||||||
153
internal/dis/component/auth/panel/tokens.go
Normal file
153
internal/dis/component/auth/panel/tokens.go
Normal 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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,7 +33,7 @@ func (panel *UserPanel) routeTOTPEnable(ctx context.Context) http.Handler {
|
||||||
FieldTemplate: field.PureCSSFieldTemplate,
|
FieldTemplate: field.PureCSSFieldTemplate,
|
||||||
|
|
||||||
SkipForm: func(r *http.Request) (data struct{}, skip bool) {
|
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()
|
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) {
|
Validate: func(r *http.Request, values map[string]string) (struct{}, error) {
|
||||||
password := values["password"]
|
password := values["password"]
|
||||||
|
|
||||||
user, err := panel.Dependencies.Auth.UserOf(r)
|
user, err := panel.Dependencies.Auth.UserOfSession(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return struct{}{}, err
|
return struct{}{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -105,11 +105,11 @@ func (panel *UserPanel) routeTOTPEnroll(ctx context.Context) http.Handler {
|
||||||
FieldTemplate: field.PureCSSFieldTemplate,
|
FieldTemplate: field.PureCSSFieldTemplate,
|
||||||
|
|
||||||
SkipForm: func(r *http.Request) (data struct{}, skip bool) {
|
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()
|
return struct{}{}, err == nil && user != nil && user.IsTOTPEnabled()
|
||||||
},
|
},
|
||||||
RenderTemplateContext: func(context httpx.FormContext, r *http.Request) any {
|
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{
|
ctx := totpEnrollContext{
|
||||||
userFormContext: userFormContext{
|
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) {
|
Validate: func(r *http.Request, values map[string]string) (struct{}, error) {
|
||||||
password, otp := values["password"], values["otp"]
|
password, otp := values["password"], values["otp"]
|
||||||
|
|
||||||
user, err := panel.Dependencies.Auth.UserOf(r)
|
user, err := panel.Dependencies.Auth.UserOfSession(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return struct{}{}, err
|
return struct{}{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -184,7 +184,7 @@ func (panel *UserPanel) routeTOTPDisable(ctx context.Context) http.Handler {
|
||||||
FieldTemplate: field.PureCSSFieldTemplate,
|
FieldTemplate: field.PureCSSFieldTemplate,
|
||||||
|
|
||||||
SkipForm: func(r *http.Request) (data struct{}, skip bool) {
|
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()
|
return struct{}{}, err == nil && user != nil && !user.IsTOTPEnabled()
|
||||||
},
|
},
|
||||||
RenderTemplate: tpl.Template(),
|
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) {
|
Validate: func(r *http.Request, values map[string]string) (struct{}, error) {
|
||||||
password, otp := values["password"], values["otp"]
|
password, otp := values["password"], values["otp"]
|
||||||
|
|
||||||
user, err := panel.Dependencies.Auth.UserOf(r)
|
user, err := panel.Dependencies.Auth.UserOfSession(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return struct{}{}, err
|
return struct{}{}, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,13 @@ func (panel *UserPanel) routeUser(ctx context.Context) http.Handler {
|
||||||
menuChangePassword,
|
menuChangePassword,
|
||||||
menuTOTPAction,
|
menuTOTPAction,
|
||||||
menuSSH,
|
menuSSH,
|
||||||
|
menuTokens,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
return tpl.HTMLHandlerWithFlags(func(r *http.Request) (uc userContext, funcs []templating.FlagFunc, err error) {
|
return tpl.HTMLHandlerWithFlags(func(r *http.Request) (uc userContext, funcs []templating.FlagFunc, err error) {
|
||||||
// find the user
|
// 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 {
|
if err != nil || uc.AuthUser == nil {
|
||||||
return uc, nil, err
|
return uc, nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,22 +9,30 @@ import (
|
||||||
"github.com/tkw1536/pkglib/httpx"
|
"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 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 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.
|
// 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) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
var forbiddenMessage string
|
var forbiddenMessage string
|
||||||
var paramValue string
|
var paramValue string
|
||||||
|
|
||||||
// load the user in the session
|
// 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 {
|
if err != nil {
|
||||||
goto err
|
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 there is no user in the session, they need to login first!
|
||||||
if user == nil {
|
if user == nil {
|
||||||
// we can't redirect anything other than GET
|
// 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.
|
// Require returns a slice containing one decorator that acts like auth.Protect(allowToken,scope,param) on every request.
|
||||||
func (auth *Auth) Require(scope component.Scope, param func(*http.Request) string) func(http.Handler) http.Handler {
|
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 func(h http.Handler) http.Handler {
|
||||||
return auth.Protect(h, scope, param)
|
return auth.Protect(h, allowToken, scope, param)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,46 +13,62 @@ var (
|
||||||
ErrNoParam = errors.New("scope does not take parameter")
|
ErrNoParam = errors.New("scope does not take parameter")
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckScope checks if the given session has the given scope.
|
// Scopes returns a map of all available scopes
|
||||||
// If the user is denied a scope, the error will be of type AccessDeniedError.
|
func (auth *Auth) Scopes() map[component.Scope]component.ScopeInfo {
|
||||||
func (auth *Auth) CheckScope(param string, scope component.Scope, r *http.Request) error {
|
scopes := auth.getScopeMap()
|
||||||
// get all the infos about all of the scopes
|
mp := make(map[component.Scope]component.ScopeInfo, len(scopes))
|
||||||
infos := auth.scopeInfos.Get(func() []component.ScopeInfo {
|
for scope, entry := range scopes {
|
||||||
infos := make([]component.ScopeInfo, len(auth.Dependencies.ScopeProviders))
|
mp[scope] = entry.Info
|
||||||
for i, p := range auth.Dependencies.ScopeProviders {
|
}
|
||||||
infos[i] = p.Scope()
|
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!
|
// CheckScope checks if the given request is associated with the given request.
|
||||||
index, ok := auth.scopeIndex.Get(func() map[component.Scope]int {
|
// A request can be one of two types:
|
||||||
m := make(map[component.Scope]int, len(infos))
|
// - A signed in user with an implicitly associated set of scopes
|
||||||
for idx, i := range infos {
|
// - A session authorized with a token only
|
||||||
m[i.Scope] = idx
|
// 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 {
|
||||||
return m
|
// the empty scope is always permitted implicitly
|
||||||
})[scope]
|
if scope == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, ok := auth.getScopeMap()[scope]
|
||||||
if !ok {
|
if !ok {
|
||||||
return ErrUnknownScope
|
return ErrUnknownScope
|
||||||
}
|
}
|
||||||
|
|
||||||
// check that we take a parameter
|
// check that we take a parameter
|
||||||
if infos[index].TakesParam && param == "" {
|
if entry.Info.TakesParam && param == "" {
|
||||||
return ErrParamRequired
|
return ErrParamRequired
|
||||||
}
|
}
|
||||||
if !infos[index].TakesParam && param != "" {
|
if !entry.Info.TakesParam && param != "" {
|
||||||
return ErrNoParam
|
return ErrNoParam
|
||||||
}
|
}
|
||||||
|
|
||||||
// call the checker and return an error
|
// 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 {
|
if err != nil {
|
||||||
return infos[index].CheckError(err)
|
return entry.Info.CheckError(err)
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return infos[index].DeniedError()
|
return entry.Info.DeniedError()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,6 @@ func (*AdminLoggedIn) Scope() component.ScopeInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (al *AdminLoggedIn) HasScope(param string, r *http.Request) (bool, error) {
|
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
|
return user != nil && user.IsAdmin() && user.IsTOTPEnabled(), err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,6 @@ func (*UserLoggedIn) Scope() component.ScopeInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (iu *UserLoggedIn) HasScope(param string, r *http.Request) (bool, error) {
|
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
|
return user != nil, err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,42 @@ import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserOf returns the user logged into the given request.
|
// UserOf returns the user logged into the provided request.
|
||||||
// If there is no user associated with the given user, user and error will be nil.
|
// 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.
|
// 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, token bool, err error) {
|
||||||
func (auth *Auth) UserOf(r *http.Request) (user *AuthUser, 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()
|
ctx := r.Context()
|
||||||
if user, ok := ctx.Value(ctxUserKey).(*AuthUser); ok && user != nil {
|
if user, ok := ctx.Value(ctxUserKey).(*AuthUser); ok && user != nil {
|
||||||
return user, nil
|
return user, nil
|
||||||
|
|
@ -44,9 +74,12 @@ func (auth *Auth) UserOf(r *http.Request) (user *AuthUser, err error) {
|
||||||
if !ok || nameS == "" {
|
if !ok || nameS == "" {
|
||||||
return nil, nil
|
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
|
// fetch the user, check if they still exist
|
||||||
user, err = auth.User(ctx, nameS)
|
user, err = auth.User(ctx, name)
|
||||||
if err == ErrUserNotFound {
|
if err == ErrUserNotFound {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +88,7 @@ func (auth *Auth) UserOf(r *http.Request) (user *AuthUser, err error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// user isn't enabled
|
// user isn't enabled
|
||||||
if !user.IsEnabled() {
|
if user == nil || !user.IsEnabled() {
|
||||||
return nil, nil
|
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 {
|
func (auth *Auth) Menu(r *http.Request) []component.MenuItem {
|
||||||
|
|
||||||
user, err := auth.UserOf(r)
|
user, err := auth.UserOfSession(r)
|
||||||
if user == nil || err != nil {
|
if user == nil || err != nil {
|
||||||
return 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) {
|
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
|
return user, err == nil && user != nil
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
90
internal/dis/component/auth/tokens/check.go
Normal file
90
internal/dis/component/auth/tokens/check.go
Normal 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
|
||||||
|
}
|
||||||
148
internal/dis/component/auth/tokens/tokens.go
Normal file
148
internal/dis/component/auth/tokens/tokens.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ var ErrUserNotFound = errors.New("user not found")
|
||||||
func (auth *Auth) TableInfo() component.TableInfo {
|
func (auth *Auth) TableInfo() component.TableInfo {
|
||||||
return component.TableInfo{
|
return component.TableInfo{
|
||||||
Name: models.UserTable,
|
Name: models.UserTable,
|
||||||
Model: reflectx.TypeOf[models.User](),
|
Model: reflectx.MakeType[models.User](),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ func (admin *Admin) Routes() component.Routes {
|
||||||
return component.Routes{
|
return component.Routes{
|
||||||
Prefix: "/admin/",
|
Prefix: "/admin/",
|
||||||
CSRF: true,
|
CSRF: true,
|
||||||
Decorator: admin.Dependencies.Auth.Require(scopes.ScopeAdminLoggedIn, nil),
|
Decorator: admin.Dependencies.Auth.Require(false, scopes.ScopeAdminLoggedIn, nil),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ func (admin *Admin) useraction(ctx context.Context, name string, action func(r *
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
me, err := admin.Dependencies.Auth.UserOf(r)
|
me, err := admin.Dependencies.Auth.UserOfSession(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Err(err).Str("action", name).Msg("failed to get current user")
|
logger.Err(err).Str("action", name).Msg("failed to get current user")
|
||||||
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
httpx.HTMLInterceptor.Fallback.ServeHTTP(w, r)
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ func (li *ListInstances) ShouldShowList(r *http.Request) bool {
|
||||||
return allowPrivate
|
return allowPrivate
|
||||||
}
|
}
|
||||||
|
|
||||||
user, _ := li.Dependencies.Auth.UserOf(r)
|
user, _, _ := li.Dependencies.Auth.UserOf(r)
|
||||||
if user == nil {
|
if user == nil {
|
||||||
return allowPublic
|
return allowPublic
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,12 @@ import (
|
||||||
|
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component"
|
"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"
|
||||||
|
"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/next"
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/panel"
|
"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/policy"
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth/scopes"
|
"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/binder"
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/docker"
|
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/docker"
|
||||||
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
|
"github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter"
|
||||||
|
|
@ -145,6 +147,7 @@ func (dis *Distillery) allComponents() []initFunc {
|
||||||
auto[*policy.Policy],
|
auto[*policy.Policy],
|
||||||
auto[*panel.UserPanel],
|
auto[*panel.UserPanel],
|
||||||
auto[*next.Next],
|
auto[*next.Next],
|
||||||
|
auto[*tokens.Tokens],
|
||||||
|
|
||||||
//scopes
|
//scopes
|
||||||
auto[*scopes.UserLoggedIn],
|
auto[*scopes.UserLoggedIn],
|
||||||
|
|
@ -196,6 +199,8 @@ func (dis *Distillery) allComponents() []initFunc {
|
||||||
auto[*cron.Cron],
|
auto[*cron.Cron],
|
||||||
|
|
||||||
// API
|
// API
|
||||||
|
auto[*api.API],
|
||||||
|
auto[*list.API],
|
||||||
auto[*list.API],
|
auto[*list.API],
|
||||||
auto[*news.API],
|
auto[*news.API],
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
internal/models/token.go
Normal file
50
internal/models/token.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue