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]>
This commit is contained in:
Zach Hirschtritt
2025-11-24 06:44:48 +00:00
committed by GitHub
co-authored by Livio Spring
parent afa92e55f7
commit 5beeb5738a
101 changed files with 3397 additions and 169 deletions
+6
View File
@@ -97,6 +97,7 @@ Errors:
InitCodeNotFound: Кодът за инициализиране не е намерен
UsernameNotChanged: Потребителското име не е променено
InvalidURLTemplate: URL шаблонът е невалиден
Locked: Потребителят е заключен
Profile:
NotFound: Профилът не е намерен
NotChanged: Профилът не е променен
@@ -179,6 +180,11 @@ Errors:
NotExisting: U2F не съществува
Passwordless:
NotExisting: Без парола не съществува
RecoveryCodes:
InvalidCode: Предоставен е невалиден код за възстановяване
MaxCountExceeded: Превишен е максималният брой кодове за възстановяване
NotReady: Кодовете за възстановяване не са готови за употреба
ConfigInvalid: Конфигурацията на кодовете за възстановяване е невалидна
WebAuthN:
NotFound: WebAuthN Token не можа да бъде намерен
BeginRegisterFailed: Неуспешна регистрация за стартиране на WebAuthN
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Inicializační kód nenalezen
UsernameNotChanged: Uživatelské jméno nezměněno
InvalidURLTemplate: Šablona URL je neplatná
Locked: Uživatel je uzamčen
Profile:
NotFound: Profil nenalezen
NotChanged: Profil nezměněn
@@ -177,6 +178,11 @@ Errors:
NotExisting: U2F neexistuje
Passwordless:
NotExisting: Bezheslové přihlášení neexistuje
RecoveryCodes:
InvalidCode: Neplatný kód pro obnovení
MaxCountExceeded: Maximální počet kódů pro obnovení překročen
NotReady: Kódy pro obnovení nejsou připraveny k použití
ConfigInvalid: Konfigurace kódů pro obnovení je neplatná
WebAuthN:
NotFound: WebAuthN token nenalezen
BeginRegisterFailed: Registrace WebAuthN selhala
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Kein Initialisierungs-Code gefunden
UsernameNotChanged: Benutzername wurde nicht verändert
InvalidURLTemplate: URL Template ist ungültig
Locked: Benutzer ist gesperrt
Profile:
NotFound: Profil nicht gefunden
NotChanged: Profil nicht verändert
@@ -177,6 +178,11 @@ Errors:
NotExisting: U2F existiert nicht
Passwordless:
NotExisting: Passwortlos existiert nicht
RecoveryCodes:
InvalidCode: Ungültiger Wiederherstellungscode angegeben
MaxCountExceeded: Maximale Anzahl von Wiederherstellungscodes überschritten
NotReady: Wiederherstellungscodes sind nicht bereit für die Verwendung
ConfigInvalid: Wiederherstellungscodes-Konfiguration ist ungültig
WebAuthN:
NotFound: WebAuthN Token konnte nicht gefunden werden
BeginRegisterFailed: Es ist ein Fehler bei der WebAuthN Registrierung aufgetreten
+6
View File
@@ -96,6 +96,7 @@ Errors:
InitCodeNotFound: Initialization Code not found
UsernameNotChanged: Username not changed
InvalidURLTemplate: URL Template is invalid
Locked: User is locked
Profile:
NotFound: Profile not found
NotChanged: Profile not changed
@@ -178,6 +179,11 @@ Errors:
NotExisting: U2F does not exist
Passwordless:
NotExisting: Passwordless does not exist
RecoveryCodes:
InvalidCode: Invalid recovery code provided
MaxCountExceeded: Maximum number of recovery codes exceeded
NotReady: Recovery codes are not ready for use
ConfigInvalid: Recovery codes configuration is invalid
WebAuthN:
NotFound: WebAuthN Token could not be found
BeginRegisterFailed: WebAuthN begin registration failed
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Código de inicialización no encontrado
UsernameNotChanged: El nombre de usuario no cambió
InvalidURLTemplate: La plantilla URL no es válida
Locked: El usuario está bloqueado
Profile:
NotFound: Perfil no encontrado
NotChanged: El perfil no ha cambiado
@@ -177,6 +178,11 @@ Errors:
NotExisting: U2F no existe
Passwordless:
NotExisting: No existe inicio sin contraseña
RecoveryCodes:
InvalidCode: Código de recuperación inválido proporcionado
MaxCountExceeded: Número máximo de códigos de recuperación excedido
NotReady: Los códigos de recuperación no están listos para usar
ConfigInvalid: La configuración de códigos de recuperación es inválida
WebAuthN:
NotFound: No pude encontrarse un token WebAuthN
BeginRegisterFailed: El comienzo del registro WebAuthN falló
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Code d'initialisation non trouvé
UsernameNotChanged: Nom d'utilisateur non modifié
InvalidURLTemplate: Le modèle d'URL n'est pas valide
Locked: L'utilisateur est verrouillé
Profile:
NotFound: Profil non trouvé
NotChanged: Le profil n'a pas changé
@@ -177,6 +178,11 @@ Errors:
NotExisting: L'U2F n'existe pas
Passwordless:
NotExisting: Passwordless n'existe pas
RecoveryCodes:
InvalidCode: Code de récupération invalide fourni
MaxCountExceeded: Nombre maximum de codes de récupération dépassé
NotReady: Les codes de récupération ne sont pas prêts à être utilisés
ConfigInvalid: La configuration des codes de récupération est invalide
WebAuthN:
NotFound: Le token WebAuthN n'a pas été trouvé
BeginRegisterFailed: L'enregistrement de WebAuthN a échoué
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Az inicializáló kód nem található
UsernameNotChanged: A felhasználónév nem változott
InvalidURLTemplate: Az URL sablon érvénytelen
Locked: A felhasználó zárolva van
Profile:
NotFound: A profil nem található
NotChanged: A profil nem változott
@@ -177,6 +178,11 @@ Errors:
NotExisting: Az U2F nem létezik
Passwordless:
NotExisting: Passwordless nem létezik
RecoveryCodes:
InvalidCode: Érvénytelen visszaállítási kód lett megadva
MaxCountExceeded: A visszaállítási kódok maximális száma túllépve
NotReady: A visszaállítási kódok nem készek a használatra
ConfigInvalid: A visszaállítási kódok konfigurációja érvénytelen
WebAuthN:
NotFound: A WebAuthN token nem található
BeginRegisterFailed: A WebAuthN regisztráció megkezdése sikertelen
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Kode Inisialisasi tidak ditemukan
UsernameNotChanged: Nama pengguna tidak diubah
InvalidURLTemplate: Templat URL tidak valid
Locked: Pengguna dikunci
Profile:
NotFound: Profil tidak ditemukan
NotChanged: Profil tidak berubah
@@ -177,6 +178,11 @@ Errors:
NotExisting: U2F tidak ada
Passwordless:
NotExisting: Tanpa kata sandi tidak ada
RecoveryCodes:
InvalidCode: Kode pemulihan yang tidak valid disediakan
MaxCountExceeded: Jumlah maksimum kode pemulihan terlampaui
NotReady: Kode pemulihan tidak siap untuk digunakan
ConfigInvalid: Konfigurasi kode pemulihan tidak valid
WebAuthN:
NotFound: Token WebAuthN tidak dapat ditemukan
BeginRegisterFailed: Pendaftaran awal WebAuthN gagal
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Codice di inizializzazione non trovato
UsernameNotChanged: Nome utente non cambiato
InvalidURLTemplate: Il modello di URL non è valido
Locked: L'utente è bloccato
Profile:
NotFound: Profilo non trovato
NotChanged: Profilo non cambiato
@@ -177,6 +178,11 @@ Errors:
NotExisting: U2F non esistente
Passwordless:
NotExisting: Passwordless non esistente
RecoveryCodes:
InvalidCode: Codice di recupero non valido fornito
MaxCountExceeded: Numero massimo di codici di recupero superato
NotReady: I codici di recupero non sono pronti per l'uso
ConfigInvalid: La configurazione dei codici di recupero non è valida
WebAuthN:
NotFound: WebAuthN Token non trovato
BeginRegisterFailed: WebAuthN inizializzazione non riuscita
+6
View File
@@ -96,6 +96,7 @@ Errors:
InitCodeNotFound: 初期化コードが見つかりません
UsernameNotChanged: ユーザー名は変更されていません
InvalidURLTemplate: URLテンプレートが無効です
Locked: ユーザーがロックされています
Profile:
NotFound: プロファイルが見つかりません
NotChanged: プロファイルが変更されていません
@@ -178,6 +179,11 @@ Errors:
NotExisting: U2Fは存在しません
Passwordless:
NotExisting: パスワードレスは存在しません
RecoveryCodes:
InvalidCode: 無効なリカバリーコードが提供されました
MaxCountExceeded: リカバリーコードの最大数を超過しました
NotReady: リカバリーコードの使用準備ができていません
ConfigInvalid: リカバリーコードの設定が無効です
WebAuthN:
NotFound: WebAuthNトークンが見つかりませんでした
BeginRegisterFailed: WebAuthN登録の開始に失敗しました
+6
View File
@@ -96,6 +96,7 @@ Errors:
InitCodeNotFound: 초기화 코드를 찾을 수 없습니다
UsernameNotChanged: 사용자 이름이 변경되지 않았습니다
InvalidURLTemplate: URL 템플릿이 잘못되었습니다
Locked: 사용자가 잠겨 있습니다
Profile:
NotFound: 프로필을 찾을 수 없습니다
NotChanged: 프로필이 변경되지 않았습니다
@@ -178,6 +179,11 @@ Errors:
NotExisting: U2F가 존재하지 않습니다
Passwordless:
NotExisting: 패스워드리스가 존재하지 않습니다
RecoveryCodes:
InvalidCode: 잘못된 복구 코드가 제공되었습니다
MaxCountExceeded: 최대 복구 코드 수가 초과되었습니다
NotReady: 복구 코드가 사용할 준비가 되지 않았습니다
ConfigInvalid: 복구 코드 구성이 잘못되었습니다
WebAuthN:
NotFound: WebAuthN 토큰을 찾을 수 없습니다
BeginRegisterFailed: WebAuthN 등록 시작에 실패했습니다
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Кодот за иницијализација не е пронајден
UsernameNotChanged: Корисничкото име не е променето
InvalidURLTemplate: Шаблонот за URL е невалиден
Locked: Корисникот е заклучен
Profile:
NotFound: Профилот не е пронајден
NotChanged: Профилот не е променет
@@ -176,6 +177,11 @@ Errors:
NotExisting: U2F не постои
Passwordless:
NotExisting: Најава без лозинка не постои
RecoveryCodes:
InvalidCode: Невалиден код за опоравување
MaxCountExceeded: Максималниот број на кодови за опоравување е надминат
NotReady: Кодовите за опоравување не се готови за употреба
ConfigInvalid: Конфигурацијата за кодовите за опоравување е невалидна
WebAuthN:
NotFound: WebAuthN токенот не може да биде пронајден
BeginRegisterFailed: Почетокот на регистрацијата на WebAuthN не успеа
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Initialisatiecode niet gevonden
UsernameNotChanged: Gebruikersnaam niet veranderd
InvalidURLTemplate: URL-sjabloon is ongeldig
Locked: Gebruiker is vergrendeld
Profile:
NotFound: Profiel niet gevonden
NotChanged: Profiel niet veranderd
@@ -177,6 +178,11 @@ Errors:
NotExisting: U2F bestaat niet
Passwordless:
NotExisting: Wachtwoordloos bestaat niet
RecoveryCodes:
InvalidCode: Ongeldige herstelcode opgegeven
MaxCountExceeded: Maximum aantal herstelcodes overschreden
NotReady: Herstelcodes zijn niet klaar voor gebruik
ConfigInvalid: Configuratie van herstelcodes is ongeldig
WebAuthN:
NotFound: WebAuthN Token kon niet worden gevonden
BeginRegisterFailed: WebAuthN begin registratie mislukt
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Kod inicjalizacji nie znaleziony
UsernameNotChanged: Nazwa użytkownika nie została zmieniona
InvalidURLTemplate: Szablon URL jest nieprawidłowy
Locked: Użytkownik jest zablokowany
Profile:
NotFound: Profil nie znaleziony
NotChanged: Profil nie zmieniony
@@ -177,6 +178,11 @@ Errors:
NotExisting: U2F nie istnieje
Passwordless:
NotExisting: Bezhasłowe nie istnieje
RecoveryCodes:
InvalidCode: Podano nieprawidłowy kod odzyskiwania
MaxCountExceeded: Przekroczono maksymalną liczbę kodów odzyskiwania
NotReady: Kody odzyskiwania nie są gotowe do użycia
ConfigInvalid: Konfiguracja kodów odzyskiwania jest nieprawidłowa
WebAuthN:
NotFound: Token WebAuthN nie został znaleziony
BeginRegisterFailed: Rozpoczęcie rejestracji WebAuthN nie powiodło się
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Código de inicialização não encontrado
UsernameNotChanged: Nome de usuário não alterado
InvalidURLTemplate: O modelo de URL é inválido
Locked: Usuário está bloqueado
Profile:
NotFound: Perfil não encontrado
NotChanged: Perfil não alterado
@@ -176,6 +177,11 @@ Errors:
NotExisting: U2F não existe
Passwordless:
NotExisting: Autenticação sem senha não existe
RecoveryCodes:
InvalidCode: Código de recuperação inválido fornecido
MaxCountExceeded: Número máximo de códigos de recuperação excedido
NotReady: Códigos de recuperação não estão prontos para uso
ConfigInvalid: Configuração de códigos de recuperação é inválida
WebAuthN:
NotFound: Token WebAuthN não pôde ser encontrado
BeginRegisterFailed: Falha ao iniciar o registro do WebAuthN
+6
View File
@@ -96,6 +96,7 @@ Errors:
InitCodeNotFound: Codul de inițializare nu a fost găsit
UsernameNotChanged: Numele de utilizator nu a fost schimbat
InvalidURLTemplate: Șablonul URL este invalid
Locked: Utilizatorul este blocat
Profile:
NotFound: Profilul nu a fost găsit
NotChanged: Profilul nu a fost schimbat
@@ -178,6 +179,11 @@ Errors:
NotExisting: U2F nu există
Passwordless:
NotExisting: Fără parolă nu există
RecoveryCodes:
InvalidCode: Codul de recuperare furnizat este invalid
MaxCountExceeded: Numărul maxim de coduri de recuperare a fost depășit
NotReady: Codurile de recuperare nu sunt gata pentru utilizare
ConfigInvalid: Configurația codurilor de recuperare este invalidă
WebAuthN:
NotFound: Token-ul WebAuthN nu a putut fi găsit
BeginRegisterFailed: Înregistrarea WebAuthN a început, dar a eșuat
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Код инициализации не найден
UsernameNotChanged: Имя пользователя не изменено
InvalidURLTemplate: Шаблон URL-адреса недействителен.
Locked: Пользователь заблокирован
Profile:
NotFound: Профиль не найден
NotChanged: Профиль не изменён
@@ -177,6 +178,11 @@ Errors:
NotExisting: Двухфакторная аутентификация не существует
Passwordless:
NotExisting: Беспарольный вход не существует
RecoveryCodes:
InvalidCode: Предоставлен неверный код восстановления
MaxCountExceeded: Превышено максимальное количество кодов восстановления
NotReady: Коды восстановления не готовы к использованию
ConfigInvalid: Конфигурация кодов восстановления недействительна
WebAuthN:
NotFound: Токен WebAuthN не найден
BeginRegisterFailed: Ошибка начала регистрации WebAuthN
+6
View File
@@ -95,6 +95,7 @@ Errors:
InitCodeNotFound: Initieringskod hittades inte
UsernameNotChanged: Användarnamn ändrades inte
InvalidURLTemplate: URL-mallen är felaktig
Locked: Användaren är låst
Profile:
NotFound: Profil hittades inte
NotChanged: Profil ändrades inte
@@ -177,6 +178,11 @@ Errors:
NotExisting: U2F finns inte
Passwordless:
NotExisting: Lösenordsfri finns inte
RecoveryCodes:
InvalidCode: Ogiltig återställningskod angiven
MaxCountExceeded: Maximalt antal återställningskoder överskridet
NotReady: Återställningskoder är inte redo för användning
ConfigInvalid: Konfiguration för återställningskoder är ogiltig
WebAuthN:
NotFound: WebAuthN-token kunde inte hittas
BeginRegisterFailed: WebAuthN-registrering misslyckades
+6
View File
@@ -96,6 +96,7 @@ Errors:
InitCodeNotFound: Başlatma Kodu bulunamadı
UsernameNotChanged: Kullanıcı adı değişmedi
InvalidURLTemplate: URL Şablonu geçersiz
Locked: Kullanıcı kilitli
Profile:
NotFound: Profil bulunamadı
NotChanged: Profil değişmedi
@@ -178,6 +179,11 @@ Errors:
NotExisting: U2F mevcut değil
Passwordless:
NotExisting: Şifresiz giriş mevcut değil
RecoveryCodes:
InvalidCode: Geçersiz kurtarma kodu sağlandı
MaxCountExceeded: Maksimum kurtarma kodu sayısı aşıldı
NotReady: Kurtarma kodları kullanıma hazır değil
ConfigInvalid: Kurtarma kodları yapılandırması geçersiz
WebAuthN:
NotFound: WebAuthN Token bulunamadı
BeginRegisterFailed: WebAuthN kayıt başlatma başarısız
+6
View File
@@ -96,6 +96,7 @@ Errors:
InitCodeNotFound: 未找到初始化验证码
UsernameNotChanged: 用户名未更改
InvalidURLTemplate: URL模板无效
Locked: 用户已被锁定
Profile:
NotFound: 未找到个人资料
NotChanged: 个人资料未更改
@@ -178,6 +179,11 @@ Errors:
NotExisting: U2F 不存在
Passwordless:
NotExisting: 未设置无密码登录
RecoveryCodes:
InvalidCode: 提供的恢复代码无效
MaxCountExceeded: 恢复代码数量已超过最大限制
NotReady: 恢复代码尚未准备好使用
ConfigInvalid: 恢复代码配置无效
WebAuthN:
NotFound: 找不到 WebAuthN 令牌
BeginRegisterFailed: WebAuthN 注册失败