Files
zitadel/internal/api/ui/login/custom_action.go
T
5beeb5738a feat: Add recovery code MFA support (#9954)
<!--
Please inform yourself about the contribution guidelines on submitting a
PR here:
https://github.com/zitadel/zitadel/blob/main/CONTRIBUTING.md#submit-a-pull-request-pr.
Take note of how PR/commit titles should be written and replace the
template texts in the sections below. Don't remove any of the sections.
It is important that the commit history clearly shows what is changed
and why.
Important: By submitting a contribution you agree to the terms from our
Licensing Policy as described here:
https://github.com/zitadel/zitadel/blob/main/LICENSING.md#community-contributions.
-->

One-time recovery codes are a common multi-factor authentication (MFA)
backup method, letting users access their account if they lose other MFA
devices. Support for recovery codes can also reduce support burden for
users locked out of their accounts and provide a more secure and
reliable form of verification than security questions.

# Which Problems Are Solved

Zitadel currently lacks support for recovery codes.

# How the Problems Are Solved

This PR partially addresses recovery code support in Zitadel.
Importantly, it adds recovery codes as a new 2FA `Factor` and an
additional `Check` type for the Session API.

```
Example recovery code flow:

1. User generates N new recovery codes using `POST /v2/users/{user_id}/recovery_codes`
2. Zitadel hashes and stores these codes and returns the un-hashed codes in the response
3. User creates new session with an additional check: `recoveryCode`
4. Code is checked against hash and, if valid, cannot be used again
5. User attempts to adds N more codes using the same endpoint 
6. If `remaining_codes + N <= RecoveryCodes->MaxCount` config value, then recovery codes are added in addition to original codes
7. User can remove all recovery codes using `DEL /v2/users/:userId/recovery_codes` 
```
This PR adds: 
- [x] Session recovery_code check support on `POST+PATCH /v2/sessions`
endpoints
- [x] Adds `mfa_recovery_code_checked_at` column (default null) to
`projections.sessions8` table
- [x] Support for `SECOND_FACTOR_TYPE_RECOVERY_CODES` as available 2FA
method on login policy
- [x] Support for importing recovery codes in /import code

Missing, will _not_ implement in this PR:
- [ ] Admin console support for displaying Recovery Code settings for
user(s)
- [ ] Zitadel Typescript login support for recovery codes 

TODO: 
- [x] Additional unit and integration tests
- [x] Error translations

# Additional Changes

None

# Additional Context

- Closes #6898

---------

Co-authored-by: Livio Spring <[email protected]>
2025-11-24 06:44:48 +00:00

495 lines
15 KiB
Go

package login
import (
"context"
"encoding/json"
"net/http"
"github.com/dop251/goja"
"github.com/zitadel/logging"
"github.com/zitadel/oidc/v3/pkg/oidc"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/actions/object"
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/idp"
"github.com/zitadel/zitadel/internal/query"
)
func (l *Login) runPostExternalAuthenticationActions(
user *domain.ExternalUser,
tokens *oidc.Tokens[*oidc.IDTokenClaims],
authRequest *domain.AuthRequest,
httpRequest *http.Request,
idpUser idp.User,
authenticationError error,
) (_ *domain.ExternalUser, userChanged bool, err error) {
ctx := httpRequest.Context()
// use the request org (scopes or domain discovery) as default
resourceOwner := authRequest.RequestedOrgID
// if the user was already linked to an IDP and redirected to that, the requested org might be empty
if resourceOwner == "" {
resourceOwner = authRequest.UserOrgID
}
// if we will have no org (e.g. user clicked directly on the IDP on the login page)
if resourceOwner == "" {
// in this case the user might nevertheless already be linked to an IDP,
// so let's do a workaround and resourceOwnerOfUserIDPLink if there would be a IDP link
resourceOwner, err = l.resourceOwnerOfUserIDPLink(ctx, authRequest.SelectedIDPConfigID, user.ExternalUserID)
logging.WithFields("authReq", authRequest.ID, "idpID", authRequest.SelectedIDPConfigID).OnError(err).
Warn("could not determine resource owner for runPostExternalAuthenticationActions, fall back to default org id")
}
// fallback to default org id
if resourceOwner == "" {
resourceOwner = authz.GetInstance(ctx).DefaultOrganisationID()
}
triggerActions, err := l.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeExternalAuthentication, domain.TriggerTypePostAuthentication, resourceOwner)
if err != nil {
return nil, false, err
}
metadataList := object.MetadataListFromDomain(user.Metadatas)
apiFields := actions.WithAPIFields(
actions.SetFields("setFirstName", func(firstName string) {
user.FirstName = firstName
userChanged = true
}),
actions.SetFields("setLastName", func(lastName string) {
user.LastName = lastName
userChanged = true
}),
actions.SetFields("setNickName", func(nickName string) {
user.NickName = nickName
userChanged = true
}),
actions.SetFields("setDisplayName", func(displayName string) {
user.DisplayName = displayName
userChanged = true
}),
actions.SetFields("setPreferredLanguage", func(preferredLanguage string) {
user.PreferredLanguage = language.Make(preferredLanguage)
userChanged = true
}),
actions.SetFields("setPreferredUsername", func(username string) {
user.PreferredUsername = username
userChanged = true
}),
actions.SetFields("setEmail", func(email domain.EmailAddress) {
user.Email = email
userChanged = true
}),
actions.SetFields("setEmailVerified", func(verified bool) {
user.IsEmailVerified = verified
userChanged = true
}),
actions.SetFields("setPhone", func(phone domain.PhoneNumber) {
user.Phone = phone
userChanged = true
}),
actions.SetFields("setPhoneVerified", func(verified bool) {
user.IsPhoneVerified = verified
userChanged = true
}),
actions.SetFields("metadata", func(c *actions.FieldConfig) interface{} {
return metadataList.MetadataListFromDomain(c.Runtime)
}),
actions.SetFields("v1",
actions.SetFields("user",
actions.SetFields("appendMetadata", metadataList.AppendMetadataFunc),
),
),
)
authErrStr := "none"
if authenticationError != nil {
authErrStr = authenticationError.Error()
}
for _, a := range triggerActions {
actionCtx, cancel := context.WithTimeout(ctx, a.Timeout())
ctxFieldOptions := append(tokenCtxFields(tokens),
actions.SetFields("v1",
actions.SetFields("externalUser", func(c *actions.FieldConfig) interface{} {
return object.UserFromExternalUser(c, user)
}),
actions.SetFields("providerInfo", func(c *actions.FieldConfig) interface{} {
return c.Runtime.ToValue(idpUser)
}),
actions.SetFields("authRequest", object.AuthRequestField(authRequest)),
actions.SetFields("httpRequest", object.HTTPRequestField(httpRequest)),
actions.SetFields("authError", authErrStr),
actions.SetFields("org",
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
return func(goja.FunctionCall) goja.Value {
metadata, err := l.query.SearchOrgMetadata(
ctx,
false,
resourceOwner,
&query.OrgMetadataSearchQueries{},
false,
)
if err != nil {
logging.WithError(err).Info("unable to get org metadata in action")
panic(err)
}
return object.OrgMetadataListFromQuery(c, metadata)
}
}),
),
),
)
ctxFields := actions.SetContextFields(ctxFieldOptions...)
err = actions.Run(
actionCtx,
ctxFields,
apiFields,
a.Script,
a.Name,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))...,
)
cancel()
if err != nil {
return nil, false, err
}
}
user.Metadatas = object.MetadataListToDomain(metadataList)
return user, userChanged, err
}
type authMethod string
const (
authMethodPassword authMethod = "password"
authMethodOTP authMethod = "OTP"
authMethodOTPSMS authMethod = "OTP SMS"
authMethodOTPEmail authMethod = "OTP Email"
authMethodRecoveryCode authMethod = "recovery code"
authMethodU2F authMethod = "U2F"
authMethodPasswordless authMethod = "passwordless"
)
func (l *Login) runPostInternalAuthenticationActions(
authRequest *domain.AuthRequest,
httpRequest *http.Request,
authMethod authMethod,
authenticationError error,
) ([]*domain.Metadata, error) {
ctx := httpRequest.Context()
resourceOwner := authRequest.RequestedOrgID
if resourceOwner == "" {
resourceOwner = authRequest.UserOrgID
}
triggerActions, err := l.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeInternalAuthentication, domain.TriggerTypePostAuthentication, resourceOwner)
if err != nil {
return nil, err
}
metadataList := object.MetadataListFromDomain(nil)
apiFields := actions.WithAPIFields(
actions.SetFields("metadata", func(c *actions.FieldConfig) interface{} {
return metadataList.MetadataListFromDomain(c.Runtime)
}),
actions.SetFields("v1",
actions.SetFields("user",
actions.SetFields("appendMetadata", metadataList.AppendMetadataFunc),
),
),
)
for _, a := range triggerActions {
actionCtx, cancel := context.WithTimeout(ctx, a.Timeout())
authErrStr := "none"
if authenticationError != nil {
authErrStr = authenticationError.Error()
}
ctxFields := actions.SetContextFields(
actions.SetFields("v1",
actions.SetFields("authMethod", authMethod),
actions.SetFields("authError", authErrStr),
actions.SetFields("authRequest", object.AuthRequestField(authRequest)),
actions.SetFields("httpRequest", object.HTTPRequestField(httpRequest)),
),
)
err = actions.Run(
actionCtx,
ctxFields,
apiFields,
a.Script,
a.Name,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))...,
)
cancel()
if err != nil {
return nil, err
}
}
return object.MetadataListToDomain(metadataList), err
}
func (l *Login) runPreCreationActions(
authRequest *domain.AuthRequest,
httpRequest *http.Request,
user *domain.Human,
metadata []*domain.Metadata,
resourceOwner string,
flowType domain.FlowType,
) (*domain.Human, []*domain.Metadata, error) {
ctx := httpRequest.Context()
triggerActions, err := l.query.GetActiveActionsByFlowAndTriggerType(ctx, flowType, domain.TriggerTypePreCreation, resourceOwner)
if err != nil {
return nil, nil, err
}
metadataList := object.MetadataListFromDomain(metadata)
apiFields := actions.WithAPIFields(
actions.SetFields("setFirstName", func(firstName string) {
user.FirstName = firstName
}),
actions.SetFields("setLastName", func(lastName string) {
user.LastName = lastName
}),
actions.SetFields("setNickName", func(nickName string) {
user.NickName = nickName
}),
actions.SetFields("setDisplayName", func(displayName string) {
user.DisplayName = displayName
}),
actions.SetFields("setPreferredLanguage", func(preferredLanguage string) {
user.PreferredLanguage = language.Make(preferredLanguage)
}),
actions.SetFields("setGender", func(gender domain.Gender) {
user.Gender = gender
}),
actions.SetFields("setUsername", func(username string) {
user.Username = username
}),
actions.SetFields("setEmail", func(email domain.EmailAddress) {
if user.Email == nil {
user.Email = &domain.Email{}
}
user.Email.EmailAddress = email
}),
actions.SetFields("setEmailVerified", func(verified bool) {
if user.Email == nil {
return
}
user.Email.IsEmailVerified = verified
}),
actions.SetFields("setPhone", func(phone domain.PhoneNumber) {
if user.Phone == nil {
user.Phone = &domain.Phone{}
}
user.Phone.PhoneNumber = phone
}),
actions.SetFields("setPhoneVerified", func(verified bool) {
if user.Phone == nil {
return
}
user.Phone.IsPhoneVerified = verified
}),
actions.SetFields("metadata", func(c *actions.FieldConfig) interface{} {
return metadataList.MetadataListFromDomain(c.Runtime)
}),
actions.SetFields("v1",
actions.SetFields("user",
actions.SetFields("appendMetadata", metadataList.AppendMetadataFunc),
),
),
)
for _, a := range triggerActions {
actionCtx, cancel := context.WithTimeout(ctx, a.Timeout())
ctxOpts := actions.SetContextFields(
actions.SetFields("v1",
actions.SetFields("user", func(c *actions.FieldConfig) interface{} {
return object.UserFromHuman(c, user)
}),
actions.SetFields("authRequest", object.AuthRequestField(authRequest)),
actions.SetFields("httpRequest", object.HTTPRequestField(httpRequest)),
actions.SetFields("org",
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
return func(goja.FunctionCall) goja.Value {
metadata, err := l.query.SearchOrgMetadata(
ctx,
false,
resourceOwner,
&query.OrgMetadataSearchQueries{},
false,
)
if err != nil {
logging.WithError(err).Info("unable to get org metadata in action")
panic(err)
}
return object.OrgMetadataListFromQuery(c, metadata)
}
}),
),
),
)
err = actions.Run(
actionCtx,
ctxOpts,
apiFields,
a.Script,
a.Name,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))...,
)
cancel()
if err != nil {
return nil, nil, err
}
}
return user, object.MetadataListToDomain(metadataList), err
}
func (l *Login) runPostCreationActions(
userID string,
authRequest *domain.AuthRequest,
httpRequest *http.Request,
resourceOwner string,
flowType domain.FlowType,
) ([]*domain.UserGrant, error) {
ctx := httpRequest.Context()
triggerActions, err := l.query.GetActiveActionsByFlowAndTriggerType(ctx, flowType, domain.TriggerTypePostCreation, resourceOwner)
if err != nil {
return nil, err
}
mutableUserGrants := &object.UserGrants{UserGrants: make([]object.UserGrant, 0)}
apiFields := actions.WithAPIFields(
actions.SetFields("userGrants", &mutableUserGrants.UserGrants),
actions.SetFields("v1",
actions.SetFields("appendUserGrant", object.AppendGrantFunc(mutableUserGrants)),
),
)
for _, a := range triggerActions {
actionCtx, cancel := context.WithTimeout(ctx, a.Timeout())
ctxFields := actions.SetContextFields(
actions.SetFields("v1",
actions.SetFields("getUser", func(c *actions.FieldConfig) interface{} {
return func(call goja.FunctionCall) goja.Value {
user, err := l.query.GetUserByID(actionCtx, true, userID)
if err != nil {
panic(err)
}
return object.UserFromQuery(c, user)
}
}),
actions.SetFields("authRequest", object.AuthRequestField(authRequest)),
actions.SetFields("httpRequest", object.HTTPRequestField(httpRequest)),
actions.SetFields("org",
actions.SetFields("getMetadata", func(c *actions.FieldConfig) interface{} {
return func(goja.FunctionCall) goja.Value {
metadata, err := l.query.SearchOrgMetadata(
ctx,
false,
resourceOwner,
&query.OrgMetadataSearchQueries{},
false,
)
if err != nil {
logging.WithError(err).Info("unable to get org metadata in action")
panic(err)
}
return object.OrgMetadataListFromQuery(c, metadata)
}
}),
),
),
)
err = actions.Run(
actionCtx,
ctxFields,
apiFields,
a.Script,
a.Name,
append(actions.ActionToOptions(a), actions.WithHTTP(actionCtx), actions.WithUUID(actionCtx))...,
)
cancel()
if err != nil {
return nil, err
}
}
return object.UserGrantsToDomain(userID, mutableUserGrants.UserGrants), err
}
func tokenCtxFields(tokens *oidc.Tokens[*oidc.IDTokenClaims]) []actions.FieldOption {
var accessToken, idToken, refreshToken string
getClaim := func(claim string) interface{} {
return nil
}
claimsJSON := func() (string, error) {
return "", nil
}
if tokens == nil {
return []actions.FieldOption{
actions.SetFields("accessToken", accessToken),
actions.SetFields("idToken", idToken),
actions.SetFields("getClaim", getClaim),
actions.SetFields("claimsJSON", claimsJSON),
actions.SetFields("refreshToken", refreshToken),
}
}
accessToken = tokens.AccessToken
refreshToken = tokens.RefreshToken
idToken = tokens.IDToken
if tokens.IDTokenClaims != nil {
getClaim = func(claim string) interface{} {
return tokens.IDTokenClaims.Claims[claim]
}
claimsJSON = func() (string, error) {
c, err := json.Marshal(tokens.IDTokenClaims)
if err != nil {
return "", err
}
return string(c), nil
}
}
return []actions.FieldOption{
actions.SetFields("accessToken", accessToken),
actions.SetFields("idToken", idToken),
actions.SetFields("getClaim", getClaim),
actions.SetFields("claimsJSON", claimsJSON),
actions.SetFields("refreshToken", refreshToken),
}
}
func (l *Login) resourceOwnerOfUserIDPLink(ctx context.Context, idpConfigID string, externalUserID string) (string, error) {
idQuery, err := query.NewIDPUserLinkIDPIDSearchQuery(idpConfigID)
if err != nil {
return "", err
}
externalIDQuery, err := query.NewIDPUserLinksExternalIDSearchQuery(externalUserID)
if err != nil {
return "", err
}
queries := []query.SearchQuery{
idQuery, externalIDQuery,
}
links, err := l.query.IDPUserLinks(ctx, &query.IDPUserLinksSearchQuery{Queries: queries}, nil)
if err != nil {
return "", err
}
if len(links.Links) != 1 {
return "", nil
}
return links.Links[0].ResourceOwner, nil
}