From 58fcf7cbe665be8d7670170097735fe8efe31c6f Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 29 Jun 2026 14:02:20 +0200 Subject: [PATCH] refactor: migrate Webauthn functionality to single `webauthn` module --- .../internal/bootstrap/router_bootstrap.go | 9 +- .../internal/bootstrap/services_bootstrap.go | 19 +- .../internal/controller/user_controller.go | 5 +- .../controller/webauthn_controller.go | 208 ------------------ backend/internal/job/db_cleanup_job.go | 29 ++- backend/internal/middleware/jwt_auth.go | 2 +- backend/internal/model/webauthn.go | 43 ---- backend/internal/service/e2etest_service.go | 5 +- backend/internal/service/jwt_service.go | 2 +- backend/internal/service/jwt_service_test.go | 4 +- backend/internal/webauthn/cleanup.go | 28 +++ backend/internal/webauthn/handler.go | 191 ++++++++++++++++ backend/internal/webauthn/models.go | 58 +++++ backend/internal/webauthn/module.go | 87 ++++++++ .../service.go} | 124 ++++++----- .../service_test.go} | 97 +++++--- 16 files changed, 541 insertions(+), 370 deletions(-) delete mode 100644 backend/internal/controller/webauthn_controller.go create mode 100644 backend/internal/webauthn/cleanup.go create mode 100644 backend/internal/webauthn/handler.go create mode 100644 backend/internal/webauthn/models.go create mode 100644 backend/internal/webauthn/module.go rename backend/internal/{service/webauthn_service.go => webauthn/service.go} (70%) rename backend/internal/{service/webauthn_service_test.go => webauthn/service_test.go} (51%) diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index fbb453a2..26d9947a 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -128,9 +128,14 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error { authMiddleware.WithAdminNotRequired().Add(), authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), ) - controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService) + webauthnRateLimitMiddleware := middleware.NewRateLimitMiddleware() + svc.webauthnModule.RegisterRoutes(apiGroup, + authMiddleware.WithAdminNotRequired().Add(), + webauthnRateLimitMiddleware.Add(rate.Every(10*time.Second), 5), + webauthnRateLimitMiddleware.Add(rate.Every(10*time.Second), 5), + ) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService) - controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.webauthnService, svc.appConfigService) + controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService) controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService) controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 9afc3b9b..5d0a33b4 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -13,6 +13,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/storage" + "github.com/pocket-id/pocket-id/backend/internal/webauthn" ) type services struct { @@ -22,7 +23,6 @@ type services struct { geoLiteService *service.GeoLiteService auditLogService *service.AuditLogService jwtService *service.JwtService - webauthnService *service.WebAuthnService scimService *service.ScimService userService *service.UserService customClaimService *service.CustomClaimService @@ -35,8 +35,9 @@ type services struct { userSignUpService *service.UserSignUpService oneTimeAccessService *service.OneTimeAccessService - apiKeyModule *apikey.Module - oidcModule *oidc.Module + apiKeyModule *apikey.Module + oidcModule *oidc.Module + webauthnModule *webauthn.Module } // Initializes all services @@ -65,9 +66,15 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima } svc.customClaimService = service.NewCustomClaimService(db) - svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService) + svc.webauthnModule, err = webauthn.New(webauthn.Dependencies{ + DB: db, + AppURL: common.EnvConfig.AppURL, + Signer: svc.jwtService, + AuditLog: svc.auditLogService, + AppConfig: svc.appConfigService, + }) if err != nil { - return nil, fmt.Errorf("failed to create WebAuthn service: %w", err) + return nil, fmt.Errorf("failed to create WebAuthn module: %w", err) } svc.scimService = service.NewScimService(db, scheduler, httpClient) @@ -82,7 +89,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima }, Signer: svc.jwtService, CustomClaims: svc.customClaimService, - Reauth: svc.webauthnService, + Reauth: svc.webauthnModule, AuditLog: svc.auditLogService, }) if err != nil { diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 1a39acd2..5f372638 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -12,6 +12,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/middleware" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" + "github.com/pocket-id/pocket-id/backend/internal/webauthn" "golang.org/x/time/rate" ) @@ -21,7 +22,7 @@ const defaultOneTimeAccessTokenDuration = 15 * time.Minute // @Summary User management controller // @Description Initializes all user-related API endpoints // @Tags Users -func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *service.WebAuthnService, appConfigService *service.AppConfigService) { +func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *service.AppConfigService) { uc := UserController{ userService: userService, oneTimeAccessService: oneTimeAccessService, @@ -63,7 +64,7 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi type UserController struct { userService *service.UserService oneTimeAccessService *service.OneTimeAccessService - webAuthnService *service.WebAuthnService + webAuthnService *webauthn.Module appConfigService *service.AppConfigService } diff --git a/backend/internal/controller/webauthn_controller.go b/backend/internal/controller/webauthn_controller.go deleted file mode 100644 index c4c0315a..00000000 --- a/backend/internal/controller/webauthn_controller.go +++ /dev/null @@ -1,208 +0,0 @@ -package controller - -import ( - "net/http" - "time" - - "github.com/go-webauthn/webauthn/protocol" - "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/middleware" - "github.com/pocket-id/pocket-id/backend/internal/utils/cookie" - - "github.com/gin-gonic/gin" - "github.com/pocket-id/pocket-id/backend/internal/service" - "golang.org/x/time/rate" -) - -func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) { - wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService} - group.GET("/webauthn/register/start", authMiddleware.WithAdminNotRequired().Add(), wc.beginRegistrationHandler) - group.POST("/webauthn/register/finish", authMiddleware.WithAdminNotRequired().Add(), wc.verifyRegistrationHandler) - - group.GET("/webauthn/login/start", wc.beginLoginHandler) - group.POST("/webauthn/login/finish", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.verifyLoginHandler) - - 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.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler) - group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler) -} - -type WebauthnController struct { - webAuthnService *service.WebAuthnService - appConfigService *service.AppConfigService -} - -func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) { - userID := c.GetString("userID") - options, err := wc.webAuthnService.BeginRegistration(c.Request.Context(), userID) - if err != nil { - _ = c.Error(err) - return - } - - cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID) - c.JSON(http.StatusOK, options.Response) -} - -func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) { - sessionID, err := c.Cookie(cookie.SessionIdCookieName) - if err != nil { - _ = c.Error(&common.MissingSessionIdError{}) - return - } - - userID := c.GetString("userID") - credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP()) - if err != nil { - _ = c.Error(err) - return - } - - var credentialDto dto.WebauthnCredentialDto - if err := dto.MapStruct(credential, &credentialDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, credentialDto) -} - -func (wc *WebauthnController) beginLoginHandler(c *gin.Context) { - options, err := wc.webAuthnService.BeginLogin(c.Request.Context()) - if err != nil { - _ = c.Error(err) - return - } - - cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID) - c.JSON(http.StatusOK, options.Response) -} - -func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) { - sessionID, err := c.Cookie(cookie.SessionIdCookieName) - if err != nil { - _ = c.Error(&common.MissingSessionIdError{}) - return - } - - credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body) - if err != nil { - _ = c.Error(err) - return - } - - user, token, err := wc.webAuthnService.VerifyLogin(c.Request.Context(), sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent()) - if err != nil { - _ = c.Error(err) - return - } - - var userDto dto.UserDto - if err := dto.MapStruct(user, &userDto); err != nil { - _ = c.Error(err) - return - } - - maxAge := int(wc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) - cookie.AddAccessTokenCookie(c, maxAge, token) - - c.JSON(http.StatusOK, userDto) -} - -func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) { - userID := c.GetString("userID") - credentials, err := wc.webAuthnService.ListCredentials(c.Request.Context(), userID) - if err != nil { - _ = c.Error(err) - return - } - - var credentialDtos []dto.WebauthnCredentialDto - if err := dto.MapStructList(credentials, &credentialDtos); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, credentialDtos) -} - -func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) { - userID := c.GetString("userID") - credentialID := c.Param("id") - clientIP := c.ClientIP() - userAgent := c.Request.UserAgent() - - err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID) - if err != nil { - _ = c.Error(err) - return - } - - c.Status(http.StatusNoContent) -} - -func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) { - userID := c.GetString("userID") - credentialID := c.Param("id") - - var input dto.WebauthnCredentialUpdateDto - if err := c.ShouldBindJSON(&input); err != nil { - _ = c.Error(err) - return - } - - credential, err := wc.webAuthnService.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name) - if err != nil { - _ = c.Error(err) - return - } - - var credentialDto dto.WebauthnCredentialDto - if err := dto.MapStruct(credential, &credentialDto); err != nil { - _ = c.Error(err) - return - } - - c.JSON(http.StatusOK, credentialDto) -} - -func (wc *WebauthnController) logoutHandler(c *gin.Context) { - cookie.AddAccessTokenCookie(c, 0, "") - 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 - } - } - - cookie.AddReauthenticationTokenCookie(c, token) - c.Status(http.StatusNoContent) -} diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index 913b440a..7e3f434a 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -15,6 +15,7 @@ import ( datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/service" + "github.com/pocket-id/pocket-id/backend/internal/webauthn" ) func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error { @@ -47,16 +48,14 @@ type DbCleanupJobs struct { db *gorm.DB } -// ClearWebauthnSessions deletes WebAuthn sessions that have expired +// clearWebauthnSessions deletes expired WebAuthn challenge sessions. func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error { - st := j.db. - WithContext(ctx). - Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())) - if st.Error != nil { - return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", st.Error) + count, err := webauthn.CleanupExpiredSessions(ctx, j.db) + if err != nil { + return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", err) } - slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", st.RowsAffected)) + slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", count)) return nil } @@ -90,8 +89,7 @@ func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error { return nil } -// clearOAuth2Sessions deletes expired and invalidated OAuth2 sessions. What counts as -// expired is owned by the oidc module. +// clearOAuth2Sessions deletes expired and invalidated OAuth2 sessions. func (j *DbCleanupJobs) clearOAuth2Sessions(ctx context.Context) error { count, err := oidc.CleanupExpiredOAuth2Sessions(ctx, j.db) if err != nil { @@ -127,16 +125,15 @@ func (j *DbCleanupJobs) clearInteractionSessions(ctx context.Context) error { return nil } -// ClearReauthenticationTokens deletes reauthentication tokens that have expired +// clearReauthenticationTokens deletes expired reauthentication tokens. What counts as +// expired is owned by the webauthn module. 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) + count, err := webauthn.CleanupExpiredReauthenticationTokens(ctx, j.db) + if err != nil { + return fmt.Errorf("failed to clean expired reauthentication tokens: %w", err) } - slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected)) + slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", count)) return nil } diff --git a/backend/internal/middleware/jwt_auth.go b/backend/internal/middleware/jwt_auth.go index 27aba3ce..7357908d 100644 --- a/backend/internal/middleware/jwt_auth.go +++ b/backend/internal/middleware/jwt_auth.go @@ -52,7 +52,7 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject if err != nil { return "", false, "", time.Time{}, &common.NotSignedInError{} } - authenticationMethod, err = service.GetAuthenticationMethod(token) + authenticationMethod, err = m.jwtService.GetAuthenticationMethod(token) if err != nil { return "", false, "", time.Time{}, &common.NotSignedInError{} } diff --git a/backend/internal/model/webauthn.go b/backend/internal/model/webauthn.go index 60f1913f..ee44147e 100644 --- a/backend/internal/model/webauthn.go +++ b/backend/internal/model/webauthn.go @@ -3,22 +3,11 @@ package model import ( "database/sql/driver" "encoding/json" - "time" "github.com/go-webauthn/webauthn/protocol" - datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/utils" ) -type WebauthnSession struct { - Base - - Challenge string - ExpiresAt datatype.DateTime - UserVerification string - CredentialParams CredentialParameters -} - type WebauthnCredential struct { Base @@ -34,27 +23,6 @@ type WebauthnCredential struct { UserID string } -type PublicKeyCredentialCreationOptions struct { - Response protocol.PublicKeyCredentialCreationOptions - SessionID string - Timeout time.Duration -} - -type PublicKeyCredentialRequestOptions struct { - Response protocol.PublicKeyCredentialRequestOptions - SessionID string - Timeout time.Duration -} - -type ReauthenticationToken struct { - Base - Token string - ExpiresAt datatype.DateTime - - UserID string - User User -} - type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck // Scan and Value methods for GORM to handle the custom type @@ -65,14 +33,3 @@ func (atl *AuthenticatorTransportList) Scan(value any) error { func (atl AuthenticatorTransportList) Value() (driver.Value, error) { return json.Marshal(atl) } - -type CredentialParameters []protocol.CredentialParameter //nolint:recvcheck - -// Scan and Value methods for GORM to handle the custom type -func (cp *CredentialParameters) Scan(value any) error { - return utils.UnmarshalJSONFromDatabase(cp, value) -} - -func (cp CredentialParameters) Value() (driver.Value, error) { - return json.Marshal(cp) -} diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index 20eb55ef..db28bfa7 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -31,6 +31,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" + "github.com/pocket-id/pocket-id/backend/internal/webauthn" "github.com/pocket-id/pocket-id/backend/resources" ) @@ -347,11 +348,11 @@ func (s *TestService) SeedDatabase(baseURL string) error { } } - webauthnSession := model.WebauthnSession{ + webauthnSession := webauthn.WebauthnSession{ Challenge: "challenge", ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)), UserVerification: "preferred", - CredentialParams: model.CredentialParameters{ + CredentialParams: webauthn.CredentialParameters{ {Type: "public-key", Algorithm: -7}, {Type: "public-key", Algorithm: -257}, }, diff --git a/backend/internal/service/jwt_service.go b/backend/internal/service/jwt_service.go index 41ac6937..48a1a252 100644 --- a/backend/internal/service/jwt_service.go +++ b/backend/internal/service/jwt_service.go @@ -291,7 +291,7 @@ func (s *JwtService) GetKeyID() (string, bool) { } // GetAuthenticationMethod returns the first authentication method in the "amr" claim in the token -func GetAuthenticationMethod(token jwt.Token) (string, error) { +func (s *JwtService) GetAuthenticationMethod(token jwt.Token) (string, error) { if !token.Has(common.AuthenticationMethodsClaim) { return "", nil } diff --git a/backend/internal/service/jwt_service_test.go b/backend/internal/service/jwt_service_test.go index 8d3f94aa..e892d8ef 100644 --- a/backend/internal/service/jwt_service_test.go +++ b/backend/internal/service/jwt_service_test.go @@ -324,7 +324,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { require.NoError(t, claims.Get(IsAdminClaim, &isAdmin), "Failed to get isAdmin claim") } assert.False(t, isAdmin, "isAdmin should be false") - authenticationMethod, err := GetAuthenticationMethod(claims) + authenticationMethod, err := service.GetAuthenticationMethod(claims) _ = assert.NoError(t, err, "Failed to get amr claim") && assert.Empty(t, authenticationMethod, "amr should be empty when not specified") audience, ok := claims.Audience() @@ -379,7 +379,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) { claims, err := service.VerifyAccessToken(tokenString) require.NoError(t, err, "Failed to verify generated token") - authenticationMethod, err := GetAuthenticationMethod(claims) + authenticationMethod, err := service.GetAuthenticationMethod(claims) _ = assert.NoError(t, err, "Failed to get amr claim") && assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match") }) diff --git a/backend/internal/webauthn/cleanup.go b/backend/internal/webauthn/cleanup.go new file mode 100644 index 00000000..64b63c9b --- /dev/null +++ b/backend/internal/webauthn/cleanup.go @@ -0,0 +1,28 @@ +package webauthn + +import ( + "context" + "time" + + "gorm.io/gorm" + + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" +) + +// CleanupExpiredSessions deletes WebAuthn sessions that have expired +// It returns the number of rows removed +func CleanupExpiredSessions(ctx context.Context, db *gorm.DB) (int64, error) { + st := db. + WithContext(ctx). + Delete(&WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())) + return st.RowsAffected, st.Error +} + +// CleanupExpiredReauthenticationTokens deletes reauthentication tokens that have expired +// It returns the number of rows removed +func CleanupExpiredReauthenticationTokens(ctx context.Context, db *gorm.DB) (int64, error) { + st := db. + WithContext(ctx). + Delete(&ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now())) + return st.RowsAffected, st.Error +} diff --git a/backend/internal/webauthn/handler.go b/backend/internal/webauthn/handler.go new file mode 100644 index 00000000..1cfeb447 --- /dev/null +++ b/backend/internal/webauthn/handler.go @@ -0,0 +1,191 @@ +package webauthn + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" + + "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/utils/cookie" +) + +type handler struct { + service *Service + appConfig AppConfigProvider +} + +func newHandler(service *Service, appConfig AppConfigProvider) *handler { + return &handler{service: service, appConfig: appConfig} +} + +func (h *handler) beginRegistration(c *gin.Context) { + userID := c.GetString("userID") + options, err := h.service.BeginRegistration(c.Request.Context(), userID) + if err != nil { + _ = c.Error(err) + return + } + + cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID) + c.JSON(http.StatusOK, options.Response) +} + +func (h *handler) verifyRegistration(c *gin.Context) { + sessionID, err := c.Cookie(cookie.SessionIdCookieName) + if err != nil { + _ = c.Error(&common.MissingSessionIdError{}) + return + } + + userID := c.GetString("userID") + credential, err := h.service.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP()) + if err != nil { + _ = c.Error(err) + return + } + + var credentialDto dto.WebauthnCredentialDto + if err := dto.MapStruct(credential, &credentialDto); err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, credentialDto) +} + +func (h *handler) beginLogin(c *gin.Context) { + options, err := h.service.BeginLogin(c.Request.Context()) + if err != nil { + _ = c.Error(err) + return + } + + cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID) + c.JSON(http.StatusOK, options.Response) +} + +func (h *handler) verifyLogin(c *gin.Context) { + sessionID, err := c.Cookie(cookie.SessionIdCookieName) + if err != nil { + _ = c.Error(&common.MissingSessionIdError{}) + return + } + + credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body) + if err != nil { + _ = c.Error(err) + return + } + + user, token, err := h.service.VerifyLogin(c.Request.Context(), sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent()) + if err != nil { + _ = c.Error(err) + return + } + + var userDto dto.UserDto + if err := dto.MapStruct(user, &userDto); err != nil { + _ = c.Error(err) + return + } + + maxAge := int(h.appConfig.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds()) + cookie.AddAccessTokenCookie(c, maxAge, token) + + c.JSON(http.StatusOK, userDto) +} + +func (h *handler) listCredentials(c *gin.Context) { + userID := c.GetString("userID") + credentials, err := h.service.ListCredentials(c.Request.Context(), userID) + if err != nil { + _ = c.Error(err) + return + } + + var credentialDtos []dto.WebauthnCredentialDto + if err := dto.MapStructList(credentials, &credentialDtos); err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, credentialDtos) +} + +func (h *handler) deleteCredential(c *gin.Context) { + userID := c.GetString("userID") + credentialID := c.Param("id") + clientIP := c.ClientIP() + userAgent := c.Request.UserAgent() + + err := h.service.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID) + if err != nil { + _ = c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} + +func (h *handler) updateCredential(c *gin.Context) { + userID := c.GetString("userID") + credentialID := c.Param("id") + + var input dto.WebauthnCredentialUpdateDto + if err := c.ShouldBindJSON(&input); err != nil { + _ = c.Error(err) + return + } + + credential, err := h.service.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name) + if err != nil { + _ = c.Error(err) + return + } + + var credentialDto dto.WebauthnCredentialDto + if err := dto.MapStruct(credential, &credentialDto); err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, credentialDto) +} + +func (h *handler) logout(c *gin.Context) { + cookie.AddAccessTokenCookie(c, 0, "") + c.Status(http.StatusNoContent) +} + +func (h *handler) reauthenticate(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 = h.service.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 = h.service.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken) + if err != nil { + _ = c.Error(err) + return + } + } + + cookie.AddReauthenticationTokenCookie(c, token) + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/webauthn/models.go b/backend/internal/webauthn/models.go new file mode 100644 index 00000000..676adfa3 --- /dev/null +++ b/backend/internal/webauthn/models.go @@ -0,0 +1,58 @@ +package webauthn + +import ( + "database/sql/driver" + "encoding/json" + "time" + + "github.com/go-webauthn/webauthn/protocol" + + "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" +) + +// WebauthnSession holds the transient challenge state for an in-progress registration or login +type WebauthnSession struct { + model.Base + + Challenge string + ExpiresAt datatype.DateTime + UserVerification string + CredentialParams CredentialParameters +} + +// ReauthenticationToken is a short-lived token proving a user recently re-verified themselves +type ReauthenticationToken struct { + model.Base + Token string + ExpiresAt datatype.DateTime + + UserID string + User model.User +} + +// PublicKeyCredentialCreationOptions is the registration challenge returned to the browser +type PublicKeyCredentialCreationOptions struct { + Response protocol.PublicKeyCredentialCreationOptions + SessionID string + Timeout time.Duration +} + +// PublicKeyCredentialRequestOptions is the login challenge returned to the browser +type PublicKeyCredentialRequestOptions struct { + Response protocol.PublicKeyCredentialRequestOptions + SessionID string + Timeout time.Duration +} + +type CredentialParameters []protocol.CredentialParameter //nolint:recvcheck + +// Scan and Value methods for GORM to handle the custom type +func (cp *CredentialParameters) Scan(value any) error { + return utils.UnmarshalJSONFromDatabase(cp, value) +} + +func (cp CredentialParameters) Value() (driver.Value, error) { + return json.Marshal(cp) +} diff --git a/backend/internal/webauthn/module.go b/backend/internal/webauthn/module.go new file mode 100644 index 00000000..41d945e8 --- /dev/null +++ b/backend/internal/webauthn/module.go @@ -0,0 +1,87 @@ +package webauthn + +import ( + "context" + "time" + + "github.com/gin-gonic/gin" + "github.com/lestrrat-go/jwx/v3/jwt" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/model" +) + +type TokenService interface { + GenerateAccessToken(user model.User, authenticationMethod string) (string, error) + VerifyAccessToken(tokenString string) (jwt.Token, error) + GetAuthenticationMethod(token jwt.Token) (string, error) +} + +type AuditLogger interface { + Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) + CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog +} + +type AppConfigProvider interface { + GetDbConfig() *model.AppConfig +} + +type Dependencies struct { + DB *gorm.DB + AppURL string + + Signer TokenService + AuditLog AuditLogger + AppConfig AppConfigProvider +} + +type Module struct { + service *Service + handler *handler +} + +func New(deps Dependencies) (*Module, error) { + service, err := newService(deps) + if err != nil { + return nil, err + } + + return &Module{ + service: service, + handler: newHandler(service, deps.AppConfig), + }, nil +} + +// RegisterRoutes mounts the WebAuthn registration, login and reauthentication endpoints +func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, userAuth, loginRateLimit, reauthRateLimit gin.HandlerFunc) { + apiGroup.GET("/webauthn/register/start", userAuth, m.handler.beginRegistration) + apiGroup.POST("/webauthn/register/finish", userAuth, m.handler.verifyRegistration) + + apiGroup.GET("/webauthn/login/start", m.handler.beginLogin) + apiGroup.POST("/webauthn/login/finish", loginRateLimit, m.handler.verifyLogin) + + apiGroup.POST("/webauthn/logout", userAuth, m.handler.logout) + + apiGroup.POST("/webauthn/reauthenticate", userAuth, reauthRateLimit, m.handler.reauthenticate) + + apiGroup.GET("/webauthn/credentials", userAuth, m.handler.listCredentials) + apiGroup.PATCH("/webauthn/credentials/:id", userAuth, m.handler.updateCredential) + apiGroup.DELETE("/webauthn/credentials/:id", userAuth, m.handler.deleteCredential) +} + +// ConsumeReauthenticationToken implements the OIDC module's ReauthenticationTokenConsumer interface +func (m *Module) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) { + return m.service.ConsumeReauthenticationToken(ctx, tx, token, userID) +} + +// ListCredentials returns the passkeys registered for the given user +// It is consumed by the user controller for the admin "manage passkeys" view +func (m *Module) ListCredentials(ctx context.Context, userID string) ([]model.WebauthnCredential, error) { + return m.service.ListCredentials(ctx, userID) +} + +// DeleteCredential removes a passkey, optionally on behalf of an admin acting for another user +// It is consumed by the user controller for the admin "manage passkeys" view +func (m *Module) DeleteCredential(ctx context.Context, userID, credentialID, ipAddress, userAgent, actorUserID string) error { + return m.service.DeleteCredential(ctx, userID, credentialID, ipAddress, userAgent, actorUserID) +} diff --git a/backend/internal/service/webauthn_service.go b/backend/internal/webauthn/service.go similarity index 70% rename from backend/internal/service/webauthn_service.go rename to backend/internal/webauthn/service.go index ce6c7e04..1fc01bcf 100644 --- a/backend/internal/service/webauthn_service.go +++ b/backend/internal/webauthn/service.go @@ -1,4 +1,4 @@ -package service +package webauthn import ( "context" @@ -9,7 +9,7 @@ import ( "time" "github.com/go-webauthn/webauthn/protocol" - "github.com/go-webauthn/webauthn/webauthn" + gowebauthn "github.com/go-webauthn/webauthn/webauthn" "gorm.io/gorm" "gorm.io/gorm/clause" @@ -19,29 +19,33 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/utils" ) -type WebAuthnService struct { - db *gorm.DB - webAuthn *webauthn.WebAuthn - jwtService *JwtService - auditLogService *AuditLogService - appConfigService *AppConfigService +// authenticationMethodPhishingResistant identifies phishing-resistant authentication, such as passkeys +// It must match the value emitted by the JWT service in the access token's "amr" claim +const authenticationMethodPhishingResistant = "phr" + +type Service struct { + db *gorm.DB + webAuthn *gowebauthn.WebAuthn + signer TokenService + auditLog AuditLogger + appConfig AppConfigProvider } -func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) (*WebAuthnService, error) { - wa, err := webauthn.New(&webauthn.Config{ - RPDisplayName: appConfigService.GetDbConfig().AppName.Value, - RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL), - RPOrigins: []string{common.EnvConfig.AppURL}, +func newService(deps Dependencies) (*Service, error) { + wa, err := gowebauthn.New(&gowebauthn.Config{ + RPDisplayName: deps.AppConfig.GetDbConfig().AppName.Value, + RPID: utils.GetHostnameFromURL(deps.AppURL), + RPOrigins: []string{deps.AppURL}, AuthenticatorSelection: protocol.AuthenticatorSelection{ UserVerification: protocol.VerificationRequired, }, - Timeouts: webauthn.TimeoutsConfig{ - Login: webauthn.TimeoutConfig{ + Timeouts: gowebauthn.TimeoutsConfig{ + Login: gowebauthn.TimeoutConfig{ Enforce: true, Timeout: time.Second * 60, TimeoutUVD: time.Second * 60, }, - Registration: webauthn.TimeoutConfig{ + Registration: gowebauthn.TimeoutConfig{ Enforce: true, Timeout: time.Second * 60, TimeoutUVD: time.Second * 60, @@ -52,16 +56,16 @@ func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *Au return nil, fmt.Errorf("failed to init webauthn object: %w", err) } - return &WebAuthnService{ - db: db, - webAuthn: wa, - jwtService: jwtService, - auditLogService: auditLogService, - appConfigService: appConfigService, + return &Service{ + db: deps.DB, + webAuthn: wa, + signer: deps.Signer, + auditLog: deps.AuditLog, + appConfig: deps.AppConfig, }, nil } -func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string) (*model.PublicKeyCredentialCreationOptions, error) { +func (s *Service) BeginRegistration(ctx context.Context, userID string) (*PublicKeyCredentialCreationOptions, error) { tx := s.db.Begin() defer func() { tx.Rollback() @@ -81,15 +85,15 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string) options, session, err := s.webAuthn.BeginRegistration( &user, - webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), - webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()), - webauthn.WithExtensions(map[string]any{"credProps": true}), // Required for Firefox Android to properly save the key in Google password manager + gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), + gowebauthn.WithExclusions(user.WebAuthnCredentialDescriptors()), + gowebauthn.WithExtensions(map[string]any{"credProps": true}), // Required for Firefox Android to properly save the key in Google password manager ) if err != nil { return nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err) } - sessionToStore := &model.WebauthnSession{ + sessionToStore := &WebauthnSession{ ExpiresAt: datatype.DateTime(session.Expires), Challenge: session.Challenge, CredentialParams: session.CredParams, @@ -109,21 +113,21 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string) return nil, fmt.Errorf("failed to commit transaction: %w", err) } - return &model.PublicKeyCredentialCreationOptions{ + return &PublicKeyCredentialCreationOptions{ Response: options.Response, SessionID: sessionToStore.ID, Timeout: s.webAuthn.Config.Timeouts.Registration.Timeout, }, nil } -func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID string, userID string, r *http.Request, ipAddress string) (model.WebauthnCredential, error) { +func (s *Service) VerifyRegistration(ctx context.Context, sessionID string, userID string, r *http.Request, ipAddress string) (model.WebauthnCredential, error) { tx := s.db.Begin() defer func() { tx.Rollback() }() // Load & delete the session row - var storedSession model.WebauthnSession + var storedSession WebauthnSession err := tx. WithContext(ctx). Clauses(clause.Returning{}). @@ -133,7 +137,7 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID stri return model.WebauthnCredential{}, fmt.Errorf("failed to load WebAuthn session: %w", err) } - session := webauthn.SessionData{ + session := gowebauthn.SessionData{ Challenge: storedSession.Challenge, Expires: storedSession.ExpiresAt.ToTime(), CredParams: storedSession.CredentialParams, @@ -176,7 +180,7 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID stri } auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.ID), "passkeyName": passkeyName} - s.auditLogService.Create(ctx, model.AuditLogEventPasskeyAdded, ipAddress, r.UserAgent(), userID, auditLogData, tx) + s.auditLog.Create(ctx, model.AuditLogEventPasskeyAdded, ipAddress, r.UserAgent(), userID, auditLogData, tx) err = tx.Commit().Error if err != nil { @@ -186,7 +190,7 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID stri return credentialToStore, nil } -func (s *WebAuthnService) determinePasskeyName(aaguid []byte) string { +func (s *Service) determinePasskeyName(aaguid []byte) string { // First try to identify by AAGUID using a combination of builtin + MDS authenticatorName := utils.GetAuthenticatorName(aaguid) if authenticatorName != "" { @@ -196,13 +200,13 @@ func (s *WebAuthnService) determinePasskeyName(aaguid []byte) string { return "New Passkey" // Default fallback } -func (s *WebAuthnService) BeginLogin(ctx context.Context) (*model.PublicKeyCredentialRequestOptions, error) { +func (s *Service) BeginLogin(ctx context.Context) (*PublicKeyCredentialRequestOptions, error) { options, session, err := s.webAuthn.BeginDiscoverableLogin() if err != nil { return nil, err } - sessionToStore := &model.WebauthnSession{ + sessionToStore := &WebauthnSession{ ExpiresAt: datatype.DateTime(session.Expires), Challenge: session.Challenge, UserVerification: string(session.UserVerification), @@ -216,21 +220,21 @@ func (s *WebAuthnService) BeginLogin(ctx context.Context) (*model.PublicKeyCrede return nil, err } - return &model.PublicKeyCredentialRequestOptions{ + return &PublicKeyCredentialRequestOptions{ Response: options.Response, SessionID: sessionToStore.ID, Timeout: s.webAuthn.Config.Timeouts.Registration.Timeout, }, nil } -func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) { +func (s *Service) VerifyLogin(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData, ipAddress, userAgent string) (model.User, string, error) { tx := s.db.Begin() defer func() { tx.Rollback() }() // Load & delete the session row - var storedSession model.WebauthnSession + var storedSession WebauthnSession err := tx. WithContext(ctx). Clauses(clause.Returning{}). @@ -240,13 +244,13 @@ func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, cre return model.User{}, "", fmt.Errorf("failed to load WebAuthn session: %w", err) } - session := webauthn.SessionData{ + session := gowebauthn.SessionData{ Challenge: storedSession.Challenge, Expires: storedSession.ExpiresAt.ToTime(), } var user *model.User - _, err = s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) { + _, err = s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (gowebauthn.User, error) { innerErr := tx. WithContext(ctx). Preload("Credentials"). @@ -266,12 +270,12 @@ func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, cre return model.User{}, "", &common.UserDisabledError{} } - token, err := s.jwtService.GenerateAccessToken(*user, AuthenticationMethodPhishingResistant) + token, err := s.signer.GenerateAccessToken(*user, authenticationMethodPhishingResistant) if err != nil { return model.User{}, "", err } - s.auditLogService.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx) + s.auditLog.CreateNewSignInWithEmail(ctx, ipAddress, userAgent, user.ID, tx) err = tx.Commit().Error if err != nil { @@ -281,7 +285,7 @@ func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, cre return *user, token, nil } -func (s *WebAuthnService) ListCredentials(ctx context.Context, userID string) ([]model.WebauthnCredential, error) { +func (s *Service) ListCredentials(ctx context.Context, userID string) ([]model.WebauthnCredential, error) { var credentials []model.WebauthnCredential err := s.db. WithContext(ctx). @@ -293,7 +297,7 @@ func (s *WebAuthnService) ListCredentials(ctx context.Context, userID string) ([ return credentials, nil } -func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string, actorUserID string) error { +func (s *Service) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string, actorUserID string) error { tx := s.db.Begin() defer func() { tx.Rollback() @@ -324,7 +328,7 @@ func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, c auditLogData["actorUserID"] = actorUserID auditLogData["actorUsername"] = actor.Username } - s.auditLogService.Create(ctx, model.AuditLogEventPasskeyRemoved, ipAddress, userAgent, userID, auditLogData, tx) + s.auditLog.Create(ctx, model.AuditLogEventPasskeyRemoved, ipAddress, userAgent, userID, auditLogData, tx) err := tx.Commit().Error if err != nil { @@ -334,7 +338,7 @@ func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, c return nil } -func (s *WebAuthnService) UpdateCredential(ctx context.Context, userID, credentialID, name string) (model.WebauthnCredential, error) { +func (s *Service) UpdateCredential(ctx context.Context, userID, credentialID, name string) (model.WebauthnCredential, error) { tx := s.db.Begin() defer func() { tx.Rollback() @@ -369,17 +373,17 @@ func (s *WebAuthnService) UpdateCredential(ctx context.Context, userID, credenti } // updateWebAuthnConfig updates the WebAuthn configuration with the app name as it can change during runtime -func (s *WebAuthnService) updateWebAuthnConfig() { - s.webAuthn.Config.RPDisplayName = s.appConfigService.GetDbConfig().AppName.Value +func (s *Service) updateWebAuthnConfig() { + s.webAuthn.Config.RPDisplayName = s.appConfig.GetDbConfig().AppName.Value } -func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) { +func (s *Service) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) { tx := s.db.Begin() defer func() { tx.Rollback() }() - token, err := s.jwtService.VerifyAccessToken(accessToken) + token, err := s.signer.VerifyAccessToken(accessToken) if err != nil { return "", fmt.Errorf("invalid access token: %w", err) } @@ -389,11 +393,11 @@ func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context return "", errors.New("access token does not contain user ID") } - authenticationMethod, err := GetAuthenticationMethod(token) + authenticationMethod, err := s.signer.GetAuthenticationMethod(token) if err != nil { return "", err } - if authenticationMethod != AuthenticationMethodPhishingResistant { + if authenticationMethod != authenticationMethodPhishingResistant { return "", &common.ReauthenticationRequiredError{} } @@ -425,14 +429,14 @@ func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context return reauthToken, nil } -func (s *WebAuthnService) CreateReauthenticationTokenWithWebauthn(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (string, error) { +func (s *Service) 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 + var storedSession WebauthnSession err := tx. WithContext(ctx). Clauses(clause.Returning{}). @@ -442,14 +446,14 @@ func (s *WebAuthnService) CreateReauthenticationTokenWithWebauthn(ctx context.Co return "", fmt.Errorf("failed to load WebAuthn session: %w", err) } - session := webauthn.SessionData{ + session := gowebauthn.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) { + _, err = s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (gowebauthn.User, error) { innerErr := tx. WithContext(ctx). Preload("Credentials"). @@ -479,9 +483,9 @@ func (s *WebAuthnService) CreateReauthenticationTokenWithWebauthn(ctx context.Co return token, nil } -func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) { +func (s *Service) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) { hashedToken := utils.CreateSha256Hash(token) - var reauthToken model.ReauthenticationToken + var reauthToken ReauthenticationToken result := tx.WithContext(ctx). Clauses(clause.Returning{}). Delete(&reauthToken, "token = ? AND user_id = ? AND expires_at > ?", hashedToken, userID, datatype.DateTime(time.Now())) @@ -495,13 +499,13 @@ func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx * return reauthToken.CreatedAt.UTC(), nil } -func (s *WebAuthnService) createReauthenticationToken(ctx context.Context, tx *gorm.DB, userID string) (string, error) { +func (s *Service) createReauthenticationToken(ctx context.Context, tx *gorm.DB, userID string) (string, error) { token, err := utils.GenerateRandomAlphanumericString(32) if err != nil { return "", err } - reauthToken := model.ReauthenticationToken{ + reauthToken := ReauthenticationToken{ Token: utils.CreateSha256Hash(token), ExpiresAt: datatype.DateTime(time.Now().Add(3 * time.Minute)), UserID: userID, diff --git a/backend/internal/service/webauthn_service_test.go b/backend/internal/webauthn/service_test.go similarity index 51% rename from backend/internal/service/webauthn_service_test.go rename to backend/internal/webauthn/service_test.go index 41553f67..0551de41 100644 --- a/backend/internal/service/webauthn_service_test.go +++ b/backend/internal/webauthn/service_test.go @@ -1,9 +1,12 @@ -package service +package webauthn import ( + "errors" + "fmt" "testing" "time" + "github.com/lestrrat-go/jwx/v3/jwt" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -11,32 +14,78 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/utils" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) -func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, - }) +// fakeSigner is an in-memory TokenService that mints opaque tokens carrying a subject, +// an issued-at time and an optional authentication method, without any real signing +type fakeSigner struct { + tokens map[string]jwt.Token + counter int +} - setupService := func(t *testing.T) (*WebAuthnService, model.User) { +func newFakeSigner() *fakeSigner { + return &fakeSigner{tokens: map[string]jwt.Token{}} +} + +func (s *fakeSigner) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) { + builder := jwt.NewBuilder(). + Subject(user.ID). + IssuedAt(time.Now()) + if authenticationMethod != "" { + builder = builder.Claim(common.AuthenticationMethodsClaim, []string{authenticationMethod}) + } + token, err := builder.Build() + if err != nil { + return "", err + } + + s.counter++ + raw := fmt.Sprintf("fake-access-token-%d", s.counter) + s.tokens[raw] = token + return raw, nil +} + +func (s *fakeSigner) VerifyAccessToken(tokenString string) (jwt.Token, error) { + token, ok := s.tokens[tokenString] + if !ok { + return nil, errors.New("invalid token") + } + return token, nil +} + +func (s *fakeSigner) GetAuthenticationMethod(token jwt.Token) (string, error) { + if !token.Has(common.AuthenticationMethodsClaim) { + return "", nil + } + var methods []string + if err := token.Get(common.AuthenticationMethodsClaim, &methods); err != nil { + return "", err + } + if len(methods) == 0 { + return "", nil + } + return methods[0], nil +} + +func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { + setupService := func(t *testing.T) (*Service, *fakeSigner, model.User) { t.Helper() - jwtService, db, _ := setupJwtService(t, mockConfig) + db := testutils.NewDatabaseForTest(t) user := model.User{ Base: model.Base{ID: "reauth-user"}, Username: "reauth-user", } require.NoError(t, db.Create(&user).Error) - return &WebAuthnService{ - db: db, - jwtService: jwtService, - }, user + signer := newFakeSigner() + return &Service{db: db, signer: signer}, signer, user } t.Run("accepts a fresh access token from WebAuthn login", func(t *testing.T) { - service, user := setupService(t) - accessToken, err := service.jwtService.GenerateAccessToken(user, AuthenticationMethodPhishingResistant) + service, signer, user := setupService(t) + accessToken, err := signer.GenerateAccessToken(user, authenticationMethodPhishingResistant) require.NoError(t, err) reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken) @@ -46,8 +95,8 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { }) t.Run("rejects a fresh access token from one-time access login", func(t *testing.T) { - service, user := setupService(t) - accessToken, err := service.jwtService.GenerateAccessToken(user, AuthenticationMethodOneTimePassword) + service, signer, user := setupService(t) + accessToken, err := signer.GenerateAccessToken(user, "otp") require.NoError(t, err) reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken) @@ -58,8 +107,8 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { }) t.Run("rejects a fresh access token without an authentication method", func(t *testing.T) { - service, user := setupService(t) - accessToken, err := service.jwtService.GenerateAccessToken(user, "") + service, signer, user := setupService(t) + accessToken, err := signer.GenerateAccessToken(user, "") require.NoError(t, err) reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken) @@ -71,14 +120,8 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) { } func TestConsumeReauthenticationTokenReturnsTokenCreationTime(t *testing.T) { - mockConfig := NewTestAppConfigService(&model.AppConfig{ - SessionDuration: model.AppConfigVariable{Value: "60"}, - }) - jwtService, db, _ := setupJwtService(t, mockConfig) - service := &WebAuthnService{ - db: db, - jwtService: jwtService, - } + db := testutils.NewDatabaseForTest(t) + service := &Service{db: db} const ( userID = "reauth-user" @@ -87,13 +130,13 @@ func TestConsumeReauthenticationTokenReturnsTokenCreationTime(t *testing.T) { require.NoError(t, db.Create(&model.User{ Base: model.Base{ID: userID}, }).Error) - require.NoError(t, db.Create(&model.ReauthenticationToken{ + require.NoError(t, db.Create(&ReauthenticationToken{ Token: utils.CreateSha256Hash(token), ExpiresAt: datatype.DateTime(time.Now().Add(time.Minute)), UserID: userID, }).Error) - var storedToken model.ReauthenticationToken + var storedToken ReauthenticationToken require.NoError(t, db.First(&storedToken, "user_id = ?", userID).Error) tx := db.Begin()