diff --git a/server/api/dto/admin/request/config.go b/server/api/dto/admin/request/config.go index 1f6c533..16cb470 100644 --- a/server/api/dto/admin/request/config.go +++ b/server/api/dto/admin/request/config.go @@ -10,7 +10,7 @@ import ( type CreateConfigDto struct { Cors CreateCorsDto `json:"cors" validate:"required"` Webauthn CreateWebauthnConfigDto `json:"webauthn" validate:"required"` - Mfa CreateWebauthnConfigDto `json:"mfa" validate:"required"` + Mfa CreateMFAConfigDto `json:"mfa" validate:"required"` } func (dto *CreateConfigDto) ToModel(tenant models.Tenant) models.Config { diff --git a/server/api/dto/admin/request/mfa.go b/server/api/dto/admin/request/mfa.go new file mode 100644 index 0000000..146ac99 --- /dev/null +++ b/server/api/dto/admin/request/mfa.go @@ -0,0 +1,55 @@ +package request + +import ( + "github.com/go-webauthn/webauthn/protocol" + "github.com/gofrs/uuid" + "github.com/teamhanko/passkey-server/persistence/models" + "time" +) + +type CreateMFAConfigDto struct { + Timeout int `json:"timeout" validate:"required,number"` + UserVerification *protocol.UserVerificationRequirement `json:"user_verification" validate:"omitempty,oneof=required preferred discouraged"` + Attachment *protocol.AuthenticatorAttachment `json:"attachment" validate:"omitempty,oneof=platform cross-platform"` + AttestationPreference *protocol.ConveyancePreference `json:"attestation_preference" validate:"omitempty,oneof=none indirect direct enterprise"` + ResidentKeyRequirement *protocol.ResidentKeyRequirement `json:"resident_key_requirement" validate:"omitempty,oneof=discouraged preferred required"` +} + +func (dto *CreateMFAConfigDto) ToModel(configModel models.Config) models.MfaConfig { + mfaConfigId, _ := uuid.NewV4() + now := time.Now() + + mfaConfig := models.MfaConfig{ + ID: mfaConfigId, + ConfigID: configModel.ID, + Timeout: dto.Timeout, + CreatedAt: now, + UpdatedAt: now, + } + + if dto.AttestationPreference == nil { + mfaConfig.AttestationPreference = protocol.PreferNoAttestation + } else { + mfaConfig.AttestationPreference = *dto.AttestationPreference + } + + if dto.ResidentKeyRequirement == nil { + mfaConfig.ResidentKeyRequirement = protocol.ResidentKeyRequirementDiscouraged + } else { + mfaConfig.ResidentKeyRequirement = *dto.ResidentKeyRequirement + } + + if dto.UserVerification == nil { + mfaConfig.UserVerification = protocol.VerificationPreferred + } else { + mfaConfig.UserVerification = *dto.UserVerification + } + + if dto.Attachment == nil { + mfaConfig.Attachment = protocol.CrossPlatform + } else { + mfaConfig.Attachment = *dto.Attachment + } + + return mfaConfig +} diff --git a/server/api/dto/admin/request/webauthn.go b/server/api/dto/admin/request/webauthn.go index 3d3a86a..b6f7b46 100644 --- a/server/api/dto/admin/request/webauthn.go +++ b/server/api/dto/admin/request/webauthn.go @@ -16,12 +16,12 @@ type CreateWebauthnConfigDto struct { ResidentKeyRequirement *protocol.ResidentKeyRequirement `json:"resident_key_requirement" validate:"omitempty,oneof=discouraged preferred required"` } -func (dto *CreateWebauthnConfigDto) toModel(configModel models.Config) models.WebauthnConfig { - webauthnConfigId, _ := uuid.NewV4() +func (dto *CreateWebauthnConfigDto) ToModel(configModel models.Config) models.WebauthnConfig { + passkeyConfigId, _ := uuid.NewV4() now := time.Now() - webauthnConfig := models.WebauthnConfig{ - ID: webauthnConfigId, + passkeyConfig := models.WebauthnConfig{ + ID: passkeyConfigId, ConfigID: configModel.ID, Timeout: dto.Timeout, CreatedAt: now, @@ -29,16 +29,11 @@ func (dto *CreateWebauthnConfigDto) toModel(configModel models.Config) models.We } if dto.AttestationPreference == nil { - webauthnConfig.AttestationPreference = protocol.PreferNoAttestation + passkeyConfig.AttestationPreference = protocol.PreferNoAttestation } else { - webauthnConfig.AttestationPreference = *dto.AttestationPreference + passkeyConfig.AttestationPreference = *dto.AttestationPreference } - return webauthnConfig -} - -func (dto *CreateWebauthnConfigDto) ToPasskeyModel(configModel models.Config) models.WebauthnConfig { - passkeyConfig := dto.toModel(configModel) passkeyConfig.Attachment = dto.Attachment if dto.ResidentKeyRequirement == nil { @@ -55,35 +50,3 @@ func (dto *CreateWebauthnConfigDto) ToPasskeyModel(configModel models.Config) mo return passkeyConfig } - -func (dto *CreateWebauthnConfigDto) ToMfaModel(configModel models.Config) models.WebauthnConfig { - mfaConfig := dto.toModel(configModel) - mfaConfig.IsMfa = true - - if dto.AttestationPreference == nil { - mfaConfig.AttestationPreference = protocol.PreferNoAttestation - } else { - mfaConfig.AttestationPreference = *dto.AttestationPreference - } - - if dto.ResidentKeyRequirement == nil { - mfaConfig.ResidentKeyRequirement = protocol.ResidentKeyRequirementDiscouraged - } else { - mfaConfig.ResidentKeyRequirement = *dto.ResidentKeyRequirement - } - - if dto.UserVerification == nil { - mfaConfig.UserVerification = protocol.VerificationPreferred - } else { - mfaConfig.UserVerification = *dto.UserVerification - } - - if dto.Attachment == nil { - cp := protocol.CrossPlatform - mfaConfig.Attachment = &cp - } else { - mfaConfig.Attachment = dto.Attachment - } - - return mfaConfig -} diff --git a/server/api/dto/admin/response/config.go b/server/api/dto/admin/response/config.go index b189b86..c71b8f2 100644 --- a/server/api/dto/admin/response/config.go +++ b/server/api/dto/admin/response/config.go @@ -5,23 +5,13 @@ import "github.com/teamhanko/passkey-server/persistence/models" type GetConfigResponse struct { Cors GetCorsResponse `json:"cors"` Webauthn GetWebauthnResponse `json:"webauthn"` - Mfa GetWebauthnResponse `json:"mfa"` + MFA GetMFAResponse `json:"mfa"` } func ToGetConfigResponse(config *models.Config) GetConfigResponse { - var passkeyConfig models.WebauthnConfig - var mfaConfig models.WebauthnConfig - for _, webauthnConfig := range config.WebauthnConfigs { - if webauthnConfig.IsMfa { - mfaConfig = webauthnConfig - } else { - passkeyConfig = webauthnConfig - } - } - return GetConfigResponse{ Cors: ToGetCorsResponse(&config.Cors), - Webauthn: ToGetWebauthnResponse(&passkeyConfig), - Mfa: ToGetWebauthnResponse(&mfaConfig), + Webauthn: ToGetWebauthnResponse(&config.WebauthnConfig), + MFA: ToGetMFAResponse(config.MfaConfig), } } diff --git a/server/api/dto/admin/response/mfa.go b/server/api/dto/admin/response/mfa.go new file mode 100644 index 0000000..55ac0cb --- /dev/null +++ b/server/api/dto/admin/response/mfa.go @@ -0,0 +1,24 @@ +package response + +import ( + "github.com/go-webauthn/webauthn/protocol" + "github.com/teamhanko/passkey-server/persistence/models" +) + +type GetMFAResponse struct { + Timeout int `json:"timeout"` + UserVerification protocol.UserVerificationRequirement `json:"user_verification"` + Attachment protocol.AuthenticatorAttachment `json:"attachment"` + AttestationPreference protocol.ConveyancePreference `json:"attestation_preference"` + ResidentKeyRequirement protocol.ResidentKeyRequirement `json:"resident_key_requirement"` +} + +func ToGetMFAResponse(webauthn *models.MfaConfig) GetMFAResponse { + return GetMFAResponse{ + Timeout: webauthn.Timeout, + UserVerification: webauthn.UserVerification, + Attachment: webauthn.Attachment, + AttestationPreference: webauthn.AttestationPreference, + ResidentKeyRequirement: webauthn.ResidentKeyRequirement, + } +} diff --git a/server/api/dto/intern/webauthn_credential.go b/server/api/dto/intern/webauthn_credential.go index c0ab0a3..99deea4 100644 --- a/server/api/dto/intern/webauthn_credential.go +++ b/server/api/dto/intern/webauthn_credential.go @@ -11,7 +11,7 @@ import ( "time" ) -func WebauthnCredentialToModel(credential *webauthn.Credential, userId string, webauthnUserId uuid.UUID, backupEligible bool, backupState bool, authenticatorMetadata mapper.AuthenticatorMetadata) *models.WebauthnCredential { +func WebauthnCredentialToModel(credential *webauthn.Credential, userId string, webauthnUserId uuid.UUID, backupEligible bool, backupState bool, authenticatorMetadata mapper.AuthenticatorMetadata, isMFACredential bool) *models.WebauthnCredential { now := time.Now().UTC() aaguid, _ := uuid.FromBytes(credential.Authenticator.AAGUID) credentialID := base64.RawURLEncoding.EncodeToString(credential.ID) @@ -34,6 +34,7 @@ func WebauthnCredentialToModel(credential *webauthn.Credential, userId string, w UpdatedAt: now, BackupEligible: backupEligible, BackupState: backupState, + IsMFA: isMFACredential, WebauthnUserID: webauthnUserId, } diff --git a/server/api/dto/request/requests.go b/server/api/dto/request/requests.go index 6e64023..5b04f67 100644 --- a/server/api/dto/request/requests.go +++ b/server/api/dto/request/requests.go @@ -97,5 +97,5 @@ type InitLoginDto struct { } type InitMfaLoginDto struct { - UserId *string `json:"userId" validate:"required,min=1"` + UserId *string `json:"user_id" validate:"required,min=1"` } diff --git a/server/api/handler/admin/tenants.go b/server/api/handler/admin/tenants.go index 799de5e..07ca922 100644 --- a/server/api/handler/admin/tenants.go +++ b/server/api/handler/admin/tenants.go @@ -65,6 +65,7 @@ func (th *TenantHandler) Create(ctx echo.Context) error { AuditConfigPersister: th.persister.GetAuditLogConfigPersister(tx), SecretPersister: th.persister.GetSecretsPersister(tx), JwkPersister: th.persister.GetJwkPersister(tx), + MFAConfigPersister: th.persister.GetMFAConfigPersister(tx), }) createResponse, err := service.Create(dto) @@ -166,6 +167,7 @@ func (th *TenantHandler) UpdateConfig(ctx echo.Context) error { RelyingPartyPerister: th.persister.GetWebauthnRelyingPartyPersister(tx), AuditConfigPersister: th.persister.GetAuditLogConfigPersister(tx), SecretPersister: th.persister.GetSecretsPersister(tx), + MFAConfigPersister: th.persister.GetMFAConfigPersister(tx), }) err := service.UpdateConfig(dto) diff --git a/server/api/handler/login.go b/server/api/handler/login.go index a2f7c48..ca5858c 100644 --- a/server/api/handler/login.go +++ b/server/api/handler/login.go @@ -13,6 +13,7 @@ import ( "github.com/teamhanko/passkey-server/persistence" "github.com/teamhanko/passkey-server/persistence/models" "net/http" + "strings" ) type loginHandler struct { @@ -39,6 +40,11 @@ func (lh *loginHandler) Init(ctx echo.Context) error { return err } + apiKey := ctx.Request().Header.Get("apiKey") + if dto.UserId != nil && strings.TrimSpace(apiKey) == "" { + return echo.NewHTTPError(http.StatusBadRequest, "api key is missing") + } + return lh.persister.GetConnection().Transaction(func(tx *pop.Connection) error { userPersister := lh.persister.GetWebauthnUserPersister(tx) sessionPersister := lh.persister.GetWebauthnSessionDataPersister(tx) @@ -55,12 +61,12 @@ func (lh *loginHandler) Init(ctx echo.Context) error { }) credentialAssertion, err := service.Initialize() - err = lh.handleError(h.AuditLog, models.AuditLogWebAuthnAuthenticationInitFailed, tx, ctx, nil, nil, err) + err = lh.handleError(h.AuditLog, models.AuditLogWebAuthnAuthenticationInitFailed, tx, ctx, dto.UserId, nil, err) if err != nil { return err } - auditErr := h.AuditLog.CreateWithConnection(tx, models.AuditLogWebAuthnAuthenticationInitSucceeded, nil, nil, nil) + auditErr := h.AuditLog.CreateWithConnection(tx, models.AuditLogWebAuthnAuthenticationInitSucceeded, dto.UserId, nil, nil) if auditErr != nil { ctx.Logger().Error(auditErr) return fmt.Errorf(auditlog.CreationFailureFormat, auditErr) diff --git a/server/api/handler/mfa_login.go b/server/api/handler/mfa_login.go index 4f237ce..06322f6 100644 --- a/server/api/handler/mfa_login.go +++ b/server/api/handler/mfa_login.go @@ -52,15 +52,16 @@ func (lh *mfaLoginHandler) Init(ctx echo.Context) error { UserPersister: userPersister, SessionPersister: sessionPersister, CredentialPersister: credentialPersister, + UseMFA: true, }) credentialAssertion, err := service.Initialize() - err = lh.handleError(h.AuditLog, models.AuditLogWebAuthnAuthenticationInitFailed, tx, ctx, nil, nil, err) + err = lh.handleError(h.AuditLog, models.AuditLogWebAuthnAuthenticationInitFailed, tx, ctx, dto.UserId, nil, err) if err != nil { return err } - auditErr := h.AuditLog.CreateWithConnection(tx, models.AuditLogWebAuthnAuthenticationInitSucceeded, nil, nil, nil) + auditErr := h.AuditLog.CreateWithConnection(tx, models.AuditLogWebAuthnAuthenticationInitSucceeded, dto.UserId, nil, nil) if auditErr != nil { ctx.Logger().Error(auditErr) return fmt.Errorf(auditlog.CreationFailureFormat, auditErr) @@ -96,6 +97,7 @@ func (lh *mfaLoginHandler) Finish(ctx echo.Context) error { SessionPersister: sessionPersister, CredentialPersister: credentialPersister, Generator: h.Generator, + UseMFA: true, }) token, userId, err := service.Finalize(parsedRequest) diff --git a/server/api/handler/registration.go b/server/api/handler/registration.go index e10f673..c50c666 100644 --- a/server/api/handler/registration.go +++ b/server/api/handler/registration.go @@ -38,7 +38,7 @@ func (r *registrationHandler) Init(ctx echo.Context) error { var h *helper.WebauthnContext var hErr error - if r.UseMfaClient { + if r.UseMFAClient { h, hErr = helper.GetMfaHandlerContext(ctx) } else { h, hErr = helper.GetHandlerContext(ctx) @@ -61,6 +61,7 @@ func (r *registrationHandler) Init(ctx echo.Context) error { UserPersister: userPersister, SessionPersister: sessionPersister, CredentialPersister: credentialPersister, + UseMFA: r.UseMFAClient, }) credentialCreation, userId, err := service.Initialize(webauthnUser) @@ -88,7 +89,7 @@ func (r *registrationHandler) Finish(ctx echo.Context) error { var h *helper.WebauthnContext var hErr error - if r.UseMfaClient { + if r.UseMFAClient { h, hErr = helper.GetMfaHandlerContext(ctx) } else { h, hErr = helper.GetHandlerContext(ctx) @@ -113,6 +114,7 @@ func (r *registrationHandler) Finish(ctx echo.Context) error { CredentialPersister: credentialPersister, Generator: h.Generator, AuthenticatorMetadata: r.AuthenticatorMetadata, + UseMFA: r.UseMFAClient, }) token, userId, err := service.Finalize(parsedRequest) diff --git a/server/api/handler/webauthn.go b/server/api/handler/webauthn.go index c9d97a7..23650b2 100644 --- a/server/api/handler/webauthn.go +++ b/server/api/handler/webauthn.go @@ -22,13 +22,13 @@ type WebauthnHandler interface { type webauthnHandler struct { persister persistence.Persister - UseMfaClient bool + UseMFAClient bool } -func newWebAuthnHandler(persister persistence.Persister, useMfaClient bool) *webauthnHandler { +func newWebAuthnHandler(persister persistence.Persister, useMFAClient bool) *webauthnHandler { return &webauthnHandler{ persister: persister, - UseMfaClient: useMfaClient, + UseMFAClient: useMFAClient, } } diff --git a/server/api/helper/context_helper.go b/server/api/helper/context_helper.go index 828b4d3..3a3e506 100644 --- a/server/api/helper/context_helper.go +++ b/server/api/helper/context_helper.go @@ -17,7 +17,7 @@ type WebauthnContext struct { Generator jwt.Generator } -func getContext(ctx echo.Context, webauthnClientKey string, jwtGeneratorKey string) (*WebauthnContext, error) { +func getContext(ctx echo.Context, webauthnClientKey string) (*WebauthnContext, error) { ctxTenant := ctx.Get("tenant") if ctxTenant == nil { return nil, echo.NewHTTPError(http.StatusNotFound, "Unable to find tenant") @@ -30,7 +30,7 @@ func getContext(ctx echo.Context, webauthnClientKey string, jwtGeneratorKey stri webauthnClient = webauthnClientCtx.(*webauthn.WebAuthn) } - jwtGeneratorCtx := ctx.Get(jwtGeneratorKey) + jwtGeneratorCtx := ctx.Get("jwt_generator") var jwtGenerator jwt.Generator if jwtGeneratorCtx != nil { jwtGenerator = jwtGeneratorCtx.(jwt.Generator) @@ -52,9 +52,9 @@ func getContext(ctx echo.Context, webauthnClientKey string, jwtGeneratorKey stri } func GetHandlerContext(ctx echo.Context) (*WebauthnContext, error) { - return getContext(ctx, "webauthn_client", "jwt_generator") + return getContext(ctx, "webauthn_client") } func GetMfaHandlerContext(ctx echo.Context) (*WebauthnContext, error) { - return getContext(ctx, "mfa_client", "mfa_jwt_generator") + return getContext(ctx, "mfa_client") } diff --git a/server/api/middleware/jwk.go b/server/api/middleware/jwk.go index de057fc..c334190 100644 --- a/server/api/middleware/jwk.go +++ b/server/api/middleware/jwk.go @@ -1,7 +1,6 @@ package middleware import ( - "github.com/gofrs/uuid" "github.com/labstack/echo/v4" hankoJwk "github.com/teamhanko/passkey-server/crypto/jwk" "github.com/teamhanko/passkey-server/crypto/jwt" @@ -46,29 +45,12 @@ func instantiateJwtGenerator(ctx echo.Context, keys []string, tenant models.Tena } ctx.Set("jwk_manager", jwkManager) - for _, webauthnConfig := range tenant.Config.WebauthnConfigs { - if webauthnConfig.IsMfa { - err = createGeneratorFromConfig(ctx, "mfa_jwt_generator", webauthnConfig, jwkManager, tenant.ID) - } else { - err = createGeneratorFromConfig(ctx, "jwt_generator", webauthnConfig, jwkManager, tenant.ID) - } - - if err != nil { - ctx.Logger().Error(err) - return err - } - } - - return nil -} - -func createGeneratorFromConfig(ctx echo.Context, ctxKey string, cfg models.WebauthnConfig, manager hankoJwk.Manager, tenantId uuid.UUID) error { - generator, err := jwt.NewGenerator(&cfg, manager, tenantId) + generator, err := jwt.NewGenerator(&tenant.Config.WebauthnConfig, jwkManager, tenant.ID) if err != nil { return err } - ctx.Set(ctxKey, generator) + ctx.Set("jwt_generator", generator) return nil } diff --git a/server/api/middleware/webauthn.go b/server/api/middleware/webauthn.go index 0166e1e..f7d8637 100644 --- a/server/api/middleware/webauthn.go +++ b/server/api/middleware/webauthn.go @@ -12,6 +12,15 @@ import ( "time" ) +type clientParams struct { + RP models.RelyingParty + Timeout int + UserVerification protocol.UserVerificationRequirement + Attachment *protocol.AuthenticatorAttachment + AttestationPreference protocol.ConveyancePreference + ResidentKeyRequirement protocol.ResidentKeyRequirement +} + func WebauthnMiddleware(persister persistence.Persister) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(ctx echo.Context) error { @@ -21,8 +30,6 @@ func WebauthnMiddleware(persister persistence.Persister) echo.MiddlewareFunc { return echo.NewHTTPError(http.StatusNotFound, "tenant not found") } - ctx.Path() - cfg := tenant.Config err := setWebauthnClientCtx(ctx, cfg, persister) @@ -38,64 +45,56 @@ func WebauthnMiddleware(persister persistence.Persister) echo.MiddlewareFunc { func setWebauthnClientCtx(ctx echo.Context, cfg models.Config, persister persistence.Persister) error { var passkeyConfig models.WebauthnConfig - for _, webauthnConfig := range cfg.WebauthnConfigs { - var err error - if webauthnConfig.IsMfa { - err = createWebauthnClient(ctx, "mfa_client", webauthnConfig) - } else { - passkeyConfig = webauthnConfig - err = createWebauthnClient(ctx, "webauthn_client", webauthnConfig) - } - if err != nil { - ctx.Logger().Error(err) - return err - } + err := createPasskeyCLient(ctx, cfg.WebauthnConfig) + if err != nil { + ctx.Logger().Error(err) + return err } - if ctx.Get("mfa_client") == nil { - mfaConfig, err := createDefaultMfaConfig(persister, passkeyConfig) + if cfg.MfaConfig == nil { + cfg.MfaConfig, err = createDefaultMfaConfig(persister, passkeyConfig) if err != nil { ctx.Logger().Error(err) return err } + } - err = createWebauthnClient(ctx, "mfa_client", *mfaConfig) - if err != nil { - ctx.Logger().Error(err) - return err - } + err = createMFAClient(ctx, *cfg.MfaConfig, cfg.WebauthnConfig.RelyingParty) + if err != nil { + ctx.Logger().Error(err) + return err } return nil } -func createWebauthnClient(ctx echo.Context, ctxKey string, cfg models.WebauthnConfig) error { +func createClient(ctx echo.Context, ctxKey string, params clientParams) error { var origins []string - for _, origin := range cfg.RelyingParty.Origins { + for _, origin := range params.RP.Origins { origins = append(origins, origin.Origin) } - requireKey := cfg.ResidentKeyRequirement == protocol.ResidentKeyRequirementRequired + requireKey := params.ResidentKeyRequirement == protocol.ResidentKeyRequirementRequired webauthnClient, err := webauthn.New(&webauthn.Config{ - RPDisplayName: cfg.RelyingParty.DisplayName, - RPID: cfg.RelyingParty.RPId, + RPDisplayName: params.RP.DisplayName, + RPID: params.RP.RPId, RPOrigins: origins, - AttestationPreference: cfg.AttestationPreference, + AttestationPreference: params.AttestationPreference, AuthenticatorSelection: protocol.AuthenticatorSelection{ RequireResidentKey: &requireKey, - ResidentKey: cfg.ResidentKeyRequirement, - UserVerification: cfg.UserVerification, + ResidentKey: params.ResidentKeyRequirement, + UserVerification: params.UserVerification, }, Debug: false, Timeouts: webauthn.TimeoutsConfig{ Login: webauthn.TimeoutConfig{ - Timeout: time.Duration(cfg.Timeout) * time.Millisecond, + Timeout: time.Duration(params.Timeout) * time.Millisecond, Enforce: true, }, Registration: webauthn.TimeoutConfig{ - Timeout: time.Duration(cfg.Timeout) * time.Millisecond, + Timeout: time.Duration(params.Timeout) * time.Millisecond, Enforce: true, }, }, @@ -110,27 +109,49 @@ func createWebauthnClient(ctx echo.Context, ctxKey string, cfg models.WebauthnCo return nil } -func createDefaultMfaConfig(persister persistence.Persister, passkeyConfig models.WebauthnConfig) (*models.WebauthnConfig, error) { +func createPasskeyCLient(ctx echo.Context, cfg models.WebauthnConfig) error { + params := clientParams{ + RP: cfg.RelyingParty, + Timeout: cfg.Timeout, + UserVerification: cfg.UserVerification, + Attachment: cfg.Attachment, + AttestationPreference: cfg.AttestationPreference, + ResidentKeyRequirement: cfg.ResidentKeyRequirement, + } + + return createClient(ctx, "webauthn_client", params) +} + +func createMFAClient(ctx echo.Context, cfg models.MfaConfig, rp models.RelyingParty) error { + params := clientParams{ + RP: rp, + Timeout: cfg.Timeout, + UserVerification: cfg.UserVerification, + Attachment: &cfg.Attachment, + AttestationPreference: cfg.AttestationPreference, + ResidentKeyRequirement: cfg.ResidentKeyRequirement, + } + + return createClient(ctx, "mfa_client", params) +} + +func createDefaultMfaConfig(persister persistence.Persister, passkeyConfig models.WebauthnConfig) (*models.MfaConfig, error) { configId, _ := uuid.NewV4() now := time.Now() - cpAttachment := protocol.CrossPlatform - - mfaConfig := &models.WebauthnConfig{ + mfaConfig := &models.MfaConfig{ ID: configId, ConfigID: passkeyConfig.ConfigID, - RelyingParty: passkeyConfig.RelyingParty, Timeout: passkeyConfig.Timeout, CreatedAt: now, UpdatedAt: now, UserVerification: protocol.VerificationPreferred, - Attachment: &cpAttachment, + Attachment: protocol.CrossPlatform, AttestationPreference: protocol.PreferNoAttestation, ResidentKeyRequirement: protocol.ResidentKeyRequirementDiscouraged, - IsMfa: true, } - err := persister.GetWebauthnConfigPersister(nil).Create(mfaConfig) + err := persister.GetMFAConfigPersister(nil).Create(mfaConfig) if err != nil { return nil, fmt.Errorf("unable to create default mfa config: %w", err) } diff --git a/server/api/router/main.go b/server/api/router/main.go index 56b94ac..5dfb864 100644 --- a/server/api/router/main.go +++ b/server/api/router/main.go @@ -86,7 +86,7 @@ func RouteCredentials(parent *echo.Group, persister persistence.Persister) { func RouteRegistration(parent *echo.Group, persister persistence.Persister, authenticatorMetadata mapper.AuthenticatorMetadata) { registrationHandler := handler.NewRegistrationHandler(persister, authenticatorMetadata, false) - group := parent.Group("/registration", passkeyMiddleware.WebauthnMiddleware(persister)) + group := parent.Group("/registration") group.POST(InitEndpoint, registrationHandler.Init, passkeyMiddleware.ApiKeyMiddleware()) group.POST(FinishEndpoint, registrationHandler.Finish) } @@ -94,7 +94,7 @@ func RouteRegistration(parent *echo.Group, persister persistence.Persister, auth func RouteLogin(parent *echo.Group, persister persistence.Persister) { loginHandler := handler.NewLoginHandler(persister) - group := parent.Group("/login", passkeyMiddleware.WebauthnMiddleware(persister)) + group := parent.Group("/login") group.POST(InitEndpoint, loginHandler.Init) group.POST(FinishEndpoint, loginHandler.Finish) } @@ -102,7 +102,7 @@ func RouteLogin(parent *echo.Group, persister persistence.Persister) { func RouteTransaction(parent *echo.Group, persister persistence.Persister) { transactionHandler := handler.NewTransactionHandler(persister) - group := parent.Group("/transaction", passkeyMiddleware.WebauthnMiddleware(persister), passkeyMiddleware.ApiKeyMiddleware()) + group := parent.Group("/transaction", passkeyMiddleware.ApiKeyMiddleware()) group.POST(InitEndpoint, transactionHandler.Init) group.POST(FinishEndpoint, transactionHandler.Finish) } diff --git a/server/api/services/admin/tenant_service.go b/server/api/services/admin/tenant_service.go index 981b7af..63e4b08 100644 --- a/server/api/services/admin/tenant_service.go +++ b/server/api/services/admin/tenant_service.go @@ -34,6 +34,7 @@ type tenantService struct { secretPersister persisters.SecretsPersister jwkPersister persisters.JwkPersister auditLogPersister persisters.AuditLogPersister + mfaConfigPersister persisters.MFAConfigPersister } type CreateTenantServiceParams struct { @@ -49,6 +50,7 @@ type CreateTenantServiceParams struct { SecretPersister persisters.SecretsPersister JwkPersister persisters.JwkPersister AuditLogPersister persisters.AuditLogPersister + MFAConfigPersister persisters.MFAConfigPersister } func NewTenantService(params CreateTenantServiceParams) TenantService { @@ -65,6 +67,7 @@ func NewTenantService(params CreateTenantServiceParams) TenantService { secretPersister: params.SecretPersister, jwkPersister: params.JwkPersister, auditLogPersister: params.AuditLogPersister, + mfaConfigPersister: params.MFAConfigPersister, } } @@ -89,10 +92,9 @@ func (ts *tenantService) Create(dto request.CreateTenantDto) (*response.CreateTe tenantModel := dto.ToModel() configModel := dto.Config.ToModel(tenantModel) corsModel := dto.Config.Cors.ToModel(configModel) - passkeyConfigModel := dto.Config.Webauthn.ToPasskeyModel(configModel) + passkeyConfigModel := dto.Config.Webauthn.ToModel(configModel) relyingPartyModel := dto.Config.Webauthn.RelyingParty.ToModel(passkeyConfigModel) - mfaConfigModel := dto.Config.Mfa.ToMfaModel(configModel) - mfaRp := dto.Config.Mfa.RelyingParty.ToModel(mfaConfigModel) + mfaConfigModel := dto.Config.Mfa.ToModel(configModel) err := ts.tenantPersister.Create(&tenantModel) if err != nil { @@ -106,7 +108,6 @@ func (ts *tenantService) Create(dto request.CreateTenantDto) (*response.CreateTe &passkeyConfigModel, &relyingPartyModel, &mfaConfigModel, - &mfaRp, ) var apiSecretModel *models.Secret = nil @@ -167,7 +168,7 @@ func (ts *tenantService) createSecret(name string, configId uuid.UUID, isAPIKey return model, nil } -func (ts *tenantService) persistConfig(config *models.Config, cors *models.Cors, webauthn *models.WebauthnConfig, rp *models.RelyingParty, mfaConfig *models.WebauthnConfig, mfaRp *models.RelyingParty) error { +func (ts *tenantService) persistConfig(config *models.Config, cors *models.Cors, webauthn *models.WebauthnConfig, rp *models.RelyingParty, mfaConfig *models.MfaConfig) error { err := ts.configPersister.Create(config) if err != nil { return err @@ -188,12 +189,7 @@ func (ts *tenantService) persistConfig(config *models.Config, cors *models.Cors, return err } - err = ts.webauthnConfigPersister.Create(mfaConfig) - if err != nil { - return err - } - - err = ts.relyingPartyPerister.Create(mfaRp) + err = ts.mfaConfigPersister.Create(mfaConfig) if err != nil { return err } @@ -223,10 +219,9 @@ func (ts *tenantService) UpdateConfig(dto request.UpdateConfigDto) error { config := ts.tenant.Config newConfig := dto.ToModel(*ts.tenant) corsModel := dto.Cors.ToModel(newConfig) - webauthnConfigModel := dto.Webauthn.ToPasskeyModel(newConfig) + webauthnConfigModel := dto.Webauthn.ToModel(newConfig) relyingPartyModel := dto.Webauthn.RelyingParty.ToModel(webauthnConfigModel) - mfaConfigModel := dto.Mfa.ToMfaModel(newConfig) - mfaRp := dto.Mfa.RelyingParty.ToModel(mfaConfigModel) + mfaConfigModel := dto.Mfa.ToModel(newConfig) err := ts.persistConfig( &newConfig, @@ -234,7 +229,6 @@ func (ts *tenantService) UpdateConfig(dto request.UpdateConfigDto) error { &webauthnConfigModel, &relyingPartyModel, &mfaConfigModel, - &mfaRp, ) if err != nil { diff --git a/server/api/services/login_service.go b/server/api/services/login_service.go index 296bd87..6ff04cd 100644 --- a/server/api/services/login_service.go +++ b/server/api/services/login_service.go @@ -19,23 +19,27 @@ type LoginService interface { type loginService struct { WebauthnService userId *string + useMFA bool } func NewLoginService(params WebauthnServiceCreateParams) LoginService { - return &loginService{WebauthnService{ - BaseService: &BaseService{ - logger: params.Ctx.Logger(), - tenant: params.Tenant, - credentialPersister: params.CredentialPersister, + return &loginService{ + WebauthnService{ + BaseService: &BaseService{ + logger: params.Ctx.Logger(), + tenant: params.Tenant, + credentialPersister: params.CredentialPersister, + }, + webauthnClient: params.WebauthnClient, + generator: params.Generator, + + userPersister: params.UserPersister, + sessionDataPersister: params.SessionPersister, }, - webauthnClient: params.WebauthnClient, - generator: params.Generator, - - userPersister: params.UserPersister, - sessionDataPersister: params.SessionPersister, - }, - params.UserId} + params.UserId, + params.UseMFA, + } } func (ls *loginService) Initialize() (*protocol.CredentialAssertion, error) { @@ -119,7 +123,12 @@ func (ls *loginService) Finalize(req *protocol.ParsedCredentialAssertionData) (s } credentialId := base64.RawURLEncoding.EncodeToString(credential.ID) - err = ls.updateCredentialForUser(webauthnUser, credentialId, req.Response.AuthenticatorData.Flags) + dbCredential := webauthnUser.FindCredentialById(credentialId) + if !ls.useMFA && dbCredential.IsMFA { + return "", userHandle, echo.NewHTTPError(http.StatusBadRequest, "MFA passkeys are not usable for normal login") + } + + err = ls.updateCredentialForUser(dbCredential, req.Response.AuthenticatorData.Flags) if err != nil { return "", userHandle, err } diff --git a/server/api/services/registration_service.go b/server/api/services/registration_service.go index 663eb62..2d1fbd5 100644 --- a/server/api/services/registration_service.go +++ b/server/api/services/registration_service.go @@ -21,6 +21,7 @@ type RegistrationService interface { type registrationService struct { WebauthnService mapper.AuthenticatorMetadata + UseMFA bool } func NewRegistrationService(params WebauthnServiceCreateParams) RegistrationService { @@ -40,6 +41,7 @@ func NewRegistrationService(params WebauthnServiceCreateParams) RegistrationServ sessionDataPersister: params.SessionPersister, }, params.AuthenticatorMetadata, + params.UseMFA, } } @@ -135,7 +137,7 @@ func (rs *registrationService) Finalize(req *protocol.ParsedCredentialCreationDa err = rs.sessionDataPersister.Delete(*dbSessionData) if err != nil { - rs.logger.Warnf("failed to delete attestation session data: %w", err) + rs.logger.Errorf("failed to delete attestation session data: %w", err) } token, err := rs.generator.Generate(dbUser.UserID, credential.ID) @@ -198,6 +200,7 @@ func (rs *registrationService) createCredential(dbUser *models.WebauthnUser, ses flags.HasBackupEligible(), flags.HasBackupState(), rs.AuthenticatorMetadata, + rs.UseMFA, ) err = rs.credentialPersister.Create(dbCredential) diff --git a/server/api/services/transaction_service.go b/server/api/services/transaction_service.go index 9a87b68..874fb38 100644 --- a/server/api/services/transaction_service.go +++ b/server/api/services/transaction_service.go @@ -28,6 +28,7 @@ type transactionService struct { *WebauthnService transactionPersister persisters.TransactionPersister + useMFA bool } func NewTransactionService(params TransactionServiceCreateParams) TransactionService { @@ -46,6 +47,7 @@ func NewTransactionService(params TransactionServiceCreateParams) TransactionSer sessionDataPersister: params.SessionPersister, }, transactionPersister: params.TransactionPersister, + useMFA: params.UseMFA, } } @@ -147,7 +149,12 @@ func (ts *transactionService) Finalize(req *protocol.ParsedCredentialAssertionDa } credentialId := base64.RawURLEncoding.EncodeToString(credential.ID) - err = ts.updateCredentialForUser(webauthnUser, credentialId, req.Response.AuthenticatorData.Flags) + dbCredential := webauthnUser.FindCredentialById(credentialId) + if !ts.useMFA && dbCredential.IsMFA { + return "", userHandle, transaction, echo.NewHTTPError(http.StatusBadRequest, "MFA passkeys are not usable for normal login") + } + + err = ts.updateCredentialForUser(dbCredential, req.Response.AuthenticatorData.Flags) if err != nil { return "", userHandle, transaction, err } diff --git a/server/api/services/webauthn_service.go b/server/api/services/webauthn_service.go index 8a25b13..8a4cbf2 100644 --- a/server/api/services/webauthn_service.go +++ b/server/api/services/webauthn_service.go @@ -32,6 +32,7 @@ type WebauthnServiceCreateParams struct { Generator jwt.Generator AuthenticatorMetadata mapper.AuthenticatorMetadata UserId *string + UseMFA bool UserPersister persisters.WebauthnUserPersister SessionPersister persisters.WebauthnSessionDataPersister @@ -87,15 +88,14 @@ func (ws *WebauthnService) createUserCredentialToken(userId string, credentialId return token, nil } -func (ws *WebauthnService) updateCredentialForUser(webauthnUser *intern.WebauthnUser, credentialId string, flags protocol.AuthenticatorFlags) error { - dbCredential := webauthnUser.FindCredentialById(credentialId) - if dbCredential != nil { +func (ws *WebauthnService) updateCredentialForUser(credential *models.WebauthnCredential, flags protocol.AuthenticatorFlags) error { + if credential != nil { now := time.Now().UTC() - dbCredential.BackupState = flags.HasBackupState() - dbCredential.BackupEligible = flags.HasBackupEligible() - dbCredential.LastUsedAt = &now - err := ws.credentialPersister.Update(dbCredential) + credential.BackupState = flags.HasBackupState() + credential.BackupEligible = flags.HasBackupEligible() + credential.LastUsedAt = &now + err := ws.credentialPersister.Update(credential) if err != nil { ws.logger.Error(err) return err diff --git a/server/persistence/migrations/20240301140353_add_mfa_identifier.down.fizz b/server/persistence/migrations/20240301140353_add_mfa_identifier.down.fizz deleted file mode 100644 index 0508e70..0000000 --- a/server/persistence/migrations/20240301140353_add_mfa_identifier.down.fizz +++ /dev/null @@ -1,2 +0,0 @@ -drop_index("webauthn_configs", "webauthn_mfa_config_idx") -drop_column("webauthn_configs", "is_mfa") diff --git a/server/persistence/migrations/20240301140353_add_mfa_identifier.up.fizz b/server/persistence/migrations/20240301140353_add_mfa_identifier.up.fizz deleted file mode 100644 index bcbccf7..0000000 --- a/server/persistence/migrations/20240301140353_add_mfa_identifier.up.fizz +++ /dev/null @@ -1,2 +0,0 @@ -add_column("webauthn_configs", "is_mfa", "boolean", { default: false}) -add_index("webauthn_configs", ["config_id", "is_mfa"], { unique: true, name:"webauthn_mfa_config_idx" }) diff --git a/server/persistence/migrations/20240322082442_add_is_mfa_to_webauthn_credential.down.fizz b/server/persistence/migrations/20240322082442_add_is_mfa_to_webauthn_credential.down.fizz new file mode 100644 index 0000000..66e11bb --- /dev/null +++ b/server/persistence/migrations/20240322082442_add_is_mfa_to_webauthn_credential.down.fizz @@ -0,0 +1 @@ +drop_column("webauthn_credentials", "is_mfa") diff --git a/server/persistence/migrations/20240322082442_add_is_mfa_to_webauthn_credential.up.fizz b/server/persistence/migrations/20240322082442_add_is_mfa_to_webauthn_credential.up.fizz new file mode 100644 index 0000000..7609645 --- /dev/null +++ b/server/persistence/migrations/20240322082442_add_is_mfa_to_webauthn_credential.up.fizz @@ -0,0 +1 @@ +add_column("webauthn_credentials", "is_mfa", "boolean", { default: false }) diff --git a/server/persistence/migrations/20240322134127_create_mfa_configs.down.fizz b/server/persistence/migrations/20240322134127_create_mfa_configs.down.fizz new file mode 100644 index 0000000..e773b21 --- /dev/null +++ b/server/persistence/migrations/20240322134127_create_mfa_configs.down.fizz @@ -0,0 +1 @@ +drop_table("mfa_configs") \ No newline at end of file diff --git a/server/persistence/migrations/20240322134127_create_mfa_configs.up.fizz b/server/persistence/migrations/20240322134127_create_mfa_configs.up.fizz new file mode 100644 index 0000000..f58499b --- /dev/null +++ b/server/persistence/migrations/20240322134127_create_mfa_configs.up.fizz @@ -0,0 +1,13 @@ +create_table("mfa_configs") { + t.Column("id", "uuid", {primary: true}) + t.Column("timeout", "integer", { "null": false, default: 60000 }) + t.Column("user_verification", "string", { "null": false, default: "discouraged" }) + t.Column("attachment", "string", { "null": false, default: "cross-platform" }) + t.Column("attestation_preference", "string", { "null": false, "default": "direct" }) + t.Column("resident_key_requirement", "string", { "null": false, default: "discouraged" }) + t.Column("config_id", "uuid", { "null": false }) + + t.ForeignKey("config_id", {"configs": ["id"]}, {"on_delete": "cascade", "on_update": "cascade"}) + + t.Timestamps() +} diff --git a/server/persistence/models/config.go b/server/persistence/models/config.go index 1d41fda..a3b68a9 100644 --- a/server/persistence/models/config.go +++ b/server/persistence/models/config.go @@ -1,10 +1,11 @@ package models import ( + "time" + "github.com/gobuffalo/pop/v6" "github.com/gobuffalo/validate/v3" "github.com/gobuffalo/validate/v3/validators" - "time" "github.com/gofrs/uuid" ) @@ -15,10 +16,11 @@ type Config struct { TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"` Tenant *Tenant `json:"tenant,omitempty" belongs_to:"tenant"` - WebauthnConfigs []WebauthnConfig `json:"webauthn_config,omitempty" has_many:"webauthn_config"` - Cors Cors `json:"cors,omitempty" has_one:"cor"` - AuditLogConfig AuditLogConfig `json:"audit_log_config,omitempty" has_one:"audit_log_config"` - Secrets Secrets `json:"secrets,omitempty" has_many:"secrets"` + WebauthnConfig WebauthnConfig `json:"webauthn_config,omitempty" has_one:"webauthn_config"` + MfaConfig *MfaConfig `json:"mfa_config,omitempty" has_one:"mfa_config"` + Cors Cors `json:"cors,omitempty" has_one:"cor"` + AuditLogConfig AuditLogConfig `json:"audit_log_config,omitempty" has_one:"audit_log_config"` + Secrets Secrets `json:"secrets,omitempty" has_many:"secrets"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` @@ -26,7 +28,7 @@ type Config struct { // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. // This method is not required and may be deleted. -func (config *Config) Validate(tx *pop.Connection) (*validate.Errors, error) { +func (config *Config) Validate(_ *pop.Connection) (*validate.Errors, error) { return validate.Validate( &validators.UUIDIsPresent{Name: "ID", Field: config.ID}, &validators.TimeIsPresent{Name: "UpdatedAt", Field: config.UpdatedAt}, diff --git a/server/persistence/models/mfa_config.go b/server/persistence/models/mfa_config.go new file mode 100644 index 0000000..6c7419b --- /dev/null +++ b/server/persistence/models/mfa_config.go @@ -0,0 +1,40 @@ +package models + +import ( + "github.com/go-webauthn/webauthn/protocol" + "github.com/gobuffalo/validate/v3/validators" + "time" + + "github.com/gobuffalo/pop/v6" + "github.com/gobuffalo/validate/v3" + "github.com/gofrs/uuid" +) + +// MfaConfig is used by pop to map your mfa_configs database table to your go code. +type MfaConfig struct { + ID uuid.UUID `json:"id" db:"id"` + Config *Config `json:"config" belongs_to:"configs"` + ConfigID uuid.UUID `json:"config_id" db:"config_id"` + Timeout int `json:"timeout" db:"timeout"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + UserVerification protocol.UserVerificationRequirement `json:"user_verification" db:"user_verification"` + Attachment protocol.AuthenticatorAttachment `json:"attachment" db:"attachment"` + AttestationPreference protocol.ConveyancePreference `json:"attestation_preference" db:"attestation_preference"` + ResidentKeyRequirement protocol.ResidentKeyRequirement `json:"resident_key_requirement" db:"resident_key_requirement"` +} + +// Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. +// This method is not required and may be deleted. +func (mfa *MfaConfig) Validate(_ *pop.Connection) (*validate.Errors, error) { + return validate.Validate( + &validators.UUIDIsPresent{Name: "ID", Field: mfa.ID}, + &validators.IntIsPresent{Name: "Timeout", Field: mfa.Timeout}, + &validators.StringIsPresent{Name: "UserVerification", Field: string(mfa.UserVerification)}, + &validators.StringIsPresent{Name: "Attachment", Field: string(mfa.Attachment)}, + &validators.StringIsPresent{Name: "AttestationPreference", Field: string(mfa.AttestationPreference)}, + &validators.StringIsPresent{Name: "ResidentKeyRequirement", Field: string(mfa.ResidentKeyRequirement)}, + &validators.TimeIsPresent{Name: "UpdatedAt", Field: mfa.UpdatedAt}, + &validators.TimeIsPresent{Name: "CreatedAt", Field: mfa.CreatedAt}, + ), nil +} diff --git a/server/persistence/models/webauthn_config.go b/server/persistence/models/webauthn_config.go index 9d520fd..84353b2 100644 --- a/server/persistence/models/webauthn_config.go +++ b/server/persistence/models/webauthn_config.go @@ -1,9 +1,10 @@ package models import ( + "time" + "github.com/go-webauthn/webauthn/protocol" "github.com/gobuffalo/validate/v3/validators" - "time" "github.com/gobuffalo/pop/v6" "github.com/gobuffalo/validate/v3" @@ -23,7 +24,6 @@ type WebauthnConfig struct { Attachment *protocol.AuthenticatorAttachment `json:"attachment" db:"attachment"` AttestationPreference protocol.ConveyancePreference `json:"attestation_preference" db:"attestation_preference"` ResidentKeyRequirement protocol.ResidentKeyRequirement `json:"resident_key_requirement" db:"resident_key_requirement"` - IsMfa bool `json:"is_mfa" db:"is_mfa"` } // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. diff --git a/server/persistence/models/webauthn_credential.go b/server/persistence/models/webauthn_credential.go index 26a7beb..cdc4694 100644 --- a/server/persistence/models/webauthn_credential.go +++ b/server/persistence/models/webauthn_credential.go @@ -24,6 +24,7 @@ type WebauthnCredential struct { Transports Transports `has_many:"webauthn_credential_transports" json:"-"` BackupEligible bool `db:"backup_eligible" json:"-"` BackupState bool `db:"backup_state" json:"-"` + IsMFA bool `db:"is_mfa" json:"-"` WebauthnUserID uuid.UUID `db:"webauthn_user_id"` WebauthnUser *WebauthnUser `belongs_to:"webauthn_user"` @@ -32,7 +33,7 @@ type WebauthnCredential struct { type WebauthnCredentials []WebauthnCredential // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. -func (credential *WebauthnCredential) Validate(tx *pop.Connection) (*validate.Errors, error) { +func (credential *WebauthnCredential) Validate(_ *pop.Connection) (*validate.Errors, error) { return validate.Validate( &validators.StringIsPresent{Name: "ID", Field: credential.ID}, &validators.StringIsPresent{Name: "UserId", Field: credential.UserId}, diff --git a/server/persistence/persister.go b/server/persistence/persister.go index 20740be..4a307f3 100644 --- a/server/persistence/persister.go +++ b/server/persistence/persister.go @@ -31,6 +31,7 @@ type Persister interface { GetCorsPersister(tx *pop.Connection) persisters.CorsPersister GetAuditLogConfigPersister(tx *pop.Connection) persisters.AuditLogConfigPersister GetTransactionPersister(tx *pop.Connection) persisters.TransactionPersister + GetMFAConfigPersister(tx *pop.Connection) persisters.MFAConfigPersister } type Migrator interface { @@ -211,3 +212,11 @@ func (p *persister) GetTransactionPersister(tx *pop.Connection) persisters.Trans return persisters.NewTransactionPersister(tx) } + +func (p *persister) GetMFAConfigPersister(tx *pop.Connection) persisters.MFAConfigPersister { + if tx == nil { + return persisters.NewMFAConfigPersister(p.Database) + } + + return persisters.NewMFAConfigPersister(tx) +} diff --git a/server/persistence/persisters/mfa_config_persister.go b/server/persistence/persisters/mfa_config_persister.go new file mode 100644 index 0000000..1deb359 --- /dev/null +++ b/server/persistence/persisters/mfa_config_persister.go @@ -0,0 +1,33 @@ +package persisters + +import ( + "fmt" + + "github.com/gobuffalo/pop/v6" + "github.com/teamhanko/passkey-server/persistence/models" +) + +type MFAConfigPersister interface { + Create(mfaConfig *models.MfaConfig) error +} + +type mfaConfigPersister struct { + database *pop.Connection +} + +func NewMFAConfigPersister(database *pop.Connection) MFAConfigPersister { + return &mfaConfigPersister{database: database} +} + +func (mp *mfaConfigPersister) Create(mfaConfig *models.MfaConfig) error { + validationErr, err := mp.database.ValidateAndCreate(mfaConfig) + if err != nil { + return fmt.Errorf("failed to store mfa config: %w", err) + } + + if validationErr != nil && validationErr.HasAny() { + return fmt.Errorf("mfa config validation failed: %w", validationErr) + } + + return nil +} diff --git a/server/persistence/persisters/tenant_persister.go b/server/persistence/persisters/tenant_persister.go index 5866bb3..4f9e4fa 100644 --- a/server/persistence/persisters/tenant_persister.go +++ b/server/persistence/persisters/tenant_persister.go @@ -43,7 +43,8 @@ func (t tenantPersister) Get(tenantId uuid.UUID) (*models.Tenant, error) { tenant := models.Tenant{} err := t.database.Eager( "Config.Secrets", - "Config.WebauthnConfigs.RelyingParty.Origins", + "Config.WebauthnConfig.RelyingParty.Origins", + "Config.MfaConfig", "Config.Cors.Origins", "Config.AuditLogConfig", ).Find(&tenant, tenantId) diff --git a/spec/passkey-server-admin.yaml b/spec/passkey-server-admin.yaml index 8cb32c5..ba00bd2 100644 --- a/spec/passkey-server-admin.yaml +++ b/spec/passkey-server-admin.yaml @@ -627,9 +627,12 @@ components: $ref: '#/components/schemas/cors' webauthn: $ref: '#/components/schemas/webauthn' + mfa: + $ref: '#/components/schemas/mfa' required: - cors - webauthn + - mfa cors: type: object title: cors @@ -664,6 +667,7 @@ components: - required - preferred - discouraged + description: defaults to `required` when omitted attachment: type: string enum: @@ -677,7 +681,7 @@ components: - indirect - direct - enterprise - description: defaults to `none` when omitted + description: defaults to `direct` when omitted resident_key_requirement: type: string enum: @@ -688,7 +692,6 @@ components: required: - relying_party - timeout - - user_verification relying_party: type: object title: relying_party @@ -719,6 +722,43 @@ components: - id - display_name - origins + mfa: + type: object + title: mfa + properties: + timeout: + type: number + default: 60000 + user_verification: + type: string + enum: + - required + - preferred + - discouraged + description: defaults to `discouraged` when omitted + attachment: + type: string + enum: + - platform + - cross-platform + description: defaults to `cross-platform` when omitted + attestation_preference: + type: string + enum: + - none + - indirect + - direct + - enterprise + description: defaults to `direct` when omitted + resident_key_requirement: + type: string + enum: + - discouraged + - preferred + - required + description: defaults to `preferred` when omitted + required: + - timeout secret_list: type: array title: secret_list diff --git a/spec/passkey-server.yaml b/spec/passkey-server.yaml index ac76e1a..46d79d7 100644 --- a/spec/passkey-server.yaml +++ b/spec/passkey-server.yaml @@ -301,9 +301,157 @@ paths: default: localhost path_prefix: default: '' + '/{tenant_id}/mfa/registration/initialize': + post: + tags: + - credentials + - mfa + summary: Start MFA Registration + description: Initialize a registration for mfa credentials + operationId: post-mfa-registration-initialize + parameters: + - $ref: '#/components/parameters/X-API-KEY' + - name: tenant_id + in: path + description: Tenant ID + required: true + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/post-registration-initialize' + responses: + '200': + $ref: '#/components/responses/post-registration-initialize' + '400': + $ref: '#/components/responses/error' + '401': + $ref: '#/components/responses/error' + '500': + $ref: '#/components/responses/error' + security: [] + servers: + - url: 'http://{host}:8000/{path_prefix}' + variables: + host: + default: localhost + path_prefix: + default: '' + '/{tenant_id}/mfa/registration/finalize': + post: + tags: + - credentials + - mfa + summary: Finish MFA Registration + description: Finish credential registration process + operationId: post-mfa-registration-finalize + parameters: + - $ref: '#/components/parameters/X-API-KEY' + - name: tenant_id + in: path + description: Tenant ID + required: true + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/post-registration-finalize' + responses: + '200': + $ref: '#/components/responses/token' + '400': + $ref: '#/components/responses/error' + '401': + $ref: '#/components/responses/error' + '404': + $ref: '#/components/responses/error' + '500': + $ref: '#/components/responses/error' + security: [] + servers: + - url: 'http://{host}:8000/{path_prefix}' + variables: + host: + default: localhost + path_prefix: + default: '' + '/{tenant_id}/mfa/login/initialize': + post: + tags: + - credentials + - mfa + summary: Start MFA Login + description: Initialize a login flow for MFA + operationId: post-mfa-login-initialize + parameters: + - $ref: '#/components/parameters/X-API-KEY' + - name: tenant_id + in: path + description: Tenant ID + required: true + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/post-mfa-login-initialize' + responses: + '200': + $ref: '#/components/responses/post-login-initialize' + '400': + $ref: '#/components/responses/error' + '401': + $ref: '#/components/responses/error' + '404': + $ref: '#/components/responses/error' + '500': + $ref: '#/components/responses/error' + security: [] + servers: + - url: 'http://{host}:8000/{path_prefix}' + variables: + host: + default: localhost + path_prefix: + default: '' + '/{tenant_id}/mfa/login/finalize': + post: + tags: + - credentials + - mfa + summary: Finish MFA Login + description: Finalize the login operation + operationId: post-mfa-login-finalize + parameters: + - $ref: '#/components/parameters/X-API-KEY' + - name: tenant_id + in: path + description: Tenant ID + required: true + schema: + type: string + requestBody: + $ref: '#/components/requestBodies/post-login-finalize' + responses: + '200': + $ref: '#/components/responses/token' + '400': + $ref: '#/components/responses/error' + '401': + $ref: '#/components/responses/error' + '404': + $ref: '#/components/responses/error' + '500': + $ref: '#/components/responses/error' + security: [] + servers: + - url: 'http://{host}:8000/{path_prefix}' + variables: + host: + default: localhost + path_prefix: + default: '' tags: - name: credentials description: Represents all objects which are related to WebAuthn credentials + - name: mfa + description: Represents all objects which are related to MFA in common - name: webauthn description: Represents all objects which are related to WebAuthn in common components: @@ -439,7 +587,17 @@ components: properties: user_id: type: string - description: optional + description: optional - when provided the API Key needs to be sent to the server too. + post-mfa-login-initialize: + content: + application/json: + schema: + type: object + properties: + user_id: + type: string + required: + - user_id responses: get-credentials: description: Example response