diff --git a/server/README.md b/server/README.md index 774c972..01e002d 100644 --- a/server/README.md +++ b/server/README.md @@ -143,7 +143,9 @@ curl --location 'http://:8001/tenants' \ ] }, "timeout": 60000, - "user_verification": "preferred" + "user_verification": "preferred", + "attestation_preference": "none", + "resident_key_requirement": "required" }, "create_api_key": true } @@ -225,7 +227,9 @@ the [WebAuthn Relying Party](https://www.w3.org/TR/webauthn-2/#webauthn-relying- ] }, "timeout": 60000, - "user_verification": "preferred" + "user_verification": "preferred", + "attestation_preference": "none", + "resident_key_requirement": "required" } } } @@ -252,7 +256,9 @@ As an example: If the login should be available at `https://login.example.com` i ] }, "timeout": 60000, - "user_verification": "preferred" + "user_verification": "preferred", + "attestation_preference": "none", + "resident_key_requirement": "required" } } } @@ -275,7 +281,9 @@ point. Then the WebAuthn config would look like this: ] }, "timeout": 60000, - "user_verification": "preferred" + "user_verification": "preferred", + "attestation_preference": "none", + "resident_key_requirement": "required" } } } diff --git a/server/api/dto/admin/request/config.go b/server/api/dto/admin/request/config.go index 75fa5bb..d562bc1 100644 --- a/server/api/dto/admin/request/config.go +++ b/server/api/dto/admin/request/config.go @@ -8,8 +8,9 @@ import ( ) type CreateConfigDto struct { - Cors CreateCorsDto `json:"cors" validate:"required"` - Webauthn CreateWebauthnDto `json:"webauthn" validate:"required"` + Cors CreateCorsDto `json:"cors" validate:"required"` + Passkey CreatePasskeyConfigDto `json:"webauthn" validate:"required"` + Mfa *CreateMFAConfigDto `json:"mfa" validate:"omitempty"` } 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..26b6513 --- /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.PreferDirectAttestation + } 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 cbe22f3..f453490 100644 --- a/server/api/dto/admin/request/webauthn.go +++ b/server/api/dto/admin/request/webauthn.go @@ -7,24 +7,65 @@ import ( "time" ) -type CreateWebauthnDto struct { - RelyingParty CreateRelyingPartyDto `json:"relying_party" validate:"required"` - Timeout int `json:"timeout" validate:"required,number"` - UserVerification protocol.UserVerificationRequirement `json:"user_verification" validate:"required,oneof=required preferred discouraged"` +type CreatePasskeyConfigDto struct { + RelyingParty CreateRelyingPartyDto `json:"relying_party" validate:"required"` + 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 *CreateWebauthnDto) ToModel(configModel models.Config) models.WebauthnConfig { - webauthnConfigId, _ := uuid.NewV4() +func (dto *CreatePasskeyConfigDto) ToModel(configModel models.Config) models.WebauthnConfig { + passkeyConfigId, _ := uuid.NewV4() now := time.Now() - webauthnConfig := models.WebauthnConfig{ - ID: webauthnConfigId, - ConfigID: configModel.ID, - Timeout: dto.Timeout, - CreatedAt: now, - UpdatedAt: now, - UserVerification: dto.UserVerification, + passkeyConfig := models.WebauthnConfig{ + ID: passkeyConfigId, + ConfigID: configModel.ID, + Timeout: dto.Timeout, + CreatedAt: now, + UpdatedAt: now, } - return webauthnConfig + if dto.AttestationPreference == nil { + passkeyConfig.AttestationPreference = protocol.PreferDirectAttestation + } else { + passkeyConfig.AttestationPreference = *dto.AttestationPreference + } + + passkeyConfig.Attachment = dto.Attachment + + if dto.ResidentKeyRequirement == nil { + passkeyConfig.ResidentKeyRequirement = protocol.ResidentKeyRequirementRequired + } else { + passkeyConfig.ResidentKeyRequirement = *dto.ResidentKeyRequirement + } + + if dto.UserVerification == nil { + passkeyConfig.UserVerification = protocol.VerificationRequired + } else { + passkeyConfig.UserVerification = *dto.UserVerification + } + + return passkeyConfig +} + +func (dto *CreatePasskeyConfigDto) ToMfaModel(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, + AttestationPreference: protocol.PreferDirectAttestation, + ResidentKeyRequirement: protocol.ResidentKeyRequirementDiscouraged, + UserVerification: protocol.VerificationPreferred, + Attachment: protocol.CrossPlatform, + } + + return mfaConfig } diff --git a/server/api/dto/admin/response/config.go b/server/api/dto/admin/response/config.go index 05c9abb..c71b8f2 100644 --- a/server/api/dto/admin/response/config.go +++ b/server/api/dto/admin/response/config.go @@ -5,11 +5,13 @@ import "github.com/teamhanko/passkey-server/persistence/models" type GetConfigResponse struct { Cors GetCorsResponse `json:"cors"` Webauthn GetWebauthnResponse `json:"webauthn"` + MFA GetMFAResponse `json:"mfa"` } func ToGetConfigResponse(config *models.Config) GetConfigResponse { return GetConfigResponse{ Cors: ToGetCorsResponse(&config.Cors), 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/admin/response/webatuhn.go b/server/api/dto/admin/response/webatuhn.go index 2fdb290..94272b3 100644 --- a/server/api/dto/admin/response/webatuhn.go +++ b/server/api/dto/admin/response/webatuhn.go @@ -6,15 +6,21 @@ import ( ) type GetWebauthnResponse struct { - RelyingParty GetRelyingPartyResponse `json:"relying_party"` - Timeout int `json:"timeout"` - UserVerification protocol.UserVerificationRequirement `json:"user_verification"` + RelyingParty GetRelyingPartyResponse `json:"relying_party"` + Timeout int `json:"timeout"` + UserVerification protocol.UserVerificationRequirement `json:"user_verification"` + Attachment *protocol.AuthenticatorAttachment `json:"attachment,omitempty"` + AttestationPreference protocol.ConveyancePreference `json:"attestation_preference"` + ResidentKeyRequirement protocol.ResidentKeyRequirement `json:"resident_key_requirement"` } func ToGetWebauthnResponse(webauthn *models.WebauthnConfig) GetWebauthnResponse { return GetWebauthnResponse{ - RelyingParty: ToGetRelyingPartyResponse(&webauthn.RelyingParty), - Timeout: webauthn.Timeout, - UserVerification: webauthn.UserVerification, + RelyingParty: ToGetRelyingPartyResponse(&webauthn.RelyingParty), + 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/intern/webauthn_session_data.go b/server/api/dto/intern/webauthn_session_data.go index 62e451f..414b7ac 100644 --- a/server/api/dto/intern/webauthn_session_data.go +++ b/server/api/dto/intern/webauthn_session_data.go @@ -33,7 +33,7 @@ func WebauthnSessionDataFromModel(data *models.WebauthnSessionData) *webauthn.Se } } -func WebauthnSessionDataToModel(data *webauthn.SessionData, tenantId uuid.UUID, operation models.Operation) *models.WebauthnSessionData { +func WebauthnSessionDataToModel(data *webauthn.SessionData, tenantId uuid.UUID, operation models.Operation, isDiscoverable bool) *models.WebauthnSessionData { id, _ := uuid.NewV4() now := time.Now() @@ -62,5 +62,6 @@ func WebauthnSessionDataToModel(data *webauthn.SessionData, tenantId uuid.UUID, AllowedCredentials: allowedCredentials, ExpiresAt: nulls.NewTime(data.Expires), TenantID: tenantId, + IsDiscoverable: isDiscoverable, } } diff --git a/server/api/dto/intern/webauthn_user.go b/server/api/dto/intern/webauthn_user.go index 8284f19..164180d 100644 --- a/server/api/dto/intern/webauthn_user.go +++ b/server/api/dto/intern/webauthn_user.go @@ -11,15 +11,17 @@ type WebauthnUser struct { Icon string DisplayName string WebauthnCredentials []models.WebauthnCredential + IsMfaUser bool } -func NewWebauthnUser(user models.WebauthnUser) *WebauthnUser { +func NewWebauthnUser(user models.WebauthnUser, isMfaUser bool) *WebauthnUser { return &WebauthnUser{ UserId: user.UserID, Name: user.Name, Icon: user.Icon, DisplayName: user.DisplayName, WebauthnCredentials: user.WebauthnCredentials, + IsMfaUser: isMfaUser, } } @@ -42,6 +44,11 @@ func (u *WebauthnUser) WebAuthnIcon() string { func (u *WebauthnUser) WebAuthnCredentials() []webauthn.Credential { var credentials []webauthn.Credential for _, credential := range u.WebauthnCredentials { + if !u.IsMfaUser && credential.IsMFA { + // Skip if request is not for an MFA cred but the credential is a mfa cred + continue + } + cred := credential c := WebauthnCredentialFromModel(&cred) credentials = append(credentials, *c) @@ -52,6 +59,11 @@ func (u *WebauthnUser) WebAuthnCredentials() []webauthn.Credential { func (u *WebauthnUser) FindCredentialById(credentialId string) *models.WebauthnCredential { for i := range u.WebauthnCredentials { + if !u.IsMfaUser && u.WebauthnCredentials[i].IsMFA { + // Skip if request is not for an MFA cred but the credential is a mfa cred + continue + } + if u.WebauthnCredentials[i].ID == credentialId { return &u.WebauthnCredentials[i] } diff --git a/server/api/dto/request/requests.go b/server/api/dto/request/requests.go index 0b15bdd..5b04f67 100644 --- a/server/api/dto/request/requests.go +++ b/server/api/dto/request/requests.go @@ -30,7 +30,7 @@ type UpdateCredentialsDto struct { } type WebauthnRequests interface { - InitRegistrationDto | InitTransactionDto + InitRegistrationDto | InitTransactionDto | InitLoginDto | InitMfaLoginDto } type InitRegistrationDto struct { @@ -91,3 +91,11 @@ func (initTransaction *InitTransactionDto) ToModel() (*models.Transaction, error UpdatedAt: now, }, nil } + +type InitLoginDto struct { + UserId *string `json:"user_id" validate:"omitempty,min=1"` +} + +type InitMfaLoginDto struct { + UserId *string `json:"user_id" validate:"required,min=1"` +} diff --git a/server/api/dto/response/responses.go b/server/api/dto/response/responses.go index d2b21e8..deac1ff 100644 --- a/server/api/dto/response/responses.go +++ b/server/api/dto/response/responses.go @@ -17,6 +17,7 @@ type CredentialDto struct { Transports []string `json:"transports"` BackupEligible bool `json:"backup_eligible"` BackupState bool `json:"backup_state"` + IsMFA bool `json:"is_mfa"` } type CredentialDtoList []CredentialDto @@ -37,5 +38,6 @@ func CredentialDtoFromModel(credential models.WebauthnCredential) CredentialDto Transports: credential.Transports.GetNames(), BackupEligible: credential.BackupEligible, BackupState: credential.BackupState, + IsMFA: credential.IsMFA, } } 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/credentials.go b/server/api/handler/credentials.go index 0efe5bd..1cb87bd 100644 --- a/server/api/handler/credentials.go +++ b/server/api/handler/credentials.go @@ -22,7 +22,7 @@ type credentialsHandler struct { } func NewCredentialsHandler(persister persistence.Persister) CredentialsHandler { - webauthnHandler := newWebAuthnHandler(persister) + webauthnHandler := newWebAuthnHandler(persister, false) return &credentialsHandler{ webauthnHandler, diff --git a/server/api/handler/login.go b/server/api/handler/login.go index c1d70ee..1f70085 100644 --- a/server/api/handler/login.go +++ b/server/api/handler/login.go @@ -5,6 +5,7 @@ import ( "github.com/go-webauthn/webauthn/protocol" "github.com/gobuffalo/pop/v6" "github.com/labstack/echo/v4" + "github.com/teamhanko/passkey-server/api/dto/request" "github.com/teamhanko/passkey-server/api/dto/response" "github.com/teamhanko/passkey-server/api/helper" "github.com/teamhanko/passkey-server/api/services" @@ -12,6 +13,7 @@ import ( "github.com/teamhanko/passkey-server/persistence" "github.com/teamhanko/passkey-server/persistence/models" "net/http" + "strings" ) type loginHandler struct { @@ -19,7 +21,7 @@ type loginHandler struct { } func NewLoginHandler(persister persistence.Persister) WebauthnHandler { - webauthnHandler := newWebAuthnHandler(persister) + webauthnHandler := newWebAuthnHandler(persister, false) return &loginHandler{ webauthnHandler, @@ -33,6 +35,23 @@ func (lh *loginHandler) Init(ctx echo.Context) error { return err } + dto, err := BindAndValidateRequest[request.InitLoginDto](ctx) + if err != nil { + return err + } + + apiKey := ctx.Request().Header.Get("apiKey") + if dto.UserId != nil { + if strings.TrimSpace(apiKey) == "" { + return echo.NewHTTPError(http.StatusUnauthorized, "api key is missing") + } + + err = helper.CheckApiKey(h.Config.Secrets, apiKey) + if err != nil { + return err + } + } + return lh.persister.GetConnection().Transaction(func(tx *pop.Connection) error { userPersister := lh.persister.GetWebauthnUserPersister(tx) sessionPersister := lh.persister.GetWebauthnSessionDataPersister(tx) @@ -41,19 +60,20 @@ func (lh *loginHandler) Init(ctx echo.Context) error { service := services.NewLoginService(services.WebauthnServiceCreateParams{ Ctx: ctx, Tenant: *h.Tenant, - WebauthnClient: *h.Webauthn, + WebauthnClient: *h.WebauthnClient, + UserId: dto.UserId, UserPersister: userPersister, SessionPersister: sessionPersister, CredentialPersister: credentialPersister, }) 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) @@ -84,7 +104,7 @@ func (lh *loginHandler) Finish(ctx echo.Context) error { service := services.NewLoginService(services.WebauthnServiceCreateParams{ Ctx: ctx, Tenant: *h.Tenant, - WebauthnClient: *h.Webauthn, + WebauthnClient: *h.WebauthnClient, UserPersister: userPersister, SessionPersister: sessionPersister, CredentialPersister: credentialPersister, diff --git a/server/api/handler/mfa_login.go b/server/api/handler/mfa_login.go new file mode 100644 index 0000000..f72bc05 --- /dev/null +++ b/server/api/handler/mfa_login.go @@ -0,0 +1,117 @@ +package handler + +import ( + "fmt" + "github.com/go-webauthn/webauthn/protocol" + "github.com/gobuffalo/pop/v6" + "github.com/labstack/echo/v4" + "github.com/teamhanko/passkey-server/api/dto/request" + "github.com/teamhanko/passkey-server/api/dto/response" + "github.com/teamhanko/passkey-server/api/helper" + "github.com/teamhanko/passkey-server/api/services" + auditlog "github.com/teamhanko/passkey-server/audit_log" + "github.com/teamhanko/passkey-server/persistence" + "github.com/teamhanko/passkey-server/persistence/models" + "net/http" +) + +type mfaLoginHandler struct { + *webauthnHandler +} + +func NewMfaLoginHandler(persister persistence.Persister) WebauthnHandler { + webauthnHandler := newWebAuthnHandler(persister, true) + + return &mfaLoginHandler{ + webauthnHandler, + } +} + +func (lh *mfaLoginHandler) Init(ctx echo.Context) error { + h, err := helper.GetMfaHandlerContext(ctx) + if err != nil { + ctx.Logger().Error(err) + return err + } + + dto, err := BindAndValidateRequest[request.InitMfaLoginDto](ctx) + if err != nil { + return err + } + + return lh.persister.GetConnection().Transaction(func(tx *pop.Connection) error { + userPersister := lh.persister.GetWebauthnUserPersister(tx) + sessionPersister := lh.persister.GetWebauthnSessionDataPersister(tx) + credentialPersister := lh.persister.GetWebauthnCredentialPersister(tx) + + service := services.NewLoginService(services.WebauthnServiceCreateParams{ + Ctx: ctx, + Tenant: *h.Tenant, + WebauthnClient: *h.WebauthnClient, + UserId: dto.UserId, + UserPersister: userPersister, + SessionPersister: sessionPersister, + CredentialPersister: credentialPersister, + UseMFA: true, + }) + + credentialAssertion, err := service.Initialize() + err = lh.handleError(h.AuditLog, models.AuditLogMfaAuthenticationInitFailed, tx, ctx, dto.UserId, nil, err) + if err != nil { + return err + } + + auditErr := h.AuditLog.CreateWithConnection(tx, models.AuditLogMfaAuthenticationInitSucceeded, dto.UserId, nil, nil) + if auditErr != nil { + ctx.Logger().Error(auditErr) + return fmt.Errorf(auditlog.CreationFailureFormat, auditErr) + } + + return ctx.JSON(http.StatusOK, credentialAssertion) + }) +} + +func (lh *mfaLoginHandler) Finish(ctx echo.Context) error { + parsedRequest, err := protocol.ParseCredentialRequestResponse(ctx.Request()) + if err != nil { + ctx.Logger().Error(err) + return echo.NewHTTPError(http.StatusBadRequest, "unable to finish login").SetInternal(err) + } + + h, err := helper.GetMfaHandlerContext(ctx) + if err != nil { + ctx.Logger().Error(err) + return err + } + + return lh.persister.Transaction(func(tx *pop.Connection) error { + userPersister := lh.persister.GetWebauthnUserPersister(tx) + sessionPersister := lh.persister.GetWebauthnSessionDataPersister(tx) + credentialPersister := lh.persister.GetWebauthnCredentialPersister(tx) + + service := services.NewLoginService(services.WebauthnServiceCreateParams{ + Ctx: ctx, + Tenant: *h.Tenant, + WebauthnClient: *h.WebauthnClient, + UserPersister: userPersister, + SessionPersister: sessionPersister, + CredentialPersister: credentialPersister, + Generator: h.Generator, + UseMFA: true, + }) + + token, userId, err := service.Finalize(parsedRequest) + err = lh.handleError(h.AuditLog, models.AuditLogMfaAuthenticationFinalFailed, tx, ctx, &userId, nil, err) + if err != nil { + return err + } + + auditErr := h.AuditLog.CreateWithConnection(tx, models.AuditLogMfaAuthenticationFinalSucceeded, &userId, nil, nil) + if auditErr != nil { + ctx.Logger().Error(auditErr) + return fmt.Errorf(auditlog.CreationFailureFormat, auditErr) + } + + return ctx.JSON(http.StatusOK, &response.TokenDto{Token: token}) + }) +} diff --git a/server/api/handler/registration.go b/server/api/handler/registration.go index d7ea2df..15fbc96 100644 --- a/server/api/handler/registration.go +++ b/server/api/handler/registration.go @@ -19,8 +19,8 @@ type registrationHandler struct { mapper.AuthenticatorMetadata } -func NewRegistrationHandler(persister persistence.Persister, authenticatorMetadata mapper.AuthenticatorMetadata) WebauthnHandler { - webauthnHandler := newWebAuthnHandler(persister) +func NewRegistrationHandler(persister persistence.Persister, authenticatorMetadata mapper.AuthenticatorMetadata, useMfaClient bool) WebauthnHandler { + webauthnHandler := newWebAuthnHandler(persister, useMfaClient) return ®istrationHandler{ webauthnHandler, @@ -36,7 +36,13 @@ func (r *registrationHandler) Init(ctx echo.Context) error { webauthnUser := dto.ToModel() - h, err := helper.GetHandlerContext(ctx) + var h *helper.WebauthnContext + if r.UseMFAClient { + h, err = helper.GetMfaHandlerContext(ctx) + } else { + h, err = helper.GetHandlerContext(ctx) + } + if err != nil { ctx.Logger().Error(err) return err @@ -50,22 +56,32 @@ func (r *registrationHandler) Init(ctx echo.Context) error { service := services.NewRegistrationService(services.WebauthnServiceCreateParams{ Ctx: ctx, Tenant: *h.Tenant, - WebauthnClient: *h.Webauthn, + WebauthnClient: *h.WebauthnClient, UserPersister: userPersister, SessionPersister: sessionPersister, CredentialPersister: credentialPersister, + UseMFA: r.UseMFAClient, }) credentialCreation, userId, err := service.Initialize(webauthnUser) - err = r.handleError(h.AuditLog, models.AuditLogWebAuthnRegistrationInitFailed, tx, ctx, &userId, nil, err) + + if r.UseMFAClient { + err = r.handleError(h.AuditLog, models.AuditLogMfaRegistrationInitFailed, tx, ctx, &userId, nil, err) + } else { + err = r.handleError(h.AuditLog, models.AuditLogWebAuthnRegistrationInitFailed, tx, ctx, &userId, nil, err) + } if err != nil { return err } - auditErr := h.AuditLog.CreateWithConnection(tx, models.AuditLogWebAuthnRegistrationInitSucceeded, &userId, nil, err) - if auditErr != nil { - ctx.Logger().Error(auditErr) - return auditErr + if r.UseMFAClient { + err = h.AuditLog.CreateWithConnection(tx, models.AuditLogMfaRegistrationInitSucceeded, &userId, nil, err) + } else { + err = h.AuditLog.CreateWithConnection(tx, models.AuditLogWebAuthnRegistrationInitSucceeded, &userId, nil, err) + } + if err != nil { + ctx.Logger().Error(err) + return err } return ctx.JSON(http.StatusOK, credentialCreation) @@ -79,8 +95,15 @@ func (r *registrationHandler) Finish(ctx echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "unable to parse credential creation response").SetInternal(err) } - h, err := helper.GetHandlerContext(ctx) - if err != nil { + var h *helper.WebauthnContext + var hErr error + if r.UseMFAClient { + h, hErr = helper.GetMfaHandlerContext(ctx) + } else { + h, hErr = helper.GetHandlerContext(ctx) + } + + if hErr != nil { ctx.Logger().Error(err) return err } @@ -93,21 +116,31 @@ func (r *registrationHandler) Finish(ctx echo.Context) error { service := services.NewRegistrationService(services.WebauthnServiceCreateParams{ Ctx: ctx, Tenant: *h.Tenant, - WebauthnClient: *h.Webauthn, + WebauthnClient: *h.WebauthnClient, UserPersister: userPersister, SessionPersister: sessionPersister, CredentialPersister: credentialPersister, Generator: h.Generator, AuthenticatorMetadata: r.AuthenticatorMetadata, + UseMFA: r.UseMFAClient, }) token, userId, err := service.Finalize(parsedRequest) - err = r.handleError(h.AuditLog, models.AuditLogWebAuthnRegistrationFinalFailed, tx, ctx, userId, nil, err) + + if r.UseMFAClient { + err = r.handleError(h.AuditLog, models.AuditLogMfaRegistrationFinalFailed, tx, ctx, userId, nil, err) + } else { + err = r.handleError(h.AuditLog, models.AuditLogWebAuthnRegistrationFinalFailed, tx, ctx, userId, nil, err) + } if err != nil { return err } - err = h.AuditLog.CreateWithConnection(tx, models.AuditLogWebAuthnRegistrationFinalSucceeded, userId, nil, nil) + if r.UseMFAClient { + err = h.AuditLog.CreateWithConnection(tx, models.AuditLogMfaRegistrationFinalSucceeded, userId, nil, nil) + } else { + err = h.AuditLog.CreateWithConnection(tx, models.AuditLogWebAuthnRegistrationFinalSucceeded, userId, nil, nil) + } if err != nil { ctx.Logger().Error(err) return err diff --git a/server/api/handler/transaction.go b/server/api/handler/transaction.go index 20eb59c..9253f9a 100644 --- a/server/api/handler/transaction.go +++ b/server/api/handler/transaction.go @@ -24,7 +24,7 @@ type transactionHandler struct { } func NewTransactionHandler(persister persistence.Persister) WebauthnHandler { - webauthnHandler := newWebAuthnHandler(persister) + webauthnHandler := newWebAuthnHandler(persister, false) return &transactionHandler{webauthnHandler} } @@ -56,7 +56,7 @@ func (t *transactionHandler) Init(ctx echo.Context) error { WebauthnServiceCreateParams: &services.WebauthnServiceCreateParams{ Ctx: ctx, Tenant: *h.Tenant, - WebauthnClient: *h.Webauthn, + WebauthnClient: *h.WebauthnClient, UserPersister: webauthnUserPersister, SessionPersister: sessionDataPersister, }, @@ -103,7 +103,7 @@ func (t *transactionHandler) Finish(ctx echo.Context) error { WebauthnServiceCreateParams: &services.WebauthnServiceCreateParams{ Ctx: ctx, Tenant: *h.Tenant, - WebauthnClient: *h.Webauthn, + WebauthnClient: *h.WebauthnClient, UserPersister: webauthnUserPersister, SessionPersister: sessionDataPersister, CredentialPersister: credentialPersister, diff --git a/server/api/handler/webauthn.go b/server/api/handler/webauthn.go index 751a000..fbbe79f 100644 --- a/server/api/handler/webauthn.go +++ b/server/api/handler/webauthn.go @@ -2,16 +2,12 @@ package handler import ( "errors" - "fmt" "github.com/gobuffalo/pop/v6" - "github.com/gofrs/uuid" "github.com/labstack/echo/v4" - "github.com/teamhanko/passkey-server/api/dto/intern" "github.com/teamhanko/passkey-server/api/dto/request" auditlog "github.com/teamhanko/passkey-server/audit_log" "github.com/teamhanko/passkey-server/persistence" "github.com/teamhanko/passkey-server/persistence/models" - "github.com/teamhanko/passkey-server/persistence/persisters" "net/http" ) @@ -21,12 +17,14 @@ type WebauthnHandler interface { } type webauthnHandler struct { - persister persistence.Persister + persister persistence.Persister + UseMFAClient bool } -func newWebAuthnHandler(persister persistence.Persister) *webauthnHandler { +func newWebAuthnHandler(persister persistence.Persister, useMFAClient bool) *webauthnHandler { return &webauthnHandler{ - persister: persister, + persister: persister, + UseMFAClient: useMFAClient, } } @@ -49,29 +47,6 @@ func (w *webauthnHandler) handleError(logger auditlog.Logger, logType models.Aud return nil } -func (w *webauthnHandler) getWebauthnUserByUserHandle(userHandle string, tenantId uuid.UUID, persister persisters.WebauthnUserPersister) (*intern.WebauthnUser, error) { - user, err := persister.GetByUserId(userHandle, tenantId) - if err != nil { - return nil, fmt.Errorf("failed to get user: %w", err) - } - - if user == nil { - return nil, fmt.Errorf("user not found") - } - - return intern.NewWebauthnUser(*user), nil -} - -func (w *webauthnHandler) convertUserHandle(userHandle []byte) string { - userId := string(userHandle) - userUuid, err := uuid.FromBytes(userHandle) - if err == nil { - userId = userUuid.String() - } - - return userId -} - func BindAndValidateRequest[I request.CredentialRequests | request.WebauthnRequests](ctx echo.Context) (*I, error) { var requestDto I err := ctx.Bind(&requestDto) diff --git a/server/api/helper/context_helper.go b/server/api/helper/context_helper.go index 2611c16..3a3e506 100644 --- a/server/api/helper/context_helper.go +++ b/server/api/helper/context_helper.go @@ -10,24 +10,30 @@ import ( ) type WebauthnContext struct { - Tenant *models.Tenant - Webauthn *webauthn.WebAuthn - Config models.Config - AuditLog auditlog.Logger - Generator jwt.Generator + Tenant *models.Tenant + WebauthnClient *webauthn.WebAuthn + Config models.Config + AuditLog auditlog.Logger + Generator jwt.Generator } -func GetHandlerContext(ctx echo.Context) (*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") } tenant := ctxTenant.(*models.Tenant) - ctxWebautn := ctx.Get("webauthn_client") + webauthnClientCtx := ctx.Get(webauthnClientKey) var webauthnClient *webauthn.WebAuthn - if ctxWebautn != nil { - webauthnClient = ctxWebautn.(*webauthn.WebAuthn) + if webauthnClientCtx != nil { + webauthnClient = webauthnClientCtx.(*webauthn.WebAuthn) + } + + jwtGeneratorCtx := ctx.Get("jwt_generator") + var jwtGenerator jwt.Generator + if jwtGeneratorCtx != nil { + jwtGenerator = jwtGeneratorCtx.(jwt.Generator) } ctxAuditLog := ctx.Get("audit_logger") @@ -36,17 +42,19 @@ func GetHandlerContext(ctx echo.Context) (*WebauthnContext, error) { auditLogger = ctxAuditLog.(auditlog.Logger) } - ctxGenerator := ctx.Get("jwt_generator") - var generator jwt.Generator - if ctxGenerator != nil { - generator = ctxGenerator.(jwt.Generator) - } - return &WebauthnContext{ - Tenant: tenant, - Webauthn: webauthnClient, - Config: tenant.Config, - AuditLog: auditLogger, - Generator: generator, + Tenant: tenant, + WebauthnClient: webauthnClient, + Config: tenant.Config, + AuditLog: auditLogger, + Generator: jwtGenerator, }, nil } + +func GetHandlerContext(ctx echo.Context) (*WebauthnContext, error) { + return getContext(ctx, "webauthn_client") +} + +func GetMfaHandlerContext(ctx echo.Context) (*WebauthnContext, error) { + return getContext(ctx, "mfa_client") +} diff --git a/server/api/helper/tenant_helper.go b/server/api/helper/tenant_helper.go index 6a045df..ae7a643 100644 --- a/server/api/helper/tenant_helper.go +++ b/server/api/helper/tenant_helper.go @@ -2,27 +2,34 @@ package helper import ( "fmt" - "github.com/gofrs/uuid" "github.com/labstack/echo/v4" "github.com/teamhanko/passkey-server/persistence/models" - "github.com/teamhanko/passkey-server/persistence/persisters" "net/http" + "strings" ) -func FindTenantByIdString(id string, tenantPersister persisters.TenantPersister) (*models.Tenant, error) { - tenantId, err := uuid.FromString(id) - if err != nil { - return nil, echo.NewHTTPError(http.StatusBadRequest, "unable to parse tenant id").SetInternal(err) +func CheckApiKey(keys []models.Secret, apiKey string) error { + var foundKey *models.Secret + for _, key := range keys { + if strings.TrimSpace(apiKey) == key.Key { + foundKey = &key + break + } } - tenant, err := tenantPersister.Get(tenantId) - if err != nil { - return nil, err + if foundKey == nil { + title := "The api key is invalid" + details := "api keys needs to be an apiKey Header and 32 byte long" + + return echo.NewHTTPError(http.StatusUnauthorized, title).SetInternal(fmt.Errorf(details)) } - if tenant == nil { - return nil, echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("no tenant with ID '%s' was found", id)) + if !foundKey.IsAPISecret { + title := "The api key is invalid" + details := "provided key is not an api key" + + return echo.NewHTTPError(http.StatusUnauthorized, title).SetInternal(fmt.Errorf(details)) } - return tenant, nil + return nil } diff --git a/server/api/middleware/api_key.go b/server/api/middleware/api_key.go index 0dbcb09..f4eff12 100644 --- a/server/api/middleware/api_key.go +++ b/server/api/middleware/api_key.go @@ -1,11 +1,10 @@ package middleware import ( - "fmt" "github.com/labstack/echo/v4" + "github.com/teamhanko/passkey-server/api/helper" "github.com/teamhanko/passkey-server/persistence/models" "net/http" - "strings" ) func ApiKeyMiddleware() echo.MiddlewareFunc { @@ -19,15 +18,7 @@ func ApiKeyMiddleware() echo.MiddlewareFunc { return echo.NewHTTPError(http.StatusNotFound, "tenant not found") } - var foundKey *models.Secret - for _, key := range tenant.Config.Secrets { - if strings.TrimSpace(apiKey) == key.Key { - foundKey = &key - break - } - } - - err := createApiKeyError(foundKey) + err := helper.CheckApiKey(tenant.Config.Secrets, apiKey) if err != nil { return err } @@ -36,21 +27,3 @@ func ApiKeyMiddleware() echo.MiddlewareFunc { } } } - -func createApiKeyError(key *models.Secret) error { - if key == nil { - title := "The api key is invalid" - details := "api keys needs to be an apiKey Header and 32 byte long" - - return echo.NewHTTPError(http.StatusUnauthorized, title).SetInternal(fmt.Errorf(details)) - } - - if !key.IsAPISecret { - title := "The api key is invalid" - details := "provided key is not an api key" - - return echo.NewHTTPError(http.StatusUnauthorized, title).SetInternal(fmt.Errorf(details)) - } - - return nil -} diff --git a/server/api/middleware/jwk.go b/server/api/middleware/jwk.go index 883a22e..c334190 100644 --- a/server/api/middleware/jwk.go +++ b/server/api/middleware/jwk.go @@ -47,7 +47,6 @@ func instantiateJwtGenerator(ctx echo.Context, keys []string, tenant models.Tena generator, err := jwt.NewGenerator(&tenant.Config.WebauthnConfig, jwkManager, tenant.ID) if err != nil { - ctx.Logger().Error(err) return err } diff --git a/server/api/middleware/webauthn.go b/server/api/middleware/webauthn.go index 63e562f..b129f35 100644 --- a/server/api/middleware/webauthn.go +++ b/server/api/middleware/webauthn.go @@ -1,61 +1,166 @@ package middleware import ( + "fmt" "github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/webauthn" + "github.com/gofrs/uuid" "github.com/labstack/echo/v4" + "github.com/teamhanko/passkey-server/persistence" "github.com/teamhanko/passkey-server/persistence/models" "net/http" "time" ) -func WebauthnMiddleware() echo.MiddlewareFunc { +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 { tenant := ctx.Get("tenant").(*models.Tenant) if tenant == nil { - ctx.Logger().Errorf("tenant for webauthn middleware net found") + ctx.Logger().Errorf("tenant for webauthn middleware not found") return echo.NewHTTPError(http.StatusNotFound, "tenant not found") } cfg := tenant.Config - var origins []string - for _, origin := range cfg.WebauthnConfig.RelyingParty.Origins { - origins = append(origins, origin.Origin) - } - - f := false - webauthnClient, err := webauthn.New(&webauthn.Config{ - RPDisplayName: cfg.WebauthnConfig.RelyingParty.DisplayName, - RPID: cfg.WebauthnConfig.RelyingParty.RPId, - RPOrigins: origins, - AttestationPreference: protocol.PreferNoAttestation, - AuthenticatorSelection: protocol.AuthenticatorSelection{ - RequireResidentKey: &f, - ResidentKey: protocol.ResidentKeyRequirementDiscouraged, - UserVerification: protocol.VerificationRequired, - }, - Debug: false, - Timeouts: webauthn.TimeoutsConfig{ - Login: webauthn.TimeoutConfig{ - Timeout: time.Duration(cfg.WebauthnConfig.Timeout) * time.Millisecond, - Enforce: true, - }, - Registration: webauthn.TimeoutConfig{ - Timeout: time.Duration(cfg.WebauthnConfig.Timeout) * time.Millisecond, - Enforce: true, - }, - }, - }) - + err := setWebauthnClientCtx(ctx, cfg, persister) if err != nil { ctx.Logger().Error(err) return err } - ctx.Set("webauthn_client", webauthnClient) return next(ctx) } } } + +func setWebauthnClientCtx(ctx echo.Context, cfg models.Config, persister persistence.Persister) error { + var passkeyConfig models.WebauthnConfig + + err := createPasskeyClient(ctx, cfg.WebauthnConfig) + if err != nil { + ctx.Logger().Error(err) + return err + } + + if cfg.MfaConfig == nil { + cfg.MfaConfig, err = createDefaultMfaConfig(persister, passkeyConfig) + 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 createClient(ctx echo.Context, ctxKey string, params clientParams) error { + var origins []string + for _, origin := range params.RP.Origins { + origins = append(origins, origin.Origin) + } + + requireResidentKey := params.ResidentKeyRequirement == protocol.ResidentKeyRequirementRequired + + authenticatorSelection := protocol.AuthenticatorSelection{ + RequireResidentKey: &requireResidentKey, + ResidentKey: params.ResidentKeyRequirement, + UserVerification: params.UserVerification, + } + + if params.Attachment != nil { + authenticatorSelection.AuthenticatorAttachment = *params.Attachment + } + + webauthnClient, err := webauthn.New(&webauthn.Config{ + RPDisplayName: params.RP.DisplayName, + RPID: params.RP.RPId, + RPOrigins: origins, + AttestationPreference: params.AttestationPreference, + AuthenticatorSelection: authenticatorSelection, + Debug: false, + Timeouts: webauthn.TimeoutsConfig{ + Login: webauthn.TimeoutConfig{ + Timeout: time.Duration(params.Timeout) * time.Millisecond, + Enforce: true, + }, + Registration: webauthn.TimeoutConfig{ + Timeout: time.Duration(params.Timeout) * time.Millisecond, + Enforce: true, + }, + }, + }) + + if err != nil { + return err + } + + ctx.Set(ctxKey, webauthnClient) + + return nil +} + +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() + + mfaConfig := &models.MfaConfig{ + ID: configId, + ConfigID: passkeyConfig.ConfigID, + Timeout: passkeyConfig.Timeout, + CreatedAt: now, + UpdatedAt: now, + UserVerification: protocol.VerificationPreferred, + Attachment: protocol.CrossPlatform, + AttestationPreference: protocol.PreferDirectAttestation, + ResidentKeyRequirement: protocol.ResidentKeyRequirementDiscouraged, + } + + err := persister.GetMFAConfigPersister(nil).Create(mfaConfig) + if err != nil { + return nil, fmt.Errorf("unable to create default mfa config: %w", err) + } + + return mfaConfig, nil +} diff --git a/server/api/router/main.go b/server/api/router/main.go index 573936a..5dfb864 100644 --- a/server/api/router/main.go +++ b/server/api/router/main.go @@ -1,6 +1,7 @@ package router import ( + "fmt" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "github.com/teamhanko/passkey-server/api/handler" @@ -46,9 +47,12 @@ func NewMainRouter(cfg *config.Config, persister persistence.Persister, authenti RouteWellKnown(tenantGroup) RouteCredentials(tenantGroup, persister) - RouteRegistration(tenantGroup, persister, authenticatorMetadata) - RouteLogin(tenantGroup, persister) - RouteTransaction(tenantGroup, persister) + + webauthnGroup := tenantGroup.Group("", passkeyMiddleware.WebauthnMiddleware(persister)) + RouteRegistration(webauthnGroup, persister, authenticatorMetadata) + RouteLogin(webauthnGroup, persister) + RouteTransaction(webauthnGroup, persister) + RouteMfa(webauthnGroup, persister, authenticatorMetadata) return main } @@ -80,9 +84,9 @@ func RouteCredentials(parent *echo.Group, persister persistence.Persister) { } func RouteRegistration(parent *echo.Group, persister persistence.Persister, authenticatorMetadata mapper.AuthenticatorMetadata) { - registrationHandler := handler.NewRegistrationHandler(persister, authenticatorMetadata) + registrationHandler := handler.NewRegistrationHandler(persister, authenticatorMetadata, false) - group := parent.Group("/registration", passkeyMiddleware.WebauthnMiddleware()) + group := parent.Group("/registration") group.POST(InitEndpoint, registrationHandler.Init, passkeyMiddleware.ApiKeyMiddleware()) group.POST(FinishEndpoint, registrationHandler.Finish) } @@ -90,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()) + group := parent.Group("/login") group.POST(InitEndpoint, loginHandler.Init) group.POST(FinishEndpoint, loginHandler.Finish) } @@ -98,7 +102,18 @@ 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(), passkeyMiddleware.ApiKeyMiddleware()) + group := parent.Group("/transaction", passkeyMiddleware.ApiKeyMiddleware()) group.POST(InitEndpoint, transactionHandler.Init) group.POST(FinishEndpoint, transactionHandler.Finish) } + +func RouteMfa(parent *echo.Group, persister persistence.Persister, authenticatorMetadata mapper.AuthenticatorMetadata) { + mfaRegistrationHandler := handler.NewRegistrationHandler(persister, authenticatorMetadata, true) + mfaLoginHandler := handler.NewMfaLoginHandler(persister) + + group := parent.Group("/mfa", passkeyMiddleware.ApiKeyMiddleware()) + group.POST(fmt.Sprintf("/registration%s", InitEndpoint), mfaRegistrationHandler.Init) + group.POST(fmt.Sprintf("/registration%s", FinishEndpoint), mfaRegistrationHandler.Finish) + group.POST(fmt.Sprintf("/login%s", InitEndpoint), mfaLoginHandler.Init) + group.POST(fmt.Sprintf("/login%s", FinishEndpoint), mfaLoginHandler.Finish) +} diff --git a/server/api/services/admin/tenant_service.go b/server/api/services/admin/tenant_service.go index 4f5bb23..722ce44 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,8 +92,15 @@ func (ts *tenantService) Create(dto request.CreateTenantDto) (*response.CreateTe tenantModel := dto.ToModel() configModel := dto.Config.ToModel(tenantModel) corsModel := dto.Config.Cors.ToModel(configModel) - webauthnConfigModel := dto.Config.Webauthn.ToModel(configModel) - relyingPartyModel := dto.Config.Webauthn.RelyingParty.ToModel(webauthnConfigModel) + passkeyConfigModel := dto.Config.Passkey.ToModel(configModel) + relyingPartyModel := dto.Config.Passkey.RelyingParty.ToModel(passkeyConfigModel) + + var mfaConfigModel models.MfaConfig + if dto.Config.Mfa == nil { + mfaConfigModel = dto.Config.Passkey.ToMfaModel(configModel) + } else { + mfaConfigModel = dto.Config.Mfa.ToModel(configModel) + } err := ts.tenantPersister.Create(&tenantModel) if err != nil { @@ -101,8 +111,9 @@ func (ts *tenantService) Create(dto request.CreateTenantDto) (*response.CreateTe err = ts.persistConfig( &configModel, &corsModel, - &webauthnConfigModel, + &passkeyConfigModel, &relyingPartyModel, + &mfaConfigModel, ) var apiSecretModel *models.Secret = nil @@ -163,7 +174,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) 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 @@ -184,6 +195,11 @@ func (ts *tenantService) persistConfig(config *models.Config, cors *models.Cors, return err } + err = ts.mfaConfigPersister.Create(mfaConfig) + if err != nil { + return err + } + err = ts.auditConfigPersister.Create(&config.AuditLogConfig) if err != nil { return err @@ -209,10 +225,24 @@ 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.ToModel(newConfig) - relyingPartyModel := dto.Webauthn.RelyingParty.ToModel(webauthnConfigModel) + webauthnConfigModel := dto.Passkey.ToModel(newConfig) + relyingPartyModel := dto.Passkey.RelyingParty.ToModel(webauthnConfigModel) + + var mfaConfigModel models.MfaConfig + if dto.Mfa == nil { + mfaConfigModel = dto.Passkey.ToMfaModel(newConfig) + } else { + mfaConfigModel = dto.Mfa.ToModel(newConfig) + } + + err := ts.persistConfig( + &newConfig, + &corsModel, + &webauthnConfigModel, + &relyingPartyModel, + &mfaConfigModel, + ) - err := ts.persistConfig(&newConfig, &corsModel, &webauthnConfigModel, &relyingPartyModel) if err != nil { ts.logger.Error(err) return err diff --git a/server/api/services/login_service.go b/server/api/services/login_service.go index 5ff4425..a8703c9 100644 --- a/server/api/services/login_service.go +++ b/server/api/services/login_service.go @@ -18,38 +18,66 @@ type LoginService interface { type loginService struct { WebauthnService + userId *string } 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, + useMFA: params.UseMFA, }, - webauthnClient: params.WebauthnClient, - generator: params.Generator, - - userPersister: params.UserPersister, - sessionDataPersister: params.SessionPersister, - }} + params.UserId, + } } func (ls *loginService) Initialize() (*protocol.CredentialAssertion, error) { - credentialAssertion, sessionData, err := ls.webauthnClient.BeginDiscoverableLogin( - webauthn.WithUserVerification(ls.tenant.Config.WebauthnConfig.UserVerification), - ) - - if err != nil { - ls.logger.Error(err) - return nil, echo.NewHTTPError( - http.StatusInternalServerError, - fmt.Errorf("failed to create webauthn assertion options for discoverable login: %w", err), - ) + var credentialAssertion *protocol.CredentialAssertion + var sessionData *webauthn.SessionData + var err error + isDiscoverable := true + + if ls.userId != nil { + user, err := ls.getWebauthnUserByUserHandle(*ls.userId) + if err != nil { + ls.logger.Error(err) + + return nil, echo.NewHTTPError(http.StatusNotFound, err) + } + + credentialAssertion, sessionData, err = ls.webauthnClient.BeginLogin(user) + if err != nil { + ls.logger.Error(err) + return nil, echo.NewHTTPError( + http.StatusInternalServerError, + fmt.Errorf("failed to create webauthn assertion options for login: %w", err), + ) + } + + isDiscoverable = false + } else { + credentialAssertion, sessionData, err = ls.webauthnClient.BeginDiscoverableLogin() + + if err != nil { + ls.logger.Error(err) + return nil, echo.NewHTTPError( + http.StatusInternalServerError, + fmt.Errorf("failed to create webauthn assertion options for discoverable login: %w", err), + ) + } } - err = ls.sessionDataPersister.Create(*intern.WebauthnSessionDataToModel(sessionData, ls.tenant.ID, models.WebauthnOperationAuthentication)) + err = ls.sessionDataPersister.Create(*intern.WebauthnSessionDataToModel(sessionData, ls.tenant.ID, models.WebauthnOperationAuthentication, isDiscoverable)) if err != nil { ls.logger.Error(err) return nil, err @@ -67,28 +95,43 @@ func (ls *loginService) Initialize() (*protocol.CredentialAssertion, error) { func (ls *loginService) Finalize(req *protocol.ParsedCredentialAssertionData) (string, string, error) { // backward compatibility userHandle := ls.convertUserHandle(req.Response.UserHandle) - req.Response.UserHandle = []byte(userHandle) - sessionData, dbSessionData, err := ls.getSessionByChallenge(req.Response.CollectedClientData.Challenge, models.WebauthnOperationAuthentication) if err != nil { return "", userHandle, echo.NewHTTPError(http.StatusUnauthorized, "failed to get session data").SetInternal(err) } + // when using MFA or session was initialized for a non-discoverable cred + if ls.useMFA || !dbSessionData.IsDiscoverable { + userHandle = ls.convertUserHandle(sessionData.UserID) + } + + req.Response.UserHandle = []byte(userHandle) webauthnUser, err := ls.getWebauthnUserByUserHandle(userHandle) if err != nil { return "", userHandle, echo.NewHTTPError(http.StatusUnauthorized, "failed to get user handle").SetInternal(err) } - credential, err := ls.webauthnClient.ValidateDiscoverableLogin(func(rawID, userHandle []byte) (user webauthn.User, err error) { - return webauthnUser, nil - }, *sessionData, req) + var credential *webauthn.Credential + if dbSessionData.IsDiscoverable { + credential, err = ls.webauthnClient.ValidateDiscoverableLogin(func(rawID, userHandle []byte) (user webauthn.User, err error) { + return webauthnUser, nil + }, *sessionData, req) + } else { + credential, err = ls.webauthnClient.ValidateLogin(webauthnUser, *sessionData, req) + } + if err != nil { ls.logger.Error(err) return "", userHandle, echo.NewHTTPError(http.StatusUnauthorized, "failed to validate assertion").SetInternal(err) } 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 credentials 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 fea24bf..2090fda 100644 --- a/server/api/services/registration_service.go +++ b/server/api/services/registration_service.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "github.com/go-webauthn/webauthn/protocol" - "github.com/go-webauthn/webauthn/webauthn" "github.com/labstack/echo/v4" "github.com/teamhanko/passkey-server/api/dto/intern" "github.com/teamhanko/passkey-server/mapper" @@ -39,6 +38,8 @@ func NewRegistrationService(params WebauthnServiceCreateParams) RegistrationServ userPersister: params.UserPersister, sessionDataPersister: params.SessionPersister, + + useMFA: params.UseMFA, }, params.AuthenticatorMetadata, } @@ -50,21 +51,14 @@ func (rs *registrationService) Initialize(user *models.WebauthnUser) (*protocol. return nil, user.UserID, err } - t := true credentialCreation, sessionData, err := rs.webauthnClient.BeginRegistration( internalUser, - webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{ - RequireResidentKey: &t, - ResidentKey: protocol.ResidentKeyRequirementRequired, - UserVerification: rs.tenant.Config.WebauthnConfig.UserVerification, - }), - webauthn.WithConveyancePreference(protocol.PreferNoAttestation), ) if err != nil { return nil, internalUser.UserId, err } - err = rs.sessionDataPersister.Create(*intern.WebauthnSessionDataToModel(sessionData, rs.tenant.ID, models.WebauthnOperationRegistration)) + err = rs.sessionDataPersister.Create(*intern.WebauthnSessionDataToModel(sessionData, rs.tenant.ID, models.WebauthnOperationRegistration, false)) if err != nil { return nil, internalUser.UserId, err } @@ -93,7 +87,7 @@ func (rs *registrationService) createOrUpdateUser(user models.WebauthnUser) (*in return nil, err } - return intern.NewWebauthnUser(user), err + return intern.NewWebauthnUser(user, rs.useMFA), err } func (rs *registrationService) getDbUser(userId string) (*models.WebauthnUser, error) { @@ -143,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) @@ -176,7 +170,7 @@ func (rs *registrationService) geDbtUserAndSessionFromRequest(req *protocol.Pars } func (rs *registrationService) createCredential(dbUser *models.WebauthnUser, session *models.WebauthnSessionData, req *protocol.ParsedCredentialCreationData) (*models.WebauthnCredential, error) { - credential, err := rs.webauthnClient.CreateCredential(intern.NewWebauthnUser(*dbUser), *intern.WebauthnSessionDataFromModel(session), req) + credential, err := rs.webauthnClient.CreateCredential(intern.NewWebauthnUser(*dbUser, rs.useMFA), *intern.WebauthnSessionDataFromModel(session), req) if err != nil { rs.logger.Error(err) @@ -206,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 84e5d86..04bd351 100644 --- a/server/api/services/transaction_service.go +++ b/server/api/services/transaction_service.go @@ -44,6 +44,8 @@ func NewTransactionService(params TransactionServiceCreateParams) TransactionSer userPersister: params.UserPersister, sessionDataPersister: params.SessionPersister, + + useMFA: params.UseMFA, }, transactionPersister: params.TransactionPersister, } @@ -69,8 +71,7 @@ func (ts *transactionService) Initialize(userId string, transaction *models.Tran } credentialAssertion, sessionData, err := ts.webauthnClient.BeginLogin( - intern.NewWebauthnUser(*webauthnUser), - webauthn.WithUserVerification(ts.tenant.Config.WebauthnConfig.UserVerification), + intern.NewWebauthnUser(*webauthnUser, ts.useMFA), ts.withTransaction(transaction.Identifier, transaction.Data), ) if err != nil { @@ -92,7 +93,7 @@ func (ts *transactionService) Initialize(userId string, transaction *models.Tran return nil, err } - err = ts.sessionDataPersister.Create(*intern.WebauthnSessionDataToModel(sessionData, ts.tenant.ID, models.WebauthnOperationTransaction)) + err = ts.sessionDataPersister.Create(*intern.WebauthnSessionDataToModel(sessionData, ts.tenant.ID, models.WebauthnOperationTransaction, false)) if err != nil { ts.logger.Error(err) return nil, err @@ -148,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 credentials are not usable for transactions") + } + + 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 2872bcd..b16c6a3 100644 --- a/server/api/services/webauthn_service.go +++ b/server/api/services/webauthn_service.go @@ -23,6 +23,8 @@ type WebauthnService struct { userPersister persisters.WebauthnUserPersister sessionDataPersister persisters.WebauthnSessionDataPersister + + useMFA bool } type WebauthnServiceCreateParams struct { @@ -31,6 +33,8 @@ type WebauthnServiceCreateParams struct { WebauthnClient webauthn.WebAuthn Generator jwt.Generator AuthenticatorMetadata mapper.AuthenticatorMetadata + UserId *string + UseMFA bool UserPersister persisters.WebauthnUserPersister SessionPersister persisters.WebauthnSessionDataPersister @@ -73,7 +77,7 @@ func (ws *WebauthnService) getWebauthnUserByUserHandle(userHandle string) (*inte return nil, fmt.Errorf("user not found") } - return intern.NewWebauthnUser(*user), nil + return intern.NewWebauthnUser(*user, ws.useMFA), nil } func (ws *WebauthnService) createUserCredentialToken(userId string, credentialId string) (string, error) { @@ -86,15 +90,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/20240220140353_add_webauthn_options.down.fizz b/server/persistence/migrations/20240220140353_add_webauthn_options.down.fizz new file mode 100644 index 0000000..63eb4bb --- /dev/null +++ b/server/persistence/migrations/20240220140353_add_webauthn_options.down.fizz @@ -0,0 +1,3 @@ +drop_column("webauthn_configs", "resident_key_requirement") +drop_column("webauthn_configs", "attestation_preference") +drop_column("webauthn_configs", "attachment") diff --git a/server/persistence/migrations/20240220140353_add_webauthn_options.up.fizz b/server/persistence/migrations/20240220140353_add_webauthn_options.up.fizz new file mode 100644 index 0000000..8ccbd09 --- /dev/null +++ b/server/persistence/migrations/20240220140353_add_webauthn_options.up.fizz @@ -0,0 +1,3 @@ +add_column("webauthn_configs", "attachment", "string", { "null": true }) +add_column("webauthn_configs", "attestation_preference", "string", { default: "none" }) +add_column("webauthn_configs", "resident_key_requirement", "string", { default: "required" }) diff --git a/server/persistence/migrations/20240226103525_add_discoverable_to_sessiondata.down.fizz b/server/persistence/migrations/20240226103525_add_discoverable_to_sessiondata.down.fizz new file mode 100644 index 0000000..b86bb84 --- /dev/null +++ b/server/persistence/migrations/20240226103525_add_discoverable_to_sessiondata.down.fizz @@ -0,0 +1 @@ +drop_column("webauthn_session_data", "is_discoverable") diff --git a/server/persistence/migrations/20240226103525_add_discoverable_to_sessiondata.up.fizz b/server/persistence/migrations/20240226103525_add_discoverable_to_sessiondata.up.fizz new file mode 100644 index 0000000..b3e5409 --- /dev/null +++ b/server/persistence/migrations/20240226103525_add_discoverable_to_sessiondata.up.fizz @@ -0,0 +1 @@ +add_column("webauthn_session_data", "is_discoverable", "bool", { "default": true }) 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..de04e6f --- /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", { default: 60000 }) + t.Column("user_verification", "string", { default: "discouraged" }) + t.Column("attachment", "string", { default: "cross-platform" }) + t.Column("attestation_preference", "string", { "default": "direct" }) + t.Column("resident_key_requirement", "string", { default: "discouraged" }) + t.Column("config_id", "uuid", {}) + + t.ForeignKey("config_id", {"configs": ["id"]}, {"on_delete": "cascade", "on_update": "cascade"}) + + t.Timestamps() +} diff --git a/server/persistence/models/audit_log.go b/server/persistence/models/audit_log.go index e0fc3fe..5944fea 100644 --- a/server/persistence/models/audit_log.go +++ b/server/persistence/models/audit_log.go @@ -44,4 +44,14 @@ var ( AuditLogWebAuthnTransactionFinalFailed AuditLogType = "webauthn_transaction_final_failed" AuditLogWebAuthnTransactionFinalSucceeded AuditLogType = "webauthn_transaction_final_succeeded" + + AuditLogMfaRegistrationInitFailed AuditLogType = "mfa_registration_init_failed" + AuditLogMfaRegistrationInitSucceeded AuditLogType = "mfa_registration_init_succeeded" + AuditLogMfaRegistrationFinalSucceeded AuditLogType = "mfa_registration_final_succeeded" + AuditLogMfaRegistrationFinalFailed AuditLogType = "mfa_registration_final_failed" + + AuditLogMfaAuthenticationInitSucceeded AuditLogType = "mfa_authentication_init_succeeded" + AuditLogMfaAuthenticationInitFailed AuditLogType = "mfa_authentication_init_failed" + AuditLogMfaAuthenticationFinalSucceeded AuditLogType = "mfa_authentication_final_succeeded" + AuditLogMfaAuthenticationFinalFailed AuditLogType = "mfa_authentication_final_failed" ) diff --git a/server/persistence/models/config.go b/server/persistence/models/config.go index 3a82b4d..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" ) @@ -16,6 +17,7 @@ type Config struct { Tenant *Tenant `json:"tenant,omitempty" belongs_to:"tenant"` 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"` @@ -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 3a71b7b..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" @@ -12,23 +13,28 @@ import ( // WebauthnConfig is used by pop to map your webauthn_configs database table to your go code. type WebauthnConfig struct { - ID uuid.UUID `json:"id" db:"id"` - Config *Config `json:"config" belongs_to:"configs"` - ConfigID uuid.UUID `json:"config_id" db:"config_id"` - RelyingParty RelyingParty `json:"relying_party" has_one:"relying_parties"` - 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"` + ID uuid.UUID `json:"id" db:"id"` + Config *Config `json:"config" belongs_to:"configs"` + ConfigID uuid.UUID `json:"config_id" db:"config_id"` + RelyingParty RelyingParty `json:"relying_party" has_one:"relying_parties"` + 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 (webauthn *WebauthnConfig) Validate(tx *pop.Connection) (*validate.Errors, error) { +func (webauthn *WebauthnConfig) Validate(_ *pop.Connection) (*validate.Errors, error) { return validate.Validate( &validators.UUIDIsPresent{Name: "ID", Field: webauthn.ID}, &validators.IntIsPresent{Name: "Timeout", Field: webauthn.Timeout}, &validators.StringIsPresent{Name: "UserVerification", Field: string(webauthn.UserVerification)}, + &validators.StringIsPresent{Name: "AttestationPreference", Field: string(webauthn.AttestationPreference)}, + &validators.StringIsPresent{Name: "ResidentKeyRequirement", Field: string(webauthn.ResidentKeyRequirement)}, &validators.TimeIsPresent{Name: "UpdatedAt", Field: webauthn.UpdatedAt}, &validators.TimeIsPresent{Name: "CreatedAt", Field: webauthn.CreatedAt}, ), nil 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/models/webauthn_session_data.go b/server/persistence/models/webauthn_session_data.go index 2f73368..669e61b 100644 --- a/server/persistence/models/webauthn_session_data.go +++ b/server/persistence/models/webauthn_session_data.go @@ -29,13 +29,14 @@ type WebauthnSessionData struct { Operation Operation `db:"operation"` AllowedCredentials []WebauthnSessionDataAllowedCredential `has_many:"webauthn_session_data_allowed_credentials"` ExpiresAt nulls.Time `db:"expires_at"` + IsDiscoverable bool `db:"is_discoverable"` TenantID uuid.UUID `db:"tenant_id"` Tenant *Tenant `belongs_to:"tenants"` } // Validate gets run every time you call a "pop.Validate*" (pop.ValidateAndSave, pop.ValidateAndCreate, pop.ValidateAndUpdate) method. -func (sd *WebauthnSessionData) Validate(tx *pop.Connection) (*validate.Errors, error) { +func (sd *WebauthnSessionData) Validate(_ *pop.Connection) (*validate.Errors, error) { return validate.Validate( &validators.UUIDIsPresent{Name: "ID", Field: sd.ID}, &validators.StringIsPresent{Name: "Challenge", Field: sd.Challenge}, 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 7a121e0..4f9e4fa 100644 --- a/server/persistence/persisters/tenant_persister.go +++ b/server/persistence/persisters/tenant_persister.go @@ -44,6 +44,7 @@ func (t tenantPersister) Get(tenantId uuid.UUID) (*models.Tenant, error) { err := t.database.Eager( "Config.Secrets", "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 b12b7fa..2479417 100644 --- a/spec/passkey-server-admin.yaml +++ b/spec/passkey-server-admin.yaml @@ -1,6 +1,6 @@ openapi: 3.1.0 info: - version: '1.1' + version: '1.2' title: passkey-server-admin summary: Admin API for Passkey Server description: 'ADmin API for Hanko Passkey Server. Allows creation and configiration of tenants and api keys, ' @@ -580,6 +580,7 @@ components: maxLength: 36 api_key: $ref: '#/components/schemas/secret' + description: omitted when `create_api_key`is omitted or set to `false` required: - id secret: @@ -626,6 +627,8 @@ components: $ref: '#/components/schemas/cors' webauthn: $ref: '#/components/schemas/webauthn' + mfa: + $ref: '#/components/schemas/mfa' required: - cors - webauthn @@ -658,14 +661,36 @@ components: examples: - 60000 user_verification: + type: string enum: - required - preferred - discouraged + description: defaults to `required` when omitted + attachment: + type: string + enum: + - platform + - cross-platform + description: uses all authenticator attachments 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 `required` when omitted required: - relying_party - timeout - - user_verification relying_party: type: object title: relying_party @@ -696,6 +721,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 `preferred` 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 `discouraged` when omitted + required: + - timeout secret_list: type: array title: secret_list diff --git a/spec/passkey-server.yaml b/spec/passkey-server.yaml index 5a9dde8..541e78d 100644 --- a/spec/passkey-server.yaml +++ b/spec/passkey-server.yaml @@ -1,6 +1,6 @@ openapi: 3.1.0 info: - version: '1.0' + version: '1.1' title: passkey-server summary: 'OpenAPI Spec for creating, managing and using passkeys' description: 'This API shall represent the private and public endpoints for passkey registration, management and authentication' @@ -176,6 +176,8 @@ paths: operationId: post-login-initialize parameters: - $ref: '#/components/parameters/tenant_id' + requestBody: + $ref: '#/components/requestBodies/post-login-initialize' responses: '200': $ref: '#/components/responses/post-login-initialize' @@ -299,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: @@ -428,6 +578,26 @@ components: - user_id - transaction_id - transaction_data + post-login-initialize: + description: Body for login/initialize + content: + application/json: + schema: + type: object + properties: + user_id: + type: string + 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 @@ -474,6 +644,9 @@ components: backup_state: type: boolean default: false + is_mfa: + type: boolean + default: false required: - id - public_key @@ -483,6 +656,7 @@ components: - transports - backup_eligible - backup_state + - is_mfa error: description: Error Response with detailed information content: