Files
zitadel/internal/domain/human_recovery_codes.go
c6386cb807 feat: rt recovery code check (#11848)
# Which Problems Are Solved

Adding recovery code check step required for the CreateSession RT
implementation.

# How the Problems Are Solved

- Introduce `RecoveryCodeCheckCommand` (validate/execute/events) for
session creation checks
- Add unit tests

# Additional Changes
- refactor password-check code to reuse a shared lockout-policy helper
- add some missing error translations in `internal/static/i18n/en.yaml`

# Additional Context
- Related to https://github.com/zitadel/zitadel/issues/11035

---------

Co-authored-by: Silvan <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-03-23 10:56:40 +01:00

140 lines
4.0 KiB
Go

package domain
import (
"context"
"strings"
"github.com/google/uuid"
"github.com/zitadel/zitadel/internal/crypto"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
"github.com/zitadel/zitadel/internal/zerrors"
)
type HumanRecoveryCodes struct {
*ObjectDetails
Codes []string
}
type ImportHumanRecoveryCode struct {
RawCode string
HashedCode string
}
func HashRecoveryCodesIfNeeded(ctx context.Context, codes []ImportHumanRecoveryCode, hasher *crypto.Hasher) (_ []string, err error) {
_, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
hashedCodes := make([]string, len(codes))
for i, code := range codes {
hashed, err := HashRecoveryCodeIfNeeded(ctx, code, hasher)
if err != nil {
return nil, err
}
hashedCodes[i] = hashed
}
return hashedCodes, nil
}
func HashRecoveryCodeIfNeeded(ctx context.Context, code ImportHumanRecoveryCode, hasher *crypto.Hasher) (_ string, err error) {
_, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if code.RawCode != "" {
return hasher.Hash(code.RawCode)
}
if !hasher.EncodingSupported(code.HashedCode) {
return "", zerrors.ThrowInvalidArgument(nil, "DOMAIN-JDk4t", "Errors.Hash.NotSupported")
}
return code.HashedCode, nil
}
func GenerateRecoveryCodes(ctx context.Context, count int, config RecoveryCodesConfig, hasher *crypto.Hasher) (hashedCodes []string, rawCodes []string, err error) {
hashedCodes, rawCodes = make([]string, count), make([]string, count)
for i := range count {
hashedCodes[i], rawCodes[i], err = generateRecoveryCode(ctx, config, hasher)
if err != nil {
return nil, nil, err
}
}
return hashedCodes, rawCodes, nil
}
func generateRecoveryCode(ctx context.Context, config RecoveryCodesConfig, hasher *crypto.Hasher) (hashedCode string, rawCode string, err error) {
_, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
rawCode, err = makeRawCode(config)
if err != nil {
return "", "", err
}
hashedCode, err = hasher.Hash(rawCode)
if err != nil {
return "", "", err
}
return hashedCode, rawCode, nil
}
func makeRawCode(config RecoveryCodesConfig) (string, error) {
switch config.Format {
case RecoveryCodeFormatAlphanumeric:
return generateAlphanumericCode(config.Length, config.WithHyphen)
case RecoveryCodeFormatUUID:
code := uuid.New().String()
if !config.WithHyphen {
code = strings.ReplaceAll(code, "-", "")
}
return code, nil
default:
return "", zerrors.ThrowError(nil, "DOMAIN-g52kn", "Errors.User.MFA.RecoveryCodes.ConfigInvalid")
}
}
func generateAlphanumericCode(length int, withHyphen bool) (string, error) {
if length <= 0 {
return "", zerrors.ThrowError(nil, "DOMAIN-68mvq", "Errors.User.MFA.RecoveryCodes.ConfigInvalid")
}
// lower-cased base32 character set https://www.crockford.com/base32.html
chars := []rune("0123456789abcdefghjkmnpqrstvwxyz")
code, err := crypto.GenerateRandomString(uint(length), chars)
if err != nil {
return "", err
}
if withHyphen && length > 2 {
mid := length / 2
return code[:mid] + "-" + code[mid:], nil
}
return code, nil
}
// ValidateRecoveryCode validates the input code against the saved recovery codes of a user.
// The validation logic is also implemented in backend/v3/domain/session_check_recovery_code.go.
// Keep both implementations in sync until this duplication is removed.
func ValidateRecoveryCode(ctx context.Context, code string, recoveryCodes *HumanRecoveryCodes, hasher *crypto.Hasher) (_ string, err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if code == "" {
return "", zerrors.ThrowInvalidArgument(nil, "DOMAIN-9xrr0", "Errors.User.MFA.RecoveryCodes.InvalidCode")
}
for _, recoveryCode := range recoveryCodes.Codes {
_, spanCodeComparison := tracing.NewNamedSpan(ctx, "passwap.Verify")
_, verifyErr := hasher.Verify(recoveryCode, code)
spanCodeComparison.EndWithError(verifyErr)
if verifyErr != nil {
continue
}
return recoveryCode, nil
}
return "", zerrors.ThrowInvalidArgument(nil, "DOMAIN-6uvh0", "Errors.User.MFA.RecoveryCodes.InvalidCode")
}