refactor: migrate Webauthn functionality to single webauthn module

This commit is contained in:
Elias Schneider
2026-06-29 14:02:20 +02:00
parent 96ea55b9bc
commit 58fcf7cbe6
16 changed files with 541 additions and 370 deletions

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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{}
}

View File

@@ -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)
}

View File

@@ -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},
},

View File

@@ -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
}

View File

@@ -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")
})

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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()