mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-14 21:59:05 +02:00
feat: add ability to force email verification (#1837)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
Reference in New Issue
Block a user