mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-14 13:49:03 +02:00
feat: add ability to force email verification (#1837)
This commit is contained in:
+13
-9
@@ -2,19 +2,13 @@ package appconfig
|
|||||||
|
|
||||||
import "encoding/json"
|
import "encoding/json"
|
||||||
|
|
||||||
const (
|
// UnmarshalJSON preserves settings stored in actor state before boolean toggles were replaced by modes
|
||||||
LoginNotificationDisabled = "disabled"
|
|
||||||
LoginNotificationAlways = "always"
|
|
||||||
LoginNotificationIPAndUserAgent = "ipAndUserAgent"
|
|
||||||
LoginNotificationBrowserRecognition = "browserRecognition"
|
|
||||||
)
|
|
||||||
|
|
||||||
// UnmarshalJSON preserves the notification policy when loading state written before notification modes existed
|
|
||||||
func (m *AppConfigModel) UnmarshalJSON(data []byte) error {
|
func (m *AppConfigModel) UnmarshalJSON(data []byte) error {
|
||||||
type config AppConfigModel
|
type config AppConfigModel
|
||||||
value := struct {
|
value := struct {
|
||||||
*config
|
*config
|
||||||
LegacyNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled"`
|
LegacyNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled"`
|
||||||
|
LegacyEmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled"`
|
||||||
}{config: (*config)(m)}
|
}{config: (*config)(m)}
|
||||||
if err := json.Unmarshal(data, &value); err != nil {
|
if err := json.Unmarshal(data, &value); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -22,6 +16,9 @@ func (m *AppConfigModel) UnmarshalJSON(data []byte) error {
|
|||||||
if m.EmailLoginNotificationMode == "" && value.LegacyNotificationEnabled != "" {
|
if m.EmailLoginNotificationMode == "" && value.LegacyNotificationEnabled != "" {
|
||||||
m.EmailLoginNotificationMode = legacyLoginNotificationMode(value.LegacyNotificationEnabled)
|
m.EmailLoginNotificationMode = legacyLoginNotificationMode(value.LegacyNotificationEnabled)
|
||||||
}
|
}
|
||||||
|
if m.EmailVerification == "" && value.LegacyEmailVerificationEnabled != "" {
|
||||||
|
m.EmailVerification = legacyEmailVerification(value.LegacyEmailVerificationEnabled)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,3 +28,10 @@ func legacyLoginNotificationMode(enabled AppConfigValue) AppConfigValue {
|
|||||||
}
|
}
|
||||||
return LoginNotificationDisabled
|
return LoginNotificationDisabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func legacyEmailVerification(enabled AppConfigValue) AppConfigValue {
|
||||||
|
if enabled.IsTrue() {
|
||||||
|
return EmailVerificationOptional
|
||||||
|
}
|
||||||
|
return EmailVerificationDisabled
|
||||||
|
}
|
||||||
@@ -62,6 +62,9 @@ func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
|
|||||||
if legacyCfg["emailLoginNotificationMode"] == "" {
|
if legacyCfg["emailLoginNotificationMode"] == "" {
|
||||||
dest.EmailLoginNotificationMode = legacyLoginNotificationMode(AppConfigValue(legacyCfg["emailLoginNotificationEnabled"]))
|
dest.EmailLoginNotificationMode = legacyLoginNotificationMode(AppConfigValue(legacyCfg["emailLoginNotificationEnabled"]))
|
||||||
}
|
}
|
||||||
|
if legacyCfg["emailVerification"] == "" {
|
||||||
|
dest.EmailVerification = legacyEmailVerification(AppConfigValue(legacyCfg["emailVerificationEnabled"]))
|
||||||
|
}
|
||||||
|
|
||||||
rt := reflect.ValueOf(dest).Elem().Type()
|
rt := reflect.ValueOf(dest).Elem().Type()
|
||||||
rv := reflect.ValueOf(dest).Elem()
|
rv := reflect.ValueOf(dest).Elem()
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"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 {
|
type AppConfigModel struct {
|
||||||
// General
|
// General
|
||||||
AppName AppConfigValue `json:"appName" env:"APP_NAME" public:"true"`
|
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"`
|
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"`
|
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"`
|
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
|
// LDAP
|
||||||
LdapEnabled AppConfigValue `json:"ldapEnabled" env:"LDAP_ENABLED" type:"bool" public:"true"`
|
LdapEnabled AppConfigValue `json:"ldapEnabled" env:"LDAP_ENABLED" type:"bool" public:"true"`
|
||||||
LdapUrl AppConfigValue `json:"ldapUrl" env:"LDAP_URL"`
|
LdapUrl AppConfigValue `json:"ldapUrl" env:"LDAP_URL"`
|
||||||
@@ -145,7 +158,7 @@ func getDefaultConfig() *AppConfigModel {
|
|||||||
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
|
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
|
||||||
EmailOneTimeAccessAsAdminEnabled: "false",
|
EmailOneTimeAccessAsAdminEnabled: "false",
|
||||||
EmailApiKeyExpirationEnabled: "false",
|
EmailApiKeyExpirationEnabled: "false",
|
||||||
EmailVerificationEnabled: "false",
|
EmailVerification: EmailVerificationDisabled,
|
||||||
// LDAP
|
// LDAP
|
||||||
LdapEnabled: "false",
|
LdapEnabled: "false",
|
||||||
LdapUrl: "",
|
LdapUrl: "",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package appconfig
|
package appconfig
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -300,3 +301,27 @@ func TestAppConfigModel_Update(t *testing.T) {
|
|||||||
assert.Equal(t, "thisKeyDoesNotExist", notFound.field)
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// Validate the resolved configuration before exposing values to the rest of the application
|
||||||
err := validateEnvConfig(dest)
|
err := validateEnvConfig(dest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -462,6 +462,37 @@ func TestService_CIMDURLAllowlist(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestService_LoadDbConfigFromEnv(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 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 {
|
writeSecretFile := func(t *testing.T, content string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
// This file contains utils for unit tests and it's only built when the "unit" tag is set
|
// This file contains utils for unit tests and it's only built when the "unit" tag is set
|
||||||
package appconfig
|
package appconfig
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
|
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
|
||||||
func NewTestAppConfigService(config *AppConfigModel) *AppConfigService {
|
func NewTestAppConfigService(config *AppConfigModel) *AppConfigService {
|
||||||
if config == nil {
|
if config == nil {
|
||||||
@@ -25,3 +27,12 @@ func NewTestConfig(config *AppConfigModel) *AppConfigModel {
|
|||||||
|
|
||||||
return config
|
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 {
|
func InvalidEmailVerificationToken() *Error {
|
||||||
return New(CodeEmailVerificationTokenInvalid, http.StatusBadRequest, "Email verification token is invalid")
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const (
|
|||||||
CodeDeviceLoginExpired Code = "device_login_expired"
|
CodeDeviceLoginExpired Code = "device_login_expired"
|
||||||
CodeReauthenticationRequired Code = "reauthentication_required"
|
CodeReauthenticationRequired Code = "reauthentication_required"
|
||||||
CodeEmailVerificationTokenInvalid Code = "invalid_email_verification_token"
|
CodeEmailVerificationTokenInvalid Code = "invalid_email_verification_token"
|
||||||
|
CodeEmailVerificationRequired Code = "email_verification_required"
|
||||||
|
CodeEmailChangeNotStarted Code = "email_change_not_started"
|
||||||
CodeSetupNotAvailable Code = "setup_not_available"
|
CodeSetupNotAvailable Code = "setup_not_available"
|
||||||
CodeSetupAlreadyCompleted Code = "setup_already_completed"
|
CodeSetupAlreadyCompleted Code = "setup_already_completed"
|
||||||
CodeTokenInvalidOrExpired Code = "token_invalid_or_expired"
|
CodeTokenInvalidOrExpired Code = "token_invalid_or_expired"
|
||||||
|
|||||||
@@ -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
|
// 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) {
|
if !principal.Scopes.Has(scope) {
|
||||||
c.Abort()
|
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)))
|
_ = c.Error(apperror.MissingScope(string(scope)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,12 +15,13 @@ import (
|
|||||||
|
|
||||||
// fakeAuthenticator accepts any request carrying its header and resolves to the configured outcome
|
// fakeAuthenticator accepts any request carrying its header and resolves to the configured outcome
|
||||||
type fakeAuthenticator struct {
|
type fakeAuthenticator struct {
|
||||||
kind PrincipalKind
|
kind PrincipalKind
|
||||||
header string
|
header string
|
||||||
user string
|
user string
|
||||||
admin bool
|
admin bool
|
||||||
err error
|
unverified bool
|
||||||
calls int
|
err error
|
||||||
|
calls int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *fakeAuthenticator) Kind() PrincipalKind {
|
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)}
|
principal := &Principal{Kind: a.kind, UserID: a.user, Scopes: UserScopes(a.admin, a.kind)}
|
||||||
|
if a.unverified {
|
||||||
|
principal.RestrictUnverified()
|
||||||
|
}
|
||||||
if a.kind == KindSession {
|
if a.kind == KindSession {
|
||||||
principal.AuthenticationMethod = "passkey"
|
principal.AuthenticationMethod = "passkey"
|
||||||
principal.AuthenticationTime = time.Unix(1700000000, 0)
|
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) {
|
func TestMiddlewareFallsThroughInvalidCredentials(t *testing.T) {
|
||||||
invalidSession := &fakeAuthenticator{kind: KindSession, header: "X-Session", err: apperror.NotSignedIn()}
|
invalidSession := &fakeAuthenticator{kind: KindSession, header: "X-Session", err: apperror.NotSignedIn()}
|
||||||
apiKey := &fakeAuthenticator{kind: KindAPIKey, header: "X-Key", user: "key-user"}
|
apiKey := &fakeAuthenticator{kind: KindAPIKey, header: "X-Key", user: "key-user"}
|
||||||
|
|||||||
@@ -14,11 +14,29 @@ type Principal struct {
|
|||||||
UserID string
|
UserID string
|
||||||
Scopes ScopeSet
|
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 and AuthenticationTime describe how the session was established and are only set for KindSession
|
||||||
AuthenticationMethod string
|
AuthenticationMethod string
|
||||||
AuthenticationTime time.Time
|
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
|
// 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
|
// Anonymous requests on optional and public routes get the zero Principal, whose UserID is empty
|
||||||
func PrincipalFrom(c *gin.Context) Principal {
|
func PrincipalFrom(c *gin.Context) Principal {
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ func (r *Router) Group(relativePath string) *Router {
|
|||||||
return &Router{group: r.group.Group(relativePath), auth: r.auth, optional: r.optional}
|
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
|
// 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
|
// Handlers on these routes must check PrincipalFrom before relying on a signed-in user
|
||||||
func (r *Router) Optional() *Router {
|
func (r *Router) Optional() *Router {
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package authz
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
||||||
)
|
)
|
||||||
|
|
||||||
func noop(c *gin.Context) {
|
func noop(c *gin.Context) {
|
||||||
@@ -25,6 +28,7 @@ func TestIsDeclared(t *testing.T) {
|
|||||||
api.Group("/nested").Group("/deeper").DELETE("/:id", UsersWrite, noop)
|
api.Group("/nested").Group("/deeper").DELETE("/:id", UsersWrite, noop)
|
||||||
api.Optional().GET("/optional", AccountSession, noop)
|
api.Optional().GET("/optional", AccountSession, noop)
|
||||||
api.Public().POST("/signup", noop)
|
api.Public().POST("/signup", noop)
|
||||||
|
api.Use(noop).GET("/with-middleware", AccountRead, noop)
|
||||||
m.Router(engine.Group("/")).Optional().GET("/authorize", AccountSession, noop)
|
m.Router(engine.Group("/")).Optional().GET("/authorize", AccountSession, noop)
|
||||||
|
|
||||||
// Register routes past the routers
|
// Register routes past the routers
|
||||||
@@ -41,12 +45,33 @@ func TestIsDeclared(t *testing.T) {
|
|||||||
"DELETE /api/nested/deeper/:id": true,
|
"DELETE /api/nested/deeper/:id": true,
|
||||||
"GET /api/optional": true,
|
"GET /api/optional": true,
|
||||||
"POST /api/signup": true,
|
"POST /api/signup": true,
|
||||||
|
"GET /api/with-middleware": true,
|
||||||
"GET /authorize": true,
|
"GET /authorize": true,
|
||||||
"GET /api/forgotten": false,
|
"GET /api/forgotten": false,
|
||||||
"POST /api/users": false,
|
"POST /api/users": false,
|
||||||
}, declared)
|
}, 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) {
|
func TestRouterRejectsUnknownScopes(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := NewMiddleware().Router(gin.New().Group("/api"))
|
r := NewMiddleware().Router(gin.New().Group("/api"))
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const (
|
|||||||
AccountApps Scope = "account:apps"
|
AccountApps Scope = "account:apps"
|
||||||
AccountAuditLogs Scope = "account:audit-logs"
|
AccountAuditLogs Scope = "account:audit-logs"
|
||||||
AccountSession Scope = "account:session"
|
AccountSession Scope = "account:session"
|
||||||
|
AccountAuthorize Scope = "account:authorize"
|
||||||
AccountPasskeysEnroll Scope = "account:passkeys:enroll"
|
AccountPasskeysEnroll Scope = "account:passkeys:enroll"
|
||||||
AccountAPIKeysCreate Scope = "account:api-keys:create"
|
AccountAPIKeysCreate Scope = "account:api-keys:create"
|
||||||
)
|
)
|
||||||
@@ -77,6 +78,7 @@ var catalog = []definition{
|
|||||||
{AccountApps, CategoryAccount, delegated},
|
{AccountApps, CategoryAccount, delegated},
|
||||||
{AccountAuditLogs, CategoryAccount, delegated},
|
{AccountAuditLogs, CategoryAccount, delegated},
|
||||||
{AccountSession, CategoryAccount, KindSession},
|
{AccountSession, CategoryAccount, KindSession},
|
||||||
|
{AccountAuthorize, CategoryAccount, KindSession},
|
||||||
{AccountPasskeysEnroll, CategoryAccount, KindSession},
|
{AccountPasskeysEnroll, CategoryAccount, KindSession},
|
||||||
{AccountAPIKeysCreate, CategoryAccount, KindSession},
|
{AccountAPIKeysCreate, CategoryAccount, KindSession},
|
||||||
|
|
||||||
@@ -132,6 +134,9 @@ func (s ScopeSet) Has(scope Scope) bool {
|
|||||||
return ok
|
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
|
// 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
|
// 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 {
|
func UserScopes(isAdmin bool, kind PrincipalKind) ScopeSet {
|
||||||
|
|||||||
@@ -62,6 +62,36 @@ func TestUserScopes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrincipalRestrictUnverified(t *testing.T) {
|
||||||
|
t.Run("keeps only the scopes needed to verify the email and withholds the rest", func(t *testing.T) {
|
||||||
|
principal := &Principal{Kind: KindSession, Scopes: UserScopes(false, KindSession)}
|
||||||
|
principal.RestrictUnverified()
|
||||||
|
|
||||||
|
require.Equal(t, NewScopeSet(AccountRead, AccountWrite, AccountPasskeys, AccountPasskeysEnroll, AccountSession), principal.Scopes)
|
||||||
|
require.Equal(t, NewScopeSet(AccountAPIKeys, AccountApps, AccountAuditLogs, AccountAuthorize, AccountAPIKeysCreate), principal.WithheldUntilVerified)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("never adds scopes the credential kind cannot hold", func(t *testing.T) {
|
||||||
|
principal := &Principal{Kind: KindAPIKey, Scopes: UserScopes(false, KindAPIKey)}
|
||||||
|
principal.RestrictUnverified()
|
||||||
|
|
||||||
|
require.Equal(t, NewScopeSet(AccountRead, AccountWrite, AccountPasskeys), principal.Scopes)
|
||||||
|
require.False(t, principal.WithheldUntilVerified.Has(AccountAuthorize))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("withholds admin scopes", func(t *testing.T) {
|
||||||
|
principal := &Principal{Kind: KindSession, Scopes: UserScopes(true, KindSession)}
|
||||||
|
principal.RestrictUnverified()
|
||||||
|
|
||||||
|
for _, entry := range catalog {
|
||||||
|
if entry.category == CategoryAdmin {
|
||||||
|
require.False(t, principal.Scopes.Has(entry.scope), entry.scope)
|
||||||
|
require.True(t, principal.WithheldUntilVerified.Has(entry.scope), entry.scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnknownScope(t *testing.T) {
|
func TestUnknownScope(t *testing.T) {
|
||||||
unknown := Scope("unknown:scope")
|
unknown := Scope("unknown:scope")
|
||||||
require.False(t, unknown.Known())
|
require.False(t, unknown.Known())
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func TestEveryAPIRouteDeclaresItsAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
engine := gin.New()
|
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))
|
require.NoError(t, registerRoutes(engine, nil, svc, auth, nil))
|
||||||
|
|
||||||
// Collect every API route that bypassed the authz routers
|
// 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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
err = registerRoutes(r, db, svc, auth, rateLimitServices)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -171,7 +171,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, auth *authz.Middl
|
|||||||
rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginVerification),
|
rateLimitMiddleware.Add(middleware.RateLimitDeviceLoginVerification),
|
||||||
)
|
)
|
||||||
controller.NewOidcController(apiRouter, fileSizeLimitMiddleware, svc.oidcService, svc.appConfigService)
|
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)
|
controller.NewAppConfigController(apiRouter, svc.appConfigService, svc.emailModule)
|
||||||
svc.ldapSyncModule.RegisterRoutes(apiRouter)
|
svc.ldapSyncModule.RegisterRoutes(apiRouter)
|
||||||
controller.NewAppImagesController(apiRouter, fileSizeLimitMiddleware, svc.appImagesService)
|
controller.NewAppImagesController(apiRouter, fileSizeLimitMiddleware, svc.appImagesService)
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ func initServices(
|
|||||||
Reauth: svc.webauthnModule,
|
Reauth: svc.webauthnModule,
|
||||||
AuditLog: svc.auditLogsModule,
|
AuditLog: svc.auditLogsModule,
|
||||||
APIAccess: svc.apiModule,
|
APIAccess: svc.apiModule,
|
||||||
|
AppConfig: svc.appConfigService,
|
||||||
// Disable in test environment
|
// Disable in test environment
|
||||||
CleanupDisabled: common.EnvConfig.AppEnv.IsTest(),
|
CleanupDisabled: common.EnvConfig.AppEnv.IsTest(),
|
||||||
})
|
})
|
||||||
@@ -193,7 +194,20 @@ func initServices(
|
|||||||
}
|
}
|
||||||
|
|
||||||
svc.userGroupService = service.NewUserGroupService(db, svc.scimSyncModule, backchannelLogoutService)
|
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{
|
svc.ldapSyncModule, err = ldapsync.New(ldapsync.Dependencies{
|
||||||
DB: db,
|
DB: db,
|
||||||
@@ -225,13 +239,17 @@ func initServices(
|
|||||||
}
|
}
|
||||||
|
|
||||||
svc.userSignUpModule, err = usersignup.New(usersignup.Dependencies{
|
svc.userSignUpModule, err = usersignup.New(usersignup.Dependencies{
|
||||||
DB: db,
|
DB: db,
|
||||||
Actors: actors,
|
Actors: actors,
|
||||||
Signer: svc.jwtService,
|
Signer: svc.jwtService,
|
||||||
AuditLog: svc.auditLogsModule,
|
AuditLog: svc.auditLogsModule,
|
||||||
UserCreator: svc.userService,
|
UserCreator: svc.userService,
|
||||||
AppConfig: svc.appConfigService,
|
AppConfig: svc.appConfigService,
|
||||||
ScimSync: svc.scimSyncModule,
|
ScimSync: svc.scimSyncModule,
|
||||||
|
UserDeleter: svc.userService,
|
||||||
|
EmailVerification: svc.emailVerificationModule,
|
||||||
|
// Disable in test environment
|
||||||
|
CleanupDisabled: common.EnvConfig.AppEnv.IsTest(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create user signup module: %w", err)
|
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)
|
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{
|
svc.environmentModule = environment.New(environment.Dependencies{
|
||||||
HTTPClient: httpClient,
|
HTTPClient: httpClient,
|
||||||
SQLiteOnNetworkedFilesystem: sqliteOnNetworkedFilesystem,
|
SQLiteOnNetworkedFilesystem: sqliteOnNetworkedFilesystem,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
"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/apperror"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
"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)
|
jwtService, err := service.NewJwtService(t.Context(), db, instanceID)
|
||||||
require.NoError(t, err)
|
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})
|
apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db, CleanupDisabled: true})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
auth := middleware.NewAuthorization(apiKeyModule, userService, jwtService)
|
auth := middleware.NewAuthorization(apiKeyModule, userService, jwtService, appconfig.StaticResolver{})
|
||||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||||
|
|
||||||
user := model.User{Username: "upload-admin", IsAdmin: true}
|
user := model.User{Username: "upload-admin", IsAdmin: true}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ type AppConfigUpdateDto struct {
|
|||||||
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required,boolean_string"`
|
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required,boolean_string"`
|
||||||
EmailLoginNotificationMode string `json:"emailLoginNotificationMode" binding:"required,oneof=disabled always ipAndUserAgent browserRecognition"`
|
EmailLoginNotificationMode string `json:"emailLoginNotificationMode" binding:"required,oneof=disabled always ipAndUserAgent browserRecognition"`
|
||||||
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required,boolean_string"`
|
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"`
|
CIMDURLAllowlist string `json:"cimdUrlAllowlist" binding:"omitempty,cimd_url_allowlist"`
|
||||||
AutoCreateOIDCClientSecret string `json:"autoCreateOidcClientSecret" binding:"required,boolean_string"`
|
AutoCreateOIDCClientSecret string `json:"autoCreateOidcClientSecret" binding:"required,boolean_string"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin/binding"
|
"github.com/gin-gonic/gin/binding"
|
||||||
|
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserDto struct {
|
type UserDto struct {
|
||||||
@@ -20,6 +22,8 @@ type UserDto struct {
|
|||||||
UserGroups []UserGroupMinimalDto `json:"userGroups"`
|
UserGroups []UserGroupMinimalDto `json:"userGroups"`
|
||||||
LdapID *string `json:"ldapId"`
|
LdapID *string `json:"ldapId"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
|
|
||||||
|
EmailVerificationDeadline *datatype.DateTime `json:"emailVerificationDeadline,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto struct {
|
type UserCreateDto struct {
|
||||||
|
|||||||
@@ -92,6 +92,15 @@ func init() {
|
|||||||
"integer_string": func(fl validator.FieldLevel) bool {
|
"integer_string": func(fl validator.FieldLevel) bool {
|
||||||
return validateIntegerString(fl.Field().String())
|
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 {
|
for k, v := range validators {
|
||||||
err := engine.RegisterValidation(k, v)
|
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"
|
return "invalid_format", "must be either true or false"
|
||||||
case "integer_string":
|
case "integer_string":
|
||||||
return "invalid_format", "must be an integer"
|
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:
|
default:
|
||||||
return validationError.Tag(), "is invalid"
|
return validationError.Tag(), "is invalid"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ const ActorType = "EmailVerification"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// MethodIssue replaces the outstanding verification state for a user
|
// MethodIssue replaces the outstanding verification state for a user
|
||||||
MethodIssue = "issue"
|
MethodIssue = "issue"
|
||||||
methodConsume = "consume"
|
methodConsume = "consume"
|
||||||
methodDiscard = "discard"
|
methodDiscard = "discard"
|
||||||
methodRestore = "restore"
|
methodRestore = "restore"
|
||||||
|
methodCancelChange = "cancelChange"
|
||||||
|
methodPendingChange = "pendingChange"
|
||||||
)
|
)
|
||||||
|
|
||||||
type consumeStatus string
|
type consumeStatus string
|
||||||
@@ -32,7 +34,14 @@ const (
|
|||||||
|
|
||||||
// State is the persisted verification state for one user
|
// State is the persisted verification state for one user
|
||||||
type State struct {
|
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
|
Email string
|
||||||
ExpiresAt time.Time
|
ExpiresAt time.Time
|
||||||
}
|
}
|
||||||
@@ -68,11 +77,35 @@ func (a *emailVerificationActor) Invoke(ctx context.Context, method string, data
|
|||||||
return nil, a.discard(ctx, data)
|
return nil, a.discard(ctx, data)
|
||||||
case methodRestore:
|
case methodRestore:
|
||||||
return nil, a.restore(ctx, data)
|
return nil, a.restore(ctx, data)
|
||||||
|
case methodCancelChange:
|
||||||
|
return nil, a.cancelChange(ctx)
|
||||||
default:
|
default:
|
||||||
return nil, common.ErrUnsupportedActorMethod{Method: method}
|
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 {
|
func (a *emailVerificationActor) issue(ctx context.Context, data actor.Envelope) error {
|
||||||
state, err := decodeState(data, MethodIssue)
|
state, err := decodeState(data, MethodIssue)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -134,6 +167,23 @@ func (a *emailVerificationActor) discard(ctx context.Context, data actor.Envelop
|
|||||||
return nil
|
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 {
|
func (a *emailVerificationActor) restore(ctx context.Context, data actor.Envelope) error {
|
||||||
state, err := decodeState(data, methodRestore)
|
state, err := decodeState(data, methodRestore)
|
||||||
if err != nil {
|
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/authz"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
"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 {
|
type handler struct {
|
||||||
service *Service
|
service *Service
|
||||||
appConfig appconfig.AppConfigResolver
|
appConfig appconfig.AppConfigResolver
|
||||||
@@ -64,3 +71,44 @@ func (h *handler) verify(c *gin.Context) error {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package emailverification
|
package emailverification
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -16,8 +17,8 @@ type Dependencies struct {
|
|||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
Actors francishost.Host
|
Actors francishost.Host
|
||||||
|
|
||||||
Users UserProvider
|
|
||||||
EmailSender EmailSender
|
EmailSender EmailSender
|
||||||
|
ScimSync ScimSyncScheduler
|
||||||
AppConfig appconfig.AppConfigResolver
|
AppConfig appconfig.AppConfigResolver
|
||||||
AppURL string
|
AppURL string
|
||||||
}
|
}
|
||||||
@@ -33,15 +34,27 @@ func New(deps Dependencies) (*Module, error) {
|
|||||||
return nil, fmt.Errorf("error registering the %s actor: %w", ActorType, err)
|
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{
|
return &Module{
|
||||||
service: service,
|
service: service,
|
||||||
handler: newHandler(service, deps.AppConfig),
|
handler: newHandler(service, deps.AppConfig),
|
||||||
}, nil
|
}, 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
|
// RegisterRoutes mounts the email verification endpoints
|
||||||
func (m *Module) RegisterRoutes(r *authz.Router, sendRateLimit, verifyRateLimit gin.HandlerFunc) {
|
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/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.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
|
||||||
|
}
|
||||||
@@ -17,36 +17,52 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const tokenLifetime = 24 * time.Hour
|
// TokenLifetime is how long a verification link stays valid
|
||||||
|
const TokenLifetime = 24 * time.Hour
|
||||||
type UserProvider interface {
|
|
||||||
GetUser(ctx context.Context, userID string) (model.User, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
type EmailSender interface {
|
type EmailSender interface {
|
||||||
SendEmailVerification(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, verificationLink string) error
|
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 {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
actors *actor.Service
|
actors *actor.Service
|
||||||
users UserProvider
|
|
||||||
emailSender EmailSender
|
emailSender EmailSender
|
||||||
|
scimSync ScimSyncScheduler
|
||||||
appURL string
|
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{
|
return &Service{
|
||||||
db: db,
|
db: db,
|
||||||
actors: actors,
|
actors: actors,
|
||||||
users: users,
|
|
||||||
emailSender: emailSender,
|
emailSender: emailSender,
|
||||||
|
scimSync: scimSync,
|
||||||
appURL: appURL,
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -54,28 +70,76 @@ func (s *Service) Send(ctx context.Context, dbConfig *appconfig.AppConfigModel,
|
|||||||
return apperror.UserEmailNotSet()
|
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)
|
token, err := utils.GenerateRandomAlphanumericString(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the token hash in the email verification actor for this user
|
// Persist the token hash in the email verification actor for this user
|
||||||
state := State{
|
state.TokenHash = utils.CreateSha256Hash(token)
|
||||||
TokenHash: utils.CreateSha256Hash(token),
|
state.ExpiresAt = time.Now().Add(TokenLifetime)
|
||||||
Email: *user.Email,
|
|
||||||
ExpiresAt: time.Now().Add(tokenLifetime),
|
|
||||||
}
|
|
||||||
_, err = s.actors.Invoke(ctx, ActorType, user.ID, MethodIssue, state)
|
_, err = s.actors.Invoke(ctx, ActorType, user.ID, MethodIssue, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error issuing email verification token: %w", err)
|
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(
|
err = s.emailSender.SendEmailVerification(
|
||||||
ctx,
|
ctx,
|
||||||
dbConfig,
|
dbConfig,
|
||||||
user.FullName(),
|
user.FullName(),
|
||||||
*user.Email,
|
state.Email,
|
||||||
s.appURL+"/verify-email?token="+token,
|
s.appURL+"/verify-email?token="+token,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -108,16 +172,33 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error {
|
|||||||
return apperror.InvalidEmailVerificationToken()
|
return apperror.InvalidEmailVerificationToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the user's email_verified field in the database
|
// Mark the email as verified
|
||||||
// We are querying by both user ID and email to ensure that the email has not changed since the token was issued
|
// A verified account is no longer deleted for missing the signup verification deadline
|
||||||
update := s.db.
|
updates := map[string]any{
|
||||||
|
"email_verified": true,
|
||||||
|
"email_verification_deadline": nil,
|
||||||
|
"updated_at": new(datatype.DateTime(time.Now())),
|
||||||
|
}
|
||||||
|
query := s.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Model(&model.User{}).
|
Model(&model.User{}).
|
||||||
Where("id = ? AND email = ?", userID, result.State.Email).
|
Where("id = ?", userID)
|
||||||
Updates(map[string]any{
|
|
||||||
"email_verified": true,
|
// A token for an email change also moves the new address into place, unless the email was changed another way in the meantime
|
||||||
"updated_at": new(datatype.DateTime(time.Now())),
|
// 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 update.Error != nil {
|
||||||
// If the database update fails, restore the token in the actor to allow the user to retry verification
|
// If the database update fails, restore the token in the actor to allow the user to retry verification
|
||||||
s.restoreAfterDatabaseFailure(ctx, userID, result.State)
|
s.restoreAfterDatabaseFailure(ctx, userID, result.State)
|
||||||
@@ -127,6 +208,11 @@ func (s *Service) Verify(ctx context.Context, userID, token string) error {
|
|||||||
return apperror.InvalidEmailVerificationToken()
|
return apperror.InvalidEmailVerificationToken()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Service providers have to learn about the new address
|
||||||
|
if isEmailChange && s.scimSync != nil {
|
||||||
|
s.scimSync.ScheduleSync(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,20 +15,11 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
"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/apperror"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"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"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
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 {
|
type testEmailSender struct {
|
||||||
err error
|
err error
|
||||||
sent []sentVerificationEmail
|
sent []sentVerificationEmail
|
||||||
@@ -61,7 +52,7 @@ func newServiceForTest(t *testing.T, emailSender *testEmailSender) (*Service, *l
|
|||||||
var service *Service
|
var service *Service
|
||||||
host := testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) {
|
host := testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) {
|
||||||
require.NoError(t, host.RegisterActor(ActorType, NewActor))
|
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)
|
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)
|
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) {
|
func TestVerifyRejectsTokenAfterAddressChanges(t *testing.T) {
|
||||||
emailSender := &testEmailSender{}
|
emailSender := &testEmailSender{}
|
||||||
service, host, db := newServiceForTest(t, emailSender)
|
service, host, db := newServiceForTest(t, emailSender)
|
||||||
@@ -255,3 +262,107 @@ func TestSendDiscardsTokenWhenEmailDeliveryFails(t *testing.T) {
|
|||||||
var state State
|
var state State
|
||||||
require.ErrorIs(t, host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound)
|
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,
|
||||||
nil,
|
nil,
|
||||||
fileStorage,
|
fileStorage,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
svc := newService(Dependencies{
|
svc := newService(Dependencies{
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
"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/apperror"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/authz"
|
"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/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
"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
|
// 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
|
// 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(
|
return authz.NewMiddleware(
|
||||||
NewSessionAuthenticator(jwtService, userService),
|
NewSessionAuthenticator(jwtService, userService, appConfig),
|
||||||
NewAPIKeyAuthenticator(apiKeyModule),
|
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
|
// SessionAuthenticator authenticates the session access token Pocket ID issues after sign-in
|
||||||
type SessionAuthenticator struct {
|
type SessionAuthenticator struct {
|
||||||
jwtService *service.JwtService
|
jwtService *service.JwtService
|
||||||
userService *service.UserService
|
userService *service.UserService
|
||||||
|
appConfig appconfig.AppConfigResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSessionAuthenticator(jwtService *service.JwtService, userService *service.UserService) *SessionAuthenticator {
|
func NewSessionAuthenticator(jwtService *service.JwtService, userService *service.UserService, appConfig appconfig.AppConfigResolver) *SessionAuthenticator {
|
||||||
return &SessionAuthenticator{jwtService: jwtService, userService: userService}
|
return &SessionAuthenticator{jwtService: jwtService, userService: userService, appConfig: appConfig}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *SessionAuthenticator) Kind() authz.PrincipalKind {
|
func (a *SessionAuthenticator) Kind() authz.PrincipalKind {
|
||||||
@@ -59,7 +81,7 @@ func (a *SessionAuthenticator) Authenticate(c *gin.Context) (*authz.Principal, e
|
|||||||
return nil, apperror.TokenInvalid()
|
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)
|
user, err := a.userService.GetUser(c, subject)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, apperror.NotSignedIn()
|
return nil, apperror.NotSignedIn()
|
||||||
@@ -68,13 +90,19 @@ func (a *SessionAuthenticator) Authenticate(c *gin.Context) (*authz.Principal, e
|
|||||||
return nil, apperror.UserDisabled()
|
return nil, apperror.UserDisabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &authz.Principal{
|
principal := &authz.Principal{
|
||||||
Kind: authz.KindSession,
|
Kind: authz.KindSession,
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Scopes: authz.UserScopes(user.IsAdmin, authz.KindSession),
|
Scopes: authz.UserScopes(user.IsAdmin, authz.KindSession),
|
||||||
AuthenticationMethod: authenticationMethod,
|
AuthenticationMethod: authenticationMethod,
|
||||||
AuthenticationTime: authenticationTime,
|
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
|
// 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
|
// APIKeyAuthenticator authenticates a personal API key sent in the X-API-Key header
|
||||||
type APIKeyAuthenticator struct {
|
type APIKeyAuthenticator struct {
|
||||||
apiKeyModule *apikey.Module
|
apiKeyModule *apikey.Module
|
||||||
|
appConfig appconfig.AppConfigResolver
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAPIKeyAuthenticator(apiKeyModule *apikey.Module) *APIKeyAuthenticator {
|
func NewAPIKeyAuthenticator(apiKeyModule *apikey.Module, appConfig appconfig.AppConfigResolver) *APIKeyAuthenticator {
|
||||||
return &APIKeyAuthenticator{apiKeyModule: apiKeyModule}
|
return &APIKeyAuthenticator{apiKeyModule: apiKeyModule, appConfig: appConfig}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *APIKeyAuthenticator) Kind() authz.PrincipalKind {
|
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 nil, apperror.UserDisabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
return &authz.Principal{
|
principal := &authz.Principal{
|
||||||
Kind: authz.KindAPIKey,
|
Kind: authz.KindAPIKey,
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
Scopes: authz.UserScopes(user.IsAdmin, authz.KindAPIKey),
|
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"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
"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/authz"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
"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)
|
jwtService, err := service.NewJwtService(t.Context(), db, instanceID)
|
||||||
require.NoError(t, err)
|
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})
|
apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db, CleanupDisabled: true})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Create one credential of each kind for a regular user, an admin and a disabled user
|
// Create one credential of each kind for a regular user, an admin and a disabled user
|
||||||
user := createUserForAuthorizationTest(t, db, "auth-user", false, false)
|
// The regular users and the admin have a verified email, the others still have to verify theirs
|
||||||
admin := createUserForAuthorizationTest(t, db, "auth-admin", true, false)
|
user := createUserForAuthorizationTest(t, db, "auth-user", false, false, true)
|
||||||
disabled := createUserForAuthorizationTest(t, db, "auth-disabled", true, 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)
|
userSession, err := jwtService.GenerateAccessToken(user, "", time.Hour)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
unverifiedSession, err := jwtService.GenerateAccessToken(unverified, "", time.Hour)
|
||||||
|
require.NoError(t, err)
|
||||||
userAPIKey := createAPIKeyForAuthorizationTest(t, db, user, "user-raw-api-key")
|
userAPIKey := createAPIKeyForAuthorizationTest(t, db, user, "user-raw-api-key")
|
||||||
adminAPIKey := createAPIKeyForAuthorizationTest(t, db, admin, "admin-raw-api-key")
|
adminAPIKey := createAPIKeyForAuthorizationTest(t, db, admin, "admin-raw-api-key")
|
||||||
disabledAPIKey := createAPIKeyForAuthorizationTest(t, db, disabled, "disabled-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
|
// 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 := gin.New()
|
||||||
router.Use(NewErrorHandlerMiddleware().Add())
|
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) {
|
ok := func(c *gin.Context) {
|
||||||
c.String(http.StatusOK, authz.PrincipalFrom(c).UserID)
|
c.String(http.StatusOK, authz.PrincipalFrom(c).UserID)
|
||||||
}
|
}
|
||||||
apiRouter.GET("/session-only", authz.AccountSession, ok)
|
apiRouter.GET("/session-only", authz.AccountSession, ok)
|
||||||
apiRouter.GET("/account", authz.AccountRead, 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)
|
apiRouter.GET("/admin", authz.UsersRead, ok)
|
||||||
|
|
||||||
tests := []struct {
|
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: "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: "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: "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: "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"},
|
{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()
|
t.Helper()
|
||||||
|
|
||||||
user := model.User{
|
user := model.User{
|
||||||
Username: username,
|
Username: username,
|
||||||
Email: new(username + "@example.com"),
|
Email: new(username + "@example.com"),
|
||||||
FirstName: "Auth",
|
EmailVerified: emailVerified,
|
||||||
LastName: "User",
|
FirstName: "Auth",
|
||||||
DisplayName: "Auth User",
|
LastName: "User",
|
||||||
IsAdmin: isAdmin,
|
DisplayName: "Auth User",
|
||||||
Disabled: disabled,
|
IsAdmin: isAdmin,
|
||||||
|
Disabled: disabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := db.Create(&user).Error
|
err := db.Create(&user).Error
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/apperror"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||||
@@ -228,7 +229,7 @@ func TestValidationResponseUsesAppConfigTypeMessages(t *testing.T) {
|
|||||||
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
|
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
|
||||||
EmailLoginNotificationMode: "disabled",
|
EmailLoginNotificationMode: "disabled",
|
||||||
EmailApiKeyExpirationEnabled: "false",
|
EmailApiKeyExpirationEnabled: "false",
|
||||||
EmailVerificationEnabled: "false",
|
EmailVerification: "disabled",
|
||||||
AutoCreateOIDCClientSecret: "true",
|
AutoCreateOIDCClientSecret: "true",
|
||||||
}
|
}
|
||||||
payload, err := json.Marshal(input)
|
payload, err := json.Marshal(input)
|
||||||
@@ -269,3 +270,57 @@ func TestClassifyDeadlineAsRequestTimeout(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusGatewayTimeout, classified.status)
|
require.Equal(t, http.StatusGatewayTimeout, classified.status)
|
||||||
require.Equal(t, "Request timed out", classified.message)
|
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 (
|
const (
|
||||||
RateLimitAPI = "api"
|
RateLimitAPI = "api"
|
||||||
RateLimitSignup = "signup"
|
RateLimitSignup = "signup"
|
||||||
RateLimitUpdateOwnAccount = "update-own-account"
|
|
||||||
RateLimitWebauthnLogin = "webauthn-login"
|
RateLimitWebauthnLogin = "webauthn-login"
|
||||||
RateLimitWebauthnReauthenticate = "webauthn-reauthenticate"
|
RateLimitWebauthnReauthenticate = "webauthn-reauthenticate"
|
||||||
RateLimitOneTimeAccessToken = "one-time-access-token"
|
RateLimitOneTimeAccessToken = "one-time-access-token"
|
||||||
@@ -33,7 +32,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RateLimitPolicy is the configuration for a single rate-limit actor
|
// 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 {
|
type RateLimitPolicy struct {
|
||||||
// Name must be unique across policies and must not contain '/'
|
// Name must be unique across policies and must not contain '/'
|
||||||
Name string
|
Name string
|
||||||
@@ -51,7 +50,6 @@ func RateLimitPolicies() []RateLimitPolicy {
|
|||||||
return []RateLimitPolicy{
|
return []RateLimitPolicy{
|
||||||
{Name: RateLimitAPI, Rate: 100, Per: time.Second, Burst: 300},
|
{Name: RateLimitAPI, Rate: 100, Per: time.Second, Burst: 300},
|
||||||
{Name: RateLimitSignup, Rate: 2, Per: time.Minute, Burst: 10},
|
{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: RateLimitWebauthnLogin, Rate: 1, Per: 5 * time.Second, Burst: 10},
|
||||||
{Name: RateLimitWebauthnReauthenticate, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
{Name: RateLimitWebauthnReauthenticate, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||||
{Name: RateLimitOneTimeAccessToken, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
{Name: RateLimitOneTimeAccessToken, Rate: 1, Per: 10 * time.Second, Burst: 5},
|
||||||
|
|||||||
@@ -13,17 +13,18 @@ import (
|
|||||||
type User struct {
|
type User struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Username string `sortable:"case-insensitive"`
|
Username string `sortable:"case-insensitive"`
|
||||||
Email *string `sortable:"case-insensitive"`
|
Email *string `sortable:"case-insensitive"`
|
||||||
EmailVerified bool `sortable:"true" filterable:"true"`
|
EmailVerified bool `sortable:"true" filterable:"true"`
|
||||||
FirstName string `sortable:"case-insensitive"`
|
EmailVerificationDeadline *datatype.DateTime
|
||||||
LastName string `sortable:"case-insensitive"`
|
FirstName string `sortable:"case-insensitive"`
|
||||||
DisplayName string `sortable:"case-insensitive"`
|
LastName string `sortable:"case-insensitive"`
|
||||||
IsAdmin bool `sortable:"true" filterable:"true"`
|
DisplayName string `sortable:"case-insensitive"`
|
||||||
Locale *string
|
IsAdmin bool `sortable:"true" filterable:"true"`
|
||||||
LdapID *string
|
Locale *string
|
||||||
Disabled bool `sortable:"true" filterable:"true"`
|
LdapID *string
|
||||||
UpdatedAt *datatype.DateTime
|
Disabled bool `sortable:"true" filterable:"true"`
|
||||||
|
UpdatedAt *datatype.DateTime
|
||||||
|
|
||||||
CustomClaims []CustomClaim
|
CustomClaims []CustomClaim
|
||||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pocket-id/fosite"
|
"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/authz"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
"github.com/pocket-id/pocket-id/backend/internal/httpserver"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"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)
|
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) {
|
func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) {
|
||||||
if rfcErr, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
|
if rfcErr, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
|
||||||
attrs := []slog.Attr{
|
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) {
|
func (h *authorizationHandler) redirectToInteractionError(c *gin.Context, err error) {
|
||||||
errorMessage := "An unknown error occurred during the authorization request."
|
errorMessage := "An unknown error occurred during the authorization request."
|
||||||
if err, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
|
if rfcErr, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
|
||||||
if err.HintField != "" {
|
if rfcErr.HintField != "" {
|
||||||
errorMessage = err.HintField
|
errorMessage = rfcErr.HintField
|
||||||
} else if err.DescriptionField != "" {
|
} else if rfcErr.DescriptionField != "" {
|
||||||
errorMessage = err.DescriptionField
|
errorMessage = rfcErr.DescriptionField
|
||||||
}
|
}
|
||||||
|
} else if appErr, ok := errors.AsType[*apperror.Error](err); ok {
|
||||||
|
errorMessage = appErr.ClientMessage()
|
||||||
}
|
}
|
||||||
|
|
||||||
query := url.Values{}
|
query := url.Values{}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/authz"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
@@ -34,6 +35,58 @@ type authorizationPARTestCase struct {
|
|||||||
malformed bool
|
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) {
|
func TestAuthorizationHandlerRequiresPAR(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
const fakeURI = parRequestURIPrefix + "missing"
|
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"),
|
BaseURL: baseURL, TokenBaseURL: baseURL, Secret: []byte("test-secret"),
|
||||||
}, nil)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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)
|
handler := newAuthorizationHandler(provider, service)
|
||||||
params := url.Values{
|
params := url.Values{
|
||||||
"client_id": {clientID},
|
"client_id": {clientID},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/gorm"
|
"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/apperror"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
|
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"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) {
|
func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
auditLogger := &fakeAuditLogger{}
|
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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -66,7 +67,7 @@ func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
|
|||||||
|
|
||||||
func TestAuthorizationServiceRejectsCustomScopeWithoutResource(t *testing.T) {
|
func TestAuthorizationServiceRejectsCustomScopeWithoutResource(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -106,7 +107,7 @@ func TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication(t *test
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
db := testutils.NewDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
|
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}})
|
requester := newTestAuthorizeRequesterWithForm("resource-probe", clientID, url.Values{"resource": {resource}})
|
||||||
_, err := service.authorize(t.Context(), authorizeInput{
|
_, err := service.authorize(t.Context(), authorizeInput{
|
||||||
@@ -140,7 +141,7 @@ func TestAuthorizationServiceCollapsesResourceErrorsBeforeAuthentication(t *test
|
|||||||
func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T) {
|
func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
auditLogger := &fakeAuditLogger{}
|
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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -183,7 +184,7 @@ func TestAuthorizationServiceConsentMergesAudienceQualifiedScopeKeys(t *testing.
|
|||||||
apiB = "https://api-b.example.com"
|
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"},
|
apiA: {"read"},
|
||||||
apiB: {"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
|
// 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: {},
|
api: {},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -292,7 +293,7 @@ func TestAuthorizationServiceRequiresConsentForScopelessAPIAccess(t *testing.T)
|
|||||||
|
|
||||||
func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) {
|
func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -376,7 +377,7 @@ func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) {
|
|||||||
|
|
||||||
func TestInteractionAPIClassifiesMissingSession(t *testing.T) {
|
func TestInteractionAPIClassifiesMissingSession(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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")
|
_, err := service.getInteractionSession(t.Context(), "missing-interaction")
|
||||||
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
require.True(t, apperror.IsCode(err, apperror.CodeNotFound))
|
||||||
@@ -387,7 +388,7 @@ func TestInteractionAPIClassifiesMissingSession(t *testing.T) {
|
|||||||
|
|
||||||
func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) {
|
func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -442,7 +443,7 @@ func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing
|
|||||||
|
|
||||||
func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *testing.T) {
|
func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -481,7 +482,7 @@ func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *
|
|||||||
|
|
||||||
func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testing.T) {
|
func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -541,7 +542,7 @@ func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testi
|
|||||||
|
|
||||||
func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *testing.T) {
|
func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -574,7 +575,7 @@ func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *te
|
|||||||
|
|
||||||
func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T) {
|
func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -610,7 +611,7 @@ func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T)
|
|||||||
|
|
||||||
func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirements(t *testing.T) {
|
func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirements(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -663,7 +664,7 @@ func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirement
|
|||||||
// still be required for that user rather than being inherited from the initiator.
|
// still be required for that user rather than being inherited from the initiator.
|
||||||
func TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser(t *testing.T) {
|
func TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
initiatorID = "initiator-user"
|
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).
|
// flag is honored for confidential clients (fosite only enforces PKCE for public clients).
|
||||||
func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
|
func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -761,7 +762,7 @@ func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
|
|||||||
|
|
||||||
func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
|
func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -826,7 +827,7 @@ func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
|
|||||||
|
|
||||||
func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
|
func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
clientID = "test-client"
|
clientID = "test-client"
|
||||||
@@ -866,7 +867,7 @@ func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
|
|||||||
|
|
||||||
func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T) {
|
func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -908,7 +909,7 @@ func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T)
|
|||||||
|
|
||||||
func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded(t *testing.T) {
|
func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -945,7 +946,7 @@ func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded
|
|||||||
|
|
||||||
func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testing.T) {
|
func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -992,7 +993,7 @@ func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testi
|
|||||||
|
|
||||||
func TestAuthorizationServiceAuthorizeUsesOriginalInteractionRequestTime(t *testing.T) {
|
func TestAuthorizationServiceAuthorizeUsesOriginalInteractionRequestTime(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -1143,7 +1144,7 @@ func TestConsentRequired(t *testing.T) {
|
|||||||
func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
|
func TestAuthorizationServiceSkipConsentGrantsWithoutInteraction(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
auditLogger := &fakeAuditLogger{}
|
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 (
|
const (
|
||||||
userID = "test-user"
|
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
|
// 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) {
|
func TestAuthorizationServiceSkipConsentHonorsPromptConsent(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/pocket-id/fosite"
|
"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/common"
|
||||||
|
"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/model"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -18,22 +20,40 @@ const (
|
|||||||
idTokenType = "id-token"
|
idTokenType = "id-token"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const emailVerificationRequiredHint = "You must verify your email address before you can access this service."
|
||||||
|
|
||||||
type ClaimsService struct {
|
type ClaimsService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
customClaims CustomClaimSource
|
customClaims CustomClaimSource
|
||||||
baseURL string
|
baseURL string
|
||||||
signer TokenSigner
|
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{
|
return &ClaimsService{
|
||||||
db: db,
|
db: db,
|
||||||
customClaims: customClaims,
|
customClaims: customClaims,
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
signer: signer,
|
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
|
// ValidateUserAccess re-checks, at token-issuance time, that the user behind a grant is
|
||||||
// still allowed to obtain tokens for the client.
|
// still allowed to obtain tokens for the client.
|
||||||
func (s *ClaimsService) ValidateUserAccess(ctx context.Context, userID string, client Client) error {
|
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.")
|
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) {
|
if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
|
||||||
return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
|
return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||||
)
|
)
|
||||||
@@ -20,7 +21,7 @@ import (
|
|||||||
// refresh token.
|
// refresh token.
|
||||||
func TestClaimsServiceValidateUserAccess(t *testing.T) {
|
func TestClaimsServiceValidateUserAccess(t *testing.T) {
|
||||||
db := testutils.NewDatabaseForTest(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"}
|
group := model.UserGroup{Base: model.Base{ID: "group-allowed"}, Name: "allowed", FriendlyName: "Allowed"}
|
||||||
require.NoError(t, db.Create(&group).Error)
|
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 {
|
type fakeCustomClaimSource struct {
|
||||||
claims []model.CustomClaim
|
claims []model.CustomClaim
|
||||||
}
|
}
|
||||||
@@ -94,7 +138,7 @@ func TestClaimsServiceGetUserClaims(t *testing.T) {
|
|||||||
{Key: "department", Value: "engineering"}, // plain string
|
{Key: "department", Value: "engineering"}, // plain string
|
||||||
{Key: "roles", Value: `["admin","dev"]`}, // JSON document
|
{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"}
|
group := model.UserGroup{Base: model.Base{ID: "group-1"}, Name: "developers", FriendlyName: "Developers"}
|
||||||
require.NoError(t, db.Create(&group).Error)
|
require.NoError(t, db.Create(&group).Error)
|
||||||
@@ -164,7 +208,7 @@ func TestClaimsServiceGetUserClaimsKeepsIntegerCustomClaims(t *testing.T) {
|
|||||||
{Key: "nested", Value: `{"ids":[1234567890123456789]}`},
|
{Key: "nested", Value: `{"ids":[1234567890123456789]}`},
|
||||||
{Key: "not_json", Value: "1 2"},
|
{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"})
|
claims, err := service.GetUserClaims(t.Context(), "user-1", []string{"profile"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -184,7 +228,7 @@ func TestClaimsServiceAppliesSigningAlgToIDTokenHeader(t *testing.T) {
|
|||||||
|
|
||||||
for _, alg := range []jwa.SignatureAlgorithm{jwa.RS256(), jwa.RS384(), jwa.ES512()} {
|
for _, alg := range []jwa.SignatureAlgorithm{jwa.RS256(), jwa.RS384(), jwa.ES512()} {
|
||||||
t.Run(alg.String(), func(t *testing.T) {
|
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 := NewEmptySession()
|
||||||
session.Subject = "alg-user"
|
session.Subject = "alg-user"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocket-id/fosite"
|
"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/apperror"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
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)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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)
|
authorizationService := newAuthorizationService(db, newInteractionSessionService(db), claimsService, reauth, &fakeAuditLogger{}, apiAccess)
|
||||||
service := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, &fakeAuditLogger{}, db)
|
service := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, &fakeAuditLogger{}, db)
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||||
"gorm.io/gorm"
|
"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/auditlogs"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/authz"
|
"github.com/pocket-id/pocket-id/backend/internal/authz"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
@@ -56,6 +57,7 @@ type Dependencies struct {
|
|||||||
Reauth ReauthenticationTokenConsumer
|
Reauth ReauthenticationTokenConsumer
|
||||||
AuditLog AuditLogger
|
AuditLog AuditLogger
|
||||||
APIAccess APIAccessProvider
|
APIAccess APIAccessProvider
|
||||||
|
AppConfig appconfig.AppConfigResolver
|
||||||
|
|
||||||
// CleanupDisabled skips registering the cron jobs that delete expired rows from the database, for example in tests
|
// CleanupDisabled skips registering the cron jobs that delete expired rows from the database, for example in tests
|
||||||
CleanupDisabled bool
|
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)
|
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)
|
previewBuilder := newClientPreviewBuilder(claimsService, provider.tokenStrategies)
|
||||||
interactionSessionService := newInteractionSessionService(deps.DB)
|
interactionSessionService := newInteractionSessionService(deps.DB)
|
||||||
authorizationService := newAuthorizationService(deps.DB, interactionSessionService, claimsService, deps.Reauth, deps.AuditLog, deps.APIAccess)
|
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) {
|
func (m *Module) RegisterRoutes(root, api *authz.Router) {
|
||||||
root.Optional().GET("/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
|
||||||
root.Optional().POST("/authorize", authz.AccountSession, m.authorizationHandler.authorize)
|
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.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)
|
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.Optional().POST("/oidc/end-session", authz.AccountSession, m.endSessionHandler.endSession)
|
||||||
|
|
||||||
api.Public().POST("/oidc/device/authorize", m.deviceHandler.authorizeDevice)
|
api.Public().POST("/oidc/device/authorize", m.deviceHandler.authorizeDevice)
|
||||||
api.POST("/oidc/device/verify", authz.AccountSession, m.deviceHandler.verifyDeviceCode)
|
api.POST("/oidc/device/verify", authz.AccountAuthorize, m.deviceHandler.verifyDeviceCode)
|
||||||
api.GET("/oidc/device/info", authz.AccountSession, m.deviceHandler.deviceCodeInfo)
|
api.GET("/oidc/device/info", authz.AccountAuthorize, m.deviceHandler.deviceCodeInfo)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||||
)
|
)
|
||||||
@@ -26,7 +27,7 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
|
|||||||
}, nil)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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 (
|
const (
|
||||||
userID = "test-user"
|
userID = "test-user"
|
||||||
@@ -89,7 +90,7 @@ func TestClientPreviewBuilderIgnoresUnknownScopes(t *testing.T) {
|
|||||||
|
|
||||||
// The preview mirrors the authorize endpoint: unknown scopes are dropped
|
// The preview mirrors the authorize endpoint: unknown scopes are dropped
|
||||||
// from the previewed tokens instead of failing the preview.
|
// 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{
|
preview, err := builder.BuildClientPreview(t.Context(), model.OidcClient{
|
||||||
Base: model.Base{ID: "test-client"},
|
Base: model.Base{ID: "test-client"},
|
||||||
Name: "Test Client",
|
Name: "Test Client",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
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)
|
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)
|
provider, err := newProvider(store, nil, testTokenSigner{key: key}, Config{BaseURL: issuer, TokenBaseURL: issuer, Secret: []byte(secret)}, nil)
|
||||||
require.NoError(t, err)
|
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
|
// Seed an old grant so the grace period must start at rotation rather than token issuance
|
||||||
request := newTestRequester(requestID, clientID, userID, "grace-jti")
|
request := newTestRequester(requestID, clientID, userID, "grace-jti")
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||||
@@ -63,7 +64,7 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
|
|||||||
Secret: []byte(secret),
|
Secret: []byte(secret),
|
||||||
}, nil)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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"}}
|
form := url.Values{"grant_type": {"client_credentials"}}
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
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),
|
Secret: []byte(secret),
|
||||||
}, nil)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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"}}
|
form := url.Values{"grant_type": {"client_credentials"}, "scope": {"openid"}}
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
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),
|
Secret: []byte(secret),
|
||||||
}, nil)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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 {
|
requestToken := func(t *testing.T, scope string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -253,7 +254,7 @@ func TestTokenHandlerClientCredentialsDefaultsResourceScopes(t *testing.T) {
|
|||||||
Secret: []byte(secret),
|
Secret: []byte(secret),
|
||||||
}, nil)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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 {
|
requestToken := func(t *testing.T, target string, form url.Values) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -408,7 +409,7 @@ func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) {
|
|||||||
Secret: []byte(secret),
|
Secret: []byte(secret),
|
||||||
}, nil)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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{
|
form := url.Values{
|
||||||
"grant_type": {"refresh_token"},
|
"grant_type": {"refresh_token"},
|
||||||
@@ -592,7 +593,7 @@ func TestTokenHandlerRefreshGrantPreservesAudienceAndScope(t *testing.T) {
|
|||||||
Secret: []byte(secret),
|
Secret: []byte(secret),
|
||||||
}, nil)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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{
|
form := url.Values{
|
||||||
"grant_type": {"refresh_token"},
|
"grant_type": {"refresh_token"},
|
||||||
@@ -759,7 +760,7 @@ func TestTokenHandlerClientSecretRotation(t *testing.T) {
|
|||||||
Secret: []byte(secret),
|
Secret: []byte(secret),
|
||||||
}, nil)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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 {
|
requestToken := func(t *testing.T, clientSecret string) map[string]any {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -827,7 +828,7 @@ func TestTokenHandlerClientWithoutSecrets(t *testing.T) {
|
|||||||
Secret: []byte(secret),
|
Secret: []byte(secret),
|
||||||
}, nil)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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"}}
|
form := url.Values{"grant_type": {"client_credentials"}}
|
||||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
|
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/pocket-id/fosite"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||||
)
|
)
|
||||||
@@ -54,7 +55,7 @@ func TestUserInfoHandler(t *testing.T) {
|
|||||||
}, nil)
|
}, nil)
|
||||||
require.NoError(t, err)
|
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 {
|
issueAccessToken := func(t *testing.T, requestID, subject string, scopes ...string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/apperror"
|
"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/backchannellogout"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"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"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||||
@@ -36,9 +37,24 @@ type UserService struct {
|
|||||||
scimSyncScheduler ScimSyncScheduler
|
scimSyncScheduler ScimSyncScheduler
|
||||||
backchannelLogout *backchannellogout.Service
|
backchannelLogout *backchannellogout.Service
|
||||||
fileStorage storage.FileStorage
|
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{
|
return &UserService{
|
||||||
db: db,
|
db: db,
|
||||||
jwtService: jwtService,
|
jwtService: jwtService,
|
||||||
@@ -47,6 +63,7 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, customClaimService *Cus
|
|||||||
scimSyncScheduler: scimSyncScheduler,
|
scimSyncScheduler: scimSyncScheduler,
|
||||||
backchannelLogout: backchannelLogout,
|
backchannelLogout: backchannelLogout,
|
||||||
fileStorage: fileStorage,
|
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)
|
user, err := s.UpdateUserInternal(ctx, cfg, userID, updatedUser, updateOwnUser, isLdapSync, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.User{}, err
|
return model.User{}, err
|
||||||
@@ -516,9 +543,85 @@ func (s *UserService) UpdateUser(ctx context.Context, cfg *appconfig.AppConfigMo
|
|||||||
s.backchannelLogout.NotifyUser(ctx, userID)
|
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
|
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
|
// 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
|
// 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) {
|
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
|
return model.User{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is an LDAP user and LDAP is enabled
|
if !canEditProfile(cfg, &user, updateOwnUser, isLdapSync) {
|
||||||
isLdapUser := user.LdapID != nil && cfg.LdapEnabled.IsTrue()
|
|
||||||
allowOwnAccountEdit := cfg.AllowOwnAccountEdit.IsTrue()
|
|
||||||
|
|
||||||
if !isLdapSync && (isLdapUser || (!allowOwnAccountEdit && updateOwnUser)) {
|
|
||||||
// Restricted update: Only locale can be changed when:
|
// Restricted update: Only locale can be changed when:
|
||||||
// - User is from LDAP, OR
|
// - User is from LDAP, OR
|
||||||
// - User is editing their own account but global setting disallows self-editing
|
// - User is editing their own account but global setting disallows self-editing
|
||||||
// (Exception: LDAP sync operations can update everything)
|
|
||||||
user.Locale = updatedUser.Locale
|
user.Locale = updatedUser.Locale
|
||||||
} else {
|
} else {
|
||||||
// Full update: Allow updating all personal fields
|
// 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()))
|
user.UpdatedAt = new(datatype.DateTime(time.Now()))
|
||||||
|
|
||||||
err = tx.
|
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 {
|
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 {
|
var result struct {
|
||||||
Found bool
|
Found bool
|
||||||
}
|
}
|
||||||
err := tx.
|
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).
|
WithContext(ctx).
|
||||||
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND username = ?) AS found`, user.ID, user.Username).
|
Raw(`SELECT EXISTS(SELECT 1 FROM users WHERE id != ? AND username = ?) AS found`, user.ID, user.Username).
|
||||||
First(&result).
|
First(&result).
|
||||||
@@ -740,3 +841,16 @@ func (s *UserService) DisableUserInternal(ctx context.Context, tx *gorm.DB, user
|
|||||||
|
|
||||||
return nil
|
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
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -34,6 +36,7 @@ func newTestUserService(t *testing.T) (*UserService, *UserGroupService) {
|
|||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
fileStorage,
|
fileStorage,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
groupService := NewUserGroupService(db, nil, nil)
|
groupService := NewUserGroupService(db, nil, nil)
|
||||||
|
|
||||||
@@ -204,3 +207,174 @@ func TestDeleteUserBumpsGroupUpdatedAt(t *testing.T) {
|
|||||||
require.Empty(t, updated.Users)
|
require.Empty(t, updated.Users)
|
||||||
require.True(t, updated.LastModified().After(past), "deleting a member must bump the group's UpdatedAt")
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
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
|
// ScimSyncScheduler schedules SCIM after the signup transaction has committed
|
||||||
type ScimSyncScheduler interface {
|
type ScimSyncScheduler interface {
|
||||||
ScheduleSync(ctx context.Context)
|
ScheduleSync(ctx context.Context)
|
||||||
@@ -43,6 +47,12 @@ type Dependencies struct {
|
|||||||
UserCreator UserCreator
|
UserCreator UserCreator
|
||||||
AppConfig appconfig.AppConfigResolver
|
AppConfig appconfig.AppConfigResolver
|
||||||
ScimSync ScimSyncScheduler
|
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 {
|
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)
|
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())
|
service := newService(deps, deps.Actors.Service())
|
||||||
return &Module{
|
return &Module{
|
||||||
service: service,
|
service: service,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/auditlogs"
|
"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/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"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"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"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
|
// It must match the value emitted by the JWT service in the access token's "amr" claim
|
||||||
const authenticationMethodOneTimePassword = "otp"
|
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 {
|
type Service struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
actorService *actor.Service
|
actorService *actor.Service
|
||||||
userCreator UserCreator
|
userCreator UserCreator
|
||||||
signer TokenService
|
signer TokenService
|
||||||
auditLog AuditLogger
|
auditLog AuditLogger
|
||||||
scimSync ScimSyncScheduler
|
scimSync ScimSyncScheduler
|
||||||
|
emailVerification EmailVerificationSender
|
||||||
}
|
}
|
||||||
|
|
||||||
func newService(deps Dependencies, actorService *actor.Service) *Service {
|
func newService(deps Dependencies, actorService *actor.Service) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
db: deps.DB,
|
db: deps.DB,
|
||||||
actorService: actorService,
|
actorService: actorService,
|
||||||
userCreator: deps.UserCreator,
|
userCreator: deps.UserCreator,
|
||||||
signer: deps.Signer,
|
signer: deps.Signer,
|
||||||
auditLog: deps.AuditLog,
|
auditLog: deps.AuditLog,
|
||||||
scimSync: deps.ScimSync,
|
scimSync: deps.ScimSync,
|
||||||
|
emailVerification: deps.EmailVerification,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +100,13 @@ func (s *Service) SignUp(ctx context.Context, config *appconfig.AppConfigModel,
|
|||||||
return model.User{}, "", err
|
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
|
return user, accessToken, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +123,16 @@ func (s *Service) createSignedUpUser(ctx context.Context, config *appconfig.AppC
|
|||||||
return model.User{}, "", err
|
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())
|
accessToken, err := s.signer.GenerateAccessToken(user, "", config.SessionDuration.AsDurationMinutes())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return model.User{}, "", err
|
return model.User{}, "", err
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ func (f fakeUserCreator) CreateUserInternal(_ context.Context, _ *appconfig.AppC
|
|||||||
return f.user, nil
|
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{}
|
type fakeSigner struct{}
|
||||||
|
|
||||||
func (fakeSigner) GenerateAccessToken(_ model.User, _ string, _ time.Duration) (string, error) {
|
func (fakeSigner) GenerateAccessToken(_ model.User, _ string, _ time.Duration) (string, error) {
|
||||||
@@ -164,3 +183,56 @@ func listAllOptions() utils.ListRequestOptions {
|
|||||||
opts.Pagination.Limit = 100
|
opts.Pagination.Limit = 100
|
||||||
return opts
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN email_verification_deadline;
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN email_verification_deadline TIMESTAMPTZ;
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users DROP COLUMN email_verification_deadline;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE users ADD COLUMN email_verification_deadline DATETIME;
|
||||||
@@ -567,7 +567,23 @@
|
|||||||
"email_verification_warning": "Verify your email address",
|
"email_verification_warning": "Verify your email address",
|
||||||
"email_verification_warning_description": "Your email address is not verified yet. Please verify it as soon as possible.",
|
"email_verification_warning_description": "Your email address is not verified yet. Please verify it as soon as possible.",
|
||||||
"email_verification": "Email Verification",
|
"email_verification": "Email Verification",
|
||||||
"email_verification_description": "Send a verification email to users when they sign up or change their email address.",
|
"email_verification_description": "Choose whether users verify their email address and whether unverified accounts are restricted.",
|
||||||
|
"email_verification_disabled_description": "Users are not asked to verify their email address.",
|
||||||
|
"email_verification_optional": "Optional",
|
||||||
|
"email_verification_optional_description": "Users can verify their email address, but unverified accounts keep full access.",
|
||||||
|
"email_verification_required": "Required",
|
||||||
|
"email_verification_required_description": "Users can't sign in to applications until they verify their email address. Self-signed-up accounts that aren't verified within 24 hours are deleted.",
|
||||||
|
"email_verification_required_needs_user_email": "Requiring email verification also requires users to have an email address.",
|
||||||
|
"email_verification_required_warning": "Verify your email address to use your account",
|
||||||
|
"email_verification_required_warning_description": "Your access is limited until you verify your email address.",
|
||||||
|
"email_verification_deadline_description": "If you don't, your account will be deleted {time}.",
|
||||||
|
"verify_email_address": "Verify email address",
|
||||||
|
"email_verification_required_error": "You must verify your email address before you can sign in to applications",
|
||||||
|
"email_change_not_started": "Your other changes were saved, but the email to confirm your new email address couldn't be sent. Please try again.",
|
||||||
|
"email_change_pending_title": "Confirm your new email address",
|
||||||
|
"email_change_pending_description": "We sent a confirmation link to {email}. Your current email address stays in use until you confirm the change.",
|
||||||
|
"cancel_email_change": "Cancel change",
|
||||||
|
"email_change_canceled": "Email change canceled.",
|
||||||
"email_verification_success_title": "Email Verified Successfully",
|
"email_verification_success_title": "Email Verified Successfully",
|
||||||
"email_verification_success_description": "Your email address has been verified successfully.",
|
"email_verification_success_description": "Your email address has been verified successfully.",
|
||||||
"email_verification_error_title": "Email Verification Failed",
|
"email_verification_error_title": "Email Verification Failed",
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
import UserService from '#lib/services/user-service.ts';
|
import UserService from '#lib/services/user-service.ts';
|
||||||
import appConfigStore from '#lib/stores/application-configuration-store.ts';
|
import appConfigStore from '#lib/stores/application-configuration-store.ts';
|
||||||
import userStore from '#lib/stores/user-store.ts';
|
import userStore from '#lib/stores/user-store.ts';
|
||||||
|
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
|
||||||
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
||||||
import { LucideAlertTriangle, LucideCheckCircle2, LucideCircleX } from '@lucide/svelte';
|
import { LucideAlertTriangle, LucideCheckCircle2, LucideCircleX } from '@lucide/svelte';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
@@ -17,6 +19,10 @@
|
|||||||
|
|
||||||
let emailVerificationState = $state(page.url.searchParams.get('emailVerificationState'));
|
let emailVerificationState = $state(page.url.searchParams.get('emailVerificationState'));
|
||||||
|
|
||||||
|
const emailVerificationRequired = $derived(
|
||||||
|
isEmailVerificationPending($userStore, $appConfigStore)
|
||||||
|
);
|
||||||
|
|
||||||
async function sendEmailVerification() {
|
async function sendEmailVerification() {
|
||||||
await userService
|
await userService
|
||||||
.sendEmailVerification()
|
.sendEmailVerification()
|
||||||
@@ -60,15 +66,27 @@
|
|||||||
</Alert.Description>
|
</Alert.Description>
|
||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if $userStore && $appConfigStore.emailVerificationEnabled && !$userStore.emailVerified}
|
{:else if $userStore && $appConfigStore.emailVerification !== 'disabled' && !$userStore.emailVerified}
|
||||||
<Alert.Root variant="warning" class="flex gap-3">
|
<Alert.Root variant={emailVerificationRequired ? 'destructive' : 'warning'} class="flex gap-3">
|
||||||
<LucideAlertTriangle class="size-4" />
|
<LucideAlertTriangle class="size-4 shrink-0" />
|
||||||
<div class="md:flex md:w-full md:place-content-between">
|
<div class="md:flex md:w-full md:place-content-between">
|
||||||
<div>
|
<div>
|
||||||
<Alert.Title class="font-semibold">{m.email_verification_warning()}</Alert.Title>
|
{#if emailVerificationRequired}
|
||||||
<Alert.Description class="text-sm">
|
<Alert.Title class="font-semibold">{m.email_verification_required_warning()}</Alert.Title>
|
||||||
{m.email_verification_warning_description()}
|
<Alert.Description class="text-sm">
|
||||||
</Alert.Description>
|
{m.email_verification_required_warning_description()}
|
||||||
|
{#if $userStore.emailVerificationDeadline}
|
||||||
|
{m.email_verification_deadline_description({
|
||||||
|
time: formatDistanceToNow($userStore.emailVerificationDeadline, { addSuffix: true })
|
||||||
|
})}
|
||||||
|
{/if}
|
||||||
|
</Alert.Description>
|
||||||
|
{:else}
|
||||||
|
<Alert.Title class="font-semibold">{m.email_verification_warning()}</Alert.Title>
|
||||||
|
<Alert.Description class="text-sm">
|
||||||
|
{m.email_verification_warning_description()}
|
||||||
|
</Alert.Description>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button class="mt-2 md:mt-0" usePromiseLoading onclick={sendEmailVerification}>
|
<Button class="mt-2 md:mt-0" usePromiseLoading onclick={sendEmailVerification}>
|
||||||
|
|||||||
@@ -12,9 +12,6 @@
|
|||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
bind:ref
|
bind:ref
|
||||||
data-slot="tabs-content"
|
data-slot="tabs-content"
|
||||||
class={cn(
|
class={cn('flex-1 text-sm outline-none', className)}
|
||||||
'flex-1 text-sm outline-none',
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,7 +3,13 @@ import type { ListRequestOptions, Paginated } from '#lib/types/list-request.type
|
|||||||
import type { Passkey } from '#lib/types/passkey.type.ts';
|
import type { Passkey } from '#lib/types/passkey.type.ts';
|
||||||
import type { SignupToken } from '#lib/types/signup-token.type.ts';
|
import type { SignupToken } from '#lib/types/signup-token.type.ts';
|
||||||
import type { UserGroup } from '#lib/types/user-group.type.ts';
|
import type { UserGroup } from '#lib/types/user-group.type.ts';
|
||||||
import type { AccountUpdate, User, UserCreate, UserSignUp } from '#lib/types/user.type.ts';
|
import type {
|
||||||
|
AccountUpdate,
|
||||||
|
EmailChange,
|
||||||
|
User,
|
||||||
|
UserCreate,
|
||||||
|
UserSignUp
|
||||||
|
} from '#lib/types/user.type.ts';
|
||||||
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
|
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
@@ -145,4 +151,13 @@ export default class UserService extends APIService {
|
|||||||
const res = await this.api.post('/users/me/verify-email', { token });
|
const res = await this.api.post('/users/me/verify-email', { token });
|
||||||
return res.data as User;
|
return res.data as User;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
getPendingEmailChange = async () => {
|
||||||
|
const res = await this.api.get('/users/me/email-change');
|
||||||
|
return res.status === 204 ? null : (res.data as EmailChange);
|
||||||
|
};
|
||||||
|
|
||||||
|
cancelEmailChange = async () => {
|
||||||
|
await this.api.delete('/users/me/email-change');
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export type AppConfig = {
|
|||||||
allowUserSignups: 'disabled' | 'withToken' | 'open';
|
allowUserSignups: 'disabled' | 'withToken' | 'open';
|
||||||
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
|
emailOneTimeAccessAsUnauthenticatedEnabled: boolean;
|
||||||
emailOneTimeAccessAsAdminEnabled: boolean;
|
emailOneTimeAccessAsAdminEnabled: boolean;
|
||||||
emailVerificationEnabled: boolean;
|
emailVerification: 'disabled' | 'optional' | 'required';
|
||||||
ldapEnabled: boolean;
|
ldapEnabled: boolean;
|
||||||
disableAnimations: boolean;
|
disableAnimations: boolean;
|
||||||
uiConfigDisabled: boolean;
|
uiConfigDisabled: boolean;
|
||||||
|
|||||||
@@ -16,9 +16,18 @@ export type User = {
|
|||||||
locale?: Locale;
|
locale?: Locale;
|
||||||
ldapId?: string;
|
ldapId?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
emailVerificationDeadline?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UserCreate = Omit<User, 'id' | 'customClaims' | 'ldapId' | 'userGroups'>;
|
export type EmailChange = {
|
||||||
|
email: string;
|
||||||
|
expiresAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserCreate = Omit<
|
||||||
|
User,
|
||||||
|
'id' | 'customClaims' | 'ldapId' | 'userGroups' | 'emailVerificationDeadline'
|
||||||
|
>;
|
||||||
|
|
||||||
export type AccountUpdate = Omit<UserCreate, 'isAdmin' | 'disabled' | 'emailVerified'>;
|
export type AccountUpdate = Omit<UserCreate, 'isAdmin' | 'disabled' | 'emailVerified'>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { AppConfig } from '#lib/types/application-configuration.type.ts';
|
||||||
|
import type { User } from '#lib/types/user.type.ts';
|
||||||
|
|
||||||
|
// Reports whether the user must verify their email before they can use their account
|
||||||
|
// Mirrors the backend rule: only regular users are restricted, and only while verification is required
|
||||||
|
export function isEmailVerificationPending(
|
||||||
|
user: User | null | undefined,
|
||||||
|
appConfig: AppConfig | null | undefined
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
!!user && appConfig?.emailVerification === 'required' && !user.isAdmin && !user.emailVerified
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@ const codeMessages: Record<string, () => string> = {
|
|||||||
webauthn_authentication_failed: () => m.passkey_verification_failed(),
|
webauthn_authentication_failed: () => m.passkey_verification_failed(),
|
||||||
passkey_user_verification_required: () => m.passkey_user_verification_required(),
|
passkey_user_verification_required: () => m.passkey_user_verification_required(),
|
||||||
synced_passkey_not_allowed: () => m.synced_passkeys_not_allowed(),
|
synced_passkey_not_allowed: () => m.synced_passkeys_not_allowed(),
|
||||||
device_login_expired: () => m.device_login_request_expired()
|
device_login_expired: () => m.device_login_request_expired(),
|
||||||
|
email_verification_required: () => m.email_verification_required_error(),
|
||||||
|
email_change_not_started: () => m.email_change_not_started()
|
||||||
};
|
};
|
||||||
|
|
||||||
function getApiErrorResponse(e: unknown): ApiErrorResponse | undefined {
|
function getApiErrorResponse(e: unknown): ApiErrorResponse | undefined {
|
||||||
@@ -49,6 +51,10 @@ export function getAxiosErrorMessage(
|
|||||||
return codeMessage || (typeof response.error === 'string' ? response.error : defaultMessage);
|
return codeMessage || (typeof response.error === 'string' ? response.error : defaultMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getAxiosErrorCode(e: unknown): string | undefined {
|
||||||
|
return getApiErrorResponse(e)?.code;
|
||||||
|
}
|
||||||
|
|
||||||
export function getAxiosErrorRequestId(e: unknown): string | undefined {
|
export function getAxiosErrorRequestId(e: unknown): string | undefined {
|
||||||
if (!isAxiosError(e)) {
|
if (!isAxiosError(e)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import type { AppConfig } from '#lib/types/application-configuration.type.ts';
|
||||||
import type { User } from '#lib/types/user.type.ts';
|
import type { User } from '#lib/types/user.type.ts';
|
||||||
|
import { isEmailVerificationPending } from './email-verification-util.ts';
|
||||||
|
|
||||||
// Returns the path to redirect to based on the current path and user authentication status
|
// Returns the path to redirect to based on the current path and user authentication status
|
||||||
// If no redirect is needed, it returns null
|
// If no redirect is needed, it returns null
|
||||||
export function getAuthRedirectPath(url: URL, user: User | null) {
|
export function getAuthRedirectPath(url: URL, user: User | null, appConfig: AppConfig | null) {
|
||||||
const path = url.pathname;
|
const path = url.pathname;
|
||||||
const isSignedIn = !!user;
|
const isSignedIn = !!user;
|
||||||
const isAdmin = user?.isAdmin;
|
const isAdmin = user?.isAdmin;
|
||||||
@@ -28,6 +30,7 @@ export function getAuthRedirectPath(url: URL, user: User | null) {
|
|||||||
].includes(path);
|
].includes(path);
|
||||||
|
|
||||||
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
|
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
|
||||||
|
const isSettingsPath = path == '/settings' || path.startsWith('/settings/');
|
||||||
|
|
||||||
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
|
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
|
||||||
const redirect = url.pathname + url.search;
|
const redirect = url.pathname + url.search;
|
||||||
@@ -42,5 +45,14 @@ export function getAuthRedirectPath(url: URL, user: User | null) {
|
|||||||
return '/settings';
|
return '/settings';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A user who still has to verify their email can only reach their account settings until they do
|
||||||
|
if (
|
||||||
|
isSettingsPath &&
|
||||||
|
path != '/settings/account' &&
|
||||||
|
isEmailVerificationPending(user, appConfig)
|
||||||
|
) {
|
||||||
|
return '/settings/account';
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const load: LayoutLoad = async ({ url }) => {
|
|||||||
|
|
||||||
const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]);
|
const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]);
|
||||||
|
|
||||||
const redirectPath = getAuthRedirectPath(url, user);
|
const redirectPath = getAuthRedirectPath(url, user, appConfig);
|
||||||
if (redirectPath) {
|
if (redirectPath) {
|
||||||
redirect(302, redirectPath);
|
redirect(302, redirectPath);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import type { DeviceLoginVerificationInfo } from '#lib/types/device-login.type.ts';
|
import type { DeviceLoginVerificationInfo } from '#lib/types/device-login.type.ts';
|
||||||
import type { OidcDeviceCodeInfo } from '#lib/types/oidc.type.ts';
|
import type { OidcDeviceCodeInfo } from '#lib/types/oidc.type.ts';
|
||||||
import { getClientIDHost } from '#lib/utils/client-id-util.ts';
|
import { getClientIDHost } from '#lib/utils/client-id-util.ts';
|
||||||
import { getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
|
import { getAxiosErrorCode, getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
|
||||||
import { preventDefault } from '#lib/utils/event-util.ts';
|
import { preventDefault } from '#lib/utils/event-util.ts';
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -44,6 +44,8 @@
|
|||||||
);
|
);
|
||||||
let codeComplete = $derived(normalizedUserCode.length === 8);
|
let codeComplete = $derived(normalizedUserCode.length === 8);
|
||||||
let completed = $derived(success || deviceLoginOutcome !== undefined);
|
let completed = $derived(success || deviceLoginOutcome !== undefined);
|
||||||
|
let errorCode: string | undefined = $state();
|
||||||
|
let emailVerificationRequired = $derived(errorCode === 'email_verification_required');
|
||||||
let deviceLoginLocation = $derived(
|
let deviceLoginLocation = $derived(
|
||||||
deviceLoginInfo?.city && deviceLoginInfo.country
|
deviceLoginInfo?.city && deviceLoginInfo.country
|
||||||
? `${deviceLoginInfo.city}, ${deviceLoginInfo.country}`
|
? `${deviceLoginInfo.city}, ${deviceLoginInfo.country}`
|
||||||
@@ -62,6 +64,7 @@
|
|||||||
|
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
errorCode = undefined;
|
||||||
try {
|
try {
|
||||||
await authenticateUserIfNeeded();
|
await authenticateUserIfNeeded();
|
||||||
|
|
||||||
@@ -93,6 +96,7 @@
|
|||||||
success = true;
|
success = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage = getWebauthnErrorMessage(error);
|
errorMessage = getWebauthnErrorMessage(error);
|
||||||
|
errorCode = getAxiosErrorCode(error);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
@@ -111,6 +115,7 @@
|
|||||||
isLoading = true;
|
isLoading = true;
|
||||||
deviceLoginDecision = decision;
|
deviceLoginDecision = decision;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
errorCode = undefined;
|
||||||
try {
|
try {
|
||||||
if (decision === 'approve') {
|
if (decision === 'approve') {
|
||||||
await reauthenticate();
|
await reauthenticate();
|
||||||
@@ -119,6 +124,7 @@
|
|||||||
deviceLoginOutcome = decision === 'approve' ? 'approved' : 'denied';
|
deviceLoginOutcome = decision === 'approve' ? 'approved' : 'denied';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage = getWebauthnErrorMessage(error);
|
errorMessage = getWebauthnErrorMessage(error);
|
||||||
|
errorCode = getAxiosErrorCode(error);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
deviceLoginDecision = undefined;
|
deviceLoginDecision = undefined;
|
||||||
@@ -137,6 +143,7 @@
|
|||||||
|
|
||||||
function retry() {
|
function retry() {
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
errorCode = undefined;
|
||||||
if (!deviceLoginInfo) {
|
if (!deviceLoginInfo) {
|
||||||
userCode = '';
|
userCode = '';
|
||||||
}
|
}
|
||||||
@@ -168,7 +175,7 @@
|
|||||||
</h1>
|
</h1>
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<p class="text-muted-foreground mt-2">
|
<p class="text-muted-foreground mt-2">
|
||||||
{errorMessage}. {m.please_try_again()}
|
{errorMessage}. {#if !emailVerificationRequired}{m.please_try_again()}{/if}
|
||||||
</p>
|
</p>
|
||||||
{:else if deviceLoginOutcome === 'approved'}
|
{:else if deviceLoginOutcome === 'approved'}
|
||||||
<p class="text-muted-foreground mt-2">{m.the_requesting_device_has_been_signed_in()}</p>
|
<p class="text-muted-foreground mt-2">{m.the_requesting_device_has_been_signed_in()}</p>
|
||||||
@@ -268,7 +275,11 @@
|
|||||||
<div class="mt-10 flex w-full max-w-112.5 gap-2">
|
<div class="mt-10 flex w-full max-w-112.5 gap-2">
|
||||||
{#if errorMessage}
|
{#if errorMessage}
|
||||||
<Button class="flex-1" variant="secondary" href="/">{m.cancel()}</Button>
|
<Button class="flex-1" variant="secondary" href="/">{m.cancel()}</Button>
|
||||||
<Button class="flex-1" onclick={retry}>{m.try_again()}</Button>
|
{#if emailVerificationRequired}
|
||||||
|
<Button class="flex-1" href="/settings/account">{m.verify_email_address()}</Button>
|
||||||
|
{:else}
|
||||||
|
<Button class="flex-1" onclick={retry}>{m.try_again()}</Button>
|
||||||
|
{/if}
|
||||||
{:else if deviceLoginInfo}
|
{:else if deviceLoginInfo}
|
||||||
<Button
|
<Button
|
||||||
class="flex-1"
|
class="flex-1"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import type { InteractionStep } from '#lib/types/oidc.type.ts';
|
import type { InteractionStep } from '#lib/types/oidc.type.ts';
|
||||||
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
|
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
|
||||||
import { getClientIDHost } from '#lib/utils/client-id-util.ts';
|
import { getClientIDHost } from '#lib/utils/client-id-util.ts';
|
||||||
import { getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
|
import { getAxiosErrorCode, getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import ClientProviderImages from '../authorize/components/client-provider-images.svelte';
|
import ClientProviderImages from '../authorize/components/client-provider-images.svelte';
|
||||||
@@ -28,6 +28,7 @@
|
|||||||
let isLoading = $state(false);
|
let isLoading = $state(false);
|
||||||
let success = $state(false);
|
let success = $state(false);
|
||||||
let errorMessage: string | null = $state(null);
|
let errorMessage: string | null = $state(null);
|
||||||
|
let errorCode: string | undefined = $state();
|
||||||
let currentStep = $derived(interactionSession.currentStep);
|
let currentStep = $derived(interactionSession.currentStep);
|
||||||
|
|
||||||
const fullName = $derived.by(() => {
|
const fullName = $derived.by(() => {
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
async function handlePipeline() {
|
async function handlePipeline() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
errorMessage = null;
|
errorMessage = null;
|
||||||
|
errorCode = undefined;
|
||||||
try {
|
try {
|
||||||
if (!$userStore) {
|
if (!$userStore) {
|
||||||
await authenticate();
|
await authenticate();
|
||||||
@@ -66,6 +68,7 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
success = false;
|
success = false;
|
||||||
errorMessage = getWebauthnErrorMessage(e);
|
errorMessage = getWebauthnErrorMessage(e);
|
||||||
|
errorCode = getAxiosErrorCode(e);
|
||||||
} finally {
|
} finally {
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
}
|
}
|
||||||
@@ -196,9 +199,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex w-full max-w-md flex-row-reverse gap-2">
|
<div class="flex w-full max-w-md flex-row-reverse gap-2">
|
||||||
<Button class="flex-1" {isLoading} onclick={handlePipeline} autofocus={true}>
|
{#if errorCode === 'email_verification_required'}
|
||||||
{errorMessage ? m.try_again() : m.sign_in()}
|
<Button class="flex-1" href="/settings/account">{m.verify_email_address()}</Button>
|
||||||
</Button>
|
{:else}
|
||||||
|
<Button class="flex-1" {isLoading} onclick={handlePipeline} autofocus={true}>
|
||||||
|
{errorMessage ? m.try_again() : m.sign_in()}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
<Button class="flex-1" variant="secondary" href={document.referrer || '/'}>
|
<Button class="flex-1" variant="secondary" href={document.referrer || '/'}>
|
||||||
{m.cancel()}
|
{m.cancel()}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
import SignInWrapper from '#lib/components/login-wrapper.svelte';
|
import SignInWrapper from '#lib/components/login-wrapper.svelte';
|
||||||
import { Button } from '#lib/components/ui/button/index.ts';
|
import { Button } from '#lib/components/ui/button/index.ts';
|
||||||
import { m } from '#lib/paraglide/messages.js';
|
import { m } from '#lib/paraglide/messages.js';
|
||||||
|
import appConfigStore from '#lib/stores/application-configuration-store.ts';
|
||||||
|
import userStore from '#lib/stores/user-store.ts';
|
||||||
|
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
|
||||||
import ClientProviderImages from '../../authorize/components/client-provider-images.svelte';
|
import ClientProviderImages from '../../authorize/components/client-provider-images.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
@@ -22,7 +25,20 @@
|
|||||||
{error}
|
{error}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Button class="w-full sm:w-[50%]" variant="secondary" href={document.referrer || '/'}>
|
<div class="flex w-full flex-col items-center gap-3">
|
||||||
{m.go_back()}
|
{#if isEmailVerificationPending($userStore, $appConfigStore)}
|
||||||
</Button>
|
<div class="flex w-full max-w-md flex-row-reverse gap-2">
|
||||||
|
<Button class="flex-1" href="/settings/account">
|
||||||
|
{m.verify_email_address()}
|
||||||
|
</Button>
|
||||||
|
<Button class="flex-1" variant="secondary" href={document.referrer || '/'}>
|
||||||
|
{m.go_back()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Button class="w-full sm:w-[50%]" variant="secondary" href={document.referrer || '/'}>
|
||||||
|
{m.go_back()}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</SignInWrapper>
|
</SignInWrapper>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { m } from '#lib/paraglide/messages.js';
|
import { m } from '#lib/paraglide/messages.js';
|
||||||
import appConfigStore from '#lib/stores/application-configuration-store.ts';
|
import appConfigStore from '#lib/stores/application-configuration-store.ts';
|
||||||
import userStore from '#lib/stores/user-store.ts';
|
import userStore from '#lib/stores/user-store.ts';
|
||||||
|
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
|
||||||
import { LucideTriangleAlert } from '@lucide/svelte';
|
import { LucideTriangleAlert } from '@lucide/svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import { fade, fly } from 'svelte/transition';
|
import { fade, fly } from 'svelte/transition';
|
||||||
@@ -28,11 +29,14 @@
|
|||||||
children?: NavItem[];
|
children?: NavItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const items: NavItem[] = [
|
// A user who still has to verify their email can only reach their account until they do
|
||||||
{ href: '/settings/account', label: m.my_account() },
|
const items: NavItem[] = isEmailVerificationPending(user, $appConfigStore)
|
||||||
{ href: '/settings/apps', label: m.my_apps() },
|
? [{ href: '/settings/account', label: m.my_account() }]
|
||||||
{ href: '/settings/audit-log', label: m.audit_log() }
|
: [
|
||||||
];
|
{ href: '/settings/account', label: m.my_account() },
|
||||||
|
{ href: '/settings/apps', label: m.my_apps() },
|
||||||
|
{ href: '/settings/audit-log', label: m.audit_log() }
|
||||||
|
];
|
||||||
|
|
||||||
const adminChildren: NavItem[] = [
|
const adminChildren: NavItem[] = [
|
||||||
{ href: '/settings/admin/users', label: m.users() },
|
{ href: '/settings/admin/users', label: m.users() },
|
||||||
|
|||||||
@@ -11,8 +11,14 @@
|
|||||||
import userStore from '#lib/stores/user-store.ts';
|
import userStore from '#lib/stores/user-store.ts';
|
||||||
import type { Passkey } from '#lib/types/passkey.type.ts';
|
import type { Passkey } from '#lib/types/passkey.type.ts';
|
||||||
import type { AccountUpdate } from '#lib/types/user.type.ts';
|
import type { AccountUpdate } from '#lib/types/user.type.ts';
|
||||||
import { getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
|
import { axiosErrorToast, getWebauthnErrorMessage } from '#lib/utils/error-util.ts';
|
||||||
import { KeyRound, Languages, LucideAlertTriangle, UserCog } from '@lucide/svelte';
|
import {
|
||||||
|
KeyRound,
|
||||||
|
Languages,
|
||||||
|
LucideAlertTriangle,
|
||||||
|
LucideMailCheck,
|
||||||
|
UserCog
|
||||||
|
} from '@lucide/svelte';
|
||||||
import { startRegistration } from '@simplewebauthn/browser';
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import AccountForm from './account-form.svelte';
|
import AccountForm from './account-form.svelte';
|
||||||
@@ -23,6 +29,7 @@
|
|||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
let account = $state(data.account);
|
let account = $state(data.account);
|
||||||
let passkeys = $state(data.passkeys);
|
let passkeys = $state(data.passkeys);
|
||||||
|
let pendingEmailChange = $state(data.pendingEmailChange);
|
||||||
let passkeyToRename: Passkey | null = $state(null);
|
let passkeyToRename: Passkey | null = $state(null);
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
const webauthnService = new WebAuthnService();
|
const webauthnService = new WebAuthnService();
|
||||||
@@ -32,7 +39,25 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function updateAccount(user: AccountUpdate) {
|
async function updateAccount(user: AccountUpdate) {
|
||||||
userStore.setUser(await userService.updateCurrent(user));
|
const updated = await userService.updateCurrent(user);
|
||||||
|
userStore.setUser(updated);
|
||||||
|
|
||||||
|
// A verified email is only replaced once the new address is confirmed, so the change shows up as pending instead
|
||||||
|
if (user.email && user.email !== updated.email) {
|
||||||
|
pendingEmailChange = await userService.getPendingEmailChange().catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelEmailChange() {
|
||||||
|
await userService
|
||||||
|
.cancelEmailChange()
|
||||||
|
.then(() => {
|
||||||
|
pendingEmailChange = null;
|
||||||
|
toast.success(m.email_change_canceled());
|
||||||
|
})
|
||||||
|
.catch(axiosErrorToast);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createPasskey() {
|
async function createPasskey() {
|
||||||
@@ -55,7 +80,7 @@
|
|||||||
|
|
||||||
{#if passkeys.length == 0}
|
{#if passkeys.length == 0}
|
||||||
<Alert.Root variant="warning" class="flex gap-3">
|
<Alert.Root variant="warning" class="flex gap-3">
|
||||||
<LucideAlertTriangle class="size-4" />
|
<LucideAlertTriangle class="size-4 shrink-0" />
|
||||||
<div class="md:flex md:w-full md:place-content-between">
|
<div class="md:flex md:w-full md:place-content-between">
|
||||||
<div>
|
<div>
|
||||||
<Alert.Title class="font-semibold">{m.passkey_missing()}</Alert.Title>
|
<Alert.Title class="font-semibold">{m.passkey_missing()}</Alert.Title>
|
||||||
@@ -72,7 +97,7 @@
|
|||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
{:else if passkeys.length == 1}
|
{:else if passkeys.length == 1}
|
||||||
<Alert.Root variant="warning" dismissibleId="single-passkey" class="flex gap-3">
|
<Alert.Root variant="warning" dismissibleId="single-passkey" class="flex gap-3">
|
||||||
<LucideAlertTriangle class="size-4" />
|
<LucideAlertTriangle class="size-4 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<Alert.Title class="font-semibold">{m.single_passkey_configured()}</Alert.Title>
|
<Alert.Title class="font-semibold">{m.single_passkey_configured()}</Alert.Title>
|
||||||
<Alert.Description class="text-sm">
|
<Alert.Description class="text-sm">
|
||||||
@@ -82,6 +107,30 @@
|
|||||||
</Alert.Root>
|
</Alert.Root>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if pendingEmailChange}
|
||||||
|
<Alert.Root variant="info" class="flex gap-3">
|
||||||
|
<LucideMailCheck class="size-4 shrink-0" />
|
||||||
|
<div class="md:flex md:w-full md:place-content-between">
|
||||||
|
<div>
|
||||||
|
<Alert.Title class="font-semibold">{m.email_change_pending_title()}</Alert.Title>
|
||||||
|
<Alert.Description class="text-sm">
|
||||||
|
{m.email_change_pending_description({ email: pendingEmailChange.email })}
|
||||||
|
</Alert.Description>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
class="mt-2 md:mt-0"
|
||||||
|
variant="outline"
|
||||||
|
usePromiseLoading
|
||||||
|
onclick={cancelEmailChange}
|
||||||
|
>
|
||||||
|
{m.cancel_email_change()}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Alert.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>
|
<Card.Title>
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import type { PageLoad } from './$types';
|
|||||||
|
|
||||||
export const load: PageLoad = async () => {
|
export const load: PageLoad = async () => {
|
||||||
const userService = new UserService();
|
const userService = new UserService();
|
||||||
const account = await userService.getCurrent();
|
const [account, pendingEmailChange] = await Promise.all([
|
||||||
|
userService.getCurrent(),
|
||||||
|
userService.getPendingEmailChange().catch(() => null)
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
account
|
account,
|
||||||
|
pendingEmailChange
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { m } from '#lib/paraglide/messages.js';
|
import { m } from '#lib/paraglide/messages.js';
|
||||||
import UserService from '#lib/services/user-service.ts';
|
import UserService from '#lib/services/user-service.ts';
|
||||||
import appConfigStore from '#lib/stores/application-configuration-store.ts';
|
import appConfigStore from '#lib/stores/application-configuration-store.ts';
|
||||||
import type { AccountUpdate } from '#lib/types/user.type.ts';
|
import type { AccountUpdate, User } from '#lib/types/user.type.ts';
|
||||||
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
import { axiosErrorToast } from '#lib/utils/error-util.ts';
|
||||||
import { createForm } from '#lib/utils/form-util.ts';
|
import { createForm } from '#lib/utils/form-util.ts';
|
||||||
import { trackFormChanges } from '#lib/utils/unsaved-changes-util.svelte.ts';
|
import { trackFormChanges } from '#lib/utils/unsaved-changes-util.svelte.ts';
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
}: {
|
}: {
|
||||||
account: AccountUpdate;
|
account: AccountUpdate;
|
||||||
userId: string;
|
userId: string;
|
||||||
callback: (user: AccountUpdate) => Promise<void>;
|
callback: (user: AccountUpdate) => Promise<User>;
|
||||||
isLdapUser?: boolean;
|
isLdapUser?: boolean;
|
||||||
userInfoInputDisabled?: boolean;
|
userInfoInputDisabled?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
@@ -55,7 +55,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
trackFormChanges(() => formStore, callback);
|
trackFormChanges(
|
||||||
|
() => formStore,
|
||||||
|
async (data) => {
|
||||||
|
const saved = await callback(data);
|
||||||
|
|
||||||
|
// A verified email is only replaced once the new address is confirmed, so keep showing the email that is still in effect
|
||||||
|
// The saved data becomes the new baseline, so it has to carry that email as well
|
||||||
|
if (saved.email && data.email && saved.email !== data.email) {
|
||||||
|
data.email = saved.email;
|
||||||
|
formStore.setValue('email', saved.email);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
async function updateProfilePicture(image: File) {
|
async function updateProfilePicture(image: File) {
|
||||||
await userService
|
await userService
|
||||||
|
|||||||
+68
-9
@@ -46,6 +46,18 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emailVerificationOptions = $derived({
|
||||||
|
disabled: { label: m.disabled(), description: m.email_verification_disabled_description() },
|
||||||
|
optional: {
|
||||||
|
label: m.email_verification_optional(),
|
||||||
|
description: m.email_verification_optional_description()
|
||||||
|
},
|
||||||
|
required: {
|
||||||
|
label: m.email_verification_required(),
|
||||||
|
description: m.email_verification_required_description()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let isSendingTestEmail = $state(false);
|
let isSendingTestEmail = $state(false);
|
||||||
|
|
||||||
const formSchema = z
|
const formSchema = z
|
||||||
@@ -63,7 +75,7 @@
|
|||||||
smtpTls: z.enum(['none', 'starttls', 'tls']),
|
smtpTls: z.enum(['none', 'starttls', 'tls']),
|
||||||
smtpSkipCertVerify: z.boolean(),
|
smtpSkipCertVerify: z.boolean(),
|
||||||
emailOneTimeAccessAsUnauthenticatedEnabled: z.boolean(),
|
emailOneTimeAccessAsUnauthenticatedEnabled: z.boolean(),
|
||||||
emailVerificationEnabled: z.boolean(),
|
emailVerification: z.enum(['disabled', 'optional', 'required']),
|
||||||
emailOneTimeAccessAsAdminEnabled: z.boolean(),
|
emailOneTimeAccessAsAdminEnabled: z.boolean(),
|
||||||
emailLoginNotificationMode: z.enum([
|
emailLoginNotificationMode: z.enum([
|
||||||
'disabled',
|
'disabled',
|
||||||
@@ -82,7 +94,6 @@
|
|||||||
|
|
||||||
const emailFields: (keyof z.infer<typeof formSchema>)[] = [
|
const emailFields: (keyof z.infer<typeof formSchema>)[] = [
|
||||||
'emailOneTimeAccessAsUnauthenticatedEnabled',
|
'emailOneTimeAccessAsUnauthenticatedEnabled',
|
||||||
'emailVerificationEnabled',
|
|
||||||
'emailOneTimeAccessAsAdminEnabled',
|
'emailOneTimeAccessAsAdminEnabled',
|
||||||
'emailApiKeyExpirationEnabled'
|
'emailApiKeyExpirationEnabled'
|
||||||
];
|
];
|
||||||
@@ -105,8 +116,19 @@
|
|||||||
requireFieldsWhen(anyProvided, m.smtp_field_required_when_other_provided());
|
requireFieldsWhen(anyProvided, m.smtp_field_required_when_other_provided());
|
||||||
|
|
||||||
const emailEnabled =
|
const emailEnabled =
|
||||||
data.emailLoginNotificationMode !== 'disabled' || emailFields.some((f) => data[f]);
|
data.emailLoginNotificationMode !== 'disabled' ||
|
||||||
|
data.emailVerification !== 'disabled' ||
|
||||||
|
emailFields.some((f) => data[f]);
|
||||||
requireFieldsWhen(emailEnabled, m.smtp_field_required_when_email_enabled());
|
requireFieldsWhen(emailEnabled, m.smtp_field_required_when_email_enabled());
|
||||||
|
|
||||||
|
// Requiring verification only protects accounts that have an email
|
||||||
|
if (data.emailVerification === 'required' && !data.requireUserEmail) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['emailVerification'],
|
||||||
|
message: m.email_verification_required_needs_user_email()
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const formStore = createForm(formSchema, pickSchemaValues(formSchema, appConfig));
|
const formStore = createForm(formSchema, pickSchemaValues(formSchema, appConfig));
|
||||||
@@ -175,10 +197,53 @@
|
|||||||
description={m.require_user_email_description()}
|
description={m.require_user_email_description()}
|
||||||
bind:checked={$inputs.requireUserEmail.value}
|
bind:checked={$inputs.requireUserEmail.value}
|
||||||
/>
|
/>
|
||||||
|
<Field.Field>
|
||||||
|
<div>
|
||||||
|
<Field.Label for="email-verification">{m.email_verification()}</Field.Label>
|
||||||
|
<Field.Description>{m.email_verification_description()}</Field.Description>
|
||||||
|
</div>
|
||||||
|
<Select.Root
|
||||||
|
type="single"
|
||||||
|
disabled={$appConfigStore.uiConfigDisabled}
|
||||||
|
value={$inputs.emailVerification.value}
|
||||||
|
allowDeselect={false}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
$inputs.emailVerification.value = value as AllAppConfig['emailVerification'];
|
||||||
|
// Emails verified by default would let every new account skip the verification
|
||||||
|
if (value === 'required') {
|
||||||
|
$inputs.emailsVerified.value = false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Select.Trigger
|
||||||
|
id="email-verification"
|
||||||
|
class="w-full"
|
||||||
|
aria-invalid={!!$inputs.emailVerification.error}
|
||||||
|
>
|
||||||
|
{emailVerificationOptions[$inputs.emailVerification.value].label}
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content class="w-[calc(var(--bits-select-anchor-width)+--spacing(3))]">
|
||||||
|
<Select.Group>
|
||||||
|
{#each Object.entries(emailVerificationOptions) as [value, option] (value)}
|
||||||
|
<Select.Item {value} label={option.label}>
|
||||||
|
<div class="flex flex-col items-start gap-1 whitespace-normal">
|
||||||
|
<span class="font-medium">{option.label}</span>
|
||||||
|
<span class="text-muted-foreground text-xs">{option.description}</span>
|
||||||
|
</div>
|
||||||
|
</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Group>
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
{#if $inputs.emailVerification.error}
|
||||||
|
<Field.Error>{$inputs.emailVerification.error}</Field.Error>
|
||||||
|
{/if}
|
||||||
|
</Field.Field>
|
||||||
<SwitchWithLabel
|
<SwitchWithLabel
|
||||||
id="emails-verified-by-default"
|
id="emails-verified-by-default"
|
||||||
label={m.emails_verified_by_default()}
|
label={m.emails_verified_by_default()}
|
||||||
description={m.emails_verified_by_default_description()}
|
description={m.emails_verified_by_default_description()}
|
||||||
|
disabled={$inputs.emailVerification.value === 'required'}
|
||||||
bind:checked={$inputs.emailsVerified.value}
|
bind:checked={$inputs.emailsVerified.value}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -246,12 +311,6 @@
|
|||||||
</Select.Content>
|
</Select.Content>
|
||||||
</Select.Root>
|
</Select.Root>
|
||||||
</Field.Field>
|
</Field.Field>
|
||||||
<SwitchWithLabel
|
|
||||||
id="email-verification"
|
|
||||||
label={m.email_verification()}
|
|
||||||
description={m.email_verification_description()}
|
|
||||||
bind:checked={$inputs.emailVerificationEnabled.value}
|
|
||||||
/>
|
|
||||||
<SwitchWithLabel
|
<SwitchWithLabel
|
||||||
id="email-login-admin"
|
id="email-login-admin"
|
||||||
label={m.email_login_code_from_admin()}
|
label={m.email_login_code_from_admin()}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"provider": "sqlite",
|
"provider": "sqlite",
|
||||||
"version": 20260929120000,
|
"version": 20261011120000,
|
||||||
"tableOrder": [
|
"tableOrder": [
|
||||||
"users",
|
"users",
|
||||||
"user_groups",
|
"user_groups",
|
||||||
@@ -420,6 +420,7 @@
|
|||||||
"disabled": false,
|
"disabled": false,
|
||||||
"display_name": "Tim Cook",
|
"display_name": "Tim Cook",
|
||||||
"email": "tim.cook@test.com",
|
"email": "tim.cook@test.com",
|
||||||
|
"email_verification_deadline": null,
|
||||||
"email_verified": true,
|
"email_verified": true,
|
||||||
"first_name": "Tim",
|
"first_name": "Tim",
|
||||||
"id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
"id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||||
@@ -435,6 +436,7 @@
|
|||||||
"disabled": false,
|
"disabled": false,
|
||||||
"display_name": "Craig Federighi",
|
"display_name": "Craig Federighi",
|
||||||
"email": "craig.federighi@test.com",
|
"email": "craig.federighi@test.com",
|
||||||
|
"email_verification_deadline": null,
|
||||||
"email_verified": false,
|
"email_verified": false,
|
||||||
"first_name": "Craig",
|
"first_name": "Craig",
|
||||||
"id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
"id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||||
@@ -450,6 +452,7 @@
|
|||||||
"disabled": false,
|
"disabled": false,
|
||||||
"display_name": "Eddy Cue",
|
"display_name": "Eddy Cue",
|
||||||
"email": "eddy.cue@test.com",
|
"email": "eddy.cue@test.com",
|
||||||
|
"email_verification_deadline": null,
|
||||||
"email_verified": false,
|
"email_verified": false,
|
||||||
"first_name": "Eddy",
|
"first_name": "Eddy",
|
||||||
"id": "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
|
"id": "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
|
||||||
|
|||||||
@@ -250,6 +250,8 @@ test('Update email configuration', async ({ page }) => {
|
|||||||
await page.getByLabel('Email Login Code Requested by User').click();
|
await page.getByLabel('Email Login Code Requested by User').click();
|
||||||
await page.getByLabel('Email Login Code from Admin').click();
|
await page.getByLabel('Email Login Code from Admin').click();
|
||||||
await page.getByLabel('API Key Expiration').click();
|
await page.getByLabel('API Key Expiration').click();
|
||||||
|
await page.getByLabel('Email Verification', { exact: true }).click();
|
||||||
|
await page.getByRole('option', { name: /^Required/ }).click();
|
||||||
|
|
||||||
await saveUnsavedChanges(page);
|
await saveUnsavedChanges(page);
|
||||||
|
|
||||||
@@ -264,6 +266,26 @@ test('Update email configuration', async ({ page }) => {
|
|||||||
await expect(page.getByLabel('Email Login Code Requested by User')).toBeChecked();
|
await expect(page.getByLabel('Email Login Code Requested by User')).toBeChecked();
|
||||||
await expect(page.getByLabel('Email Login Code from Admin')).toBeChecked();
|
await expect(page.getByLabel('Email Login Code from Admin')).toBeChecked();
|
||||||
await expect(page.getByLabel('API Key Expiration')).toBeChecked();
|
await expect(page.getByLabel('API Key Expiration')).toBeChecked();
|
||||||
|
await expect(page.getByLabel('Email Verification', { exact: true })).toContainText('Required');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Required email verification turns off emails verified by default', async ({ page }) => {
|
||||||
|
await page.getByRole('tab', { name: 'Email' }).click();
|
||||||
|
|
||||||
|
const emailsVerifiedByDefault = page.getByLabel('Emails verified by default');
|
||||||
|
await emailsVerifiedByDefault.click();
|
||||||
|
await expect(emailsVerifiedByDefault).toBeChecked();
|
||||||
|
|
||||||
|
await page.getByLabel('Email Verification', { exact: true }).click();
|
||||||
|
await page.getByRole('option', { name: /^Required/ }).click();
|
||||||
|
|
||||||
|
await expect(emailsVerifiedByDefault).not.toBeChecked();
|
||||||
|
await expect(emailsVerifiedByDefault).toBeDisabled();
|
||||||
|
|
||||||
|
await page.getByLabel('Email Verification', { exact: true }).click();
|
||||||
|
await page.getByRole('option', { name: /^Optional/ }).click();
|
||||||
|
|
||||||
|
await expect(emailsVerifiedByDefault).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Save LDAP configuration while LDAP remains disabled', async ({ page }) => {
|
test('Save LDAP configuration while LDAP remains disabled', async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import base, { expect, type APIRequestContext } from '@playwright/test';
|
||||||
|
import { emailVerificationTokens, oidcClients, users } from '../data';
|
||||||
|
import { setAppConfigValues } from '../utils/app-config.util';
|
||||||
|
import authUtil from '../utils/auth.util';
|
||||||
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
|
import { startSmtpSink, verificationLinkPath, type SmtpSink } from '../utils/smtp.util';
|
||||||
|
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
|
||||||
|
|
||||||
|
const smtpSinkPort = 18125;
|
||||||
|
|
||||||
|
const test = base.extend<{ smtp: SmtpSink }>({
|
||||||
|
smtp: async ({}, use) => {
|
||||||
|
const sink = await startSmtpSink(smtpSinkPort);
|
||||||
|
try {
|
||||||
|
await use(sink);
|
||||||
|
} finally {
|
||||||
|
await sink.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Requires email verification and points Pocket ID at the SMTP sink, so the verification links can be followed
|
||||||
|
async function requireEmailVerificationWithSmtp(request: APIRequestContext) {
|
||||||
|
await setAppConfigValues(request, {
|
||||||
|
emailVerification: 'required',
|
||||||
|
smtpHost: 'host.docker.internal',
|
||||||
|
smtpPort: String(smtpSinkPort),
|
||||||
|
smtpFrom: 'pocket-id@test.com',
|
||||||
|
smtpTls: 'none'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds the query of an authorization code request for the Nextcloud client
|
||||||
|
function nextcloudAuthorizeParams() {
|
||||||
|
const oidcClient = oidcClients.nextcloud;
|
||||||
|
return new URLSearchParams({
|
||||||
|
client_id: oidcClient.id,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
redirect_uri: oidcClient.callbackUrl,
|
||||||
|
state: 'nXx-6Qr-owc1SHBa',
|
||||||
|
nonce: 'P1gN3PtpKHJgKUVcLpLjm'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marks Craig's email as verified through the admin API
|
||||||
|
async function markCraigVerified(request: APIRequestContext) {
|
||||||
|
const craig = await request.get(`/api/users/${users.craig.id}`).then((r) => r.json());
|
||||||
|
const response = await request.put(`/api/users/${users.craig.id}`, {
|
||||||
|
data: { ...craig, emailVerified: true }
|
||||||
|
});
|
||||||
|
expect(response.ok()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('Required email verification', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await cleanupBackend();
|
||||||
|
await requireEmailVerificationWithSmtp(page.request);
|
||||||
|
|
||||||
|
// Craig is a regular user whose email is not verified yet
|
||||||
|
await authUtil.changeUser(page, 'craig');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unverified user can only reach their account', async ({ page }) => {
|
||||||
|
await page.goto('/settings/apps');
|
||||||
|
await page.waitForURL('/settings/account');
|
||||||
|
|
||||||
|
await expect(page.getByText('Verify your email address to use your account')).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: 'My Apps' })).toHaveCount(0);
|
||||||
|
|
||||||
|
const response = await page.request.get('/api/oidc/users/me/clients');
|
||||||
|
expect(response.status()).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unverified user cannot sign in to a client', async ({ page }) => {
|
||||||
|
await page.goto(`/authorize?${nextcloudAuthorizeParams().toString()}`);
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/interaction\/error\?error=/);
|
||||||
|
await expect(
|
||||||
|
page.getByRole('paragraph').filter({
|
||||||
|
hasText: 'You must verify your email address before you can access this service.'
|
||||||
|
})
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Verify email address' }).click();
|
||||||
|
await page.waitForURL('/settings/account');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Unverified user is stopped right after signing in during authorization', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
// Sign out so the sign-in happens on the interaction page, with the passkey the setup already registered for Craig
|
||||||
|
await page.context().clearCookies();
|
||||||
|
await page.goto(`/authorize?${nextcloudAuthorizeParams().toString()}`);
|
||||||
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||||
|
|
||||||
|
// Completing the sign-in step already requires a verified email, so the consent screen is never shown
|
||||||
|
await expect(
|
||||||
|
page.getByText('You must verify your email address before you can sign in to applications.')
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByTestId('scopes')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole('link', { name: 'Verify email address' }).click();
|
||||||
|
await page.waitForURL('/settings/account');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Verifying the email restores full access', async ({ page }) => {
|
||||||
|
const token = emailVerificationTokens.find((t) => !t.expired)!.token;
|
||||||
|
await page.goto(`/verify-email?token=${token}`);
|
||||||
|
await page.waitForURL('/settings/account?emailVerificationState=success');
|
||||||
|
|
||||||
|
await page.goto('/settings/apps');
|
||||||
|
await expect(page).toHaveURL('/settings/apps');
|
||||||
|
await expect(page.getByRole('link', { name: 'My Apps' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Email change with required verification', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await cleanupBackend();
|
||||||
|
await requireEmailVerificationWithSmtp(page.request);
|
||||||
|
await markCraigVerified(page.request);
|
||||||
|
await authUtil.changeUser(page, 'craig');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A new email takes effect once it is confirmed', async ({ page, smtp }) => {
|
||||||
|
const newEmail = 'craig.new@test.com';
|
||||||
|
await page.goto('/settings/account');
|
||||||
|
await page.getByLabel('Email').fill(newEmail);
|
||||||
|
await saveUnsavedChanges(page);
|
||||||
|
|
||||||
|
// The verified email stays in effect and the account keeps full access until the change is confirmed
|
||||||
|
await expect(page.getByText('Confirm your new email address')).toBeVisible();
|
||||||
|
await expect(page.getByLabel('Email')).toHaveValue(users.craig.email);
|
||||||
|
await page.goto('/settings/apps');
|
||||||
|
await expect(page).toHaveURL('/settings/apps');
|
||||||
|
|
||||||
|
await page.goto(verificationLinkPath(smtp, newEmail));
|
||||||
|
await page.waitForURL('/settings/account?emailVerificationState=success');
|
||||||
|
|
||||||
|
await expect(page.getByLabel('Email')).toHaveValue(newEmail);
|
||||||
|
await expect(page.getByText('Confirm your new email address')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('A canceled email change can no longer be confirmed', async ({ page, smtp }) => {
|
||||||
|
const newEmail = 'craig.canceled@test.com';
|
||||||
|
await page.goto('/settings/account');
|
||||||
|
await page.getByLabel('Email').fill(newEmail);
|
||||||
|
await saveUnsavedChanges(page);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Cancel change' }).click();
|
||||||
|
await expect(page.getByText('Email change canceled.')).toBeVisible();
|
||||||
|
await expect(page.getByText('Confirm your new email address')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.goto(verificationLinkPath(smtp, newEmail));
|
||||||
|
await page.waitForURL(
|
||||||
|
'/settings/account?emailVerificationState=Email+verification+token+is+invalid'
|
||||||
|
);
|
||||||
|
await expect(page.getByLabel('Email')).toHaveValue(users.craig.email);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Signing up sends a verification link that unlocks the account', async ({ page, smtp }) => {
|
||||||
|
await cleanupBackend();
|
||||||
|
await requireEmailVerificationWithSmtp(page.request);
|
||||||
|
await setAppConfigValues(page.request, { allowUserSignups: 'open' });
|
||||||
|
await page.context().clearCookies();
|
||||||
|
|
||||||
|
const email = 'verified.signup@test.com';
|
||||||
|
await page.goto('/signup');
|
||||||
|
await page.getByLabel('First name').fill('Verified');
|
||||||
|
await page.getByLabel('Last name').fill('Signup');
|
||||||
|
await page.getByLabel('Username').fill('verifiedsignup');
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signup/add-passkey');
|
||||||
|
await page.getByRole('button', { name: 'Skip for now' }).click();
|
||||||
|
await page
|
||||||
|
.getByRole('alertdialog', { name: 'Skip Passkey Setup' })
|
||||||
|
.getByRole('button', { name: 'Skip for now' })
|
||||||
|
.click();
|
||||||
|
await page.waitForURL('/settings/account');
|
||||||
|
|
||||||
|
await page.goto(verificationLinkPath(smtp, email));
|
||||||
|
await page.waitForURL('/settings/account?emailVerificationState=success');
|
||||||
|
|
||||||
|
await page.goto('/settings/apps');
|
||||||
|
await expect(page).toHaveURL('/settings/apps');
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import test, { expect, type Page } from '@playwright/test';
|
import test, { expect, type Page } from '@playwright/test';
|
||||||
import { signupTokens, userGroups, users } from '../data';
|
import { signupTokens, userGroups, users } from '../data';
|
||||||
|
import { setAppConfigValues } from '../utils/app-config.util';
|
||||||
import { cleanupBackend } from '../utils/cleanup.util';
|
import { cleanupBackend } from '../utils/cleanup.util';
|
||||||
import passkeyUtil from '../utils/passkey.util';
|
import passkeyUtil from '../utils/passkey.util';
|
||||||
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
|
import { saveUnsavedChanges } from '../utils/unsaved-changes.util';
|
||||||
@@ -296,6 +297,39 @@ test.describe('User Signup', () => {
|
|||||||
await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Set up your passkey' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Open signup with required email verification restricts the new account', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
// Required verification needs an SMTP server, but nothing listens on it, so the email sent at signup just fails
|
||||||
|
await setAppConfigValues(page.request, {
|
||||||
|
emailVerification: 'required',
|
||||||
|
smtpHost: 'localhost',
|
||||||
|
smtpFrom: 'pocket-id@test.com'
|
||||||
|
});
|
||||||
|
await setSignupMode(page, 'Open Signup');
|
||||||
|
|
||||||
|
await page.goto('/signup');
|
||||||
|
await page.getByLabel('First name').fill('Pending');
|
||||||
|
await page.getByLabel('Last name').fill('User');
|
||||||
|
await page.getByLabel('Username').fill('pendinguser');
|
||||||
|
await page.getByLabel('Email').fill('pending.user@test.com');
|
||||||
|
await page.getByRole('button', { name: 'Sign Up' }).click();
|
||||||
|
|
||||||
|
await page.waitForURL('/signup/add-passkey');
|
||||||
|
await page.getByRole('button', { name: 'Skip for now' }).click();
|
||||||
|
await page
|
||||||
|
.getByRole('alertdialog', { name: 'Skip Passkey Setup' })
|
||||||
|
.getByRole('button', { name: 'Skip for now' })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.waitForURL('/settings/account');
|
||||||
|
await expect(page.getByText('Verify your email address to use your account')).toBeVisible();
|
||||||
|
await expect(page.getByText('your account will be deleted')).toBeVisible();
|
||||||
|
|
||||||
|
await page.goto('/settings/apps');
|
||||||
|
await expect(page).toHaveURL('/settings/account');
|
||||||
|
});
|
||||||
|
|
||||||
test('Token usage limit is enforced', async ({ page }) => {
|
test('Token usage limit is enforced', async ({ page }) => {
|
||||||
await setSignupMode(page, 'Signup with token');
|
await setSignupMode(page, 'Signup with token');
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { expect, type APIRequestContext } from '@playwright/test';
|
||||||
|
|
||||||
|
// Overrides application configuration values through the API, keeping every other value unchanged
|
||||||
|
// The request context must be authenticated as an admin
|
||||||
|
export async function setAppConfigValues(
|
||||||
|
request: APIRequestContext,
|
||||||
|
values: Record<string, string>
|
||||||
|
) {
|
||||||
|
const configResponse = await request.get('/api/application-configuration/all');
|
||||||
|
expect(configResponse.ok()).toBe(true);
|
||||||
|
|
||||||
|
const config = Object.fromEntries(
|
||||||
|
((await configResponse.json()) as Array<{ key: string; value: string }>).map(
|
||||||
|
({ key, value }) => [key, value]
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateResponse = await request.put('/api/application-configuration', {
|
||||||
|
data: { ...config, ...values }
|
||||||
|
});
|
||||||
|
expect(updateResponse.ok()).toBe(true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { createServer } from 'node:net';
|
||||||
|
|
||||||
|
export type CapturedEmail = {
|
||||||
|
recipients: string[];
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SmtpSink = {
|
||||||
|
emails: CapturedEmail[];
|
||||||
|
close: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Starts a minimal SMTP server that accepts every message, so tests can read the emails Pocket ID sends
|
||||||
|
// Docker reaches it through the host gateway, like the back-channel logout receiver
|
||||||
|
export async function startSmtpSink(port: number): Promise<SmtpSink> {
|
||||||
|
const emails: CapturedEmail[] = [];
|
||||||
|
|
||||||
|
const server = createServer((socket) => {
|
||||||
|
let buffer = '';
|
||||||
|
let inData = false;
|
||||||
|
let recipients: string[] = [];
|
||||||
|
|
||||||
|
socket.write('220 smtp-sink ESMTP\r\n');
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buffer += chunk.toString();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
// The message ends with a line that only holds a dot
|
||||||
|
if (inData) {
|
||||||
|
const end = buffer.indexOf('\r\n.\r\n');
|
||||||
|
if (end === -1) return;
|
||||||
|
|
||||||
|
emails.push({ recipients, body: decodeQuotedPrintable(buffer.slice(0, end)) });
|
||||||
|
buffer = buffer.slice(end + 5);
|
||||||
|
inData = false;
|
||||||
|
recipients = [];
|
||||||
|
socket.write('250 OK\r\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineEnd = buffer.indexOf('\r\n');
|
||||||
|
if (lineEnd === -1) return;
|
||||||
|
const line = buffer.slice(0, lineEnd);
|
||||||
|
buffer = buffer.slice(lineEnd + 2);
|
||||||
|
|
||||||
|
// Answer just enough of the protocol for a client that neither authenticates nor uses TLS
|
||||||
|
const command = line.slice(0, 4).toUpperCase();
|
||||||
|
if (command === 'EHLO' || command === 'HELO') {
|
||||||
|
socket.write('250 smtp-sink\r\n');
|
||||||
|
} else if (command === 'RCPT') {
|
||||||
|
recipients.push(line.match(/<(.*)>/)?.[1] ?? '');
|
||||||
|
socket.write('250 OK\r\n');
|
||||||
|
} else if (command === 'DATA') {
|
||||||
|
inData = true;
|
||||||
|
socket.write('354 End data with <CR><LF>.<CR><LF>\r\n');
|
||||||
|
} else if (command === 'QUIT') {
|
||||||
|
socket.end('221 Bye\r\n');
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
socket.write('250 OK\r\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(port, '0.0.0.0', resolve);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
emails,
|
||||||
|
close: () =>
|
||||||
|
new Promise<void>((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the path of the newest email verification link sent to the recipient
|
||||||
|
export function verificationLinkPath(sink: SmtpSink, recipient: string) {
|
||||||
|
const email = sink.emails.findLast((e) => e.recipients.includes(recipient));
|
||||||
|
const token = email?.body.match(/\/verify-email\?token=([A-Za-z0-9]+)/)?.[1];
|
||||||
|
if (!token) throw new Error(`No verification link was sent to ${recipient}`);
|
||||||
|
return `/verify-email?token=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeQuotedPrintable(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/=\r\n/g, '')
|
||||||
|
.replace(/=([0-9A-F]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user