mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-16 17:23:24 +03:00
feat: add option to OIDC client to require re-authentication (#747)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us> Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -48,8 +48,12 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
|
|||||||
|
|
||||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
|
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
|
||||||
svc.customClaimService = service.NewCustomClaimService(db)
|
svc.customClaimService = service.NewCustomClaimService(db)
|
||||||
|
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
|
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
||||||
}
|
}
|
||||||
@@ -58,10 +62,5 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
|
|||||||
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
|
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
|
||||||
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
||||||
|
|
||||||
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -350,6 +350,15 @@ func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
|
|||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReauthenticationRequiredError struct{}
|
||||||
|
|
||||||
|
func (e *ReauthenticationRequiredError) Error() string {
|
||||||
|
return "reauthentication required"
|
||||||
|
}
|
||||||
|
func (e *ReauthenticationRequiredError) HttpStatusCode() int {
|
||||||
|
return http.StatusUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
type OpenSignupDisabledError struct{}
|
type OpenSignupDisabledError struct{}
|
||||||
|
|
||||||
func (e *OpenSignupDisabledError) Error() string {
|
func (e *OpenSignupDisabledError) Error() string {
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.Au
|
|||||||
|
|
||||||
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
|
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
|
||||||
|
|
||||||
|
group.POST("/webauthn/reauthenticate", authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.reauthenticateHandler)
|
||||||
|
|
||||||
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
|
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
|
||||||
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
|
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
|
||||||
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
|
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
|
||||||
@@ -171,3 +173,33 @@ func (wc *WebauthnController) logoutHandler(c *gin.Context) {
|
|||||||
cookie.AddAccessTokenCookie(c, 0, "")
|
cookie.AddAccessTokenCookie(c, 0, "")
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) {
|
||||||
|
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(&common.MissingSessionIdError{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var token string
|
||||||
|
|
||||||
|
// Try to create a reauthentication token with WebAuthn
|
||||||
|
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||||
|
if err == nil {
|
||||||
|
token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If WebAuthn fails, try to create a reauthentication token with the access token
|
||||||
|
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
|
||||||
|
token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ package dto
|
|||||||
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
|
||||||
type OidcClientMetaDataDto struct {
|
type OidcClientMetaDataDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
HasLogo bool `json:"hasLogo"`
|
HasLogo bool `json:"hasLogo"`
|
||||||
LaunchURL *string `json:"launchURL"`
|
LaunchURL *string `json:"launchURL"`
|
||||||
|
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientDto struct {
|
type OidcClientDto struct {
|
||||||
@@ -29,13 +30,14 @@ type OidcClientWithAllowedGroupsCountDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientCreateDto struct {
|
type OidcClientCreateDto struct {
|
||||||
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
||||||
CallbackURLs []string `json:"callbackURLs"`
|
CallbackURLs []string `json:"callbackURLs"`
|
||||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||||
IsPublic bool `json:"isPublic"`
|
IsPublic bool `json:"isPublic"`
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||||
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||||
|
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientCredentialsDto struct {
|
type OidcClientCredentialsDto struct {
|
||||||
@@ -50,12 +52,13 @@ type OidcClientFederatedIdentityDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientRequestDto struct {
|
type AuthorizeOidcClientRequestDto struct {
|
||||||
ClientID string `json:"clientID" binding:"required"`
|
ClientID string `json:"clientID" binding:"required"`
|
||||||
Scope string `json:"scope" binding:"required"`
|
Scope string `json:"scope" binding:"required"`
|
||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
Nonce string `json:"nonce"`
|
Nonce string `json:"nonce"`
|
||||||
CodeChallenge string `json:"codeChallenge"`
|
CodeChallenge string `json:"codeChallenge"`
|
||||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||||
|
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientResponseDto struct {
|
type AuthorizeOidcClientResponseDto struct {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
|
|||||||
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||||
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||||
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||||
|
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
|
||||||
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -104,6 +105,20 @@ func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClearReauthenticationTokens deletes reauthentication tokens that have expired
|
||||||
|
func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
|
||||||
|
st := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ClearAuditLogs deletes audit logs older than 90 days
|
// ClearAuditLogs deletes audit logs older than 90 days
|
||||||
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
|
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
|
||||||
st := j.db.
|
st := j.db.
|
||||||
|
|||||||
@@ -40,16 +40,17 @@ type OidcAuthorizationCode struct {
|
|||||||
type OidcClient struct {
|
type OidcClient struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Name string `sortable:"true"`
|
Name string `sortable:"true"`
|
||||||
Secret string
|
Secret string
|
||||||
CallbackURLs UrlList
|
CallbackURLs UrlList
|
||||||
LogoutCallbackURLs UrlList
|
LogoutCallbackURLs UrlList
|
||||||
ImageType *string
|
ImageType *string
|
||||||
HasLogo bool `gorm:"-"`
|
HasLogo bool `gorm:"-"`
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
PkceEnabled bool
|
PkceEnabled bool
|
||||||
Credentials OidcClientCredentials
|
RequiresReauthentication bool
|
||||||
LaunchURL *string
|
Credentials OidcClientCredentials
|
||||||
|
LaunchURL *string
|
||||||
|
|
||||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||||
CreatedByID string
|
CreatedByID string
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ type PublicKeyCredentialRequestOptions struct {
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReauthenticationToken struct {
|
||||||
|
Base
|
||||||
|
Token string
|
||||||
|
ExpiresAt datatype.DateTime
|
||||||
|
|
||||||
|
UserID string
|
||||||
|
User User
|
||||||
|
}
|
||||||
|
|
||||||
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
|
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
|
||||||
|
|
||||||
// Scan and Value methods for GORM to handle the custom type
|
// Scan and Value methods for GORM to handle the custom type
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ type OidcService struct {
|
|||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
auditLogService *AuditLogService
|
auditLogService *AuditLogService
|
||||||
customClaimService *CustomClaimService
|
customClaimService *CustomClaimService
|
||||||
|
webAuthnService *WebAuthnService
|
||||||
|
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
jwkCache *jwk.Cache
|
jwkCache *jwk.Cache
|
||||||
@@ -62,6 +63,7 @@ func NewOidcService(
|
|||||||
appConfigService *AppConfigService,
|
appConfigService *AppConfigService,
|
||||||
auditLogService *AuditLogService,
|
auditLogService *AuditLogService,
|
||||||
customClaimService *CustomClaimService,
|
customClaimService *CustomClaimService,
|
||||||
|
webAuthnService *WebAuthnService,
|
||||||
) (s *OidcService, err error) {
|
) (s *OidcService, err error) {
|
||||||
s = &OidcService{
|
s = &OidcService{
|
||||||
db: db,
|
db: db,
|
||||||
@@ -69,6 +71,7 @@ func NewOidcService(
|
|||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
auditLogService: auditLogService,
|
auditLogService: auditLogService,
|
||||||
customClaimService: customClaimService,
|
customClaimService: customClaimService,
|
||||||
|
webAuthnService: webAuthnService,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
|
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
|
||||||
@@ -123,6 +126,16 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
|||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if client.RequiresReauthentication {
|
||||||
|
if input.ReauthenticationToken == "" {
|
||||||
|
return "", "", &common.ReauthenticationRequiredError{}
|
||||||
|
}
|
||||||
|
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If the client is not public, the code challenge must be provided
|
// If the client is not public, the code challenge must be provided
|
||||||
if client.IsPublic && input.CodeChallenge == "" {
|
if client.IsPublic && input.CodeChallenge == "" {
|
||||||
return "", "", &common.OidcMissingCodeChallengeError{}
|
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||||
@@ -714,6 +727,7 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
|
|||||||
client.IsPublic = input.IsPublic
|
client.IsPublic = input.IsPublic
|
||||||
// PKCE is required for public clients
|
// PKCE is required for public clients
|
||||||
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
||||||
|
client.RequiresReauthentication = input.RequiresReauthentication
|
||||||
client.LaunchURL = input.LaunchURL
|
client.LaunchURL = input.LaunchURL
|
||||||
|
|
||||||
// Credentials
|
// Credentials
|
||||||
|
|||||||
@@ -336,3 +336,136 @@ func (s *WebAuthnService) UpdateCredential(ctx context.Context, userID, credenti
|
|||||||
func (s *WebAuthnService) updateWebAuthnConfig() {
|
func (s *WebAuthnService) updateWebAuthnConfig() {
|
||||||
s.webAuthn.Config.RPDisplayName = s.appConfigService.GetDbConfig().AppName.Value
|
s.webAuthn.Config.RPDisplayName = s.appConfigService.GetDbConfig().AppName.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) {
|
||||||
|
tx := s.db.Begin()
|
||||||
|
defer func() {
|
||||||
|
tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
token, err := s.jwtService.VerifyAccessToken(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid access token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := token.Subject()
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("access token does not contain user ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token is issued less than a minute ago
|
||||||
|
tokenExpiration, ok := token.IssuedAt()
|
||||||
|
if !ok || time.Since(tokenExpiration) > time.Minute {
|
||||||
|
return "", &common.ReauthenticationRequiredError{}
|
||||||
|
}
|
||||||
|
|
||||||
|
var user model.User
|
||||||
|
err = tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
First(&user, "id = ?", userID).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to load user: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reauthToken, err := s.createReauthenticationToken(ctx, tx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit().Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return reauthToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebAuthnService) CreateReauthenticationTokenWithWebauthn(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (string, error) {
|
||||||
|
tx := s.db.Begin()
|
||||||
|
defer func() {
|
||||||
|
tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Retrieve and delete the session
|
||||||
|
var storedSession model.WebauthnSession
|
||||||
|
err := tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
Clauses(clause.Returning{}).
|
||||||
|
Delete(&storedSession, "id = ? AND expires_at > ?", sessionID, datatype.DateTime(time.Now())).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to load WebAuthn session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session := webauthn.SessionData{
|
||||||
|
Challenge: storedSession.Challenge,
|
||||||
|
Expires: storedSession.ExpiresAt.ToTime(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the credential assertion
|
||||||
|
var user *model.User
|
||||||
|
_, err = s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
|
||||||
|
innerErr := tx.
|
||||||
|
WithContext(ctx).
|
||||||
|
Preload("Credentials").
|
||||||
|
First(&user, "id = ?", string(userHandle)).
|
||||||
|
Error
|
||||||
|
if innerErr != nil {
|
||||||
|
return nil, innerErr
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}, session, credentialAssertionData)
|
||||||
|
|
||||||
|
if err != nil || user == nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create reauthentication token
|
||||||
|
token, err := s.createReauthenticationToken(ctx, tx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit().Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) error {
|
||||||
|
hashedToken := utils.CreateSha256Hash(token)
|
||||||
|
result := tx.WithContext(ctx).
|
||||||
|
Clauses(clause.Returning{}).
|
||||||
|
Delete(&model.ReauthenticationToken{}, "token = ? AND user_id = ? AND expires_at > ?", hashedToken, userID, datatype.DateTime(time.Now()))
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return &common.ReauthenticationRequiredError{}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *WebAuthnService) createReauthenticationToken(ctx context.Context, tx *gorm.DB, userID string) (string, error) {
|
||||||
|
token, err := utils.GenerateRandomAlphanumericString(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
reauthToken := model.ReauthenticationToken{
|
||||||
|
Token: utils.CreateSha256Hash(token),
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(3 * time.Minute)),
|
||||||
|
UserID: userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.WithContext(ctx).Create(&reauthToken).Error
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE oidc_clients DROP COLUMN requires_reauthentication;
|
||||||
|
DROP TABLE IF EXISTS reauthentication_tokens;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE oidc_clients ADD COLUMN requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE TABLE reauthentication_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens(token);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE oidc_clients DROP COLUMN requires_reauthentication;
|
||||||
|
DROP INDEX IF EXISTS idx_reauthentication_tokens_token;
|
||||||
|
DROP TABLE IF EXISTS reauthentication_tokens;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
ALTER TABLE oidc_clients ADD COLUMN requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE TABLE reauthentication_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at DATETIME NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens(token);
|
||||||
@@ -276,6 +276,8 @@
|
|||||||
"public_clients_description": "Öffentliche Clients haben kein Client-Geheimnis und verwenden stattdessen PKCE. Aktiviere dies, wenn dein Client eine SPA oder mobile App ist.",
|
"public_clients_description": "Öffentliche Clients haben kein Client-Geheimnis und verwenden stattdessen PKCE. Aktiviere dies, wenn dein Client eine SPA oder mobile App ist.",
|
||||||
"pkce": "PKCE",
|
"pkce": "PKCE",
|
||||||
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Der Public Key Code Exchange (öffentlicher Schlüsselaustausch) ist eine Sicherheitsfunktion, um CSRF Angriffe und Angriffe zum Abfangen von Autorisierungscodes zu verhindern.",
|
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Der Public Key Code Exchange (öffentlicher Schlüsselaustausch) ist eine Sicherheitsfunktion, um CSRF Angriffe und Angriffe zum Abfangen von Autorisierungscodes zu verhindern.",
|
||||||
|
"requires_reauthentication": "Erfordert erneute Authentifizierung",
|
||||||
|
"requires_users_to_authenticate_again_on_each_authorization": "Erfordert eine neue Authentifizierung bei jeder Autorisierung, auch wenn der Benutzer bereits angemeldet ist",
|
||||||
"name_logo": "{name} Logo",
|
"name_logo": "{name} Logo",
|
||||||
"change_logo": "Logo ändern",
|
"change_logo": "Logo ändern",
|
||||||
"upload_logo": "Logo hochladen",
|
"upload_logo": "Logo hochladen",
|
||||||
|
|||||||
@@ -276,6 +276,8 @@
|
|||||||
"public_clients_description": "Public clients do not have a client secret. They are designed for mobile, web, and native applications where secrets cannot be securely stored.",
|
"public_clients_description": "Public clients do not have a client secret. They are designed for mobile, web, and native applications where secrets cannot be securely stored.",
|
||||||
"pkce": "PKCE",
|
"pkce": "PKCE",
|
||||||
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
|
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
|
||||||
|
"requires_reauthentication": "Requires Re-Authentication",
|
||||||
|
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
|
||||||
"name_logo": "{name} logo",
|
"name_logo": "{name} logo",
|
||||||
"change_logo": "Change Logo",
|
"change_logo": "Change Logo",
|
||||||
"upload_logo": "Upload Logo",
|
"upload_logo": "Upload Logo",
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ class OidcService extends APIService {
|
|||||||
callbackURL: string,
|
callbackURL: string,
|
||||||
nonce?: string,
|
nonce?: string,
|
||||||
codeChallenge?: string,
|
codeChallenge?: string,
|
||||||
codeChallengeMethod?: string
|
codeChallengeMethod?: string,
|
||||||
|
reauthenticationToken?: string
|
||||||
) {
|
) {
|
||||||
const res = await this.api.post('/oidc/authorize', {
|
const res = await this.api.post('/oidc/authorize', {
|
||||||
scope,
|
scope,
|
||||||
@@ -27,7 +28,8 @@ class OidcService extends APIService {
|
|||||||
callbackURL,
|
callbackURL,
|
||||||
clientId,
|
clientId,
|
||||||
codeChallenge,
|
codeChallenge,
|
||||||
codeChallengeMethod
|
codeChallengeMethod,
|
||||||
|
reauthenticationToken
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.data as AuthorizeResponse;
|
return res.data as AuthorizeResponse;
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ class WebAuthnService extends APIService {
|
|||||||
async updateCredentialName(id: string, name: string) {
|
async updateCredentialName(id: string, name: string) {
|
||||||
await this.api.patch(`/webauthn/credentials/${id}`, { name });
|
await this.api.patch(`/webauthn/credentials/${id}`, { name });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async reauthenticate(body?: AuthenticationResponseJSON) {
|
||||||
|
const res = await this.api.post('/webauthn/reauthenticate', body);
|
||||||
|
return res.data.reauthenticationToken as string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default WebAuthnService;
|
export default WebAuthnService;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type OidcClientMetaData = {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
hasLogo: boolean;
|
hasLogo: boolean;
|
||||||
|
requiresReauthentication: boolean;
|
||||||
launchURL?: string;
|
launchURL?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ export type OidcClient = OidcClientMetaData & {
|
|||||||
logoutCallbackURLs: string[];
|
logoutCallbackURLs: string[];
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
pkceEnabled: boolean;
|
pkceEnabled: boolean;
|
||||||
|
requiresReauthentication: boolean;
|
||||||
credentials?: OidcClientCredentials;
|
credentials?: OidcClientCredentials;
|
||||||
launchURL?: string;
|
launchURL?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||||
import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
|
import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
import { startAuthentication, type AuthenticationResponseJSON } from '@simplewebauthn/browser';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
let errorMessage: string | null = $state(null);
|
let errorMessage: string | null = $state(null);
|
||||||
let authorizationRequired = $state(false);
|
let authorizationRequired = $state(false);
|
||||||
let authorizationConfirmed = $state(false);
|
let authorizationConfirmed = $state(false);
|
||||||
|
let userSignedInAt: Date | undefined;
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if ($userStore) {
|
if ($userStore) {
|
||||||
@@ -38,13 +39,16 @@
|
|||||||
|
|
||||||
async function authorize() {
|
async function authorize() {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
|
||||||
|
let authResponse: AuthenticationResponseJSON | undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get access token if not signed in
|
|
||||||
if (!$userStore?.id) {
|
if (!$userStore?.id) {
|
||||||
const loginOptions = await webauthnService.getLoginOptions();
|
const loginOptions = await webauthnService.getLoginOptions();
|
||||||
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||||
const user = await webauthnService.finishLogin(authResponse);
|
const user = await webauthnService.finishLogin(authResponse);
|
||||||
await userStore.setUser(user);
|
userStore.setUser(user);
|
||||||
|
userSignedInAt = new Date();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!authorizationConfirmed) {
|
if (!authorizationConfirmed) {
|
||||||
@@ -56,8 +60,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reauthToken: string | undefined;
|
||||||
|
if (client?.requiresReauthentication) {
|
||||||
|
let authResponse;
|
||||||
|
const signedInRecently =
|
||||||
|
userSignedInAt && userSignedInAt.getTime() > Date.now() - 60 * 1000;
|
||||||
|
if (!signedInRecently) {
|
||||||
|
const loginOptions = await webauthnService.getLoginOptions();
|
||||||
|
authResponse = await startAuthentication({ optionsJSON: loginOptions });
|
||||||
|
}
|
||||||
|
reauthToken = await webauthnService.reauthenticate(authResponse);
|
||||||
|
}
|
||||||
|
|
||||||
await oidService
|
await oidService
|
||||||
.authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
|
.authorize(
|
||||||
|
client!.id,
|
||||||
|
scope,
|
||||||
|
callbackURL,
|
||||||
|
nonce,
|
||||||
|
codeChallenge,
|
||||||
|
codeChallengeMethod,
|
||||||
|
reauthToken
|
||||||
|
)
|
||||||
.then(async ({ code, callbackURL, issuer }) => {
|
.then(async ({ code, callbackURL, issuer }) => {
|
||||||
onSuccess(code, callbackURL, issuer);
|
onSuccess(code, callbackURL, issuer);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -36,7 +36,8 @@
|
|||||||
[m.userinfo_url()]: `https://${page.url.hostname}/api/oidc/userinfo`,
|
[m.userinfo_url()]: `https://${page.url.hostname}/api/oidc/userinfo`,
|
||||||
[m.logout_url()]: `https://${page.url.hostname}/api/oidc/end-session`,
|
[m.logout_url()]: `https://${page.url.hostname}/api/oidc/end-session`,
|
||||||
[m.certificate_url()]: `https://${page.url.hostname}/.well-known/jwks.json`,
|
[m.certificate_url()]: `https://${page.url.hostname}/.well-known/jwks.json`,
|
||||||
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled()
|
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled(),
|
||||||
|
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled()
|
||||||
});
|
});
|
||||||
|
|
||||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||||
@@ -49,6 +50,9 @@
|
|||||||
|
|
||||||
client.isPublic = updatedClient.isPublic;
|
client.isPublic = updatedClient.isPublic;
|
||||||
setupDetails[m.pkce()] = updatedClient.pkceEnabled ? m.enabled() : m.disabled();
|
setupDetails[m.pkce()] = updatedClient.pkceEnabled ? m.enabled() : m.disabled();
|
||||||
|
setupDetails[m.requires_reauthentication()] = updatedClient.requiresReauthentication
|
||||||
|
? m.enabled()
|
||||||
|
: m.disabled();
|
||||||
|
|
||||||
await Promise.all([dataPromise, imagePromise])
|
await Promise.all([dataPromise, imagePromise])
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -120,14 +124,14 @@
|
|||||||
<Card.Content>
|
<Card.Content>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
|
<div class="mb-2 flex flex-col sm:flex-row sm:items-center">
|
||||||
<Label class="mb-0 w-44">{m.client_id()}</Label>
|
<Label class="mb-0 w-50">{m.client_id()}</Label>
|
||||||
<CopyToClipboard value={client.id}>
|
<CopyToClipboard value={client.id}>
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
</div>
|
</div>
|
||||||
{#if !client.isPublic}
|
{#if !client.isPublic}
|
||||||
<div class="mt-1 mb-2 flex flex-col sm:flex-row sm:items-center">
|
<div class="mt-1 mb-2 flex flex-col sm:flex-row sm:items-center">
|
||||||
<Label class="mb-0 w-44">{m.client_secret()}</Label>
|
<Label class="mb-0 w-50">{m.client_secret()}</Label>
|
||||||
{#if $clientSecretStore}
|
{#if $clientSecretStore}
|
||||||
<CopyToClipboard value={$clientSecretStore}>
|
<CopyToClipboard value={$clientSecretStore}>
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
<span class="text-muted-foreground text-sm" data-testid="client-secret">
|
||||||
@@ -154,7 +158,7 @@
|
|||||||
<div transition:slide>
|
<div transition:slide>
|
||||||
{#each Object.entries(setupDetails) as [key, value]}
|
{#each Object.entries(setupDetails) as [key, value]}
|
||||||
<div class="mb-5 flex flex-col sm:flex-row sm:items-center">
|
<div class="mb-5 flex flex-col sm:flex-row sm:items-center">
|
||||||
<Label class="mb-0 w-44">{key}</Label>
|
<Label class="mb-0 w-50">{key}</Label>
|
||||||
<CopyToClipboard {value}>
|
<CopyToClipboard {value}>
|
||||||
<span class="text-muted-foreground text-sm">{value}</span>
|
<span class="text-muted-foreground text-sm">{value}</span>
|
||||||
</CopyToClipboard>
|
</CopyToClipboard>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
|
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
|
||||||
isPublic: existingClient?.isPublic || false,
|
isPublic: existingClient?.isPublic || false,
|
||||||
pkceEnabled: existingClient?.pkceEnabled || false,
|
pkceEnabled: existingClient?.pkceEnabled || false,
|
||||||
|
requiresReauthentication: existingClient?.requiresReauthentication || false,
|
||||||
launchURL: existingClient?.launchURL || '',
|
launchURL: existingClient?.launchURL || '',
|
||||||
credentials: {
|
credentials: {
|
||||||
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
|
||||||
@@ -51,6 +52,7 @@
|
|||||||
logoutCallbackURLs: z.array(z.string().nonempty()),
|
logoutCallbackURLs: z.array(z.string().nonempty()),
|
||||||
isPublic: z.boolean(),
|
isPublic: z.boolean(),
|
||||||
pkceEnabled: z.boolean(),
|
pkceEnabled: z.boolean(),
|
||||||
|
requiresReauthentication: z.boolean(),
|
||||||
launchURL: optionalUrl,
|
launchURL: optionalUrl,
|
||||||
credentials: z.object({
|
credentials: z.object({
|
||||||
federatedIdentities: z.array(
|
federatedIdentities: z.array(
|
||||||
@@ -147,6 +149,12 @@
|
|||||||
description={m.public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
|
description={m.public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
|
||||||
bind:checked={$inputs.pkceEnabled.value}
|
bind:checked={$inputs.pkceEnabled.value}
|
||||||
/>
|
/>
|
||||||
|
<SwitchWithLabel
|
||||||
|
id="requires-reauthentication"
|
||||||
|
label={m.requires_reauthentication()}
|
||||||
|
description={m.requires_users_to_authenticate_again_on_each_authorization()}
|
||||||
|
bind:checked={$inputs.requiresReauthentication.value}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<Label for="logo">{m.logo()}</Label>
|
<Label for="logo">{m.logo()}</Label>
|
||||||
|
|||||||
@@ -594,3 +594,30 @@ test('Authorize existing client with federated identity', async ({ page }) => {
|
|||||||
expect(res.expires_in).not.toBeNull;
|
expect(res.expires_in).not.toBeNull;
|
||||||
expect(res.token_type).toBe('Bearer');
|
expect(res.token_type).toBe('Bearer');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Forces reauthentication when client requires it', async ({ page, request }) => {
|
||||||
|
let webauthnStartCalled = false;
|
||||||
|
await page.route('/api/webauthn/login/start', async (route) => {
|
||||||
|
webauthnStartCalled = true;
|
||||||
|
await route.continue();
|
||||||
|
});
|
||||||
|
|
||||||
|
await request.put(`/api/oidc/clients/${oidcClients.nextcloud.id}`, {
|
||||||
|
data: { ...oidcClients.nextcloud, requiresReauthentication: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
await (await passkeyUtil.init(page)).addPasskey();
|
||||||
|
|
||||||
|
const urlParams = createUrlParams(oidcClients.nextcloud);
|
||||||
|
await page.goto(`/authorize?${urlParams.toString()}`);
|
||||||
|
|
||||||
|
await expect(page.getByTestId('scopes')).not.toBeVisible();
|
||||||
|
|
||||||
|
await page.waitForURL(oidcClients.nextcloud.callbackUrl).catch((e) => {
|
||||||
|
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(webauthnStartCalled).toBe(true);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user