Skip to content

Commit

Permalink
feat(webauthn): make webauthn params configurable (#48) & add mfa routes
Browse files Browse the repository at this point in the history
* feat(webauthn): make webauthn params configurable

* add attachment, attestation preference and resident key requirement params to create and update tenant endpoints
* add fallback values when params are not provided
* add migrations for new fields in webauthn config in database
* reflect changes in README.md and admin spec

Closes: #45

* feat(webauthn): allow optional user_id for login/initialize

* add login init dto with user_id as optional param
* extend service to switch to BeginLogin /ValidateLogin when user_id was given
* extend database to persist login state in sessiondata for login/finalize
* extend openapi spec to reflect optional user_id parameter for login/initialize

Closes: #33

* feat(mfa): add multi factor authentication endpoints

* add config for mfa passkeys
* add endpoints for mfa passkeys

TODO: add docs

Closes: #45

* feat(mfa): rework mfa persistence

* mfa config has now its own database table
* mfa config does not require RP config anymore
* webauthn client for mfa uses rp from passkey config
* update openapi specs to reflect changes

Closes: #45

* fix(credentials): add is_mfa flag to discern creds

* add is_mfa to DTO

Closes: #45

* chore(webauthn): change attestation default mode

* change attestation default mode from 'none' to 'direct'

Closes: #45

* fix(webauthn): make mfa config optional

* make MFA config optional for backwards compatibility
* rename some variables for better clarity
* add audit log types for MFA login for better distinction
* add missing attachment option to mfa client
* add api key vs tenant secrets check for mfa/non-discover login

Closes: #45

* fix(openapi): make mfa config optional

Closes: #45

* fix(dto): use mfa defaults instead of passkey options

use mfa default params instead of passkey dto ones when creating a mfa default config on admin operations `create tenant` or `update config`

* fix(dto): add missing commas

...

* fix(login): use sessiondata userID as userHandle for login

Use sessionData userId as userhandle when login is MFA or non-discoverable.
Also check if credential is in allowed list

Closes: #45

* fix(login): remove cred check

credential check will be done by go-webauthn lib

Closes: #45

* fix(mfa): filter credentials on non mfa methods

* remove mfa credentials from allowedCredentials when using non mfa routes
* for good measure also remove mfa credentials from FindCredentialById when used on Non-MFA routes
* rename CreateApiKeyError to CheckApiKey
* increase versions of openapi spec
* change defaults for mfa config object in admin spec
* cleanup: remove unused methods from handler/webauthn.go

Closes: #45

---------

Co-authored-by: Stefan Jacobi <[email protected]>
  • Loading branch information
shentschel and Stefan Jacobi authored Apr 24, 2024
1 parent aab9319 commit 442450c
Show file tree
Hide file tree
Showing 49 changed files with 1,115 additions and 260 deletions.
16 changes: 12 additions & 4 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,9 @@ curl --location 'http://<YOUR DOMAIN>:8001/tenants' \
]
},
"timeout": 60000,
"user_verification": "preferred"
"user_verification": "preferred",
"attestation_preference": "none",
"resident_key_requirement": "required"
},
"create_api_key": true
}
Expand Down Expand Up @@ -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"
}
}
}
Expand All @@ -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"
}
}
}
Expand All @@ -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"
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions server/api/dto/admin/request/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
55 changes: 55 additions & 0 deletions server/api/dto/admin/request/mfa.go
Original file line number Diff line number Diff line change
@@ -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
}
69 changes: 55 additions & 14 deletions server/api/dto/admin/request/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
2 changes: 2 additions & 0 deletions server/api/dto/admin/response/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
24 changes: 24 additions & 0 deletions server/api/dto/admin/response/mfa.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
18 changes: 12 additions & 6 deletions server/api/dto/admin/response/webatuhn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
3 changes: 2 additions & 1 deletion server/api/dto/intern/webauthn_credential.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -34,6 +34,7 @@ func WebauthnCredentialToModel(credential *webauthn.Credential, userId string, w
UpdatedAt: now,
BackupEligible: backupEligible,
BackupState: backupState,
IsMFA: isMFACredential,

WebauthnUserID: webauthnUserId,
}
Expand Down
3 changes: 2 additions & 1 deletion server/api/dto/intern/webauthn_session_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -62,5 +62,6 @@ func WebauthnSessionDataToModel(data *webauthn.SessionData, tenantId uuid.UUID,
AllowedCredentials: allowedCredentials,
ExpiresAt: nulls.NewTime(data.Expires),
TenantID: tenantId,
IsDiscoverable: isDiscoverable,
}
}
14 changes: 13 additions & 1 deletion server/api/dto/intern/webauthn_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -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)
Expand All @@ -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]
}
Expand Down
10 changes: 9 additions & 1 deletion server/api/dto/request/requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ type UpdateCredentialsDto struct {
}

type WebauthnRequests interface {
InitRegistrationDto | InitTransactionDto
InitRegistrationDto | InitTransactionDto | InitLoginDto | InitMfaLoginDto
}

type InitRegistrationDto struct {
Expand Down Expand Up @@ -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"`
}
2 changes: 2 additions & 0 deletions server/api/dto/response/responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -37,5 +38,6 @@ func CredentialDtoFromModel(credential models.WebauthnCredential) CredentialDto
Transports: credential.Transports.GetNames(),
BackupEligible: credential.BackupEligible,
BackupState: credential.BackupState,
IsMFA: credential.IsMFA,
}
}
2 changes: 2 additions & 0 deletions server/api/handler/admin/tenants.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion server/api/handler/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type credentialsHandler struct {
}

func NewCredentialsHandler(persister persistence.Persister) CredentialsHandler {
webauthnHandler := newWebAuthnHandler(persister)
webauthnHandler := newWebAuthnHandler(persister, false)

return &credentialsHandler{
webauthnHandler,
Expand Down
Loading

0 comments on commit 442450c

Please sign in to comment.