Files
zitadel/internal/authz/repository/eventsourcing/eventstore/token_verifier.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

366 lines
14 KiB
Go

package eventstore
import (
"context"
"encoding/base64"
"fmt"
"slices"
"strings"
"time"
"github.com/go-jose/go-jose/v4"
"github.com/muhlemmer/gu"
"github.com/zitadel/logging"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"github.com/zitadel/zitadel/internal/api/authz"
http_util "github.com/zitadel/zitadel/internal/api/http"
"github.com/zitadel/zitadel/internal/authz/repository/eventsourcing/view"
"github.com/zitadel/zitadel/internal/command"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/eventstore"
"github.com/zitadel/zitadel/internal/query"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
usr_model "github.com/zitadel/zitadel/internal/user/model"
usr_view "github.com/zitadel/zitadel/internal/user/repository/view"
"github.com/zitadel/zitadel/internal/user/repository/view/model"
"github.com/zitadel/zitadel/internal/zerrors"
)
type TokenVerifierRepo struct {
TokenVerificationKey crypto.EncryptionAlgorithm
Eventstore *eventstore.Eventstore
View *view.View
Query *query.Queries
ExternalSecure bool
}
func (repo *TokenVerifierRepo) Health() error {
return repo.View.Health()
}
func (repo *TokenVerifierRepo) tokenByID(ctx context.Context, tokenID, userID string) (_ *usr_model.TokenView, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
instanceID := authz.GetInstance(ctx).InstanceID()
// always load the latest sequence first, so in case the token was not found by id,
// the sequence will be equal or lower than the actual projection and no events are lost
sequence, err := repo.View.GetLatestState(ctx)
logging.WithFields("instanceID", instanceID, "userID", userID, "tokenID", tokenID).
OnError(err).
Errorf("could not get current sequence for token check")
token, viewErr := repo.View.TokenByIDs(tokenID, userID, instanceID)
if viewErr != nil && !zerrors.IsNotFound(viewErr) {
return nil, viewErr
}
if zerrors.IsNotFound(viewErr) {
token = new(model.TokenView)
token.ID = tokenID
token.UserID = userID
if sequence != nil {
token.ChangeDate = sequence.EventCreatedAt
}
}
events, esErr := repo.getUserEvents(ctx, userID, instanceID, token.ChangeDate, token.GetRelevantEventTypes())
if zerrors.IsNotFound(viewErr) && len(events) == 0 {
return nil, zerrors.ThrowNotFound(nil, "EVENT-4T90g", "Errors.Token.NotFound")
}
if esErr != nil {
logging.WithError(viewErr).WithField("traceID", tracing.TraceIDFromCtx(ctx)).Debug("error retrieving new events")
return model.TokenViewToModel(token), nil
}
viewToken := *token
for _, event := range events {
err := token.AppendEventIfMyToken(event)
if err != nil {
return model.TokenViewToModel(&viewToken), nil
}
}
if !token.Expiration.After(time.Now().UTC()) || token.Deactivated {
return nil, zerrors.ThrowNotFound(nil, "EVENT-5Bm9s", "Errors.Token.NotFound")
}
return model.TokenViewToModel(token), nil
}
func (repo *TokenVerifierRepo) VerifyAccessToken(ctx context.Context, tokenString, verifierClientID, projectID string) (userID string, agentID string, clientID, prefLang, resourceOwner string, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
tokenID, subject, ok := repo.getTokenIDAndSubject(ctx, tokenString)
if !ok {
return "", "", "", "", "", zerrors.ThrowUnauthenticated(nil, "APP-Reb32", "invalid token")
}
if strings.HasPrefix(tokenID, command.IDPrefixV2) {
return repo.verifyAccessTokenV2(ctx, tokenID, verifierClientID, projectID)
}
if sessionID, ok := strings.CutPrefix(tokenID, authz.SessionTokenPrefix); ok {
userID, clientID, resourceOwner, err = repo.verifySessionToken(ctx, sessionID, tokenString)
return
}
return repo.verifyAccessTokenV1(ctx, tokenID, subject, verifierClientID, projectID)
}
func (repo *TokenVerifierRepo) verifyAccessTokenV1(ctx context.Context, tokenID, subject, verifierClientID, projectID string) (userID, agentID, clientID, prefLang, resourceOwner string, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
_, tokenSpan := tracing.NewNamedSpan(ctx, "tokenByID")
token, err := repo.tokenByID(ctx, tokenID, subject)
tokenSpan.EndWithError(err)
if err != nil {
return "", "", "", "", "", zerrors.ThrowUnauthenticated(err, "APP-BxUSiL", "invalid token")
}
if token.Actor != nil {
return "", "", "", "", "", zerrors.ThrowPermissionDenied(nil, "APP-wai8O", "Errors.TokenExchange.Token.NotForAPI")
}
if !token.Expiration.After(time.Now().UTC()) {
return "", "", "", "", "", zerrors.ThrowUnauthenticated(err, "APP-k9KS0", "invalid token")
}
if token.IsPAT {
return token.UserID, "", "", "", token.ResourceOwner, nil
}
if err = verifyAudience(token.Audience, verifierClientID, projectID); err != nil {
return "", "", "", "", "", err
}
return token.UserID, token.UserAgentID, token.ApplicationID, token.PreferredLanguage, token.ResourceOwner, nil
}
func (repo *TokenVerifierRepo) verifyAccessTokenV2(ctx context.Context, token, verifierClientID, projectID string) (userID, agentID, clientID, prefLang, resourceOwner string, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
activeToken, err := repo.Query.ActiveAccessTokenByToken(ctx, token)
if err != nil {
return "", "", "", "", "", err
}
if activeToken.Actor != nil {
return "", "", "", "", "", zerrors.ThrowPermissionDenied(nil, "APP-Shi0J", "Errors.TokenExchange.Token.NotForAPI")
}
if err = verifyAudience(activeToken.Audience, verifierClientID, projectID); err != nil {
return "", "", "", "", "", err
}
if err = repo.checkAuthentication(ctx, activeToken.AuthMethods, activeToken.UserID); err != nil {
return "", "", "", "", "", err
}
prefLang = gu.Value(activeToken.PreferredLanguage).String()
agentID = gu.Value(gu.Value(activeToken.UserAgent).FingerprintID)
return activeToken.UserID, agentID, activeToken.ClientID, prefLang, activeToken.ResourceOwner, nil
}
func (repo *TokenVerifierRepo) verifySessionToken(ctx context.Context, sessionID, token string) (userID, clientID, resourceOwner string, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
session, err := repo.Query.SessionByID(ctx, true, sessionID, token, nil)
if err != nil {
return "", "", "", err
}
if !session.Expiration.IsZero() && session.Expiration.Before(time.Now()) {
return "", "", "", zerrors.ThrowPermissionDenied(nil, "AUTHZ-EGDo3", "session expired")
}
if err = repo.checkAuthentication(ctx, authMethodsFromSession(session), session.UserFactor.UserID); err != nil {
return "", "", "", err
}
return session.UserFactor.UserID, "", session.UserFactor.ResourceOwner, nil
}
// checkAuthentication ensures the session or token was authenticated (at least a single [domain.UserAuthMethodType]).
// It will also check if there was a multi-factor authentication, if either MFA is forced by the login policy or if the user has set up any second factor
func (repo *TokenVerifierRepo) checkAuthentication(ctx context.Context, authMethods []domain.UserAuthMethodType, userID string) error {
if len(authMethods) == 0 {
return zerrors.ThrowPermissionDenied(nil, "AUTHZ-Kl3p0", "authentication required")
}
// if the user has MFA, we don't need to check any mfa requirements
if domain.HasMFA(authMethods) {
return nil
}
requirements, err := repo.Query.ListUserAuthMethodTypesRequired(setCallerCtx(ctx, userID), userID)
if err != nil {
return err
}
// machine users do not have interactive logins, so we don't check for MFA requirements
if requirements.UserType == domain.UserTypeMachine {
return nil
}
// we'll only require 2FA factors, that are allowed by the policy
allowedFactors := allowed2FAFactors(requirements.AllowedSecondFactors, requirements.SetUpFactors)
// if either the user has set up a factor that is allowed by the policy
// or the policy requires MFA, we'll require it and can directly return the error
// since the token/session was not authenticated with MFA
if domain.Has2FA(allowedFactors) ||
domain.RequiresMFA(
requirements.ForceMFA,
requirements.ForceMFALocalOnly,
!hasIDPAuthentication(authMethods),
) {
return zerrors.ThrowPermissionDenied(nil, "AUTHZ-Kl3p0", "mfa required")
}
return nil
}
func allowed2FAFactors(factors []domain.SecondFactorType, authMethods []domain.UserAuthMethodType) []domain.UserAuthMethodType {
allowedFactors := make([]domain.UserAuthMethodType, 0, len(factors))
for _, method := range authMethods {
factorType := domain.AuthMethodToSecondFactor(method)
if factorType != domain.SecondFactorTypeUnspecified &&
slices.Contains(factors, factorType) {
allowedFactors = append(allowedFactors, method)
}
}
return allowedFactors
}
func hasIDPAuthentication(authMethods []domain.UserAuthMethodType) bool {
for _, method := range authMethods {
if method == domain.UserAuthMethodTypeIDP {
return true
}
}
return false
}
func authMethodsFromSession(session *query.Session) []domain.UserAuthMethodType {
types := make([]domain.UserAuthMethodType, 0, domain.UserAuthMethodTypeIDP)
if !session.PasswordFactor.PasswordCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypePassword)
}
if !session.WebAuthNFactor.WebAuthNCheckedAt.IsZero() {
if session.WebAuthNFactor.UserVerified {
types = append(types, domain.UserAuthMethodTypePasswordless)
} else {
types = append(types, domain.UserAuthMethodTypeU2F)
}
}
if !session.IntentFactor.IntentCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeIDP)
}
if !session.TOTPFactor.TOTPCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeTOTP)
}
if !session.OTPSMSFactor.OTPCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeOTPSMS)
}
if !session.OTPEmailFactor.OTPCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeOTPEmail)
}
if !session.RecoveryCodeFactor.RecoveryCodeCheckedAt.IsZero() {
types = append(types, domain.UserAuthMethodTypeRecoveryCode)
}
return types
}
func setCallerCtx(ctx context.Context, userID string) context.Context {
ctxData := authz.GetCtxData(ctx)
ctxData.UserID = userID
return authz.SetCtxData(ctx, ctxData)
}
func (repo *TokenVerifierRepo) ProjectIDAndOriginsByClientID(ctx context.Context, clientID string) (projectID string, origins []string, err error) {
app, err := repo.View.ApplicationByOIDCClientID(ctx, clientID)
if err != nil {
return "", nil, err
}
return app.ProjectID, app.OIDCConfig.AllowedOrigins, nil
}
func (repo *TokenVerifierRepo) VerifierClientID(ctx context.Context, appName string) (clientID, projectID string, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
app, err := repo.View.ApplicationByProjecIDAndAppName(ctx, authz.GetInstance(ctx).ProjectID(), appName)
if err != nil {
return "", "", err
}
if app.OIDCConfig != nil {
clientID = app.OIDCConfig.ClientID
} else if app.APIConfig != nil {
clientID = app.APIConfig.ClientID
}
return clientID, app.ProjectID, nil
}
func (repo *TokenVerifierRepo) getUserEvents(ctx context.Context, userID, instanceID string, changeDate time.Time, eventTypes []eventstore.EventType) (_ []eventstore.Event, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
query, err := usr_view.UserByIDQuery(userID, instanceID, changeDate, eventTypes)
if err != nil {
return nil, err
}
return repo.Eventstore.Filter(ctx, query)
}
// getTokenIDAndSubject returns the TokenID and Subject of both opaque tokens and JWTs
func (repo *TokenVerifierRepo) getTokenIDAndSubject(ctx context.Context, accessToken string) (tokenID string, subject string, valid bool) {
// accessToken can be either opaque or JWT
// let's try opaque first:
tokenIDSubject, err := repo.decryptAccessToken(accessToken)
if err != nil {
logging.WithError(err).Warn("token verifier repo: decrypt access token")
// if decryption did not work, it might be a JWT
accessTokenClaims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](ctx, accessToken, repo.jwtTokenVerifier(ctx))
if err != nil {
logging.WithError(err).Warn("token verifier repo: verify JWT access token")
return "", "", false
}
return accessTokenClaims.JWTID, accessTokenClaims.Subject, true
}
splitToken := strings.Split(tokenIDSubject, ":")
if len(splitToken) != 2 {
return "", "", false
}
return splitToken[0], splitToken[1], true
}
func (repo *TokenVerifierRepo) jwtTokenVerifier(ctx context.Context) *op.AccessTokenVerifier {
keySet := &openIDKeySet{repo.Query}
return op.NewAccessTokenVerifier(http_util.DomainContext(ctx).Origin(), keySet)
}
func (repo *TokenVerifierRepo) decryptAccessToken(token string) (string, error) {
tokenData, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return "", zerrors.ThrowUnauthenticated(nil, "APP-ASdgg", "invalid token")
}
tokenIDSubject, err := repo.TokenVerificationKey.DecryptString(tokenData, repo.TokenVerificationKey.EncryptionKeyID())
if err != nil {
return "", zerrors.ThrowUnauthenticated(nil, "APP-8EF0zZ", "invalid token")
}
return tokenIDSubject, nil
}
func verifyAudience(audience []string, verifierClientID, projectID string) error {
for _, aud := range audience {
if verifierClientID == aud || projectID == aud {
return nil
}
}
return zerrors.ThrowUnauthenticated(nil, "APP-Zxfako", "invalid audience")
}
type openIDKeySet struct {
*query.Queries
}
// VerifySignature implements the oidc.KeySet interface
// providing an implementation for the keys retrieved directly from Queries
func (o *openIDKeySet) VerifySignature(ctx context.Context, jws *jose.JSONWebSignature) (payload []byte, err error) {
keySet, err := o.Queries.GetWebKeySet(ctx)
if err != nil {
return nil, err
}
keyID, alg := oidc.GetKeyIDAndAlg(jws)
key, err := oidc.FindMatchingKey(keyID, oidc.KeyUseSignature, alg, keySet.Keys...)
if err != nil {
return nil, fmt.Errorf("invalid signature: %w", err)
}
return jws.Verify(&key)
}