Skip to content

Commit

Permalink
feat(mfa): add multi factor authentication endpoints
Browse files Browse the repository at this point in the history
* add config for mfa passkeys
* add endpoints for mfa passkeys

TODO: add docs

Closes: #45
  • Loading branch information
Stefan Jacobi committed Mar 5, 2024
1 parent ff48313 commit 6f8b064
Show file tree
Hide file tree
Showing 24 changed files with 466 additions and 138 deletions.
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"`
Webauthn CreateWebauthnConfigDto `json:"webauthn" validate:"required"`
Mfa CreateWebauthnConfigDto `json:"mfa" validate:"required"`
}

func (dto *CreateConfigDto) ToModel(tenant models.Tenant) models.Config {
Expand Down
79 changes: 61 additions & 18 deletions server/api/dto/admin/request/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,25 @@ 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"`
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"`
type CreateWebauthnConfigDto 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 {
func (dto *CreateWebauthnConfigDto) toModel(configModel models.Config) models.WebauthnConfig {
webauthnConfigId, _ := uuid.NewV4()
now := time.Now()

webauthnConfig := models.WebauthnConfig{
ID: webauthnConfigId,
ConfigID: configModel.ID,
Timeout: dto.Timeout,
CreatedAt: now,
UpdatedAt: now,
UserVerification: dto.UserVerification,
Attachment: dto.Attachment,
ID: webauthnConfigId,
ConfigID: configModel.ID,
Timeout: dto.Timeout,
CreatedAt: now,
UpdatedAt: now,
}

if dto.AttestationPreference == nil {
Expand All @@ -36,11 +34,56 @@ func (dto *CreateWebauthnDto) ToModel(configModel models.Config) models.Webauthn
webauthnConfig.AttestationPreference = *dto.AttestationPreference
}

return webauthnConfig
}

func (dto *CreateWebauthnConfigDto) ToPasskeyModel(configModel models.Config) models.WebauthnConfig {
passkeyConfig := dto.toModel(configModel)
passkeyConfig.Attachment = dto.Attachment

if dto.ResidentKeyRequirement == nil {
webauthnConfig.ResidentKeyRequirement = protocol.ResidentKeyRequirementRequired
passkeyConfig.ResidentKeyRequirement = protocol.ResidentKeyRequirementRequired
} else {
webauthnConfig.ResidentKeyRequirement = *dto.ResidentKeyRequirement
passkeyConfig.ResidentKeyRequirement = *dto.ResidentKeyRequirement
}

return webauthnConfig
if dto.UserVerification == nil {
passkeyConfig.UserVerification = protocol.VerificationRequired
} else {
passkeyConfig.UserVerification = *dto.UserVerification
}

return passkeyConfig
}

func (dto *CreateWebauthnConfigDto) ToMfaModel(configModel models.Config) models.WebauthnConfig {
mfaConfig := dto.toModel(configModel)
mfaConfig.IsMfa = true

if dto.AttestationPreference == nil {
mfaConfig.AttestationPreference = protocol.PreferNoAttestation
} else {
mfaConfig.AttestationPreference = *dto.AttestationPreference
}

if dto.ResidentKeyRequirement == nil {
mfaConfig.ResidentKeyRequirement = protocol.ResidentKeyRequirementDiscouraged
} else {
mfaConfig.ResidentKeyRequirement = *dto.ResidentKeyRequirement
}

if dto.UserVerification == nil {
mfaConfig.UserVerification = protocol.VerificationPreferred
} else {
mfaConfig.UserVerification = *dto.UserVerification
}

if dto.Attachment == nil {
cp := protocol.CrossPlatform
mfaConfig.Attachment = &cp
} else {
mfaConfig.Attachment = dto.Attachment
}

return mfaConfig
}
14 changes: 13 additions & 1 deletion server/api/dto/admin/response/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,23 @@ import "github.com/teamhanko/passkey-server/persistence/models"
type GetConfigResponse struct {
Cors GetCorsResponse `json:"cors"`
Webauthn GetWebauthnResponse `json:"webauthn"`
Mfa GetWebauthnResponse `json:"mfa"`
}

func ToGetConfigResponse(config *models.Config) GetConfigResponse {
var passkeyConfig models.WebauthnConfig
var mfaConfig models.WebauthnConfig
for _, webauthnConfig := range config.WebauthnConfigs {
if webauthnConfig.IsMfa {
mfaConfig = webauthnConfig
} else {
passkeyConfig = webauthnConfig
}
}

return GetConfigResponse{
Cors: ToGetCorsResponse(&config.Cors),
Webauthn: ToGetWebauthnResponse(&config.WebauthnConfig),
Webauthn: ToGetWebauthnResponse(&passkeyConfig),
Mfa: ToGetWebauthnResponse(&mfaConfig),
}
}
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,
}
}
6 changes: 5 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 | InitLoginDto
InitRegistrationDto | InitTransactionDto | InitLoginDto | InitMfaLoginDto
}

type InitRegistrationDto struct {
Expand Down Expand Up @@ -95,3 +95,7 @@ func (initTransaction *InitTransactionDto) ToModel() (*models.Transaction, error
type InitLoginDto struct {
UserId *string `json:"user_id" validate:"omitempty,min=1"`
}

type InitMfaLoginDto struct {
UserId *string `json:"userId" validate:"required,min=1"`
}
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
6 changes: 3 additions & 3 deletions server/api/handler/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ type loginHandler struct {
}

func NewLoginHandler(persister persistence.Persister) WebauthnHandler {
webauthnHandler := newWebAuthnHandler(persister)
webauthnHandler := newWebAuthnHandler(persister, false)

return &loginHandler{
webauthnHandler,
Expand All @@ -47,7 +47,7 @@ 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,
Expand Down Expand Up @@ -91,7 +91,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,
Expand Down
115 changes: 115 additions & 0 deletions server/api/handler/mfa_login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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,
})

credentialAssertion, err := service.Initialize()
err = lh.handleError(h.AuditLog, models.AuditLogWebAuthnAuthenticationInitFailed, tx, ctx, nil, nil, err)
if err != nil {
return err
}

auditErr := h.AuditLog.CreateWithConnection(tx, models.AuditLogWebAuthnAuthenticationInitSucceeded, nil, 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,
})

token, userId, err := service.Finalize(parsedRequest)
err = lh.handleError(h.AuditLog, models.AuditLogWebAuthnAuthenticationFinalFailed, tx, ctx, &userId, nil, err)
if err != nil {
return err
}

auditErr := h.AuditLog.CreateWithConnection(tx, models.AuditLogWebAuthnAuthenticationFinalSucceeded, &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})
})
}
30 changes: 22 additions & 8 deletions server/api/handler/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 &registrationHandler{
webauthnHandler,
Expand All @@ -36,8 +36,15 @@ func (r *registrationHandler) Init(ctx echo.Context) error {

webauthnUser := dto.ToModel()

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
}
Expand All @@ -50,7 +57,7 @@ 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,
Expand Down Expand Up @@ -79,8 +86,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
}
Expand All @@ -93,7 +107,7 @@ 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,
Expand Down
Loading

0 comments on commit 6f8b064

Please sign in to comment.