mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-15 06:09:07 +02:00
feat: add ability to force email verification (#1837)
This commit is contained in:
@@ -85,6 +85,11 @@ func (m *Middleware) require(scope Scope, optional bool) gin.HandlerFunc {
|
||||
// A valid credential without the scope is forbidden even on optional routes, so a caller is never silently downgraded to anonymous
|
||||
if !principal.Scopes.Has(scope) {
|
||||
c.Abort()
|
||||
// A scope that is only withheld until the email is verified tells the user how to regain it
|
||||
if principal.WithheldUntilVerified.Has(scope) {
|
||||
_ = c.Error(apperror.EmailVerificationRequired())
|
||||
return
|
||||
}
|
||||
_ = c.Error(apperror.MissingScope(string(scope)))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -15,12 +15,13 @@ import (
|
||||
|
||||
// fakeAuthenticator accepts any request carrying its header and resolves to the configured outcome
|
||||
type fakeAuthenticator struct {
|
||||
kind PrincipalKind
|
||||
header string
|
||||
user string
|
||||
admin bool
|
||||
err error
|
||||
calls int
|
||||
kind PrincipalKind
|
||||
header string
|
||||
user string
|
||||
admin bool
|
||||
unverified bool
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (a *fakeAuthenticator) Kind() PrincipalKind {
|
||||
@@ -38,6 +39,9 @@ func (a *fakeAuthenticator) Authenticate(*gin.Context) (*Principal, error) {
|
||||
}
|
||||
|
||||
principal := &Principal{Kind: a.kind, UserID: a.user, Scopes: UserScopes(a.admin, a.kind)}
|
||||
if a.unverified {
|
||||
principal.RestrictUnverified()
|
||||
}
|
||||
if a.kind == KindSession {
|
||||
principal.AuthenticationMethod = "passkey"
|
||||
principal.AuthenticationTime = time.Unix(1700000000, 0)
|
||||
@@ -136,6 +140,31 @@ func TestMiddlewareAuthorizes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestMiddlewareExplainsScopesWithheldUntilVerified(t *testing.T) {
|
||||
session := &fakeAuthenticator{kind: KindSession, header: "X-Session", user: "session-user", unverified: true}
|
||||
m := NewMiddleware(session)
|
||||
|
||||
t.Run("allows a scope needed to verify the email", func(t *testing.T) {
|
||||
result := serve(t, m, AccountWrite, false, map[string]string{"X-Session": "1"})
|
||||
|
||||
require.Equal(t, http.StatusNoContent, result.status)
|
||||
})
|
||||
|
||||
t.Run("asks to verify the email for a withheld scope, even on optional routes", func(t *testing.T) {
|
||||
for _, optional := range []bool{false, true} {
|
||||
result := serve(t, m, AccountAuthorize, optional, map[string]string{"X-Session": "1"})
|
||||
|
||||
require.True(t, apperror.IsCode(result.err, apperror.CodeEmailVerificationRequired), "optional=%v", optional)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("reports a scope the role never grants as missing", func(t *testing.T) {
|
||||
result := serve(t, m, UsersRead, false, map[string]string{"X-Session": "1"})
|
||||
|
||||
require.True(t, apperror.IsCode(result.err, apperror.CodeForbidden))
|
||||
})
|
||||
}
|
||||
|
||||
func TestMiddlewareFallsThroughInvalidCredentials(t *testing.T) {
|
||||
invalidSession := &fakeAuthenticator{kind: KindSession, header: "X-Session", err: apperror.NotSignedIn()}
|
||||
apiKey := &fakeAuthenticator{kind: KindAPIKey, header: "X-Key", user: "key-user"}
|
||||
|
||||
@@ -14,11 +14,29 @@ type Principal struct {
|
||||
UserID string
|
||||
Scopes ScopeSet
|
||||
|
||||
// WithheldUntilVerified are the scopes the user's role grants but that are withheld until the user verifies their email
|
||||
WithheldUntilVerified ScopeSet
|
||||
|
||||
// AuthenticationMethod and AuthenticationTime describe how the session was established and are only set for KindSession
|
||||
AuthenticationMethod string
|
||||
AuthenticationTime time.Time
|
||||
}
|
||||
|
||||
// RestrictUnverified withholds every scope except those a user needs to verify their email, such as signing out or correcting the address
|
||||
func (p *Principal) RestrictUnverified() {
|
||||
kept := make(ScopeSet, len(unverifiedScopes))
|
||||
withheld := make(ScopeSet, len(p.Scopes))
|
||||
for scope := range p.Scopes {
|
||||
if unverifiedScopes.Has(scope) {
|
||||
kept[scope] = struct{}{}
|
||||
} else {
|
||||
withheld[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
p.Scopes = kept
|
||||
p.WithheldUntilVerified = withheld
|
||||
}
|
||||
|
||||
// PrincipalFrom returns the principal the authorization middleware attached to the request
|
||||
// Anonymous requests on optional and public routes get the zero Principal, whose UserID is empty
|
||||
func PrincipalFrom(c *gin.Context) Principal {
|
||||
|
||||
@@ -22,6 +22,11 @@ func (r *Router) Group(relativePath string) *Router {
|
||||
return &Router{group: r.group.Group(relativePath), auth: r.auth, optional: r.optional}
|
||||
}
|
||||
|
||||
// Use returns a router whose routes run the handlers before authorization, for example to render its errors in a way the route's clients understand
|
||||
func (r *Router) Use(handlers ...gin.HandlerFunc) *Router {
|
||||
return &Router{group: r.group.Group("", handlers...), auth: r.auth, optional: r.optional}
|
||||
}
|
||||
|
||||
// Optional returns a router whose routes let requests without a usable credential through as anonymous
|
||||
// Handlers on these routes must check PrincipalFrom before relying on a signed-in user
|
||||
func (r *Router) Optional() *Router {
|
||||
|
||||
@@ -2,10 +2,13 @@ package authz
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||
)
|
||||
|
||||
func noop(c *gin.Context) {
|
||||
@@ -25,6 +28,7 @@ func TestIsDeclared(t *testing.T) {
|
||||
api.Group("/nested").Group("/deeper").DELETE("/:id", UsersWrite, noop)
|
||||
api.Optional().GET("/optional", AccountSession, noop)
|
||||
api.Public().POST("/signup", noop)
|
||||
api.Use(noop).GET("/with-middleware", AccountRead, noop)
|
||||
m.Router(engine.Group("/")).Optional().GET("/authorize", AccountSession, noop)
|
||||
|
||||
// Register routes past the routers
|
||||
@@ -41,12 +45,33 @@ func TestIsDeclared(t *testing.T) {
|
||||
"DELETE /api/nested/deeper/:id": true,
|
||||
"GET /api/optional": true,
|
||||
"POST /api/signup": true,
|
||||
"GET /api/with-middleware": true,
|
||||
"GET /authorize": true,
|
||||
"GET /api/forgotten": false,
|
||||
"POST /api/users": false,
|
||||
}, declared)
|
||||
}
|
||||
|
||||
func TestRouterUseWrapsAuthorization(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
|
||||
// The middleware added with Use runs around authorization, so it sees the error of a refused request and can render it
|
||||
var seen error
|
||||
r := NewMiddleware().Router(engine.Group("/api")).Use(func(c *gin.Context) {
|
||||
c.Next()
|
||||
if len(c.Errors) > 0 {
|
||||
seen = c.Errors.Last().Err
|
||||
}
|
||||
})
|
||||
r.GET("/account", AccountRead, noop)
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/account", nil))
|
||||
|
||||
require.True(t, apperror.IsCode(seen, apperror.CodeNotSignedIn))
|
||||
}
|
||||
|
||||
func TestRouterRejectsUnknownScopes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := NewMiddleware().Router(gin.New().Group("/api"))
|
||||
|
||||
@@ -13,6 +13,7 @@ const (
|
||||
AccountApps Scope = "account:apps"
|
||||
AccountAuditLogs Scope = "account:audit-logs"
|
||||
AccountSession Scope = "account:session"
|
||||
AccountAuthorize Scope = "account:authorize"
|
||||
AccountPasskeysEnroll Scope = "account:passkeys:enroll"
|
||||
AccountAPIKeysCreate Scope = "account:api-keys:create"
|
||||
)
|
||||
@@ -77,6 +78,7 @@ var catalog = []definition{
|
||||
{AccountApps, CategoryAccount, delegated},
|
||||
{AccountAuditLogs, CategoryAccount, delegated},
|
||||
{AccountSession, CategoryAccount, KindSession},
|
||||
{AccountAuthorize, CategoryAccount, KindSession},
|
||||
{AccountPasskeysEnroll, CategoryAccount, KindSession},
|
||||
{AccountAPIKeysCreate, CategoryAccount, KindSession},
|
||||
|
||||
@@ -132,6 +134,9 @@ func (s ScopeSet) Has(scope Scope) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// unverifiedScopes are the scopes a user keeps while their email still has to be verified
|
||||
var unverifiedScopes = NewScopeSet(AccountRead, AccountWrite, AccountPasskeys, AccountPasskeysEnroll, AccountSession)
|
||||
|
||||
// UserScopes returns the scopes a user holds when authenticated with a credential of the given kind
|
||||
// The admin flag stands in for roles: admins hold every scope and other users hold the account scopes
|
||||
func UserScopes(isAdmin bool, kind PrincipalKind) ScopeSet {
|
||||
|
||||
@@ -62,6 +62,36 @@ func TestUserScopes(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrincipalRestrictUnverified(t *testing.T) {
|
||||
t.Run("keeps only the scopes needed to verify the email and withholds the rest", func(t *testing.T) {
|
||||
principal := &Principal{Kind: KindSession, Scopes: UserScopes(false, KindSession)}
|
||||
principal.RestrictUnverified()
|
||||
|
||||
require.Equal(t, NewScopeSet(AccountRead, AccountWrite, AccountPasskeys, AccountPasskeysEnroll, AccountSession), principal.Scopes)
|
||||
require.Equal(t, NewScopeSet(AccountAPIKeys, AccountApps, AccountAuditLogs, AccountAuthorize, AccountAPIKeysCreate), principal.WithheldUntilVerified)
|
||||
})
|
||||
|
||||
t.Run("never adds scopes the credential kind cannot hold", func(t *testing.T) {
|
||||
principal := &Principal{Kind: KindAPIKey, Scopes: UserScopes(false, KindAPIKey)}
|
||||
principal.RestrictUnverified()
|
||||
|
||||
require.Equal(t, NewScopeSet(AccountRead, AccountWrite, AccountPasskeys), principal.Scopes)
|
||||
require.False(t, principal.WithheldUntilVerified.Has(AccountAuthorize))
|
||||
})
|
||||
|
||||
t.Run("withholds admin scopes", func(t *testing.T) {
|
||||
principal := &Principal{Kind: KindSession, Scopes: UserScopes(true, KindSession)}
|
||||
principal.RestrictUnverified()
|
||||
|
||||
for _, entry := range catalog {
|
||||
if entry.category == CategoryAdmin {
|
||||
require.False(t, principal.Scopes.Has(entry.scope), entry.scope)
|
||||
require.True(t, principal.WithheldUntilVerified.Has(entry.scope), entry.scope)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestUnknownScope(t *testing.T) {
|
||||
unknown := Scope("unknown:scope")
|
||||
require.False(t, unknown.Known())
|
||||
|
||||
Reference in New Issue
Block a user