mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-15 21:48:13 +03:00
refactor: migrate Webauthn functionality to single webauthn module
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
|
||||
28
backend/internal/webauthn/cleanup.go
Normal file
28
backend/internal/webauthn/cleanup.go
Normal 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
|
||||
}
|
||||
191
backend/internal/webauthn/handler.go
Normal file
191
backend/internal/webauthn/handler.go
Normal 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)
|
||||
}
|
||||
58
backend/internal/webauthn/models.go
Normal file
58
backend/internal/webauthn/models.go
Normal 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)
|
||||
}
|
||||
87
backend/internal/webauthn/module.go
Normal file
87
backend/internal/webauthn/module.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user