feat: add ability to force email verification (#1837)

This commit is contained in:
Elias Schneider
2026-10-13 22:04:33 +02:00
committed by GitHub
parent eb8ea6bed2
commit 0ffeb170cf
81 changed files with 2310 additions and 263 deletions
+5
View File
@@ -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
}
+35 -6
View File
@@ -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"}
+18
View File
@@ -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 {
+5
View File
@@ -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 {
+25
View File
@@ -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"))
+5
View File
@@ -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 {
+30
View File
@@ -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())