From 064ae2f564cd892b9ba3d234ba22f08c6197e8cb Mon Sep 17 00:00:00 2001 From: Tom Date: Wed, 3 May 2023 14:21:58 +0200 Subject: [PATCH] Implement scopes --- go.mod | 2 +- go.sum | 4 +- internal/dis/component.go | 1 + internal/dis/component/auth/auth.go | 4 + internal/dis/component/auth/next/next.go | 2 +- internal/dis/component/auth/panel/panel.go | 4 +- internal/dis/component/auth/permission.go | 88 -------------------- internal/dis/component/auth/protect.go | 61 +++++--------- internal/dis/component/auth/scope.go | 58 +++++++++++++ internal/dis/component/auth/scopes/admin.go | 33 ++++++++ internal/dis/component/auth/scopes/user.go | 32 +++++++ internal/dis/component/resolver/resolver.go | 3 +- internal/dis/component/scope.go | 68 +++++++++++++++ internal/dis/component/server/admin/admin.go | 4 +- internal/dis/distillery.go | 5 ++ 15 files changed, 232 insertions(+), 137 deletions(-) delete mode 100644 internal/dis/component/auth/permission.go create mode 100644 internal/dis/component/auth/scope.go create mode 100644 internal/dis/component/auth/scopes/admin.go create mode 100644 internal/dis/component/auth/scopes/user.go create mode 100644 internal/dis/component/scope.go diff --git a/go.mod b/go.mod index 1a816bf..e6471b5 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/pquerna/otp v1.4.0 github.com/rs/zerolog v1.29.1 github.com/tkw1536/goprogram v0.3.5 - github.com/tkw1536/pkglib v0.0.0-20230501182828-8cb15f4fccd8 + github.com/tkw1536/pkglib v0.0.0-20230503121722-b0c615fc34ee github.com/yuin/goldmark v1.5.4 github.com/yuin/goldmark-meta v1.1.0 golang.org/x/crypto v0.8.0 diff --git a/go.sum b/go.sum index 6862686..a48c555 100644 --- a/go.sum +++ b/go.sum @@ -114,8 +114,8 @@ github.com/tdewolff/parse v2.3.4+incompatible/go.mod h1:8oBwCsVmUkgHO8M5iCzSIDtp github.com/tdewolff/test v1.0.7 h1:8Vs0142DmPFW/bQeHRP3MV19m1gvndjUb1sn8yy74LM= github.com/tkw1536/goprogram v0.3.5 h1:S0axKo3R/vGa4zhYqYDKAZEPhAfwUSSeMtVwnAu4sNY= github.com/tkw1536/goprogram v0.3.5/go.mod h1:pYr4dMHOSVurbPQ4KTR0ett8XWNISbsRS6zlh9Nsxa8= -github.com/tkw1536/pkglib v0.0.0-20230501182828-8cb15f4fccd8 h1:adL2kWKOiDeSaaZi93sII5szzpowRSz2UrCNaAv760Y= -github.com/tkw1536/pkglib v0.0.0-20230501182828-8cb15f4fccd8/go.mod h1:0A1B9Cc5+yJXR3eeB14CqD4dFSbEjjWRo5Pr9M3XYuI= +github.com/tkw1536/pkglib v0.0.0-20230503121722-b0c615fc34ee h1:MJZzhO8Fq7WVNGjWLhS4cGhWWmK92BMgzDtLSaYeFx4= +github.com/tkw1536/pkglib v0.0.0-20230503121722-b0c615fc34ee/go.mod h1:0A1B9Cc5+yJXR3eeB14CqD4dFSbEjjWRo5Pr9M3XYuI= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= diff --git a/internal/dis/component.go b/internal/dis/component.go index f0edece..3bbfa01 100644 --- a/internal/dis/component.go +++ b/internal/dis/component.go @@ -24,6 +24,7 @@ func (dis *Distillery) init() { lifetime.RegisterGroup[component.UserDeleteHook](&dis.lifetime) lifetime.RegisterGroup[component.Table](&dis.lifetime) lifetime.RegisterGroup[component.Menuable](&dis.lifetime) + lifetime.RegisterGroup[component.ScopeProvider](&dis.lifetime) }) } diff --git a/internal/dis/component/auth/auth.go b/internal/dis/component/auth/auth.go index 38604d0..99712a3 100644 --- a/internal/dis/component/auth/auth.go +++ b/internal/dis/component/auth/auth.go @@ -18,9 +18,13 @@ type Auth struct { SQL *sql.SQL UserDeleteHooks []component.UserDeleteHook Templating *templating.Templating + ScopeProviders []component.ScopeProvider } store lazy.Lazy[sessions.Store] + + scopeInfos lazy.Lazy[[]component.ScopeInfo] + scopeIndex lazy.Lazy[map[component.Scope]int] } var ( diff --git a/internal/dis/component/auth/next/next.go b/internal/dis/component/auth/next/next.go index 6e985eb..00483e4 100644 --- a/internal/dis/component/auth/next/next.go +++ b/internal/dis/component/auth/next/next.go @@ -30,7 +30,7 @@ var ( func (next *Next) Routes() component.Routes { return component.Routes{ Prefix: "/next/", - Decorator: next.Dependencies.Auth.Require(auth.User), + Decorator: next.Dependencies.Auth.Require(component.ScopeUserLoggedIn, nil), } } diff --git a/internal/dis/component/auth/panel/panel.go b/internal/dis/component/auth/panel/panel.go index ff076e4..78906ac 100644 --- a/internal/dis/component/auth/panel/panel.go +++ b/internal/dis/component/auth/panel/panel.go @@ -39,7 +39,7 @@ func (panel *UserPanel) Routes() component.Routes { return component.Routes{ Prefix: "/user/", CSRF: true, - Decorator: panel.Dependencies.Auth.Require(nil), + Decorator: panel.Dependencies.Auth.Require(component.ScopeUserLoggedIn, nil), } } @@ -115,7 +115,7 @@ func (panel *UserPanel) HandleRoute(ctx context.Context, route string) (http.Han } // ensure that the user is logged in! - return panel.Dependencies.Auth.Protect(router, nil), nil + return panel.Dependencies.Auth.Protect(router, component.ScopeUserLoggedIn, nil), nil } type userFormContext struct { diff --git a/internal/dis/component/auth/permission.go b/internal/dis/component/auth/permission.go deleted file mode 100644 index 31c504e..0000000 --- a/internal/dis/component/auth/permission.go +++ /dev/null @@ -1,88 +0,0 @@ -package auth - -import ( - "errors" - "net/http" -) - -// Permission represents a permission granted to a user. -// -// The nil permission represents any authenticated user. -type Permission func(user *AuthUser, r *http.Request) (ok Grant, err error) - -// Grant represents an object that either grants or denies access for a certain permission -type Grant interface { - isGranted() - - // Granted returns a boolean indicating if permission to the resource in question - // has been granted - Granted() bool - - // Denied returns a string containing an error message to display to the user when permission is denied. - // When Granted() returns true, the behaviour is undefined. - Denied() string -} - -// Bool2Grant returns a new grant that returns granted for the given boolean, and message as the denied message. -func Bool2Grant(granted bool, message string) Grant { - if granted { - return grantAllow{} - } - return grantDeny(message) -} - -type grantAllow struct{} - -func (grantAllow) isGranted() {} -func (grantAllow) Granted() bool { return true } -func (grantAllow) Denied() string { return "" } - -type grantDeny string - -func (grantDeny) isGranted() {} -func (g grantDeny) Granted() bool { return false } -func (g grantDeny) Denied() string { - if g == "" { - return "Forbidden" - } - return string(g) -} - -// AllPermissions returns a new permission that checks if all the given permissions are set -func AllPermissions(clauses ...Permission) Permission { - return func(user *AuthUser, r *http.Request) (ok Grant, err error) { - for _, clause := range clauses { - perm, err := clause.Permit(user, r) - if err != nil { - return perm, err - } - if !perm.Granted() { - return perm, nil - } - } - - // everything was fine - return grantAllow{}, nil - } -} - -var errPermissionPanic = errors.New("permission: `panic()'") - -// Permit checks if the given user has this permission. -func (perm Permission) Permit(user *AuthUser, r *http.Request) (ok Grant, err error) { - // if there is no permission, then we just check if there is some user - if perm == nil { - return Bool2Grant(user != nil, ""), nil - } - - // recover any panic()ed permission call - // to prevent the handler from panic()ing - defer func() { - if p := recover(); p != nil { - ok = Bool2Grant(false, "unknown error") - err = errPermissionPanic - } - }() - - return perm(user, r) -} diff --git a/internal/dis/component/auth/protect.go b/internal/dis/component/auth/protect.go index 8f64c3b..8fd5e06 100644 --- a/internal/dis/component/auth/protect.go +++ b/internal/dis/component/auth/protect.go @@ -5,17 +5,19 @@ import ( "net/http" "net/url" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" "github.com/tkw1536/pkglib/httpx" ) -// Protect returns a new handler which requires a user to be logged in and pass the perm function. +// Protect returns a new handler which requires a user to be logged in and have the provided scope and // // If an unauthenticated user attempts to access the returned handler, they are redirected to the login endpoint. -// If an authenticated user is missing permissions, 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. -func (auth *Auth) Protect(handler http.Handler, perm Permission) http.Handler { +func (auth *Auth) Protect(handler http.Handler, scope component.Scope, param func(*http.Request) string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var grant Grant + var forbiddenMessage string + var paramValue string // load the user in the session user, err := auth.UserOf(r) @@ -38,16 +40,21 @@ func (auth *Auth) Protect(handler http.Handler, perm Permission) http.Handler { return } + // check if we need to load the parameter + if param != nil { + paramValue = param(r) + } + + // check if we have the actual scope { - var err error - // call the permission check - grant, err = perm.Permit(user, r) + err = auth.CheckScope(paramValue, scope, r) + if ade, ok := err.(component.AccessDeniedError); ok { + forbiddenMessage = ade.Error() + goto forbidden + } if err != nil { goto err } - if !grant.Granted() { - goto forbidden - } } // store the user into the session, and then return the new session @@ -56,14 +63,10 @@ func (auth *Auth) Protect(handler http.Handler, perm Permission) http.Handler { return forbidden: { - message := "Forbidden" - if grant != nil { - message = grant.Denied() - } httpx.Response{ ContentType: "text/plain", StatusCode: http.StatusForbidden, - Body: []byte(message), + Body: []byte(forbiddenMessage), }.ServeHTTP(w, r) return } @@ -72,31 +75,9 @@ func (auth *Auth) Protect(handler http.Handler, perm Permission) http.Handler { }) } -// Require returns a slice containing one decorator that acts like Protect(perm) on every request. -// It returns -func (auth *Auth) Require(perm Permission) func(http.Handler) http.Handler { +// 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 { return func(h http.Handler) http.Handler { - return auth.Protect(h, perm) + return auth.Protect(h, scope, param) } } - -// Has checks if the given request has the given permission. -// If an error occurs, returns false. -func (auth *Auth) Has(perm Permission, r *http.Request) bool { - user, err := auth.UserOf(r) - if err != nil || user == nil { - return false - } - ok, err := perm.Permit(user, r) - return err == nil && ok.Granted() -} - -// Admin represents a permission that checks if a user is an administrator and has totp enabled. -var Admin Permission = func(user *AuthUser, r *http.Request) (ok Grant, err error) { - return Bool2Grant(user != nil && user.IsAdmin() && user.IsTOTPEnabled(), "user needs to have admin permissions and passcode enabled"), nil -} - -// User represents a permission that checks if a user is enabled -var User Permission = func(user *AuthUser, r *http.Request) (ok Grant, err error) { - return Bool2Grant(user != nil && user.IsEnabled(), "user needs to be enabled"), nil -} diff --git a/internal/dis/component/auth/scope.go b/internal/dis/component/auth/scope.go new file mode 100644 index 0000000..cbc423e --- /dev/null +++ b/internal/dis/component/auth/scope.go @@ -0,0 +1,58 @@ +package auth + +import ( + "errors" + "net/http" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" +) + +var ( + ErrUnknownScope = errors.New("unknown scope") + ErrParamRequired = errors.New("scope requires parameter") + 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() + } + return infos + }) + + // 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] + + if !ok { + return ErrUnknownScope + } + + // check that we take a parameter + if infos[index].TakesParam && param == "" { + return ErrParamRequired + } + if !infos[index].TakesParam && param != "" { + return ErrNoParam + } + + // call the checker and return an error + ok, err := auth.Dependencies.ScopeProviders[index].HasScope(param, r) + if err != nil { + return infos[index].CheckError(err) + } + if ok { + return nil + } + return infos[index].DeniedError() +} diff --git a/internal/dis/component/auth/scopes/admin.go b/internal/dis/component/auth/scopes/admin.go new file mode 100644 index 0000000..3918bc2 --- /dev/null +++ b/internal/dis/component/auth/scopes/admin.go @@ -0,0 +1,33 @@ +package scopes + +import ( + "net/http" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" +) + +type AdminLoggedIn struct { + component.Base + Dependencies struct { + Auth *auth.Auth + } +} + +var ( + _ component.ScopeProvider = (*UserLoggedIn)(nil) +) + +func (*AdminLoggedIn) Scope() component.ScopeInfo { + return component.ScopeInfo{ + Scope: component.ScopeAdminLoggedIn, + Description: "session has a signed in admin", + DeniedMessage: "user must be signed into an admin account with TOTP enabled", + TakesParam: false, + } +} + +func (al *AdminLoggedIn) HasScope(param string, r *http.Request) (bool, error) { + 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 new file mode 100644 index 0000000..6d055d5 --- /dev/null +++ b/internal/dis/component/auth/scopes/user.go @@ -0,0 +1,32 @@ +package scopes + +import ( + "net/http" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component" + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" +) + +type UserLoggedIn struct { + component.Base + Dependencies struct { + Auth *auth.Auth + } +} + +var ( + _ component.ScopeProvider = (*UserLoggedIn)(nil) +) + +func (*UserLoggedIn) Scope() component.ScopeInfo { + return component.ScopeInfo{ + Scope: component.ScopeUserLoggedIn, + Description: "session has an associated user", + TakesParam: false, + } +} + +func (iu *UserLoggedIn) HasScope(param string, r *http.Request) (bool, error) { + user, err := iu.Dependencies.Auth.UserOf(r) + return user != nil, err +} diff --git a/internal/dis/component/resolver/resolver.go b/internal/dis/component/resolver/resolver.go index 4b8d2b7..df9353d 100644 --- a/internal/dis/component/resolver/resolver.go +++ b/internal/dis/component/resolver/resolver.go @@ -101,7 +101,8 @@ func (resolver *Resolver) HandleRoute(ctx context.Context, route string) (http.H ctx := resolverContext{ IndexContext: context, } - if !resolver.Dependencies.Auth.Has(auth.User, r) { + + if resolver.Dependencies.Auth.CheckScope("", component.ScopeUserLoggedIn, r) != nil { ctx.IndexContext.Prefixes = nil } httpx.WriteHTML(tpl.Context(r, ctx), nil, t, "", w, r) diff --git a/internal/dis/component/scope.go b/internal/dis/component/scope.go new file mode 100644 index 0000000..08e2489 --- /dev/null +++ b/internal/dis/component/scope.go @@ -0,0 +1,68 @@ +package component + +import ( + "fmt" + "net/http" +) + +// Scope represents a single permit by a session to perform some action. +// Scopes consist of two parts: A general name and a specific object. +// Scopes are checked by ScopeCheckers. +type Scope string + +type ScopeInfo struct { + Scope + + // Description is a human readable description of the scope + Description string + + // error returned to a user when the permission is denied. + // defaults to "missing scope {{ name }}" + DeniedMessage string + + // TakesParam indicates if the scope accepts a parameter + TakesParam bool +} + +type CheckError struct { + Scope Scope + Err error +} + +func (ce CheckError) Unwrap() error { return ce.Err } +func (ce CheckError) Error() string { + return fmt.Sprintf("unable to check scope %q: %s", string(ce.Scope), ce.Err) +} + +type AccessDeniedError string + +func (aed AccessDeniedError) Error() string { return string(aed) } + +// DeniedError returns an AccessDeniedError that indivates the access is denied. +func (scope ScopeInfo) DeniedError() error { + if scope.DeniedMessage == "" { + return AccessDeniedError(fmt.Sprintf("missing scope %q", string(scope.Scope))) + } + return AccessDeniedError(scope.DeniedMessage) +} + +// CheckError returns a CheckError with the given underlying error. +func (scope ScopeInfo) CheckError(err error) error { + return CheckError{Scope: scope.Scope, Err: err} +} + +const ( + ScopeUserLoggedIn Scope = "login.user" + ScopeAdminLoggedIn Scope = "login.admin" +) + +// ScopeProvider is a component that can check a specific scope +type ScopeProvider interface { + Component + + // Scopes returns information about the scope + Scope() ScopeInfo + + // Check checks if the given session has access to the given scope + HasScope(param string, r *http.Request) (bool, error) +} diff --git a/internal/dis/component/server/admin/admin.go b/internal/dis/component/server/admin/admin.go index 6f23ee8..14e1c00 100644 --- a/internal/dis/component/server/admin/admin.go +++ b/internal/dis/component/server/admin/admin.go @@ -46,12 +46,12 @@ func (admin *Admin) Routes() component.Routes { return component.Routes{ Prefix: "/admin/", CSRF: true, - Decorator: admin.Dependencies.Auth.Require(auth.Admin), + Decorator: admin.Dependencies.Auth.Require(component.ScopeAdminLoggedIn, nil), } } func (admin *Admin) Menu(r *http.Request) []component.MenuItem { - if !admin.Dependencies.Auth.Has(auth.Admin, r) { + if admin.Dependencies.Auth.CheckScope("", component.ScopeAdminLoggedIn, r) != nil { return nil } return []component.MenuItem{ diff --git a/internal/dis/distillery.go b/internal/dis/distillery.go index 179b5ec..6bf5606 100644 --- a/internal/dis/distillery.go +++ b/internal/dis/distillery.go @@ -11,6 +11,7 @@ import ( "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/binder" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/docker" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/exporter" @@ -144,6 +145,10 @@ func (dis *Distillery) allComponents() []initFunc { auto[*panel.UserPanel], auto[*next.Next], + //scopes + auto[*scopes.UserLoggedIn], + auto[*scopes.AdminLoggedIn], + // instances auto[*instances.Instances], auto[*meta.Meta],