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
@@ -2,19 +2,13 @@ package appconfig
import "encoding/json"
const (
LoginNotificationDisabled = "disabled"
LoginNotificationAlways = "always"
LoginNotificationIPAndUserAgent = "ipAndUserAgent"
LoginNotificationBrowserRecognition = "browserRecognition"
)
// UnmarshalJSON preserves the notification policy when loading state written before notification modes existed
// UnmarshalJSON preserves settings stored in actor state before boolean toggles were replaced by modes
func (m *AppConfigModel) UnmarshalJSON(data []byte) error {
type config AppConfigModel
value := struct {
*config
LegacyNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled"`
LegacyNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled"`
LegacyEmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled"`
}{config: (*config)(m)}
if err := json.Unmarshal(data, &value); err != nil {
return err
@@ -22,6 +16,9 @@ func (m *AppConfigModel) UnmarshalJSON(data []byte) error {
if m.EmailLoginNotificationMode == "" && value.LegacyNotificationEnabled != "" {
m.EmailLoginNotificationMode = legacyLoginNotificationMode(value.LegacyNotificationEnabled)
}
if m.EmailVerification == "" && value.LegacyEmailVerificationEnabled != "" {
m.EmailVerification = legacyEmailVerification(value.LegacyEmailVerificationEnabled)
}
return nil
}
@@ -31,3 +28,10 @@ func legacyLoginNotificationMode(enabled AppConfigValue) AppConfigValue {
}
return LoginNotificationDisabled
}
func legacyEmailVerification(enabled AppConfigValue) AppConfigValue {
if enabled.IsTrue() {
return EmailVerificationOptional
}
return EmailVerificationDisabled
}
+3
View File
@@ -62,6 +62,9 @@ func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
if legacyCfg["emailLoginNotificationMode"] == "" {
dest.EmailLoginNotificationMode = legacyLoginNotificationMode(AppConfigValue(legacyCfg["emailLoginNotificationEnabled"]))
}
if legacyCfg["emailVerification"] == "" {
dest.EmailVerification = legacyEmailVerification(AppConfigValue(legacyCfg["emailVerificationEnabled"]))
}
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
+15 -2
View File
@@ -13,6 +13,19 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/dto"
)
const (
LoginNotificationDisabled = "disabled"
LoginNotificationAlways = "always"
LoginNotificationIPAndUserAgent = "ipAndUserAgent"
LoginNotificationBrowserRecognition = "browserRecognition"
)
const (
EmailVerificationDisabled = "disabled"
EmailVerificationOptional = "optional"
EmailVerificationRequired = "required"
)
type AppConfigModel struct {
// General
AppName AppConfigValue `json:"appName" env:"APP_NAME" public:"true"`
@@ -39,7 +52,7 @@ type AppConfigModel struct {
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" env:"EMAIL_ONE_TIME_ACCESS_AS_UNAUTHENTICATED_ENABLED" type:"bool" public:"true"`
EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" env:"EMAIL_ONE_TIME_ACCESS_AS_ADMIN_ENABLED" type:"bool" public:"true"`
EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" env:"EMAIL_API_KEY_EXPIRATION_ENABLED" type:"bool"`
EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" env:"EMAIL_VERIFICATION_ENABLED" type:"bool" public:"true"`
EmailVerification AppConfigValue `json:"emailVerification" env:"EMAIL_VERIFICATION" public:"true"`
// LDAP
LdapEnabled AppConfigValue `json:"ldapEnabled" env:"LDAP_ENABLED" type:"bool" public:"true"`
LdapUrl AppConfigValue `json:"ldapUrl" env:"LDAP_URL"`
@@ -145,7 +158,7 @@ func getDefaultConfig() *AppConfigModel {
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
EmailOneTimeAccessAsAdminEnabled: "false",
EmailApiKeyExpirationEnabled: "false",
EmailVerificationEnabled: "false",
EmailVerification: EmailVerificationDisabled,
// LDAP
LdapEnabled: "false",
LdapUrl: "",
+25
View File
@@ -1,6 +1,7 @@
package appconfig
import (
"encoding/json"
"errors"
"reflect"
"strings"
@@ -300,3 +301,27 @@ func TestAppConfigModel_Update(t *testing.T) {
assert.Equal(t, "thisKeyDoesNotExist", notFound.field)
})
}
func TestAppConfigModel_UnmarshalJSON(t *testing.T) {
t.Run("maps the legacy email verification toggle to a mode", func(t *testing.T) {
tests := map[string]AppConfigValue{
"true": EmailVerificationOptional,
"false": EmailVerificationDisabled,
}
for enabled, expected := range tests {
var m AppConfigModel
err := json.Unmarshal([]byte(`{"emailVerificationEnabled":"`+enabled+`"}`), &m)
require.NoError(t, err)
assert.Equal(t, expected, m.EmailVerification)
}
})
t.Run("an explicit email verification mode wins over the legacy toggle", func(t *testing.T) {
var m AppConfigModel
err := json.Unmarshal([]byte(`{"emailVerification":"required","emailVerificationEnabled":"false"}`), &m)
require.NoError(t, err)
assert.Equal(t, AppConfigValue(EmailVerificationRequired), m.EmailVerification)
})
}
+11
View File
@@ -247,6 +247,17 @@ func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) {
}
}
// TODO: Remove in next major version (v3)
// Preserve the old environment setting unless an explicit email verification mode replaces it
if _, ok := os.LookupEnv("EMAIL_VERIFICATION"); !ok {
if enabled, exists := os.LookupEnv("EMAIL_VERIFICATION_ENABLED"); exists {
if enabled != "true" && enabled != "false" {
return nil, errors.New("EMAIL_VERIFICATION_ENABLED must be true or false")
}
dest.EmailVerification = legacyEmailVerification(AppConfigValue(enabled))
}
}
// Validate the resolved configuration before exposing values to the rest of the application
err := validateEnvConfig(dest)
if err != nil {
@@ -462,6 +462,37 @@ func TestService_CIMDURLAllowlist(t *testing.T) {
}
func TestService_LoadDbConfigFromEnv(t *testing.T) {
t.Run("required verification rejects conflicting settings", func(t *testing.T) {
for _, setting := range []struct{ name, value string }{
{"EMAILS_VERIFIED", "true"},
{"REQUIRE_USER_EMAIL", "false"},
{"SMTP_HOST", ""},
{"SMTP_FROM", ""},
} {
t.Run(setting.name, func(t *testing.T) {
t.Setenv("EMAIL_VERIFICATION", "required")
t.Setenv("EMAILS_VERIFIED", "false")
t.Setenv("REQUIRE_USER_EMAIL", "true")
t.Setenv("SMTP_HOST", "smtp.example.com")
t.Setenv("SMTP_FROM", "pocket-id@example.com")
t.Setenv(setting.name, setting.value)
_, err := (&AppConfigService{}).loadDbConfigFromEnv()
require.ErrorContains(t, err, "EMAIL_VERIFICATION requires user emails, an SMTP host and sender address, and forbids emails verified by default")
})
}
})
t.Run("required verification accepts consistent settings", func(t *testing.T) {
t.Setenv("EMAIL_VERIFICATION", "required")
t.Setenv("SMTP_HOST", "smtp.example.com")
t.Setenv("SMTP_FROM", "pocket-id@example.com")
cfg, err := (&AppConfigService{}).loadDbConfigFromEnv()
require.NoError(t, err)
assert.Equal(t, AppConfigValue(EmailVerificationRequired), cfg.EmailVerification)
})
// writeSecretFile writes content to a file in a temporary directory that is removed when the test ends, returning its path
writeSecretFile := func(t *testing.T, content string) string {
t.Helper()
@@ -3,6 +3,8 @@
// This file contains utils for unit tests and it's only built when the "unit" tag is set
package appconfig
import "context"
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
func NewTestAppConfigService(config *AppConfigModel) *AppConfigService {
if config == nil {
@@ -25,3 +27,12 @@ func NewTestConfig(config *AppConfigModel) *AppConfigModel {
return config
}
// StaticResolver is an AppConfigResolver for tests that always returns the same configuration, or the default one when none is set
type StaticResolver struct {
Config *AppConfigModel
}
func (r StaticResolver) GetConfig(context.Context) (*AppConfigModel, error) {
return NewTestConfig(r.Config), nil
}
@@ -308,3 +308,11 @@ func OidcPARRequired() *Error {
func InvalidEmailVerificationToken() *Error {
return New(CodeEmailVerificationTokenInvalid, http.StatusBadRequest, "Email verification token is invalid")
}
func EmailVerificationRequired() *Error {
return New(CodeEmailVerificationRequired, http.StatusForbidden, "You must verify your email address before you can access this service.")
}
func EmailChangeNotStarted(cause error) *Error {
return Wrap(cause, CodeEmailChangeNotStarted, http.StatusInternalServerError, "Your other changes were saved, but the email to confirm your new email address could not be sent")
}
+2
View File
@@ -35,6 +35,8 @@ const (
CodeDeviceLoginExpired Code = "device_login_expired"
CodeReauthenticationRequired Code = "reauthentication_required"
CodeEmailVerificationTokenInvalid Code = "invalid_email_verification_token"
CodeEmailVerificationRequired Code = "email_verification_required"
CodeEmailChangeNotStarted Code = "email_change_not_started"
CodeSetupNotAvailable Code = "setup_not_available"
CodeSetupAlreadyCompleted Code = "setup_already_completed"
CodeTokenInvalidOrExpired Code = "token_invalid_or_expired"
+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())
@@ -45,7 +45,7 @@ func TestEveryAPIRouteDeclaresItsAccess(t *testing.T) {
}
engine := gin.New()
auth := middleware.NewAuthorization(nil, nil, nil)
auth := middleware.NewAuthorization(nil, nil, nil, nil)
require.NoError(t, registerRoutes(engine, nil, svc, auth, nil))
// Collect every API route that bypassed the authz routers
@@ -40,7 +40,7 @@ func initRouter(db *gorm.DB, svc *services, rateLimitServices map[string]*rateli
if err != nil {
return nil, err
}
auth := middleware.NewAuthorization(svc.apiKeyModule, svc.userService, svc.jwtService)
auth := middleware.NewAuthorization(svc.apiKeyModule, svc.userService, svc.jwtService, svc.appConfigService)
err = registerRoutes(r, db, svc, auth, rateLimitServices)
if err != nil {
return nil, err
@@ -171,7 +171,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, auth *authz.Middl
rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginVerification),
)
controller.NewOidcController(apiRouter, fileSizeLimitMiddleware, svc.oidcService, svc.appConfigService)
controller.NewUserController(apiRouter, fileSizeLimitMiddleware, svc.appConfigService, svc.userService, svc.webauthnModule, rateLimitMiddleware.Add(middleware.RateLimitUpdateOwnAccount))
controller.NewUserController(apiRouter, fileSizeLimitMiddleware, svc.appConfigService, svc.userService, svc.webauthnModule, rateLimitMiddleware.Add(middleware.RateLimitSendEmailVerification))
controller.NewAppConfigController(apiRouter, svc.appConfigService, svc.emailModule)
svc.ldapSyncModule.RegisterRoutes(apiRouter)
controller.NewAppImagesController(apiRouter, fileSizeLimitMiddleware, svc.appImagesService)
@@ -175,6 +175,7 @@ func initServices(
Reauth: svc.webauthnModule,
AuditLog: svc.auditLogsModule,
APIAccess: svc.apiModule,
AppConfig: svc.appConfigService,
// Disable in test environment
CleanupDisabled: common.EnvConfig.AppEnv.IsTest(),
})
@@ -193,7 +194,20 @@ func initServices(
}
svc.userGroupService = service.NewUserGroupService(db, svc.scimSyncModule, backchannelLogoutService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.customClaimService, svc.appImagesService, svc.scimSyncModule, backchannelLogoutService, fileStorage)
// The user service sends verification links through this module, so it's created first and never depends on the user service itself
svc.emailVerificationModule, err = emailverification.New(emailverification.Dependencies{
DB: db,
Actors: actors,
EmailSender: svc.emailModule,
ScimSync: svc.scimSyncModule,
AppConfig: svc.appConfigService,
AppURL: common.EnvConfig.AppURL,
})
if err != nil {
return nil, fmt.Errorf("failed to create email verification module: %w", err)
}
svc.userService = service.NewUserService(db, svc.jwtService, svc.customClaimService, svc.appImagesService, svc.scimSyncModule, backchannelLogoutService, fileStorage, svc.emailVerificationModule)
svc.ldapSyncModule, err = ldapsync.New(ldapsync.Dependencies{
DB: db,
@@ -225,13 +239,17 @@ func initServices(
}
svc.userSignUpModule, err = usersignup.New(usersignup.Dependencies{
DB: db,
Actors: actors,
Signer: svc.jwtService,
AuditLog: svc.auditLogsModule,
UserCreator: svc.userService,
AppConfig: svc.appConfigService,
ScimSync: svc.scimSyncModule,
DB: db,
Actors: actors,
Signer: svc.jwtService,
AuditLog: svc.auditLogsModule,
UserCreator: svc.userService,
AppConfig: svc.appConfigService,
ScimSync: svc.scimSyncModule,
UserDeleter: svc.userService,
EmailVerification: svc.emailVerificationModule,
// Disable in test environment
CleanupDisabled: common.EnvConfig.AppEnv.IsTest(),
})
if err != nil {
return nil, fmt.Errorf("failed to create user signup module: %w", err)
@@ -250,18 +268,6 @@ func initServices(
return nil, fmt.Errorf("failed to create one-time access module: %w", err)
}
svc.emailVerificationModule, err = emailverification.New(emailverification.Dependencies{
DB: db,
Actors: actors,
Users: svc.userService,
EmailSender: svc.emailModule,
AppConfig: svc.appConfigService,
AppURL: common.EnvConfig.AppURL,
})
if err != nil {
return nil, fmt.Errorf("failed to create email verification module: %w", err)
}
svc.environmentModule = environment.New(environment.Dependencies{
HTTPClient: httpClient,
SQLiteOnNetworkedFilesystem: sqliteOnNetworkedFilesystem,
@@ -13,6 +13,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
@@ -39,11 +40,11 @@ func TestImageUploadRoutesLimitRequestSize(t *testing.T) {
jwtService, err := service.NewJwtService(t.Context(), db, instanceID)
require.NoError(t, err)
userService := service.NewUserService(db, jwtService, nil, nil, nil, nil, nil)
userService := service.NewUserService(db, jwtService, nil, nil, nil, nil, nil, nil)
apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db, CleanupDisabled: true})
require.NoError(t, err)
auth := middleware.NewAuthorization(apiKeyModule, userService, jwtService)
auth := middleware.NewAuthorization(apiKeyModule, userService, jwtService, appconfig.StaticResolver{})
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
user := model.User{Username: "upload-admin", IsAdmin: true}
+1 -1
View File
@@ -59,7 +59,7 @@ type AppConfigUpdateDto struct {
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required,boolean_string"`
EmailLoginNotificationMode string `json:"emailLoginNotificationMode" binding:"required,oneof=disabled always ipAndUserAgent browserRecognition"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required,boolean_string"`
EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required,boolean_string"`
EmailVerification string `json:"emailVerification" binding:"required,oneof=disabled optional required,email_verification"`
CIMDURLAllowlist string `json:"cimdUrlAllowlist" binding:"omitempty,cimd_url_allowlist"`
AutoCreateOIDCClientSecret string `json:"autoCreateOidcClientSecret" binding:"required,boolean_string"`
}
+4
View File
@@ -4,6 +4,8 @@ import (
"errors"
"github.com/gin-gonic/gin/binding"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type UserDto struct {
@@ -20,6 +22,8 @@ type UserDto struct {
UserGroups []UserGroupMinimalDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
EmailVerificationDeadline *datatype.DateTime `json:"emailVerificationDeadline,omitempty"`
}
type UserCreateDto struct {
+11
View File
@@ -92,6 +92,15 @@ func init() {
"integer_string": func(fl validator.FieldLevel) bool {
return validateIntegerString(fl.Field().String())
},
"email_verification": func(fl validator.FieldLevel) bool {
// Required verification needs emails that aren't trusted by default and the SMTP host and sender address without which no verification link can be sent
parent := fl.Parent()
return fl.Field().String() != "required" ||
(parent.FieldByName("RequireUserEmail").String() == "true" &&
parent.FieldByName("EmailsVerified").String() == "false" &&
parent.FieldByName("SmtpHost").String() != "" &&
parent.FieldByName("SmtpFrom").String() != "")
},
}
for k, v := range validators {
err := engine.RegisterValidation(k, v)
@@ -182,6 +191,8 @@ func ValidationErrorDetails(validationError validator.FieldError) (string, strin
return "invalid_format", "must be either true or false"
case "integer_string":
return "invalid_format", "must be an integer"
case "email_verification":
return "invalid_configuration", "requires user emails, an SMTP host and sender address, and forbids emails verified by default"
default:
return validationError.Tag(), "is invalid"
}
+55 -5
View File
@@ -17,10 +17,12 @@ const ActorType = "EmailVerification"
const (
// MethodIssue replaces the outstanding verification state for a user
MethodIssue = "issue"
methodConsume = "consume"
methodDiscard = "discard"
methodRestore = "restore"
MethodIssue = "issue"
methodConsume = "consume"
methodDiscard = "discard"
methodRestore = "restore"
methodCancelChange = "cancelChange"
methodPendingChange = "pendingChange"
)
type consumeStatus string
@@ -32,7 +34,14 @@ const (
// State is the persisted verification state for one user
type State struct {
TokenHash string
TokenHash string
Email string
ReplacesEmail string
ExpiresAt time.Time
}
// EmailChange describes an email change that is waiting for the user to confirm it
type EmailChange struct {
Email string
ExpiresAt time.Time
}
@@ -68,11 +77,35 @@ func (a *emailVerificationActor) Invoke(ctx context.Context, method string, data
return nil, a.discard(ctx, data)
case methodRestore:
return nil, a.restore(ctx, data)
case methodCancelChange:
return nil, a.cancelChange(ctx)
default:
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
}
// Peek implements actor.ActorPeek
func (a *emailVerificationActor) Peek(ctx context.Context, method string, _ actor.Envelope) (any, error) {
if method != methodPendingChange {
return nil, common.ErrUnsupportedActorMethod{Method: method}
}
state, err := a.client.GetState(ctx)
if err != nil {
return nil, fmt.Errorf("error retrieving actor state: %w", err)
}
// Only an unexpired email change is pending, a token for the current email is not
if state.ReplacesEmail == "" || state.ExpiresAt.Before(time.Now()) {
return EmailChange{}, nil
}
return EmailChange{
Email: state.Email,
ExpiresAt: state.ExpiresAt,
}, nil
}
func (a *emailVerificationActor) issue(ctx context.Context, data actor.Envelope) error {
state, err := decodeState(data, MethodIssue)
if err != nil {
@@ -134,6 +167,23 @@ func (a *emailVerificationActor) discard(ctx context.Context, data actor.Envelop
return nil
}
func (a *emailVerificationActor) cancelChange(ctx context.Context) error {
state, err := a.client.GetState(ctx)
if err != nil {
return fmt.Errorf("error retrieving actor state: %w", err)
}
if state.ReplacesEmail == "" {
return nil
}
err = a.client.DeleteState(ctx)
if err != nil && !errors.Is(err, actor.ErrStateNotFound) {
return fmt.Errorf("error deleting actor state: %w", err)
}
return nil
}
func (a *emailVerificationActor) restore(ctx context.Context, data actor.Envelope) error {
state, err := decodeState(data, methodRestore)
if err != nil {
@@ -10,8 +10,15 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/authz"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
// emailChangeDto describes an email change that waits for the user to confirm the new address
type emailChangeDto struct {
Email string `json:"email"`
ExpiresAt datatype.DateTime `json:"expiresAt"`
}
type handler struct {
service *Service
appConfig appconfig.AppConfigResolver
@@ -64,3 +71,44 @@ func (h *handler) verify(c *gin.Context) error {
c.Status(http.StatusNoContent)
return nil
}
// pendingEmailChange godoc
// @Summary Get pending email change
// @Description Get the email change of the currently authenticated user that still has to be confirmed through the emailed link
// @Tags Users
// @Produce json
// @Success 200 {object} emailChangeDto
// @Success 204 "No pending email change"
// @Router /api/users/me/email-change [get]
func (h *handler) pendingEmailChange(c *gin.Context) error {
pending, err := h.service.PendingEmailChange(c.Request.Context(), authz.PrincipalFrom(c).UserID)
if err != nil {
return err
}
if pending == nil {
c.Status(http.StatusNoContent)
return nil
}
c.JSON(http.StatusOK, emailChangeDto{
Email: pending.Email,
ExpiresAt: datatype.DateTime(pending.ExpiresAt),
})
return nil
}
// cancelEmailChange godoc
// @Summary Cancel pending email change
// @Description Cancel the pending email change of the currently authenticated user, so its verification link stops working
// @Tags Users
// @Success 204 "No Content"
// @Router /api/users/me/email-change [delete]
func (h *handler) cancelEmailChange(c *gin.Context) error {
err := h.service.CancelEmailChange(c.Request.Context(), authz.PrincipalFrom(c).UserID)
if err != nil {
return err
}
c.Status(http.StatusNoContent)
return nil
}
+15 -2
View File
@@ -1,6 +1,7 @@
package emailverification
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
@@ -16,8 +17,8 @@ type Dependencies struct {
DB *gorm.DB
Actors francishost.Host
Users UserProvider
EmailSender EmailSender
ScimSync ScimSyncScheduler
AppConfig appconfig.AppConfigResolver
AppURL string
}
@@ -33,15 +34,27 @@ func New(deps Dependencies) (*Module, error) {
return nil, fmt.Errorf("error registering the %s actor: %w", ActorType, err)
}
service := newService(deps.DB, deps.Actors.Service(), deps.Users, deps.EmailSender, deps.AppURL)
service := newService(deps.DB, deps.Actors.Service(), deps.EmailSender, deps.ScimSync, deps.AppURL)
return &Module{
service: service,
handler: newHandler(service, deps.AppConfig),
}, nil
}
// Send issues a new verification token for the user's current email and emails the verification link
func (m *Module) Send(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error {
return m.service.Send(ctx, dbConfig, userID)
}
// RequestEmailChange emails a verification link to a new address, which replaces the user's current email once the link is used
func (m *Module) RequestEmailChange(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, newEmail string) error {
return m.service.RequestEmailChange(ctx, dbConfig, userID, newEmail)
}
// RegisterRoutes mounts the email verification endpoints
func (m *Module) RegisterRoutes(r *authz.Router, sendRateLimit, verifyRateLimit gin.HandlerFunc) {
r.POST("/users/me/send-email-verification", authz.AccountWrite, sendRateLimit, httpserver.Handle(m.handler.send))
r.POST("/users/me/verify-email", authz.AccountWrite, verifyRateLimit, httpserver.Handle(m.handler.verify))
r.GET("/users/me/email-change", authz.AccountRead, httpserver.Handle(m.handler.pendingEmailChange))
r.DELETE("/users/me/email-change", authz.AccountWrite, httpserver.Handle(m.handler.cancelEmailChange))
}
@@ -0,0 +1,21 @@
package emailverification
import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
// IsPending reports whether the user must verify their email before they can use their account
func IsPending(cfg *appconfig.AppConfigModel, user *model.User) bool {
return cfg.EmailVerification.String() == appconfig.EmailVerificationRequired && CanBePending(user)
}
// CanBePending reports whether the user would have to verify their email if verification were required
func CanBePending(user *model.User) bool {
return !user.IsAdmin && !user.EmailVerified
}
// DefersEmailChange reports whether the user's own email change has to be confirmed before it replaces their current email
func DefersEmailChange(cfg *appconfig.AppConfigModel, user *model.User) bool {
return cfg.EmailVerification.String() == appconfig.EmailVerificationRequired && !user.IsAdmin && user.EmailVerified && user.Email != nil
}
+110 -24
View File
@@ -17,36 +17,52 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
const tokenLifetime = 24 * time.Hour
type UserProvider interface {
GetUser(ctx context.Context, userID string) (model.User, error)
}
// TokenLifetime is how long a verification link stays valid
const TokenLifetime = 24 * time.Hour
type EmailSender interface {
SendEmailVerification(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, verificationLink string) error
}
// ScimSyncScheduler schedules a SCIM sync after a confirmed email change replaced a user's address
type ScimSyncScheduler interface {
ScheduleSync(ctx context.Context)
}
type Service struct {
db *gorm.DB
actors *actor.Service
users UserProvider
emailSender EmailSender
scimSync ScimSyncScheduler
appURL string
}
func newService(db *gorm.DB, actors *actor.Service, users UserProvider, emailSender EmailSender, appURL string) *Service {
func newService(db *gorm.DB, actors *actor.Service, emailSender EmailSender, scimSync ScimSyncScheduler, appURL string) *Service {
return &Service{
db: db,
actors: actors,
users: users,
emailSender: emailSender,
scimSync: scimSync,
appURL: appURL,
}
}
func (s *Service) loadUser(ctx context.Context, userID string) (model.User, error) {
var user model.User
err := s.db.
WithContext(ctx).
Where("id = ?", userID).
First(&user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, apperror.UserNotFound()
}
return user, err
}
// Send emails a verification link for the user's current email
func (s *Service) Send(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error {
user, err := s.users.GetUser(ctx, userID)
user, err := s.loadUser(ctx, userID)
if err != nil {
return err
}
@@ -54,28 +70,76 @@ func (s *Service) Send(ctx context.Context, dbConfig *appconfig.AppConfigModel,
return apperror.UserEmailNotSet()
}
return s.issue(ctx, dbConfig, user, State{Email: *user.Email})
}
// RequestEmailChange emails a verification link to a new address, which replaces the user's current email once the link is used
// Until then the current email stays in effect, so a mistyped address never restricts the account
func (s *Service) RequestEmailChange(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, newEmail string) error {
user, err := s.loadUser(ctx, userID)
if err != nil {
return err
}
if user.Email == nil {
return apperror.UserEmailNotSet()
}
return s.issue(ctx, dbConfig, user, State{Email: newEmail, ReplacesEmail: *user.Email})
}
// PendingEmailChange returns the email change the user still has to confirm, or nil if there is none
func (s *Service) PendingEmailChange(ctx context.Context, userID string) (*EmailChange, error) {
response, err := s.actors.Peek(ctx, ActorType, userID, methodPendingChange, nil)
if err != nil {
return nil, fmt.Errorf("error reading pending email change: %w", err)
}
if response == nil {
return nil, errors.New("email verification actor returned an empty response")
}
var pending EmailChange
err = response.Decode(&pending)
if err != nil {
return nil, fmt.Errorf("error decoding pending email change: %w", err)
}
if pending.Email == "" {
return nil, nil
}
return &pending, nil
}
// CancelEmailChange drops a pending email change, so its verification link stops working
func (s *Service) CancelEmailChange(ctx context.Context, userID string) error {
_, err := s.actors.Invoke(ctx, ActorType, userID, methodCancelChange, nil)
if err != nil {
return fmt.Errorf("error canceling email change: %w", err)
}
return nil
}
// issue stores a new verification token for the state's email and emails the verification link to that address
// Issuing replaces any outstanding token, so only the most recent link works
func (s *Service) issue(ctx context.Context, dbConfig *appconfig.AppConfigModel, user model.User, state State) error {
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return err
}
// Persist the token hash in the email verification actor for this user
state := State{
TokenHash: utils.CreateSha256Hash(token),
Email: *user.Email,
ExpiresAt: time.Now().Add(tokenLifetime),
}
state.TokenHash = utils.CreateSha256Hash(token)
state.ExpiresAt = time.Now().Add(TokenLifetime)
_, err = s.actors.Invoke(ctx, ActorType, user.ID, MethodIssue, state)
if err != nil {
return fmt.Errorf("error issuing email verification token: %w", err)
}
// Send the email verification message to the user
// Send the email verification message to the address being verified
err = s.emailSender.SendEmailVerification(
ctx,
dbConfig,
user.FullName(),
*user.Email,
state.Email,
s.appURL+"/verify-email?token="+token,
)
if err != nil {
@@ -108,16 +172,33 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error {
return apperror.InvalidEmailVerificationToken()
}
// Update the user's email_verified field in the database
// We are querying by both user ID and email to ensure that the email has not changed since the token was issued
update := s.db.
// Mark the email as verified
// A verified account is no longer deleted for missing the signup verification deadline
updates := map[string]any{
"email_verified": true,
"email_verification_deadline": nil,
"updated_at": new(datatype.DateTime(time.Now())),
}
query := s.db.
WithContext(ctx).
Model(&model.User{}).
Where("id = ? AND email = ?", userID, result.State.Email).
Updates(map[string]any{
"email_verified": true,
"updated_at": new(datatype.DateTime(time.Now())),
})
Where("id = ?", userID)
// A token for an email change also moves the new address into place, unless the email was changed another way in the meantime
// A token for the current email only applies while the email is still the one the token was issued for
isEmailChange := result.State.ReplacesEmail != ""
if isEmailChange {
updates["email"] = result.State.Email
query = query.Where("email = ?", result.State.ReplacesEmail)
} else {
query = query.Where("email = ?", result.State.Email)
}
update := query.Updates(updates)
if errors.Is(update.Error, gorm.ErrDuplicatedKey) {
// Another account took the new address while the change was waiting for confirmation
return apperror.AlreadyInUse("email")
}
if update.Error != nil {
// If the database update fails, restore the token in the actor to allow the user to retry verification
s.restoreAfterDatabaseFailure(ctx, userID, result.State)
@@ -127,6 +208,11 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error {
return apperror.InvalidEmailVerificationToken()
}
// Service providers have to learn about the new address
if isEmailChange && s.scimSync != nil {
s.scimSync.ScheduleSync(ctx)
}
return nil
}
@@ -15,20 +15,11 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
type testUserProvider struct {
db *gorm.DB
}
func (p testUserProvider) GetUser(ctx context.Context, userID string) (model.User, error) {
var user model.User
err := p.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error
return user, err
}
type testEmailSender struct {
err error
sent []sentVerificationEmail
@@ -61,7 +52,7 @@ func newServiceForTest(t *testing.T, emailSender *testEmailSender) (*Service, *l
var service *Service
host := testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) {
require.NoError(t, host.RegisterActor(ActorType, NewActor))
service = newService(db, host.Service(), testUserProvider{db: db}, emailSender, "https://id.example.test")
service = newService(db, host.Service(), emailSender, nil, "https://id.example.test")
})
require.NotNil(t, service)
@@ -129,6 +120,22 @@ func TestVerifyConsumesTokenAndMarksBoundAddressVerified(t *testing.T) {
require.ErrorIs(t, host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound)
}
func TestVerifyClearsSignupDeadline(t *testing.T) {
emailSender := &testEmailSender{}
service, _, db := newServiceForTest(t, emailSender)
user := createTestUser(t, db, "user-deadline", "user@example.test")
require.NoError(t, db.Model(&user).Update("email_verification_deadline", datatype.DateTime(time.Now().Add(time.Hour))).Error)
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
token := verificationTokenFromEmail(t, emailSender.sent[0])
require.NoError(t, service.Verify(t.Context(), user.ID, token))
var updated model.User
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
require.True(t, updated.EmailVerified)
require.Nil(t, updated.EmailVerificationDeadline)
}
func TestVerifyRejectsTokenAfterAddressChanges(t *testing.T) {
emailSender := &testEmailSender{}
service, host, db := newServiceForTest(t, emailSender)
@@ -255,3 +262,107 @@ func TestSendDiscardsTokenWhenEmailDeliveryFails(t *testing.T) {
var state State
require.ErrorIs(t, host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound)
}
func TestRequestEmailChangeSendsLinkToNewAddressAndKeepsCurrentEmail(t *testing.T) {
emailSender := &testEmailSender{}
service, _, db := newServiceForTest(t, emailSender)
user := createTestUser(t, db, "user-change", "old@example.test")
require.NoError(t, db.Model(&user).Update("email_verified", true).Error)
require.NoError(t, service.RequestEmailChange(t.Context(), &appconfig.AppConfigModel{}, user.ID, "new@example.test"))
require.Len(t, emailSender.sent, 1)
require.Equal(t, "new@example.test", emailSender.sent[0].userEmail)
pending, err := service.PendingEmailChange(t.Context(), user.ID)
require.NoError(t, err)
require.NotNil(t, pending)
require.Equal(t, "new@example.test", pending.Email)
var unchanged model.User
require.NoError(t, db.Where("id = ?", user.ID).First(&unchanged).Error)
require.Equal(t, "old@example.test", *unchanged.Email)
require.True(t, unchanged.EmailVerified)
}
func TestVerifyEmailChangeReplacesEmail(t *testing.T) {
emailSender := &testEmailSender{}
service, _, db := newServiceForTest(t, emailSender)
user := createTestUser(t, db, "user-confirm", "old@example.test")
require.NoError(t, service.RequestEmailChange(t.Context(), &appconfig.AppConfigModel{}, user.ID, "new@example.test"))
token := verificationTokenFromEmail(t, emailSender.sent[0])
require.NoError(t, service.Verify(t.Context(), user.ID, token))
var updated model.User
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
require.Equal(t, "new@example.test", *updated.Email)
require.True(t, updated.EmailVerified)
pending, err := service.PendingEmailChange(t.Context(), user.ID)
require.NoError(t, err)
require.Nil(t, pending)
}
func TestVerifyEmailChangeRejectsTokenAfterEmailChangedElsewhere(t *testing.T) {
emailSender := &testEmailSender{}
service, _, db := newServiceForTest(t, emailSender)
user := createTestUser(t, db, "user-admin-changed", "old@example.test")
require.NoError(t, service.RequestEmailChange(t.Context(), &appconfig.AppConfigModel{}, user.ID, "new@example.test"))
token := verificationTokenFromEmail(t, emailSender.sent[0])
// An admin sets a different email before the user confirms their change, and that one must win
require.NoError(t, db.Model(&user).Update("email", "admin-set@example.test").Error)
err := service.Verify(t.Context(), user.ID, token)
require.True(t, apperror.IsCode(err, apperror.CodeEmailVerificationTokenInvalid))
var updated model.User
require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error)
require.Equal(t, "admin-set@example.test", *updated.Email)
}
func TestVerifyEmailChangeRejectsAddressTakenInTheMeantime(t *testing.T) {
emailSender := &testEmailSender{}
service, _, db := newServiceForTest(t, emailSender)
user := createTestUser(t, db, "user-race", "old@example.test")
require.NoError(t, service.RequestEmailChange(t.Context(), &appconfig.AppConfigModel{}, user.ID, "new@example.test"))
token := verificationTokenFromEmail(t, emailSender.sent[0])
createTestUser(t, db, "user-taker", "new@example.test")
err := service.Verify(t.Context(), user.ID, token)
require.True(t, apperror.IsCode(err, apperror.CodeAlreadyInUse))
var unchanged model.User
require.NoError(t, db.Where("id = ?", user.ID).First(&unchanged).Error)
require.Equal(t, "old@example.test", *unchanged.Email)
}
func TestCancelEmailChangeOnlyDropsPendingChange(t *testing.T) {
emailSender := &testEmailSender{}
service, host, db := newServiceForTest(t, emailSender)
user := createTestUser(t, db, "user-cancel", "old@example.test")
t.Run("cancels a pending change and invalidates its link", func(t *testing.T) {
require.NoError(t, service.RequestEmailChange(t.Context(), &appconfig.AppConfigModel{}, user.ID, "new@example.test"))
token := verificationTokenFromEmail(t, emailSender.sent[len(emailSender.sent)-1])
require.NoError(t, service.CancelEmailChange(t.Context(), user.ID))
pending, err := service.PendingEmailChange(t.Context(), user.ID)
require.NoError(t, err)
require.Nil(t, pending)
require.True(t, apperror.IsCode(service.Verify(t.Context(), user.ID, token), apperror.CodeEmailVerificationTokenInvalid))
})
t.Run("keeps a verification of the current email", func(t *testing.T) {
require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID))
require.NoError(t, service.CancelEmailChange(t.Context(), user.ID))
var state State
require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state))
require.Equal(t, "old@example.test", state.Email)
})
}
@@ -586,6 +586,7 @@ func newTestLdapService(t *testing.T, client ldapClient) (*Service, *gorm.DB) {
nil,
nil,
fileStorage,
nil,
)
svc := newService(Dependencies{
+47 -12
View File
@@ -1,13 +1,17 @@
package middleware
import (
"context"
"strings"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/authz"
"github.com/pocket-id/pocket-id/backend/internal/emailverification"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
@@ -17,21 +21,39 @@ const apiKeyHeader = "X-API-Key"
// NewAuthorization creates the authorization middleware with every credential Pocket ID accepts
// A browser session is tried before an API key, so a signed-in browser is never mistaken for an API client
func NewAuthorization(apiKeyModule *apikey.Module, userService *service.UserService, jwtService *service.JwtService) *authz.Middleware {
func NewAuthorization(apiKeyModule *apikey.Module, userService *service.UserService, jwtService *service.JwtService, appConfig appconfig.AppConfigResolver) *authz.Middleware {
return authz.NewMiddleware(
NewSessionAuthenticator(jwtService, userService),
NewAPIKeyAuthenticator(apiKeyModule),
NewSessionAuthenticator(jwtService, userService, appConfig),
NewAPIKeyAuthenticator(apiKeyModule, appConfig),
)
}
// restrictUnverified withholds the scopes a user who still has to verify their email may not use yet
func restrictUnverified(ctx context.Context, appConfig appconfig.AppConfigResolver, user *model.User, principal *authz.Principal) error {
if !emailverification.CanBePending(user) {
return nil
}
cfg, err := appConfig.GetConfig(ctx)
if err != nil {
return err
}
if emailverification.IsPending(cfg, user) {
principal.RestrictUnverified()
}
return nil
}
// SessionAuthenticator authenticates the session access token Pocket ID issues after sign-in
type SessionAuthenticator struct {
jwtService *service.JwtService
userService *service.UserService
appConfig appconfig.AppConfigResolver
}
func NewSessionAuthenticator(jwtService *service.JwtService, userService *service.UserService) *SessionAuthenticator {
return &SessionAuthenticator{jwtService: jwtService, userService: userService}
func NewSessionAuthenticator(jwtService *service.JwtService, userService *service.UserService, appConfig appconfig.AppConfigResolver) *SessionAuthenticator {
return &SessionAuthenticator{jwtService: jwtService, userService: userService, appConfig: appConfig}
}
func (a *SessionAuthenticator) Kind() authz.PrincipalKind {
@@ -59,7 +81,7 @@ func (a *SessionAuthenticator) Authenticate(c *gin.Context) (*authz.Principal, e
return nil, apperror.TokenInvalid()
}
// Load the user so disabling an account or changing its admin flag takes effect before the token expires
// Load the user so disabling an account, changing its admin flag or its email verification takes effect before the token expires
user, err := a.userService.GetUser(c, subject)
if err != nil {
return nil, apperror.NotSignedIn()
@@ -68,13 +90,19 @@ func (a *SessionAuthenticator) Authenticate(c *gin.Context) (*authz.Principal, e
return nil, apperror.UserDisabled()
}
return &authz.Principal{
principal := &authz.Principal{
Kind: authz.KindSession,
UserID: user.ID,
Scopes: authz.UserScopes(user.IsAdmin, authz.KindSession),
AuthenticationMethod: authenticationMethod,
AuthenticationTime: authenticationTime,
}, nil
}
err = restrictUnverified(c.Request.Context(), a.appConfig, &user, principal)
if err != nil {
return nil, err
}
return principal, nil
}
// sessionToken reads the session access token from its cookie, or from the Authorization header when the cookie is absent
@@ -91,10 +119,11 @@ func sessionToken(c *gin.Context) string {
// APIKeyAuthenticator authenticates a personal API key sent in the X-API-Key header
type APIKeyAuthenticator struct {
apiKeyModule *apikey.Module
appConfig appconfig.AppConfigResolver
}
func NewAPIKeyAuthenticator(apiKeyModule *apikey.Module) *APIKeyAuthenticator {
return &APIKeyAuthenticator{apiKeyModule: apiKeyModule}
func NewAPIKeyAuthenticator(apiKeyModule *apikey.Module, appConfig appconfig.AppConfigResolver) *APIKeyAuthenticator {
return &APIKeyAuthenticator{apiKeyModule: apiKeyModule, appConfig: appConfig}
}
func (a *APIKeyAuthenticator) Kind() authz.PrincipalKind {
@@ -114,9 +143,15 @@ func (a *APIKeyAuthenticator) Authenticate(c *gin.Context) (*authz.Principal, er
return nil, apperror.UserDisabled()
}
return &authz.Principal{
principal := &authz.Principal{
Kind: authz.KindAPIKey,
UserID: user.ID,
Scopes: authz.UserScopes(user.IsAdmin, authz.KindAPIKey),
}, nil
}
err = restrictUnverified(c.Request.Context(), a.appConfig, &user, principal)
if err != nil {
return nil, err
}
return principal, nil
}
@@ -12,6 +12,7 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/authz"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
@@ -41,30 +42,42 @@ func TestAuthorizationWithRealCredentials(t *testing.T) {
jwtService, err := service.NewJwtService(t.Context(), db, instanceID)
require.NoError(t, err)
userService := service.NewUserService(db, jwtService, nil, nil, nil, nil, nil)
userService := service.NewUserService(db, jwtService, nil, nil, nil, nil, nil, nil)
apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db, CleanupDisabled: true})
require.NoError(t, err)
// Create one credential of each kind for a regular user, an admin and a disabled user
user := createUserForAuthorizationTest(t, db, "auth-user", false, false)
admin := createUserForAuthorizationTest(t, db, "auth-admin", true, false)
disabled := createUserForAuthorizationTest(t, db, "auth-disabled", true, true)
// The regular users and the admin have a verified email, the others still have to verify theirs
user := createUserForAuthorizationTest(t, db, "auth-user", false, false, true)
admin := createUserForAuthorizationTest(t, db, "auth-admin", true, false, true)
disabled := createUserForAuthorizationTest(t, db, "auth-disabled", true, true, true)
unverified := createUserForAuthorizationTest(t, db, "auth-unverified", false, false, false)
unverifiedAdmin := createUserForAuthorizationTest(t, db, "auth-unverified-admin", true, false, false)
userSession, err := jwtService.GenerateAccessToken(user, "", time.Hour)
require.NoError(t, err)
unverifiedSession, err := jwtService.GenerateAccessToken(unverified, "", time.Hour)
require.NoError(t, err)
userAPIKey := createAPIKeyForAuthorizationTest(t, db, user, "user-raw-api-key")
adminAPIKey := createAPIKeyForAuthorizationTest(t, db, admin, "admin-raw-api-key")
disabledAPIKey := createAPIKeyForAuthorizationTest(t, db, disabled, "disabled-raw-api-key")
unverifiedAPIKey := createAPIKeyForAuthorizationTest(t, db, unverified, "unverified-raw-api-key")
unverifiedAdminAPIKey := createAPIKeyForAuthorizationTest(t, db, unverifiedAdmin, "unverified-admin-raw-api-key")
// Mount one route per kind of requirement
// Email verification is required, so unverified regular users only keep the scopes needed to verify their email
cfg := appconfig.NewTestConfig(nil)
cfg.EmailVerification = appconfig.EmailVerificationRequired
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
apiRouter := NewAuthorization(apiKeyModule, userService, jwtService).Router(router.Group("/api"))
apiRouter := NewAuthorization(apiKeyModule, userService, jwtService, appconfig.StaticResolver{Config: cfg}).Router(router.Group("/api"))
ok := func(c *gin.Context) {
c.String(http.StatusOK, authz.PrincipalFrom(c).UserID)
}
apiRouter.GET("/session-only", authz.AccountSession, ok)
apiRouter.GET("/account", authz.AccountRead, ok)
apiRouter.GET("/apps", authz.AccountApps, ok)
apiRouter.GET("/authorize", authz.AccountAuthorize, ok)
apiRouter.GET("/admin", authz.UsersRead, ok)
tests := []struct {
@@ -85,6 +98,15 @@ func TestAuthorizationWithRealCredentials(t *testing.T) {
{name: "admin API key on admin route", path: "/api/admin", apiKey: adminAPIKey, status: http.StatusOK, userID: admin.ID},
{name: "invalid session cookie falls back to API key", path: "/api/admin", sessionCookie: "not-a-jwt", apiKey: adminAPIKey, status: http.StatusOK, userID: admin.ID},
{name: "disabled user's API key", path: "/api/account", apiKey: disabledAPIKey, status: http.StatusForbidden, errorCode: "user_disabled"},
{name: "verified user session on apps route", path: "/api/apps", authorization: "Bearer " + userSession, status: http.StatusOK, userID: user.ID},
{name: "unverified user session on session-only route", path: "/api/session-only", authorization: "Bearer " + unverifiedSession, status: http.StatusOK, userID: unverified.ID},
{name: "unverified user session on account route", path: "/api/account", authorization: "Bearer " + unverifiedSession, status: http.StatusOK, userID: unverified.ID},
{name: "unverified user session on apps route", path: "/api/apps", authorization: "Bearer " + unverifiedSession, status: http.StatusForbidden, errorCode: "email_verification_required"},
{name: "unverified user session on authorize route", path: "/api/authorize", authorization: "Bearer " + unverifiedSession, status: http.StatusForbidden, errorCode: "email_verification_required"},
{name: "verified user session on authorize route", path: "/api/authorize", authorization: "Bearer " + userSession, status: http.StatusOK, userID: user.ID},
{name: "unverified user API key on account route", path: "/api/account", apiKey: unverifiedAPIKey, status: http.StatusOK, userID: unverified.ID},
{name: "unverified user API key on apps route", path: "/api/apps", apiKey: unverifiedAPIKey, status: http.StatusForbidden, errorCode: "email_verification_required"},
{name: "unverified admin API key on admin route", path: "/api/admin", apiKey: unverifiedAdminAPIKey, status: http.StatusOK, userID: unverifiedAdmin.ID},
{name: "unknown API key", path: "/api/account", apiKey: "unknown", status: http.StatusUnauthorized, errorCode: "not_signed_in"},
{name: "no credentials", path: "/api/account", status: http.StatusUnauthorized, errorCode: "not_signed_in"},
}
@@ -118,17 +140,18 @@ func TestAuthorizationWithRealCredentials(t *testing.T) {
}
}
func createUserForAuthorizationTest(t *testing.T, db *gorm.DB, username string, isAdmin, disabled bool) model.User {
func createUserForAuthorizationTest(t *testing.T, db *gorm.DB, username string, isAdmin, disabled, emailVerified bool) model.User {
t.Helper()
user := model.User{
Username: username,
Email: new(username + "@example.com"),
FirstName: "Auth",
LastName: "User",
DisplayName: "Auth User",
IsAdmin: isAdmin,
Disabled: disabled,
Username: username,
Email: new(username + "@example.com"),
EmailVerified: emailVerified,
FirstName: "Auth",
LastName: "User",
DisplayName: "Auth User",
IsAdmin: isAdmin,
Disabled: disabled,
}
err := db.Create(&user).Error
@@ -11,6 +11,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
@@ -228,7 +229,7 @@ func TestValidationResponseUsesAppConfigTypeMessages(t *testing.T) {
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
EmailLoginNotificationMode: "disabled",
EmailApiKeyExpirationEnabled: "false",
EmailVerificationEnabled: "false",
EmailVerification: "disabled",
AutoCreateOIDCClientSecret: "true",
}
payload, err := json.Marshal(input)
@@ -269,3 +270,57 @@ func TestClassifyDeadlineAsRequestTimeout(t *testing.T) {
require.Equal(t, http.StatusGatewayTimeout, classified.status)
require.Equal(t, "Request timed out", classified.message)
}
func TestAppConfigBindingValidatesRequiredEmailVerification(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.PUT("/config", httpserver.Handle(func(c *gin.Context) error {
var input dto.AppConfigUpdateDto
return httpserver.BindJSON(c, &input)
}))
const (
smtpHost = "smtp.example.com"
smtpFrom = "pocket-id@example.com"
)
for _, tt := range []struct {
name, mode, required, verified, host, from string
status int
}{
{"required with trusted emails", "required", "true", "true", smtpHost, smtpFrom, http.StatusBadRequest},
{"required without email", "required", "false", "false", smtpHost, smtpFrom, http.StatusBadRequest},
{"required without SMTP host", "required", "true", "false", "", smtpFrom, http.StatusBadRequest},
{"required without sender address", "required", "true", "false", smtpHost, "", http.StatusBadRequest},
{"valid required", "required", "true", "false", smtpHost, smtpFrom, http.StatusOK},
{"optional with trusted emails", "optional", "false", "true", "", "", http.StatusOK},
{"disabled with trusted emails", "disabled", "false", "true", "", "", http.StatusOK},
} {
t.Run(tt.name, func(t *testing.T) {
var input dto.AppConfigUpdateDto
require.NoError(t, dto.MapStruct(appconfig.NewTestConfig(nil), &input))
input.EmailVerification = tt.mode
input.RequireUserEmail = tt.required
input.EmailsVerified = tt.verified
input.SmtpHost = tt.host
input.SmtpFrom = tt.from
payload, err := json.Marshal(input)
require.NoError(t, err)
request := httptest.NewRequestWithContext(t.Context(), http.MethodPut, "/config", strings.NewReader(string(payload)))
request.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
require.Equal(t, tt.status, recorder.Code, recorder.Body.String())
if tt.status == http.StatusBadRequest {
var body struct {
Details struct {
Fields []apperror.FieldError `json:"fields"`
} `json:"details"`
}
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &body))
require.Equal(t, []apperror.FieldError{{Field: "emailVerification", Code: "invalid_configuration", Message: "requires user emails, an SMTP host and sender address, and forbids emails verified by default"}}, body.Details.Fields)
}
})
}
}
+1 -3
View File
@@ -19,7 +19,6 @@ import (
const (
RateLimitAPI = "api"
RateLimitSignup = "signup"
RateLimitUpdateOwnAccount = "update-own-account"
RateLimitWebauthnLogin = "webauthn-login"
RateLimitWebauthnReauthenticate = "webauthn-reauthenticate"
RateLimitOneTimeAccessToken = "one-time-access-token"
@@ -33,7 +32,7 @@ const (
)
// RateLimitPolicy is the configuration for a single rate-limit actor
// Each policy maps to one Francis rate-limit actor type and requests are keyed by client IP, so every IP is limited independently and per-route limits stay isolated from each other
// Each policy maps to one Francis rate-limit actor type and requests are keyed by client IP, so every IP is limited independently and policies can be shared across routes
type RateLimitPolicy struct {
// Name must be unique across policies and must not contain '/'
Name string
@@ -51,7 +50,6 @@ func RateLimitPolicies() []RateLimitPolicy {
return []RateLimitPolicy{
{Name: RateLimitAPI, Rate: 100, Per: time.Second, Burst: 300},
{Name: RateLimitSignup, Rate: 2, Per: time.Minute, Burst: 10},
{Name: RateLimitUpdateOwnAccount, Rate: 2, Per: time.Minute, Burst: 10},
{Name: RateLimitWebauthnLogin, Rate: 1, Per: 5 * time.Second, Burst: 10},
{Name: RateLimitWebauthnReauthenticate, Rate: 1, Per: 10 * time.Second, Burst: 5},
{Name: RateLimitOneTimeAccessToken, Rate: 1, Per: 10 * time.Second, Burst: 5},
+12 -11
View File
@@ -13,17 +13,18 @@ import (
type User struct {
Base
Username string `sortable:"case-insensitive"`
Email *string `sortable:"case-insensitive"`
EmailVerified bool `sortable:"true" filterable:"true"`
FirstName string `sortable:"case-insensitive"`
LastName string `sortable:"case-insensitive"`
DisplayName string `sortable:"case-insensitive"`
IsAdmin bool `sortable:"true" filterable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true" filterable:"true"`
UpdatedAt *datatype.DateTime
Username string `sortable:"case-insensitive"`
Email *string `sortable:"case-insensitive"`
EmailVerified bool `sortable:"true" filterable:"true"`
EmailVerificationDeadline *datatype.DateTime
FirstName string `sortable:"case-insensitive"`
LastName string `sortable:"case-insensitive"`
DisplayName string `sortable:"case-insensitive"`
IsAdmin bool `sortable:"true" filterable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true" filterable:"true"`
UpdatedAt *datatype.DateTime
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
+19 -5
View File
@@ -10,6 +10,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/fosite"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/authz"
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -136,6 +137,17 @@ func (h *authorizationHandler) completeInteraction(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// redirectAuthorizationErrors shows the errors of the authorization middleware, such as a disabled account or an unverified email, on the interaction error page
// The middleware refuses these requests before the authorize handler runs, so its error is still unwritten when the chain returns here
func (h *authorizationHandler) redirectAuthorizationErrors(c *gin.Context) {
c.Next()
if len(c.Errors) == 0 || c.Writer.Written() {
return
}
h.redirectToInteractionError(c, c.Errors.Last().Err)
}
func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) {
if rfcErr, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
attrs := []slog.Attr{
@@ -170,12 +182,14 @@ func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.C
func (h *authorizationHandler) redirectToInteractionError(c *gin.Context, err error) {
errorMessage := "An unknown error occurred during the authorization request."
if err, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
if err.HintField != "" {
errorMessage = err.HintField
} else if err.DescriptionField != "" {
errorMessage = err.DescriptionField
if rfcErr, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
if rfcErr.HintField != "" {
errorMessage = rfcErr.HintField
} else if rfcErr.DescriptionField != "" {
errorMessage = rfcErr.DescriptionField
}
} else if appErr, ok := errors.AsType[*apperror.Error](err); ok {
errorMessage = appErr.ClientMessage()
}
query := url.Values{}
@@ -15,6 +15,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/authz"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -34,6 +35,58 @@ type authorizationPARTestCase struct {
malformed bool
}
// fakeSessionAuthenticator signs every request in as a regular user, optionally one who still has to verify their email
type fakeSessionAuthenticator struct {
unverified bool
}
func (fakeSessionAuthenticator) Kind() authz.PrincipalKind { return authz.KindSession }
func (fakeSessionAuthenticator) Present(*gin.Context) bool { return true }
func (a fakeSessionAuthenticator) Authenticate(*gin.Context) (*authz.Principal, error) {
principal := &authz.Principal{Kind: authz.KindSession, UserID: "test-user", Scopes: authz.UserScopes(false, authz.KindSession)}
if a.unverified {
principal.RestrictUnverified()
}
return principal, nil
}
// A browser that the authorization middleware refuses at the authorize endpoint must land on the interaction error page instead of a JSON error
func TestAuthorizationHandlerRedirectsAuthorizationErrors(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := &authorizationHandler{}
serve := func(t *testing.T, authenticator authz.Authenticator) *httptest.ResponseRecorder {
t.Helper()
router := gin.New()
authz.NewMiddleware(authenticator).Router(router.Group("/")).Use(handler.redirectAuthorizationErrors).Optional().
GET("/authorize", authz.AccountAuthorize, func(c *gin.Context) { c.Status(http.StatusNoContent) })
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/authorize", nil))
return recorder
}
t.Run("redirects a user who still has to verify their email", func(t *testing.T) {
recorder := serve(t, fakeSessionAuthenticator{unverified: true})
require.Equal(t, http.StatusFound, recorder.Code)
location, err := url.Parse(recorder.Header().Get("Location"))
require.NoError(t, err)
require.Equal(t, "/interaction/error", location.Path)
require.Equal(t, "You must verify your email address before you can access this service.", location.Query().Get("error"))
})
t.Run("leaves an authorized request to the authorize handler", func(t *testing.T) {
recorder := serve(t, fakeSessionAuthenticator{})
require.Equal(t, http.StatusNoContent, recorder.Code)
require.Empty(t, recorder.Header().Get("Location"))
})
}
func TestAuthorizationHandlerRequiresPAR(t *testing.T) {
gin.SetMode(gin.TestMode)
const fakeURI = parRequestURIPrefix + "missing"
@@ -91,7 +144,7 @@ func testAuthorizationHandlerPAR(t *testing.T, clientType string, tt authorizati
BaseURL: baseURL, TokenBaseURL: baseURL, Secret: []byte("test-secret"),
}, nil)
require.NoError(t, err)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, baseURL, nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil, nil, nil)
handler := newAuthorizationHandler(provider, service)
params := url.Values{
"client_id": {clientID},
@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -31,7 +32,7 @@ func (f *fakeAuditLogger) Create(_ context.Context, event auditlogs.Event, _, _,
func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
auditLogger := &fakeAuditLogger{}
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, auditLogger, nil)
const (
userID = "test-user"
@@ -66,7 +67,7 @@ func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
func TestAuthorizationServiceRejectsCustomScopeWithoutResource(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -106,7 +107,7 @@ func TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication(t *test
t.Helper()
db := testutils.NewDatabaseForTest(t)
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, apiAccess)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, apiAccess)
requester := newTestAuthorizeRequesterWithForm("resource-probe", clientID, url.Values{"resource": {resource}})
_, err := service.authorize(t.Context(), authorizeInput{
@@ -140,7 +141,7 @@ func TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication(t *test
func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
auditLogger := &fakeAuditLogger{}
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, auditLogger, nil)
const (
userID = "test-user"
@@ -183,7 +184,7 @@ func TestAuthorizationServiceConsentMergesAudienceQualifiedScopeKeys(t *testing.
apiB = "https://api-b.example.com"
)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, userAccess(map[string][]string{
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, auditLogger, userAccess(map[string][]string{
apiA: {"read"},
apiB: {"read"},
}))
@@ -250,7 +251,7 @@ func TestAuthorizationServiceRequiresConsentForScopelessAPIAccess(t *testing.T)
)
// The client may reach the API but was granted none of its permissions, which is the MCP-style scopeless grant
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, userAccess(map[string][]string{
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, userAccess(map[string][]string{
api: {},
}))
@@ -292,7 +293,7 @@ func TestAuthorizationServiceRequiresConsentForScopelessAPIAccess(t *testing.T)
func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -376,7 +377,7 @@ func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) {
func TestInteractionAPIClassifiesMissingSession(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
_, err := service.getInteractionSession(t.Context(), "missing-interaction")
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
@@ -387,7 +388,7 @@ func TestInteractionAPIClassifiesMissingSession(t *testing.T) {
func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -442,7 +443,7 @@ func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing
func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -481,7 +482,7 @@ func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *
func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -541,7 +542,7 @@ func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testi
func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -574,7 +575,7 @@ func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *te
func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -610,7 +611,7 @@ func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T)
func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirements(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -663,7 +664,7 @@ func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirement
// still be required for that user rather than being inherited from the initiator.
func TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
initiatorID = "initiator-user"
@@ -725,7 +726,7 @@ func TestValidateClientPKCERequirement(t *testing.T) {
// flag is honored for confidential clients (fosite only enforces PKCE for public clients).
func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -761,7 +762,7 @@ func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -826,7 +827,7 @@ func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
clientID = "test-client"
@@ -866,7 +867,7 @@ func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -908,7 +909,7 @@ func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T)
func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -945,7 +946,7 @@ func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded
func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -992,7 +993,7 @@ func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testi
func TestAuthorizationServiceAuthorizeUsesOriginalInteractionRequestTime(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
@@ -1143,7 +1144,7 @@ func TestConsentRequired(t *testing.T) {
func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
auditLogger := &fakeAuditLogger{}
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, auditLogger, nil)
const (
userID = "test-user"
@@ -1176,7 +1177,7 @@ func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
// A client with SkipConsent must still show the consent screen when the request explicitly asks for it with prompt=consent
func TestAuthorizationServiceSkipConsentHonorsPromptConsent(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil, nil)
service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil, appconfig.StaticResolver{}), nil, nil, nil)
const (
userID = "test-user"
+30 -1
View File
@@ -9,7 +9,9 @@ import (
"strings"
"github.com/pocket-id/fosite"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/emailverification"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
@@ -18,22 +20,40 @@ const (
idTokenType = "id-token"
)
const emailVerificationRequiredHint = "You must verify your email address before you can access this service."
type ClaimsService struct {
db *gorm.DB
customClaims CustomClaimSource
baseURL string
signer TokenSigner
appConfig appconfig.AppConfigResolver
}
func newClaimsService(db *gorm.DB, customClaims CustomClaimSource, baseURL string, signer TokenSigner) *ClaimsService {
func newClaimsService(db *gorm.DB, customClaims CustomClaimSource, baseURL string, signer TokenSigner, appConfig appconfig.AppConfigResolver) *ClaimsService {
return &ClaimsService{
db: db,
customClaims: customClaims,
baseURL: baseURL,
signer: signer,
appConfig: appConfig,
}
}
// isEmailVerificationPending reports whether the user still has to verify their email before tokens can be issued for them
func (s *ClaimsService) isEmailVerificationPending(ctx context.Context, user *model.User) (bool, error) {
if !emailverification.CanBePending(user) {
return false, nil
}
cfg, err := s.appConfig.GetConfig(ctx)
if err != nil {
return false, err
}
return emailverification.IsPending(cfg, user), nil
}
// ValidateUserAccess re-checks, at token-issuance time, that the user behind a grant is
// still allowed to obtain tokens for the client.
func (s *ClaimsService) ValidateUserAccess(ctx context.Context, userID string, client Client) error {
@@ -59,6 +79,15 @@ func (s *ClaimsService) ValidateUserAccess(ctx context.Context, userID string, c
return fosite.ErrInvalidGrant.WithHint("The user account is disabled.")
}
// The authorization middleware already stops these users at the authorize step, but grants issued before the email became unverified, such as refresh tokens, are only checked here
pending, err := s.isEmailVerificationPending(ctx, &user)
if err != nil {
return err
}
if pending {
return fosite.ErrInvalidGrant.WithHint(emailVerificationRequiredHint)
}
if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
}
+48 -4
View File
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -20,7 +21,7 @@ import (
// refresh token.
func TestClaimsServiceValidateUserAccess(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
claimsService := newClaimsService(db, nil, "", nil)
claimsService := newClaimsService(db, nil, "", nil, appconfig.StaticResolver{})
group := model.UserGroup{Base: model.Base{ID: "group-allowed"}, Name: "allowed", FriendlyName: "Allowed"}
require.NoError(t, db.Create(&group).Error)
@@ -71,6 +72,49 @@ func TestClaimsServiceValidateUserAccess(t *testing.T) {
})
}
// requiredEmailVerification returns a configuration resolver that requires users to verify their email
func requiredEmailVerification() appconfig.StaticResolver {
cfg := appconfig.NewTestConfig(nil)
cfg.EmailVerification = appconfig.EmailVerificationRequired
return appconfig.StaticResolver{Config: cfg}
}
// TestClaimsServiceValidateUserAccessEmailVerification makes sure users who still have to verify their email cannot redeem codes or refresh tokens when verification is required
func TestClaimsServiceValidateUserAccessEmailVerification(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
claimsService := newClaimsService(db, nil, "", nil, requiredEmailVerification())
unverifiedUser := model.User{Base: model.Base{ID: "user-unverified"}, Username: "unverified"}
require.NoError(t, db.Create(&unverifiedUser).Error)
verifiedUser := model.User{Base: model.Base{ID: "user-verified"}, Username: "verified", EmailVerified: true}
require.NoError(t, db.Create(&verifiedUser).Error)
unverifiedAdmin := model.User{Base: model.Base{ID: "admin-unverified"}, Username: "admin", IsAdmin: true}
require.NoError(t, db.Create(&unverifiedAdmin).Error)
client := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "client-open"}, Name: "Open"}}
t.Run("unverified user is rejected with invalid_grant", func(t *testing.T) {
err := claimsService.ValidateUserAccess(t.Context(), unverifiedUser.ID, client)
require.ErrorIs(t, err, fosite.ErrInvalidGrant)
require.Contains(t, fosite.ErrorToRFC6749Error(err).GetDescription(), emailVerificationRequiredHint)
})
t.Run("verified user is allowed", func(t *testing.T) {
require.NoError(t, claimsService.ValidateUserAccess(t.Context(), verifiedUser.ID, client))
})
t.Run("unverified admin is allowed", func(t *testing.T) {
require.NoError(t, claimsService.ValidateUserAccess(t.Context(), unverifiedAdmin.ID, client))
})
t.Run("unverified user is allowed when verification is optional", func(t *testing.T) {
cfg := appconfig.NewTestConfig(nil)
cfg.EmailVerification = appconfig.EmailVerificationOptional
optional := newClaimsService(db, nil, "", nil, appconfig.StaticResolver{Config: cfg})
require.NoError(t, optional.ValidateUserAccess(t.Context(), unverifiedUser.ID, client))
})
}
type fakeCustomClaimSource struct {
claims []model.CustomClaim
}
@@ -94,7 +138,7 @@ func TestClaimsServiceGetUserClaims(t *testing.T) {
{Key: "department", Value: "engineering"}, // plain string
{Key: "roles", Value: `["admin","dev"]`}, // JSON document
}}
service := newClaimsService(db, customClaims, baseURL, nil)
service := newClaimsService(db, customClaims, baseURL, nil, appconfig.StaticResolver{})
group := model.UserGroup{Base: model.Base{ID: "group-1"}, Name: "developers", FriendlyName: "Developers"}
require.NoError(t, db.Create(&group).Error)
@@ -164,7 +208,7 @@ func TestClaimsServiceGetUserClaimsKeepsIntegerCustomClaims(t *testing.T) {
{Key: "nested", Value: `{"ids":[1234567890123456789]}`},
{Key: "not_json", Value: "1 2"},
}}
service := newClaimsService(db, customClaims, "", nil)
service := newClaimsService(db, customClaims, "", nil, appconfig.StaticResolver{})
claims, err := service.GetUserClaims(t.Context(), "user-1", []string{"profile"})
require.NoError(t, err)
@@ -184,7 +228,7 @@ func TestClaimsServiceAppliesSigningAlgToIDTokenHeader(t *testing.T) {
for _, alg := range []jwa.SignatureAlgorithm{jwa.RS256(), jwa.RS384(), jwa.ES512()} {
t.Run(alg.String(), func(t *testing.T) {
service := newClaimsService(db, nil, "", algTestSigner{alg: alg})
service := newClaimsService(db, nil, "", algTestSigner{alg: alg}, appconfig.StaticResolver{})
session := NewEmptySession()
session.Subject = "alg-user"
+2 -1
View File
@@ -13,6 +13,7 @@ import (
"time"
"github.com/pocket-id/fosite"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -164,7 +165,7 @@ func newTestDeviceService(t *testing.T, clientID, userID string, requiresReauthe
}, nil)
require.NoError(t, err)
claimsService := newClaimsService(db, nil, "", nil)
claimsService := newClaimsService(db, nil, "", nil, appconfig.StaticResolver{})
authorizationService := newAuthorizationService(db, newInteractionSessionService(db), claimsService, reauth, &fakeAuditLogger{}, apiAccess)
service := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, &fakeAuditLogger{}, db)
+10 -6
View File
@@ -12,6 +12,7 @@ import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/authz"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -56,6 +57,7 @@ type Dependencies struct {
Reauth ReauthenticationTokenConsumer
AuditLog AuditLogger
APIAccess APIAccessProvider
AppConfig appconfig.AppConfigResolver
// CleanupDisabled skips registering the cron jobs that delete expired rows from the database, for example in tests
CleanupDisabled bool
@@ -97,7 +99,7 @@ func New(ctx context.Context, deps Dependencies) (*Module, error) {
return nil, fmt.Errorf("failed to create OAuth2 provider: %w", err)
}
claimsService := newClaimsService(deps.DB, deps.CustomClaims, deps.Config.BaseURL, deps.Signer)
claimsService := newClaimsService(deps.DB, deps.CustomClaims, deps.Config.BaseURL, deps.Signer, deps.AppConfig)
previewBuilder := newClientPreviewBuilder(claimsService, provider.tokenStrategies)
interactionSessionService := newInteractionSessionService(deps.DB)
authorizationService := newAuthorizationService(deps.DB, interactionSessionService, claimsService, deps.Reauth, deps.AuditLog, deps.APIAccess)
@@ -146,11 +148,13 @@ func (m *Module) RefreshClientMetadata(ctx context.Context, clientID string) (mo
}
func (m *Module) RegisterRoutes(root, api *authz.Router) {
root.Optional().GET("/authorize", authz.AccountSession, m.authorizationHandler.authorize)
root.Optional().POST("/authorize", authz.AccountSession, m.authorizationHandler.authorize)
// Browsers reach the authorize endpoint through a redirect, so authorization errors are shown on the interaction error page instead of as JSON
authorize := root.Use(m.authorizationHandler.redirectAuthorizationErrors).Optional()
authorize.GET("/authorize", authz.AccountAuthorize, m.authorizationHandler.authorize)
authorize.POST("/authorize", authz.AccountAuthorize, m.authorizationHandler.authorize)
api.Public().GET("/oidc/interactions/:id", m.authorizationHandler.getInteractionSession)
api.POST("/oidc/interactions/:id/complete", authz.AccountSession, m.authorizationHandler.completeInteraction)
api.POST("/oidc/interactions/:id/complete", authz.AccountAuthorize, m.authorizationHandler.completeInteraction)
api.Public().POST("/oidc/par", m.parHandler.pushedAuthorizationRequest)
@@ -165,6 +169,6 @@ func (m *Module) RegisterRoutes(root, api *authz.Router) {
api.Optional().POST("/oidc/end-session", authz.AccountSession, m.endSessionHandler.endSession)
api.Public().POST("/oidc/device/authorize", m.deviceHandler.authorizeDevice)
api.POST("/oidc/device/verify", authz.AccountSession, m.deviceHandler.verifyDeviceCode)
api.GET("/oidc/device/info", authz.AccountSession, m.deviceHandler.deviceCodeInfo)
api.POST("/oidc/device/verify", authz.AccountAuthorize, m.deviceHandler.verifyDeviceCode)
api.GET("/oidc/device/info", authz.AccountAuthorize, m.deviceHandler.deviceCodeInfo)
}
+3 -2
View File
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -26,7 +27,7 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
}, nil)
require.NoError(t, err)
builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies)
builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil, appconfig.StaticResolver{}), provider.tokenStrategies)
const (
userID = "test-user"
@@ -89,7 +90,7 @@ func TestClientPreviewBuilderIgnoresUnknownScopes(t *testing.T) {
// The preview mirrors the authorize endpoint: unknown scopes are dropped
// from the previewed tokens instead of failing the preview.
builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies)
builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil, appconfig.StaticResolver{}), provider.tokenStrategies)
preview, err := builder.BuildClientPreview(t.Context(), model.OidcClient{
Base: model.Base{ID: "test-client"},
Name: "Test Client",
+2 -1
View File
@@ -18,6 +18,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -42,7 +43,7 @@ func TestRefreshTokenRotationGrace(t *testing.T) {
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "grace"}).Error)
provider, err := newProvider(store, nil, testTokenSigner{key: key}, Config{BaseURL: issuer, TokenBaseURL: issuer, Secret: []byte(secret)}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, issuer, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, issuer, nil, appconfig.StaticResolver{}), nil)
// Seed an old grant so the grace period must start at rotation rather than token issuance
request := newTestRequester(requestID, clientID, userID, "grace-jti")
+9 -8
View File
@@ -22,6 +22,7 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
@@ -63,7 +64,7 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil)
form := url.Values{"grant_type": {"client_credentials"}}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
@@ -122,7 +123,7 @@ func TestTokenHandlerClientCredentialsDropsIdentityScopes(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil)
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"openid"}}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
@@ -182,7 +183,7 @@ func TestTokenHandlerClientCredentialsUsesClientSubjectGrants(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), apiAccess)
requestToken := func(t *testing.T, scope string) map[string]any {
t.Helper()
@@ -253,7 +254,7 @@ func TestTokenHandlerClientCredentialsDefaultsResourceScopes(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), apiAccess)
requestToken := func(t *testing.T, target string, form url.Values) map[string]any {
t.Helper()
@@ -408,7 +409,7 @@ func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil)
form := url.Values{
"grant_type": {"refresh_token"},
@@ -592,7 +593,7 @@ func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), apiAccess)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), apiAccess)
form := url.Values{
"grant_type": {"refresh_token"},
@@ -759,7 +760,7 @@ func TestTokenHandlerClientSecretRotation(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil)
requestToken := func(t *testing.T, clientSecret string) map[string]any {
t.Helper()
@@ -827,7 +828,7 @@ func TestTokenHandlerClientWithoutSecrets(t *testing.T) {
Secret: []byte(secret),
}, nil)
require.NoError(t, err)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil), nil)
handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), nil)
form := url.Values{"grant_type": {"client_credentials"}}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
@@ -14,6 +14,7 @@ import (
"github.com/pocket-id/fosite"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -54,7 +55,7 @@ func TestUserInfoHandler(t *testing.T) {
}, nil)
require.NoError(t, err)
handler := newUserInfoHandler(provider, newClaimsService(db, nil, baseURL, nil), baseURL)
handler := newUserInfoHandler(provider, newClaimsService(db, nil, baseURL, nil, appconfig.StaticResolver{}), baseURL)
issueAccessToken := func(t *testing.T, requestID, subject string, scopes ...string) string {
t.Helper()
+133 -19
View File
@@ -21,6 +21,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/apperror"
"github.com/pocket-id/pocket-id/backend/internal/backchannellogout"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/emailverification"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
@@ -36,9 +37,24 @@ type UserService struct {
scimSyncScheduler ScimSyncScheduler
backchannelLogout *backchannellogout.Service
fileStorage storage.FileStorage
emailVerification EmailVerificationSender
}
func NewUserService(db *gorm.DB, jwtService *JwtService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimSyncScheduler ScimSyncScheduler, backchannelLogout *backchannellogout.Service, fileStorage storage.FileStorage) *UserService {
// EmailVerificationSender emails verification links for a user's current email or for an email they want to change to
type EmailVerificationSender interface {
Send(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error
RequestEmailChange(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID, newEmail string) error
}
// ownEmailChange records how a user's change of their own email is handled once the update is committed
type ownEmailChange struct {
// applied is set when the new email replaced the current one right away
applied bool
// deferred holds the new email when it only replaces the current one after the user confirms it
deferred *string
}
func NewUserService(db *gorm.DB, jwtService *JwtService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimSyncScheduler ScimSyncScheduler, backchannelLogout *backchannellogout.Service, fileStorage storage.FileStorage, emailVerification EmailVerificationSender) *UserService {
return &UserService{
db: db,
jwtService: jwtService,
@@ -47,6 +63,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, customClaimService *Cus
scimSyncScheduler: scimSyncScheduler,
backchannelLogout: backchannelLogout,
fileStorage: fileStorage,
emailVerification: emailVerification,
}
}
@@ -498,6 +515,16 @@ func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigMo
}
}
// A user's own email change may have to be confirmed first, so that's decided before the update is applied
var change ownEmailChange
if updateOwnUser {
var err error
change, err = s.prepareOwnEmailChange(ctx, cfg, userID, &updatedUser, isLdapSync, tx)
if err != nil {
return model.User{}, err
}
}
user, err := s.UpdateUserInternal(ctx, cfg, userID, updatedUser, updateOwnUser, isLdapSync, tx)
if err != nil {
return model.User{}, err
@@ -516,9 +543,85 @@ func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigMo
s.backchannelLogout.NotifyUser(ctx, userID)
}
// The verified email stays in effect until the user confirms the new address through the emailed link
// The other changes are already saved, so the error tells the user that only the email change could not be started
if change.deferred != nil {
err = s.emailVerification.RequestEmailChange(ctx, cfg, userID, *change.deferred)
if err != nil {
return user, apperror.EmailChangeNotStarted(err)
}
}
// A user whose new email still restricts the account needs the verification link right away
if change.applied && emailverification.IsPending(cfg, &user) {
err = s.emailVerification.Send(ctx, cfg, userID)
if err != nil {
slog.WarnContext(ctx, "Failed to send the email verification message after an email change", slog.String("userID", userID), slog.Any("error", err))
}
}
return user, nil
}
// prepareOwnEmailChange decides how a user's change of their own email is handled
// A deferred change keeps the current email in the update, so the new address only takes effect once the user confirms it
func (s *UserService) prepareOwnEmailChange(ctx context.Context, cfg *appconfig.AppConfigModel, userID string, updatedUser *dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (ownEmailChange, error) {
var change ownEmailChange
if s.emailVerification == nil || updatedUser.Email == nil {
return change, nil
}
// Lock the user, so the decision below still holds when the update is applied
var user model.User
err := tx.
WithContext(ctx).
Where("id = ?", userID).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&user).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return change, apperror.UserNotFound()
}
if err != nil {
return change, err
}
// The update ignores an email the user may not edit, so there's nothing to verify then
emailChanged := user.Email == nil || *user.Email != *updatedUser.Email
if !emailChanged || !canEditProfile(cfg, &user, true, isLdapSync) {
return change, nil
}
if !emailverification.DefersEmailChange(cfg, &user) {
change.applied = true
return change, nil
}
// The address is checked now, so the user doesn't wait for a link to find out it's taken
taken, err := isEmailTaken(ctx, tx, user.ID, *updatedUser.Email)
if err != nil {
return change, err
}
if taken {
return change, apperror.AlreadyInUse("email")
}
change.deferred = updatedUser.Email
updatedUser.Email = user.Email
return change, nil
}
// canEditProfile reports whether an update may change the user's personal fields, such as their name and email
// LDAP users are managed by the directory and users may be barred from editing their own account, while the LDAP sync itself can always update them
func canEditProfile(cfg *appconfig.AppConfigModel, user *model.User, updateOwnUser bool, isLdapSync bool) bool {
if isLdapSync {
return true
}
isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue()
return !isLdapUser && (!updateOwnUser || cfg.AllowOwnAccountEdit.IsTrue())
}
// UpdateUserInternal updates a user within an existing transaction
// It's exported for the LDAP sync, which reconciles users and groups in a single transaction of its own
func (s *UserService) UpdateUserInternal(ctx context.Context, cfg *appconfig.AppConfigModel, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) {
@@ -540,15 +643,10 @@ func (s *UserService) UpdateUserInternal(ctx context.Context, cfg *appconfig.App
return model.User{}, err
}
// Check if this is an LDAP user and LDAP is enabled
isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue()
allowOwnAccountEdit := cfg.AllowOwnAccountEdit.IsTrue()
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) {
if !canEditProfile(cfg, &user, updateOwnUser, isLdapSync) {
// Restricted update: Only locale can be changed when:
// - User is from LDAP, OR
// - User is editing their own account but global setting disallows self-editing
// (Exception: LDAP sync operations can update everything)
user.Locale = updatedUser.Locale
} else {
// Full update: Allow updating all personal fields
@@ -573,6 +671,11 @@ func (s *UserService) UpdateUserInternal(ctx context.Context, cfg *appconfig.App
}
}
// A verified account is no longer deleted for missing the signup verification deadline
if user.EmailVerified {
user.EmailVerificationDeadline = nil
}
user.UpdatedAt = new(datatype.DateTime(time.Now()))
err = tx.
@@ -669,22 +772,20 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
}
func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User, tx *gorm.DB) error {
if user.Email != nil {
taken, err := isEmailTaken(ctx, tx, user.ID, *user.Email)
if err != nil {
return err
}
if taken {
return apperror.AlreadyInUse("email")
}
}
var result struct {
Found bool
}
err := tx.
WithContext(ctx).
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND email = ?) AS found`, user.ID, user.Email).
First(&result).
Error
if err != nil {
return err
}
if result.Found {
return apperror.AlreadyInUse("email")
}
err = tx.
WithContext(ctx).
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND username = ?) AS found`, user.ID, user.Username).
First(&result).
@@ -740,3 +841,16 @@ func (s *UserService) DisableUserInternal(ctx context.Context, tx *gorm.DB, user
return nil
}
// isEmailTaken reports whether a user other than the given one already uses the email
func isEmailTaken(ctx context.Context, tx *gorm.DB, userID, email string) (bool, error) {
var result struct {
Found bool
}
err := tx.
WithContext(ctx).
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND email = ?) AS found`, userID, email).
First(&result).
Error
return result.Found, err
}
@@ -1,7 +1,9 @@
package service
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
@@ -34,6 +36,7 @@ func newTestUserService(t *testing.T) (*UserService, *UserGroupService) {
nil,
nil,
fileStorage,
nil,
)
groupService := NewUserGroupService(db, nil, nil)
@@ -204,3 +207,174 @@ func TestDeleteUserBumpsGroupUpdatedAt(t *testing.T) {
require.Empty(t, updated.Users)
require.True(t, updated.LastModified().After(past), "deleting a member must bump the group's UpdatedAt")
}
type recordingEmailVerificationSender struct {
sent []string
requested []string
requestErr error
}
func (r *recordingEmailVerificationSender) Send(_ context.Context, _ *appconfig.AppConfigModel, userID string) error {
r.sent = append(r.sent, userID)
return nil
}
func (r *recordingEmailVerificationSender) RequestEmailChange(_ context.Context, _ *appconfig.AppConfigModel, _, newEmail string) error {
r.requested = append(r.requested, newEmail)
return r.requestErr
}
func TestUpdateOwnEmailWithVerification(t *testing.T) {
// setup creates a user with the given verification state and an update that changes only their email
setup := func(t *testing.T, mode string, emailVerified bool) (*UserService, *recordingEmailVerificationSender, *appconfig.AppConfigModel, model.User, dto.UserCreateDto) {
t.Helper()
config := appconfig.NewTestConfig(nil)
config.EmailVerification = appconfig.AppConfigValue(mode)
userService, _ := newTestUserService(t)
sender := &recordingEmailVerificationSender{}
userService.emailVerification = sender
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "changer",
Email: new("old@example.com"),
EmailVerified: emailVerified,
FirstName: "Email",
LastName: "Changer",
})
require.NoError(t, err)
update := dto.UserCreateDto{
Username: user.Username,
Email: new("new@example.com"),
FirstName: user.FirstName,
LastName: user.LastName,
}
return userService, sender, config, user, update
}
t.Run("a verified email stays in effect until the new address is confirmed", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "old@example.com", *updated.Email)
require.True(t, updated.EmailVerified)
require.Equal(t, []string{"new@example.com"}, sender.requested)
require.Empty(t, sender.sent)
})
t.Run("a deferred change to an address another user has is rejected right away", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
_, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{Username: "other", Email: new("new@example.com"), FirstName: "Other"})
require.NoError(t, err)
_, err = userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.True(t, apperror.IsCode(err, apperror.CodeAlreadyInUse))
require.Empty(t, sender.requested)
})
t.Run("an unverified email is replaced right away and the new one has to be verified", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, false)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "new@example.com", *updated.Email)
require.False(t, updated.EmailVerified)
require.Equal(t, []string{user.ID}, sender.sent)
require.Empty(t, sender.requested)
})
t.Run("the other fields are saved while the email change waits for confirmation", func(t *testing.T) {
userService, _, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
update.FirstName = "Renamed"
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "Renamed", updated.FirstName)
require.Equal(t, "old@example.com", *updated.Email)
})
t.Run("a change link that can't be sent is reported apart from the saved fields", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
sender.requestErr = errors.New("smtp unavailable")
update.FirstName = "Renamed"
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.True(t, apperror.IsCode(err, apperror.CodeEmailChangeNotStarted))
require.Equal(t, "Renamed", updated.FirstName)
stored, err := userService.GetUser(t.Context(), user.ID)
require.NoError(t, err)
require.Equal(t, "Renamed", stored.FirstName)
require.Equal(t, "old@example.com", *stored.Email)
})
t.Run("no change link is sent when the user may not edit their own account", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
config.AllowOwnAccountEdit = "false"
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "old@example.com", *updated.Email)
require.Empty(t, sender.requested)
require.Empty(t, sender.sent)
})
t.Run("an admin changing another user's email replaces it right away", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationRequired, true)
update.EmailVerified = true
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, false, false)
require.NoError(t, err)
require.Equal(t, "new@example.com", *updated.Email)
require.Empty(t, sender.requested)
require.Empty(t, sender.sent)
})
t.Run("the email is replaced right away when verification is optional", func(t *testing.T) {
userService, sender, config, user, update := setup(t, appconfig.EmailVerificationOptional, true)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, update, true, false)
require.NoError(t, err)
require.Equal(t, "new@example.com", *updated.Email)
require.False(t, updated.EmailVerified)
require.Empty(t, sender.sent)
require.Empty(t, sender.requested)
})
}
func TestMarkingEmailVerifiedClearsSignupDeadline(t *testing.T) {
config := appconfig.NewTestConfig(nil)
userService, _ := newTestUserService(t)
user, err := userService.CreateUser(t.Context(), config, dto.UserCreateDto{
Username: "pending",
Email: new("pending@example.com"),
FirstName: "Pending",
LastName: "User",
})
require.NoError(t, err)
deadline := datatype.DateTime(time.Now().Add(time.Hour))
require.NoError(t, userService.db.Model(&model.User{}).Where("id = ?", user.ID).Update("email_verification_deadline", deadline).Error)
updated, err := userService.UpdateUser(t.Context(), config, user.ID, dto.UserCreateDto{
Username: user.Username,
Email: user.Email,
EmailVerified: true,
FirstName: user.FirstName,
LastName: user.LastName,
}, false, false)
require.NoError(t, err)
require.True(t, updated.EmailVerified)
stored, err := userService.GetUser(t.Context(), user.ID)
require.NoError(t, err)
require.Nil(t, stored.EmailVerificationDeadline)
}
+113
View File
@@ -0,0 +1,113 @@
package usersignup
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/italypaleale/francis/builtin/cronjob"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
const (
// cleanupJobInterval is how often overdue signups are deleted, which bounds how long an account can outlive its deadline
cleanupJobInterval = time.Hour
// cleanupJobJitter spreads each occurrence around its scheduled time, so it doesn't coincide with the other cleanup jobs
cleanupJobJitter = 5 * time.Minute
)
// UserDeleter deletes a user together with everything that references it
type UserDeleter interface {
DeleteUser(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string, allowLdapDelete bool) error
}
type cleanupJob struct {
db *gorm.DB
appConfig appconfig.AppConfigResolver
users UserDeleter
}
func newCleanupJob(db *gorm.DB, appConfig appconfig.AppConfigResolver, users UserDeleter) (*cronjob.CronJob, error) {
job := &cleanupJob{
db: db,
appConfig: appConfig,
users: users,
}
cronActor, err := cronjob.New(
"DeleteUnverifiedSignups",
cronjob.WithJob(job.deleteUnverifiedSignups),
cronjob.WithInterval(cleanupJobInterval),
cronjob.WithJitter(cleanupJobJitter),
// Also run right after the job is first registered, so accounts that missed their deadline while Pocket ID wasn't running are removed at startup
cronjob.WithImmediate(),
cronjob.WithLogger(slog.Default()),
)
if err != nil {
return nil, fmt.Errorf("error creating unverified signup cleanup cron job: %w", err)
}
return cronActor, nil
}
// deleteUnverifiedSignups deletes self-signed-up accounts that did not verify their email before their deadline
func (j *cleanupJob) deleteUnverifiedSignups(ctx context.Context) error {
dbConfig, err := j.appConfig.GetConfig(ctx)
if err != nil {
return fmt.Errorf("error loading app config: %w", err)
}
// Deadlines only apply while verification is required
// Clearing them otherwise keeps accounts that were used in the meantime from being deleted once verification is required again
if dbConfig.EmailVerification.String() != appconfig.EmailVerificationRequired {
st := j.db.
WithContext(ctx).
Model(&model.User{}).
Where("email_verification_deadline IS NOT NULL").
Update("email_verification_deadline", nil)
if st.Error != nil {
return fmt.Errorf("failed to clear email verification deadlines: %w", st.Error)
}
if st.RowsAffected > 0 {
slog.InfoContext(ctx, "Cleared email verification deadlines because verification is no longer required", slog.Int64("count", st.RowsAffected))
}
return nil
}
// Load the overdue accounts
// Admins are exempt from email verification, so an account promoted to admin before its deadline is kept
var userIDs []string
err = j.db.
WithContext(ctx).
Model(&model.User{}).
Where("email_verification_deadline < ? AND email_verified = ? AND is_admin = ?", datatype.DateTime(time.Now()), false, false).
Pluck("id", &userIDs).
Error
if err != nil {
return fmt.Errorf("failed to list accounts that missed their email verification deadline: %w", err)
}
if len(userIDs) == 0 {
return nil
}
// Delete each account through the user service so its client sessions, SCIM peers and profile picture are cleaned up as well
// A failure is logged and the remaining accounts are still processed
deleted := 0
for _, userID := range userIDs {
err = j.users.DeleteUser(ctx, dbConfig, userID, false)
if err != nil {
slog.ErrorContext(ctx, "Failed to delete an account that missed its email verification deadline", slog.String("userID", userID), slog.Any("error", err))
continue
}
deleted++
}
slog.InfoContext(ctx, "Deleted accounts that missed their email verification deadline", slog.Int("count", deleted))
return nil
}
@@ -0,0 +1,76 @@
package usersignup
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
type testUserDeleter struct {
db *gorm.DB
deleted []string
}
func (d *testUserDeleter) DeleteUser(ctx context.Context, _ *appconfig.AppConfigModel, userID string, _ bool) error {
d.deleted = append(d.deleted, userID)
return d.db.WithContext(ctx).Delete(&model.User{}, "id = ?", userID).Error
}
func TestCleanupJobDeletesUnverifiedSignups(t *testing.T) {
// seed creates users whose deadline lies in the past or future, covering every condition the job checks
seed := func(t *testing.T, db *gorm.DB) {
t.Helper()
past := new(datatype.DateTime(time.Now().Add(-time.Minute)))
future := new(datatype.DateTime(time.Now().Add(time.Hour)))
users := []model.User{
{Base: model.Base{ID: "overdue"}, Username: "overdue", EmailVerificationDeadline: past},
{Base: model.Base{ID: "not-yet-due"}, Username: "not-yet-due", EmailVerificationDeadline: future},
{Base: model.Base{ID: "promoted-admin"}, Username: "promoted-admin", IsAdmin: true, EmailVerificationDeadline: past},
{Base: model.Base{ID: "verified"}, Username: "verified", EmailVerified: true, EmailVerificationDeadline: past},
{Base: model.Base{ID: "existing"}, Username: "existing"},
}
require.NoError(t, db.Create(&users).Error)
}
newJob := func(db *gorm.DB, mode string) (*cleanupJob, *testUserDeleter) {
cfg := appconfig.NewTestConfig(nil)
cfg.EmailVerification = appconfig.AppConfigValue(mode)
deleter := &testUserDeleter{db: db}
return &cleanupJob{db: db, appConfig: appconfig.StaticResolver{Config: cfg}, users: deleter}, deleter
}
t.Run("deletes only overdue unverified regular users when verification is required", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
seed(t, db)
job, deleter := newJob(db, appconfig.EmailVerificationRequired)
require.NoError(t, job.deleteUnverifiedSignups(t.Context()))
require.Equal(t, []string{"overdue"}, deleter.deleted)
var remaining []string
require.NoError(t, db.Model(&model.User{}).Order("id").Pluck("id", &remaining).Error)
require.Equal(t, []string{"existing", "not-yet-due", "promoted-admin", "verified"}, remaining)
})
t.Run("clears every deadline instead of deleting when verification is not required", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
seed(t, db)
job, deleter := newJob(db, appconfig.EmailVerificationOptional)
require.NoError(t, job.deleteUnverifiedSignups(t.Context()))
require.Empty(t, deleter.deleted)
var withDeadline int64
require.NoError(t, db.Model(&model.User{}).Where("email_verification_deadline IS NOT NULL").Count(&withDeadline).Error)
require.Zero(t, withDeadline)
})
}
+23
View File
@@ -29,6 +29,10 @@ type UserCreator interface {
CreateUserInternal(ctx context.Context, dbConfig *appconfig.AppConfigModel, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error)
}
type EmailVerificationSender interface {
Send(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error
}
// ScimSyncScheduler schedules SCIM after the signup transaction has committed
type ScimSyncScheduler interface {
ScheduleSync(ctx context.Context)
@@ -43,6 +47,12 @@ type Dependencies struct {
UserCreator UserCreator
AppConfig appconfig.AppConfigResolver
ScimSync ScimSyncScheduler
UserDeleter UserDeleter
EmailVerification EmailVerificationSender
// CleanupDisabled skips registering the cron job that deletes signups that missed their email verification deadline, for example in tests
CleanupDisabled bool
}
type Module struct {
@@ -58,6 +68,19 @@ func New(deps Dependencies) (*Module, error) {
return nil, fmt.Errorf("error registering the %s actor: %w", SignupTokenActorType, err)
}
// Register the job that deletes self-signed-up accounts that did not verify their email in time
if !deps.CleanupDisabled {
job, err := newCleanupJob(deps.DB, deps.AppConfig, deps.UserDeleter)
if err != nil {
return nil, err
}
err = deps.Actors.RegisterBuiltInActor(job)
if err != nil {
return nil, fmt.Errorf("error registering unverified signup cleanup cron actor: %w", err)
}
}
service := newService(deps, deps.Actors.Service())
return &Module{
service: service,
+35 -12
View File
@@ -17,6 +17,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/emailverification"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -26,23 +27,28 @@ import (
// It must match the value emitted by the JWT service in the access token's "amr" claim
const authenticationMethodOneTimePassword = "otp"
// signupVerificationPeriod is how long a self-signed-up account has to verify its email before it is deleted
const signupVerificationPeriod = emailverification.TokenLifetime
type Service struct {
db *gorm.DB
actorService *actor.Service
userCreator UserCreator
signer TokenService
auditLog AuditLogger
scimSync ScimSyncScheduler
db *gorm.DB
actorService *actor.Service
userCreator UserCreator
signer TokenService
auditLog AuditLogger
scimSync ScimSyncScheduler
emailVerification EmailVerificationSender
}
func newService(deps Dependencies, actorService *actor.Service) *Service {
return &Service{
db: deps.DB,
actorService: actorService,
userCreator: deps.UserCreator,
signer: deps.Signer,
auditLog: deps.AuditLog,
scimSync: deps.ScimSync,
db: deps.DB,
actorService: actorService,
userCreator: deps.UserCreator,
signer: deps.Signer,
auditLog: deps.AuditLog,
scimSync: deps.ScimSync,
emailVerification: deps.EmailVerification,
}
}
@@ -94,6 +100,13 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
return model.User{}, "", err
}
if emailverification.IsPending(config, &user) {
err = s.emailVerification.Send(ctx, config, user.ID)
if err != nil {
slog.WarnContext(ctx, "Failed to send the email verification message after signup", slog.String("userID", user.ID), slog.Any("error", err))
}
}
return user, accessToken, nil
}
@@ -110,6 +123,16 @@ func (s *Service) createSignedUpUser(ctx context.Context, config *appconfig.AppC
return model.User{}, "", err
}
// Give the account a deadline to verify its email, after which it is deleted so abandoned signups cannot squat an address
if emailverification.IsPending(config, &user) {
deadline := datatype.DateTime(time.Now().Add(signupVerificationPeriod))
err = tx.WithContext(ctx).Model(&user).Update("email_verification_deadline", deadline).Error
if err != nil {
return model.User{}, "", err
}
user.EmailVerificationDeadline = &deadline
}
accessToken, err := s.signer.GenerateAccessToken(user, "", config.SessionDuration.AsDurationMinutes())
if err != nil {
return model.User{}, "", err
@@ -30,6 +30,25 @@ func (f fakeUserCreator) CreateUserInternal(_ context.Context, _ *appconfig.AppC
return f.user, nil
}
// dbUserCreator stores the user in the signup transaction, so changes made later in that transaction can be asserted
type dbUserCreator struct{}
func (dbUserCreator) CreateUserInternal(_ context.Context, _ *appconfig.AppConfigModel, input dto.UserCreateDto, _ bool, tx *gorm.DB) (model.User, error) {
user := model.User{Username: input.Username, Email: input.Email, EmailVerified: input.EmailVerified}
err := tx.Create(&user).Error
return user, err
}
type fakeEmailVerificationSender struct {
err error
userIDs []string
}
func (f *fakeEmailVerificationSender) Send(_ context.Context, _ *appconfig.AppConfigModel, userID string) error {
f.userIDs = append(f.userIDs, userID)
return f.err
}
type fakeSigner struct{}
func (fakeSigner) GenerateAccessToken(_ model.User, _ string, _ time.Duration) (string, error) {
@@ -164,3 +183,56 @@ func listAllOptions() utils.ListRequestOptions {
opts.Pagination.Limit = 100
return opts
}
func TestSignUpRequiresEmailVerification(t *testing.T) {
signUp := func(t *testing.T, mode string, sender *fakeEmailVerificationSender) (*gorm.DB, model.User) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
svc := newSignupServiceForTest(t, db, dbUserCreator{})
svc.emailVerification = sender
config := appconfig.NewTestConfig(nil)
config.AllowUserSignups = "open"
config.EmailVerification = appconfig.AppConfigValue(mode)
user, _, err := svc.SignUp(t.Context(), config, signUpDto{
Username: "newuser",
Email: new("newuser@example.com"),
}, "1.2.3.4", "test-agent")
require.NoError(t, err)
return db, user
}
t.Run("sets a deadline and sends the verification email when verification is required", func(t *testing.T) {
sender := &fakeEmailVerificationSender{}
before := time.Now()
db, user := signUp(t, appconfig.EmailVerificationRequired, sender)
require.NotNil(t, user.EmailVerificationDeadline)
require.WithinDuration(t, before.Add(24*time.Hour), user.EmailVerificationDeadline.ToTime(), time.Minute)
var stored model.User
require.NoError(t, db.First(&stored, "id = ?", user.ID).Error)
require.NotNil(t, stored.EmailVerificationDeadline)
require.Equal(t, []string{user.ID}, sender.userIDs)
})
t.Run("keeps the account without a deadline when verification is optional", func(t *testing.T) {
sender := &fakeEmailVerificationSender{}
db, user := signUp(t, appconfig.EmailVerificationOptional, sender)
var stored model.User
require.NoError(t, db.First(&stored, "id = ?", user.ID).Error)
require.Nil(t, stored.EmailVerificationDeadline)
require.Empty(t, sender.userIDs)
})
t.Run("a failed delivery does not fail the signup", func(t *testing.T) {
sender := &fakeEmailVerificationSender{err: errors.New("smtp unavailable")}
_, user := signUp(t, appconfig.EmailVerificationRequired, sender)
require.NotNil(t, user.EmailVerificationDeadline)
require.Equal(t, []string{user.ID}, sender.userIDs)
})
}
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN email_verification_deadline;
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN email_verification_deadline TIMESTAMPTZ;
@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN email_verification_deadline;
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN email_verification_deadline DATETIME;