mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
fix(security): prevent TOTP reuse (#12616)
# Which Problems Are Solved TOTP codes could be reused, violating [RFC 6238, section 5.2](https://datatracker.ietf.org/doc/html/rfc6238#section-5.2). # How the Problems Are Solved Transmitted codes are now recorded in each success event. As the codes amount to secrets, they are not stored verbatim but as a randomly salted HMAC-SHA256 (`crypto.HMACValue`). Each value gets a fresh random salt, so an equal code always produces a different stored value and two entries can never be recognized as being derived from the same code. This is deliberately not a replacement for proper secret storage: with the salt stored alongside the hash and a 6 digit payload, anyone with event access can brute-force the code in milliseconds. That is acceptable here because a code is worthless once its window has passed. The `HMACValue` doc comment states the same. TOTP codes are low-entropy (6 digits), pseudo random codes. Therefore it is possible for the same code to be generated more than once over the lifetime of a user. In order to prevent the (unlikely) case where users login with large time intervalls and a generated code may be equal to the one last used a long time ago, we only check for codes used within the recent period window. A simpler implementation would be simply storing the latest consumed step, but the TOTP library does not return this value. Because of the allowed skew there are always 3 steps within the validation window, so we can't calculate the current step based on time alone. The retained history spans one period more than the validation window: validity is decided by the app server's clock, while the stored events carry the database clock, and the extra period absorbs the drift between them. **Note:** a reused code pushes a distinct `user.human.mfa.otp.check.reused` event, not a failed check event. A replay therefore stays observable through the Events API without counting towards the lockout policy. Otherwise anyone who captured a code could replay it to lock the user out. # Additional Changes - Read only the latest events in recheck query # Additional Context - Closes https://github.com/zitadel/zitadel/issues/6452 --------- Co-authored-by: Livio Spring <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: muhlemmer <[email protected]>
This commit is contained in:
co-authored by
Livio Spring
Copilot Autofix powered by AI
copilot-swe-agent[bot] <198982749+[email protected]>
muhlemmer
parent
c9d747d407
commit
6128aeaa89
@@ -85,7 +85,7 @@ func (t *TOTPCheckCommand) Events(ctx context.Context, opts *InvokeOpts) ([]even
|
||||
events := make([]eventstore.Command, 1, 2)
|
||||
userAgg := &user.NewAggregate(t.FetchedUser.ID, t.FetchedUser.OrganizationID).Aggregate
|
||||
if t.IsCheckSuccessful {
|
||||
events[0] = user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil)
|
||||
events[0] = user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, nil)
|
||||
return append(events, session.NewTOTPCheckedEvent(ctx, &session.NewAggregate(t.SessionID, t.InstanceID).Aggregate, t.CheckedAt)), nil
|
||||
}
|
||||
events[0] = user.NewHumanOTPCheckFailedEvent(ctx, userAgg, nil)
|
||||
|
||||
@@ -877,7 +877,7 @@ func TestTOTPCheckCommand_Events(t *testing.T) {
|
||||
},
|
||||
|
||||
expectedEvents: []eventstore.Command{
|
||||
user.NewHumanOTPCheckSucceededEvent(t.Context(), &userAgg, nil),
|
||||
user.NewHumanOTPCheckSucceededEvent(t.Context(), &userAgg, nil, nil),
|
||||
session.NewTOTPCheckedEvent(t.Context(), &sessionAgg, time.Now()),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -500,6 +500,7 @@ Errors:
|
||||
AlreadyReady: المصادقة متعددة العوامل OTP (كلمة مرور لمرة واحدة) معدة بالفعل
|
||||
NotExisting: المصادقة متعددة العوامل OTP (كلمة مرور لمرة واحدة) غير موجودة
|
||||
InvalidCode: رمز غير صالح
|
||||
Reused: تم استخدام هذا الرمز بالفعل. يرجى انتظار رمز جديد
|
||||
NotReady: المصادقة متعددة العوامل OTP (كلمة مرور لمرة واحدة) ليست جاهزة
|
||||
Locked: المستخدم مقفل
|
||||
SomethingWentWrong: حدث خطأ ما
|
||||
|
||||
@@ -492,6 +492,7 @@ Errors:
|
||||
AlreadyReady: Многофакторният OTP (OneTimePassword) вече е настроен
|
||||
NotExisting: Многофакторният OTP (OneTimePassword) не съществува
|
||||
InvalidCode: Невалиден код
|
||||
Reused: Този код вече е използван. Моля, изчакайте нов код
|
||||
NotReady: Многофакторният OTP (OneTimePassword) не е готов
|
||||
Locked: Потребителят е заключен
|
||||
SomethingWentWrong: Нещо се обърка
|
||||
|
||||
@@ -504,6 +504,7 @@ Errors:
|
||||
AlreadyReady: Vícefaktorové OTP (jednorázové heslo) je již nastaveno
|
||||
NotExisting: Vícefaktorové OTP (jednorázové heslo) neexistuje
|
||||
InvalidCode: Neplatný kód
|
||||
Reused: Tento kód již byl použit. Počkejte prosím na nový kód
|
||||
NotReady: Vícefaktorové OTP (jednorázové heslo) není připraveno
|
||||
Locked: Uživatel je uzamčen
|
||||
SomethingWentWrong: Něco se pokazilo
|
||||
|
||||
@@ -503,6 +503,7 @@ Errors:
|
||||
AlreadyReady: Multifaktor OTP (OneTimePassword) ist bereits eingerichtet
|
||||
NotExisting: Multifaktor OTP (OneTimePassword) existiert nicht
|
||||
InvalidCode: Code ist ungültig
|
||||
Reused: Dieser Code wurde bereits verwendet. Bitte warten Sie auf einen neuen Code
|
||||
NotReady: Multifaktor OTP (OneTimePassword) ist nicht bereit
|
||||
Locked: Benutzer ist gesperrt
|
||||
SomethingWentWrong: Irgendetwas ist schief gelaufen
|
||||
|
||||
@@ -504,6 +504,7 @@ Errors:
|
||||
AlreadyReady: Multifactor OTP (OneTimePassword) is already setup
|
||||
NotExisting: Multifactor OTP (OneTimePassword) doesn't exist
|
||||
InvalidCode: Invalid code
|
||||
Reused: This code was already used. Please wait for a new code
|
||||
NotReady: Multifactor OTP (OneTimePassword) isn't ready
|
||||
Locked: User is locked
|
||||
SomethingWentWrong: Something went wrong
|
||||
|
||||
@@ -487,6 +487,7 @@ Errors:
|
||||
AlreadyReady: El multifactor OTP (OneTimePassword) ya está configurado
|
||||
NotExisting: El multifactor OTP (OneTimePassword) no existe
|
||||
InvalidCode: Código no válido
|
||||
Reused: Este código ya se ha utilizado. Espera a que se genere un nuevo código
|
||||
NotReady: El multifactor OTP (OneTimePassword) no está listo
|
||||
Locked: El usuario está bloqueado
|
||||
SomethingWentWrong: Algo fue mal
|
||||
|
||||
@@ -505,6 +505,7 @@ Errors:
|
||||
AlreadyReady: L'OTP multifactoriel (Mot de passe à usage unique) est déjà configuré.
|
||||
NotExisting: OTP multifactoriel (Mot de passe à usage unique) n'existe pas.
|
||||
InvalidCode: Code invalide
|
||||
Reused: Ce code a déjà été utilisé. Veuillez attendre un nouveau code
|
||||
NotReady: Le système OTP multifactoriel (Mot de passe à usage unique) n'est pas prêt.
|
||||
Locked: L'utilisateur est verrouillé
|
||||
SomethingWentWrong: Il y a eu un problème
|
||||
|
||||
@@ -464,6 +464,7 @@ Errors:
|
||||
AlreadyReady: A többfaktoros OTP (OneTimePassword) már be van állítva
|
||||
NotExisting: A többfaktoros OTP (OneTimePassword) nem létezik
|
||||
InvalidCode: Érvénytelen kód
|
||||
Reused: Ezt a kódot már felhasználták. Kérjük, várjon egy új kódra
|
||||
NotReady: A többfaktoros OTP (OneTimePassword) nem áll készen
|
||||
Locked: A felhasználó zárolva van
|
||||
SomethingWentWrong: Valami elromlott
|
||||
|
||||
@@ -464,6 +464,7 @@ Errors:
|
||||
AlreadyReady: OTP multifaktor (OneTimePassword) sudah disiapkan
|
||||
NotExisting: OTP multifaktor (OneTimePassword) tidak ada
|
||||
InvalidCode: Kode tidak valid
|
||||
Reused: Kode ini sudah digunakan. Silakan tunggu kode baru
|
||||
NotReady: OTP multifaktor (OneTimePassword) belum siap
|
||||
Locked: Pengguna terkunci
|
||||
SomethingWentWrong: Ada yang tidak beres
|
||||
|
||||
@@ -505,6 +505,7 @@ Errors:
|
||||
AlreadyReady: Multifactor OTP (OneTimePassword) è già impostato
|
||||
NotExisting: Multifactor OTP (OneTimePassword) non esiste
|
||||
InvalidCode: Codice non valido
|
||||
Reused: Questo codice è già stato utilizzato. Attendere un nuovo codice
|
||||
NotReady: Multifattore OTP (OneTimePassword) non è pronto
|
||||
Locked: L'utente è bloccato
|
||||
SomethingWentWrong: Qualcosa è andato storto
|
||||
|
||||
@@ -505,6 +505,7 @@ Errors:
|
||||
AlreadyReady: 多要素OTP(ワンタイムパスワード)は設定済みです
|
||||
NotExisting: 多要素OTP(ワンタイムパスワード)が存在しません
|
||||
InvalidCode: 無効なコード
|
||||
Reused: このコードはすでに使用されています。新しいコードをお待ちください
|
||||
NotReady: 多要素OTP(ワンタイムパスワード)は利用可能でありません
|
||||
Locked: ユーザーはロックされています
|
||||
SomethingWentWrong: エラーが発生しました
|
||||
|
||||
@@ -504,6 +504,7 @@ Errors:
|
||||
AlreadyReady: 다중 인증 OTP(일회용 비밀번호)가 이미 설정되었습니다
|
||||
NotExisting: 다중 인증 OTP(일회용 비밀번호)가 존재하지 않습니다
|
||||
InvalidCode: 잘못된 코드입니다
|
||||
Reused: 이 코드는 이미 사용되었습니다. 새 코드를 기다려 주세요
|
||||
NotReady: 다중 인증 OTP(일회용 비밀번호)가 준비되지 않았습니다
|
||||
Locked: 사용자가 잠겼습니다
|
||||
SomethingWentWrong: 문제가 발생했습니다
|
||||
|
||||
@@ -505,6 +505,7 @@ Errors:
|
||||
AlreadyReady: Мултифактор OTP (Еднократна Лозинка) е веќе подесена
|
||||
NotExisting: Мултифактор OTP (Еднократна Лозинка) не постои
|
||||
InvalidCode: Невалиден код
|
||||
Reused: Овој код е веќе искористен. Ве молиме почекајте нов код
|
||||
NotReady: Мултифактор OTP (Еднократна Лозинка) не е подготвена
|
||||
Locked: Корисникот е заклучен
|
||||
SomethingWentWrong: Се случи нешто неочекувано
|
||||
|
||||
@@ -504,6 +504,7 @@ Errors:
|
||||
AlreadyReady: Multifactor OTP (OneTimePassword) is al ingesteld
|
||||
NotExisting: Multifactor OTP (OneTimePassword) bestaat niet
|
||||
InvalidCode: Ongeldige code
|
||||
Reused: Deze code is al gebruikt. Wacht op een nieuwe code
|
||||
NotReady: Multifactor OTP (OneTimePassword) is niet klaar
|
||||
Locked: Gebruiker is vergrendeld
|
||||
SomethingWentWrong: Er is iets misgegaan
|
||||
|
||||
@@ -505,6 +505,7 @@ Errors:
|
||||
AlreadyReady: Wieloskładnikowe OTP (jednorazowe hasło) jest już skonfigurowane
|
||||
NotExisting: Wieloskładnikowe OTP (jednorazowe hasło) nie istnieje
|
||||
InvalidCode: Nieprawidłowy kod
|
||||
Reused: Ten kod został już użyty. Poczekaj na nowy kod
|
||||
NotReady: Wieloskładnikowe OTP (jednorazowe hasło) nie jest gotowe
|
||||
Locked: Użytkownik jest zablokowany
|
||||
SomethingWentWrong: Coś poszło nie tak
|
||||
|
||||
@@ -501,6 +501,7 @@ Errors:
|
||||
AlreadyReady: A autenticação de vários fatores por OTP (senha única) já está configurada
|
||||
NotExisting: A autenticação de vários fatores por OTP (senha única) não existe
|
||||
InvalidCode: Código inválido
|
||||
Reused: Este código já foi utilizado. Aguarde um novo código
|
||||
NotReady: A autenticação de vários fatores por OTP (senha única) não está pronta
|
||||
Locked: O usuário está bloqueado
|
||||
SomethingWentWrong: Algo deu errado
|
||||
|
||||
@@ -504,6 +504,7 @@ Errors:
|
||||
AlreadyReady: MFA OTP (OneTimePassword) este deja configurat
|
||||
NotExisting: Multifactor OTP (OneTimePassword) nu există
|
||||
InvalidCode: Cod nevalid
|
||||
Reused: Acest cod a fost deja utilizat. Vă rugăm să așteptați un cod nou
|
||||
NotReady: Multifactor OTP (OneTimePassword) nu este gata
|
||||
Locked: Utilizatorul este blocat
|
||||
SomethingWentWrong: Ceva nu a mers bine
|
||||
|
||||
@@ -505,6 +505,7 @@ Errors:
|
||||
AlreadyReady: OTP (OneTimePassword) уже настроен
|
||||
NotExisting: OTP (OneTimePassword) не существует
|
||||
InvalidCode: Неверный код
|
||||
Reused: Этот код уже был использован. Пожалуйста, дождитесь нового кода
|
||||
NotReady: OTP (OneTimePassword) не готов
|
||||
Locked: Пользователь заблокирован
|
||||
SomethingWentWrong: Что-то пошло не так
|
||||
|
||||
@@ -504,6 +504,7 @@ Errors:
|
||||
AlreadyReady: Tvåfaktor OTP (OneTimePassword) är redan konfigurerat
|
||||
NotExisting: Tvåfaktor OTP (OneTimePassword) finns inte
|
||||
InvalidCode: Ogiltig kod
|
||||
Reused: Den här koden har redan använts. Vänta på en ny kod
|
||||
NotReady: Tvåfaktor OTP (OneTimePassword) är inte redo
|
||||
Locked: Användaren är spärrad
|
||||
SomethingWentWrong: Någonting gick fel
|
||||
|
||||
@@ -504,6 +504,7 @@ Errors:
|
||||
AlreadyReady: Çok faktörlü OTP (Tek Seferlik Şifre) zaten ayarlanmış
|
||||
NotExisting: Çok faktörlü OTP (Tek Seferlik Şifre) mevcut değil
|
||||
InvalidCode: Geçersiz kod
|
||||
Reused: Bu kod zaten kullanıldı. Lütfen yeni bir kod bekleyin
|
||||
NotReady: Çok faktörlü OTP (Tek Seferlik Şifre) hazır değil
|
||||
Locked: Kullanıcı kilitli
|
||||
SomethingWentWrong: Bir şeyler yanlış gitti
|
||||
|
||||
@@ -505,6 +505,7 @@ Errors:
|
||||
AlreadyReady: Багатофакторний OTP (одноразовий пароль) уже налаштовано
|
||||
NotExisting: Багатофакторний OTP (одноразовий пароль) не існує
|
||||
InvalidCode: Недійсний код
|
||||
Reused: Цей код вже було використано. Будь ласка, зачекайте на новий код
|
||||
NotReady: Багатофакторний OTP (одноразовий пароль) не готовий
|
||||
Locked: Користувача заблоковано
|
||||
SomethingWentWrong: Щось пішло не так
|
||||
|
||||
@@ -504,6 +504,7 @@ Errors:
|
||||
AlreadyReady: OTP (一次性密码) 已设置
|
||||
NotExisting: OTP (一次性密码) 不存在
|
||||
InvalidCode: 无效的验证码
|
||||
Reused: 此验证码已被使用。请等待新的验证码
|
||||
NotReady: OTP (一次性密码) 还没准备好
|
||||
Locked: 用户被锁定
|
||||
SomethingWentWrong: 似乎出问题了
|
||||
|
||||
@@ -1074,6 +1074,23 @@ func TestCommands_updateSession(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// assertTOTPCodeHash asserts that every [user.HumanOTPCheckSucceededEvent] in
|
||||
// cmds carries a hash of code and clears it afterwards. The hash is salted with
|
||||
// a random value, so it cannot be compared against a precomputed expectation.
|
||||
func assertTOTPCodeHash(t *testing.T, cmds []eventstore.Command, code string) {
|
||||
t.Helper()
|
||||
for _, cmd := range cmds {
|
||||
event, ok := cmd.(*user.HumanOTPCheckSucceededEvent)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if assert.NotNil(t, event.CodeHash, "the OTP check succeeded event must carry a code hash") {
|
||||
assert.True(t, event.CodeHash.Equal(code), "the code hash must match the checked code")
|
||||
}
|
||||
event.CodeHash = nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckTOTP(t *testing.T) {
|
||||
ctx := authz.NewMockContext("instance1", "org1", "user1")
|
||||
|
||||
@@ -1090,6 +1107,9 @@ func TestCheckTOTP(t *testing.T) {
|
||||
code, err := totp.GenerateCode(key.Secret(), testNow)
|
||||
require.NoError(t, err)
|
||||
|
||||
// hash of a code which is guaranteed to be different from the checked code.
|
||||
otherCodeHash := crypto.NewHMACValue(code + "0")
|
||||
|
||||
type fields struct {
|
||||
sessionWriteModel *SessionWriteModel
|
||||
eventstore func(*testing.T) *eventstore.Eventstore
|
||||
@@ -1236,10 +1256,138 @@ func TestCheckTOTP(t *testing.T) {
|
||||
tarpit: expectTarpit(0),
|
||||
},
|
||||
wantEventCommands: []eventstore.Command{
|
||||
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil),
|
||||
// The code hash is salted randomly and therefore not predictable.
|
||||
// It is asserted separately by assertTOTPCodeHash.
|
||||
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, nil),
|
||||
session.NewTOTPCheckedEvent(ctx, sessAgg, testNow),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok, previous check with a different code",
|
||||
code: code,
|
||||
fields: fields{
|
||||
sessionWriteModel: &SessionWriteModel{
|
||||
UserID: "user1",
|
||||
UserCheckedAt: testNow,
|
||||
aggregate: sessAgg,
|
||||
},
|
||||
eventstore: expectEventstore(
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPVerifiedEvent(ctx, userAgg, "agent1"),
|
||||
),
|
||||
eventFromEventPusherWithCreationDateNow(
|
||||
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, otherCodeHash),
|
||||
),
|
||||
),
|
||||
expectFilter(), // recheck
|
||||
),
|
||||
tarpit: expectTarpit(0),
|
||||
},
|
||||
wantEventCommands: []eventstore.Command{
|
||||
// The code hash is salted randomly and therefore not predictable.
|
||||
// It is asserted separately by assertTOTPCodeHash.
|
||||
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, nil),
|
||||
session.NewTOTPCheckedEvent(ctx, sessAgg, testNow),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ok, previous check of the same code outside of the reuse window",
|
||||
code: code,
|
||||
fields: fields{
|
||||
sessionWriteModel: &SessionWriteModel{
|
||||
UserID: "user1",
|
||||
UserCheckedAt: testNow,
|
||||
aggregate: sessAgg,
|
||||
},
|
||||
eventstore: expectEventstore(
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPVerifiedEvent(ctx, userAgg, "agent1"),
|
||||
),
|
||||
eventFromEventPusherWithCreationDate(
|
||||
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, crypto.NewHMACValue(code)),
|
||||
time.Now().Add(-2*time.Minute),
|
||||
),
|
||||
),
|
||||
expectFilter(), // recheck
|
||||
),
|
||||
tarpit: expectTarpit(0),
|
||||
},
|
||||
wantEventCommands: []eventstore.Command{
|
||||
// The code hash is salted randomly and therefore not predictable.
|
||||
// It is asserted separately by assertTOTPCodeHash.
|
||||
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, nil),
|
||||
session.NewTOTPCheckedEvent(ctx, sessAgg, testNow),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "code reuse error",
|
||||
code: code,
|
||||
fields: fields{
|
||||
sessionWriteModel: &SessionWriteModel{
|
||||
UserID: "user1",
|
||||
UserCheckedAt: testNow,
|
||||
aggregate: sessAgg,
|
||||
},
|
||||
eventstore: expectEventstore(
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPVerifiedEvent(ctx, userAgg, "agent1"),
|
||||
),
|
||||
eventFromEventPusherWithCreationDateNow(
|
||||
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, crypto.NewHMACValue(code)),
|
||||
),
|
||||
),
|
||||
expectFilter(), // recheck
|
||||
),
|
||||
tarpit: expectTarpit(0),
|
||||
},
|
||||
wantErrorCommands: []eventstore.Command{
|
||||
user.NewHumanOTPCheckReusedEvent(ctx, userAgg, nil),
|
||||
},
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "TOTP-Auw0a", "Errors.User.MFA.OTP.Reused"),
|
||||
},
|
||||
{
|
||||
name: "code reuse error, used in the meantime",
|
||||
code: code,
|
||||
fields: fields{
|
||||
sessionWriteModel: &SessionWriteModel{
|
||||
UserID: "user1",
|
||||
UserCheckedAt: testNow,
|
||||
aggregate: sessAgg,
|
||||
},
|
||||
eventstore: expectEventstore(
|
||||
expectFilter(
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPAddedEvent(ctx, userAgg, secret),
|
||||
),
|
||||
eventFromEventPusher(
|
||||
user.NewHumanOTPVerifiedEvent(ctx, userAgg, "agent1"),
|
||||
),
|
||||
),
|
||||
expectFilter( // recheck
|
||||
eventFromEventPusherWithCreationDateNow(
|
||||
user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, nil, crypto.NewHMACValue(code)),
|
||||
),
|
||||
),
|
||||
),
|
||||
tarpit: expectTarpit(0),
|
||||
},
|
||||
wantErrorCommands: []eventstore.Command{
|
||||
user.NewHumanOTPCheckReusedEvent(ctx, userAgg, nil),
|
||||
},
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "TOTP-Auw0a", "Errors.User.MFA.OTP.Reused"),
|
||||
},
|
||||
{
|
||||
name: "ok, but locked in the meantime",
|
||||
code: code,
|
||||
@@ -1279,6 +1427,7 @@ func TestCheckTOTP(t *testing.T) {
|
||||
gotCmds, err := CheckTOTP(tt.code)(ctx, cmd)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
assert.Equal(t, tt.wantErrorCommands, gotCmds)
|
||||
assertTOTPCodeHash(t, cmd.eventCommands, tt.code)
|
||||
assert.Equal(t, tt.wantEventCommands, cmd.eventCommands)
|
||||
tt.fields.tarpit.metExpectedCalls(t)
|
||||
})
|
||||
|
||||
@@ -211,7 +211,19 @@ func checkTOTP(
|
||||
|
||||
// the OTP check succeeded and the user was not locked in the meantime
|
||||
if verifyErr == nil {
|
||||
return []eventstore.Command{user.NewHumanOTPCheckSucceededEvent(ctx, userAgg, optionalAuthRequestInfo)}, nil
|
||||
// A replay is reported as a distinct event, so that it remains observable
|
||||
// through the events API, without counting towards the lockout policy.
|
||||
// Otherwise anyone who captured a code could replay it to lock the user out.
|
||||
if reuseErr := existingOTP.History.CheckReuse(code); reuseErr != nil {
|
||||
return []eventstore.Command{user.NewHumanOTPCheckReusedEvent(ctx, userAgg, optionalAuthRequestInfo)}, reuseErr
|
||||
}
|
||||
|
||||
return []eventstore.Command{user.NewHumanOTPCheckSucceededEvent(
|
||||
ctx,
|
||||
userAgg,
|
||||
optionalAuthRequestInfo,
|
||||
crypto.NewHMACValue(code),
|
||||
)}, nil
|
||||
}
|
||||
|
||||
// the OTP check failed, therefore check if the limit was reached and the user must additionally be locked
|
||||
|
||||
@@ -16,6 +16,7 @@ type HumanTOTPWriteModel struct {
|
||||
Secret *crypto.CryptoValue
|
||||
CheckFailedCount uint64
|
||||
UserLocked bool
|
||||
History *domain.TOTPHistory
|
||||
}
|
||||
|
||||
func NewHumanTOTPWriteModel(userID, resourceOwner string) *HumanTOTPWriteModel {
|
||||
@@ -24,6 +25,7 @@ func NewHumanTOTPWriteModel(userID, resourceOwner string) *HumanTOTPWriteModel {
|
||||
AggregateID: userID,
|
||||
ResourceOwner: resourceOwner,
|
||||
},
|
||||
History: new(domain.TOTPHistory),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +40,7 @@ func (wm *HumanTOTPWriteModel) Reduce() error {
|
||||
wm.CheckFailedCount = 0
|
||||
case *user.HumanOTPCheckSucceededEvent:
|
||||
wm.CheckFailedCount = 0
|
||||
wm.History.AddRecent(e.CreatedAt(), e.CodeHash)
|
||||
case *user.HumanOTPCheckFailedEvent:
|
||||
wm.CheckFailedCount++
|
||||
case *user.UserLockedEvent:
|
||||
@@ -87,6 +90,11 @@ func (wm *HumanTOTPWriteModel) Query() *eventstore.SearchQueryBuilder {
|
||||
if wm.ResourceOwner != "" {
|
||||
query.ResourceOwner(wm.ResourceOwner)
|
||||
}
|
||||
// checkTOTP rechecks the same write model for events which arrived during the
|
||||
// code verification. Bound the query so only those are reduced again.
|
||||
if wm.ProcessedSequence != 0 {
|
||||
query.SequenceGreater(wm.ProcessedSequence)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHumanTOTPWriteModel_Query(t *testing.T) {
|
||||
wm := NewHumanTOTPWriteModel("user1", "org1")
|
||||
|
||||
// A freshly created write model has nothing reduced yet
|
||||
// and therefore must query the complete stream.
|
||||
unbound := wm.Query()
|
||||
assert.Zero(t, unbound.GetEventSequenceGreater())
|
||||
|
||||
// checkTOTP rechecks the same write model after the code verification.
|
||||
// Only the events which arrived in the meantime may be reduced again.
|
||||
wm.ProcessedSequence = 42
|
||||
bound := wm.Query()
|
||||
assert.Equal(t, uint64(42), bound.GetEventSequenceGreater())
|
||||
|
||||
// The sequence must be the only difference between both queries.
|
||||
assert.Equal(t, unbound.SequenceGreater(42), bound)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
)
|
||||
|
||||
// HMACValue is a randomly salted HMAC-SHA256 of a payload.
|
||||
// It allows checking whether a payload was seen before,
|
||||
// without having to store the payload itself.
|
||||
//
|
||||
// It is intended for low entropy secrets, such as 6 digit TOTP codes:
|
||||
// the random salt makes each value unique, even for an equal payload,
|
||||
// which counters rainbow table usage and prevents that two stored values
|
||||
// can be recognized as being derived from the same payload.
|
||||
//
|
||||
// As the salt is stored alongside the hash, an HMACValue is not suitable
|
||||
// as a replacement for retrievable secrets. Use [CryptoValue] for that.
|
||||
//
|
||||
// Both fields are JSON serializable, so a value can be stored in an event payload.
|
||||
type HMACValue struct {
|
||||
Hash []byte `json:"hash"`
|
||||
Salt []byte `json:"salt"`
|
||||
}
|
||||
|
||||
// NewHMACValue returns the HMAC of payload, using a newly generated random salt.
|
||||
// Two calls with an equal payload always return different values.
|
||||
func NewHMACValue(payload string) *HMACValue {
|
||||
salt := make([]byte, 16)
|
||||
// [rand.Read] never returns an error, it panics if the system source fails.
|
||||
rand.Read(salt)
|
||||
hasher := hmac.New(sha256.New, salt)
|
||||
hasher.Write([]byte(payload))
|
||||
|
||||
return &HMACValue{
|
||||
Hash: hasher.Sum(nil),
|
||||
Salt: salt,
|
||||
}
|
||||
}
|
||||
|
||||
// Equal reports whether payload is the one v was created from.
|
||||
// The hashes are compared in constant time.
|
||||
func (v *HMACValue) Equal(payload string) bool {
|
||||
hasher := hmac.New(sha256.New, v.Salt)
|
||||
hasher.Write([]byte(payload))
|
||||
expectedHash := hasher.Sum(nil)
|
||||
return hmac.Equal(v.Hash, expectedHash)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewHMACValue(t *testing.T) {
|
||||
const payload = "123456"
|
||||
|
||||
got := NewHMACValue(payload)
|
||||
require.NotNil(t, got)
|
||||
assert.Len(t, got.Salt, 16)
|
||||
assert.Len(t, got.Hash, sha256.Size)
|
||||
assert.True(t, got.Equal(payload), "the returned value must match the payload it was created from")
|
||||
|
||||
other := NewHMACValue(payload)
|
||||
assert.NotEqual(t, got.Salt, other.Salt, "the salt must be random")
|
||||
assert.NotEqual(t, got.Hash, other.Hash, "an equal payload must not result in an equal hash")
|
||||
assert.True(t, other.Equal(payload))
|
||||
}
|
||||
|
||||
func TestNewHMACValue_emptyPayload(t *testing.T) {
|
||||
got := NewHMACValue("")
|
||||
require.NotNil(t, got)
|
||||
assert.Len(t, got.Salt, 16)
|
||||
assert.Len(t, got.Hash, sha256.Size)
|
||||
assert.True(t, got.Equal(""))
|
||||
assert.False(t, got.Equal("123456"))
|
||||
}
|
||||
|
||||
func TestHMACValue_Equal(t *testing.T) {
|
||||
const payload = "123456"
|
||||
|
||||
// tampered copies of a valid value.
|
||||
wrongHash := NewHMACValue(payload)
|
||||
wrongHash.Hash[0]++
|
||||
wrongSalt := NewHMACValue(payload)
|
||||
wrongSalt.Salt[0]++
|
||||
truncatedHash := NewHMACValue(payload)
|
||||
truncatedHash.Hash = truncatedHash.Hash[:sha256.Size-1]
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value *HMACValue
|
||||
payload string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "same payload",
|
||||
value: NewHMACValue(payload),
|
||||
payload: payload,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "different payload",
|
||||
value: NewHMACValue(payload),
|
||||
payload: "654321",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty payload",
|
||||
value: NewHMACValue(payload),
|
||||
payload: "",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "payload with an appended character",
|
||||
value: NewHMACValue(payload),
|
||||
payload: payload + "0",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "modified hash",
|
||||
value: wrongHash,
|
||||
payload: payload,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "modified salt",
|
||||
value: wrongSalt,
|
||||
payload: payload,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "truncated hash",
|
||||
value: truncatedHash,
|
||||
payload: payload,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "zero value",
|
||||
value: new(HMACValue),
|
||||
payload: payload,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "zero value, empty payload",
|
||||
value: new(HMACValue),
|
||||
payload: "",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, tt.value.Equal(tt.payload))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHMACValue_json makes sure a value survives a round-trip through an
|
||||
// event payload, as it is stored and read back as JSON.
|
||||
func TestHMACValue_json(t *testing.T) {
|
||||
const payload = "123456"
|
||||
|
||||
data, err := json.Marshal(NewHMACValue(payload))
|
||||
require.NoError(t, err)
|
||||
|
||||
got := new(HMACValue)
|
||||
require.NoError(t, json.Unmarshal(data, got))
|
||||
|
||||
assert.Len(t, got.Salt, 16)
|
||||
assert.Len(t, got.Hash, sha256.Size)
|
||||
assert.True(t, got.Equal(payload), "an unmarshalled value must still match its payload")
|
||||
assert.False(t, got.Equal("654321"))
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp"
|
||||
"github.com/pquerna/otp/totp"
|
||||
|
||||
@@ -8,6 +11,16 @@ import (
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
// These constants are not exported by the totp library,
|
||||
// but are important for our reuse prevention logic.
|
||||
// They are taken from the [totp.Validate] implementation,
|
||||
// and are compatible with Google-Authenticator and most clients.
|
||||
// See [totp.ValidateOpts] for the meaning of these values.
|
||||
const (
|
||||
TOTPPeriod = 30
|
||||
TOTPSkew = 1
|
||||
)
|
||||
|
||||
type TOTP struct {
|
||||
*ObjectDetails
|
||||
|
||||
@@ -16,7 +29,11 @@ type TOTP struct {
|
||||
}
|
||||
|
||||
func NewTOTPKey(issuer, accountName string) (*otp.Key, error) {
|
||||
key, err := totp.Generate(totp.GenerateOpts{Issuer: issuer, AccountName: accountName})
|
||||
key, err := totp.Generate(totp.GenerateOpts{
|
||||
Issuer: issuer,
|
||||
AccountName: accountName,
|
||||
Period: TOTPPeriod,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, zerrors.ThrowInternal(err, "TOTP-ieY3o", "Errors.Internal")
|
||||
}
|
||||
@@ -29,9 +46,60 @@ func VerifyTOTP(code string, secret *crypto.CryptoValue, cryptoAlg crypto.Encryp
|
||||
return err
|
||||
}
|
||||
|
||||
valid := totp.Validate(code, decrypt)
|
||||
if !valid {
|
||||
return zerrors.ThrowInvalidArgument(nil, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode")
|
||||
// Use ValidateCustom so we have compile-time alignment of constants,
|
||||
// as [totp.Validate] does not export the default values.
|
||||
valid, err := totp.ValidateCustom(
|
||||
code,
|
||||
decrypt,
|
||||
time.Now().UTC(),
|
||||
totp.ValidateOpts{
|
||||
Period: TOTPPeriod,
|
||||
Skew: TOTPSkew,
|
||||
Digits: otp.DigitsSix,
|
||||
Algorithm: otp.AlgorithmSHA1,
|
||||
})
|
||||
if err != nil || !valid {
|
||||
return zerrors.ThrowInvalidArgument(err, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
checkPeriods = 2 + 2*TOTPSkew // 1 extra period to account for DB clock skew.
|
||||
checkDuration = TOTPPeriod * time.Second * checkPeriods
|
||||
)
|
||||
|
||||
type TOTPHistory struct {
|
||||
start time.Time
|
||||
recentValues []*crypto.HMACValue
|
||||
}
|
||||
|
||||
// AddRecent appends the HMAC value of a used code if its timestamp is
|
||||
// within the current period window and value is not nil.
|
||||
// A nil value is ignored, as it originates from a legacy event
|
||||
// which did not record the used code yet.
|
||||
func (h *TOTPHistory) AddRecent(ts time.Time, value *crypto.HMACValue) {
|
||||
if value == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// If the start is zero, we assume this is the first code being added,
|
||||
// and set the start to the beginning of the current window.
|
||||
// This ensures that we only store codes that are within the valid window.
|
||||
if h.start.IsZero() {
|
||||
h.start = time.Now().Add(-checkDuration)
|
||||
}
|
||||
if ts.After(h.start) {
|
||||
h.recentValues = append(h.recentValues, value)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckReuse returns an error if the provided code was recently used.
|
||||
func (h *TOTPHistory) CheckReuse(code string) error {
|
||||
if slices.ContainsFunc(h.recentValues, func(v *crypto.HMACValue) bool {
|
||||
return v.Equal(code)
|
||||
}) {
|
||||
return zerrors.ThrowInvalidArgument(nil, "TOTP-Auw0a", "Errors.User.MFA.OTP.Reused")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/pquerna/otp/totp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/mock/gomock"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/zerrors"
|
||||
)
|
||||
|
||||
// stableNow returns a timestamp which is not in the last second of a TOTP period.
|
||||
// VerifyTOTP validates against the wall clock, so this guarantees the period
|
||||
// does not tick over between generating a code and validating it,
|
||||
// which would shift the expected validity window of the skew based test cases.
|
||||
func stableNow(t *testing.T) time.Time {
|
||||
t.Helper()
|
||||
for {
|
||||
now := time.Now()
|
||||
if now.Unix()%TOTPPeriod != TOTPPeriod-1 {
|
||||
return now
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTOTP(t *testing.T) {
|
||||
cryptoAlg := crypto.CreateMockEncryptionAlg(gomock.NewController(t))
|
||||
key, err := NewTOTPKey("example.com", "user1")
|
||||
require.NoError(t, err)
|
||||
secret, err := crypto.Encrypt([]byte(key.Secret()), cryptoAlg)
|
||||
require.NoError(t, err)
|
||||
|
||||
unknownKeySecret := &crypto.CryptoValue{
|
||||
CryptoType: secret.CryptoType,
|
||||
Algorithm: secret.Algorithm,
|
||||
KeyID: "unknown",
|
||||
Crypted: secret.Crypted,
|
||||
}
|
||||
|
||||
base := stableNow(t)
|
||||
codeAt := func(ts time.Time) string {
|
||||
t.Helper()
|
||||
code, err := totp.GenerateCode(key.Secret(), ts)
|
||||
require.NoError(t, err)
|
||||
return code
|
||||
}
|
||||
|
||||
type args struct {
|
||||
code string
|
||||
secret *crypto.CryptoValue
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "current period",
|
||||
args: args{
|
||||
code: codeAt(base),
|
||||
secret: secret,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "previous period, within skew",
|
||||
args: args{
|
||||
code: codeAt(base.Add(-TOTPPeriod * time.Second)),
|
||||
secret: secret,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "next period, within skew",
|
||||
args: args{
|
||||
code: codeAt(base.Add(TOTPPeriod * time.Second)),
|
||||
secret: secret,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "two periods in the past, outside skew",
|
||||
args: args{
|
||||
code: codeAt(base.Add(-2 * TOTPPeriod * time.Second)),
|
||||
secret: secret,
|
||||
},
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode"),
|
||||
},
|
||||
{
|
||||
name: "two periods in the future, outside skew",
|
||||
args: args{
|
||||
code: codeAt(base.Add(2 * TOTPPeriod * time.Second)),
|
||||
secret: secret,
|
||||
},
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode"),
|
||||
},
|
||||
{
|
||||
name: "malformed code",
|
||||
args: args{
|
||||
code: "foobar",
|
||||
secret: secret,
|
||||
},
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode"),
|
||||
},
|
||||
{
|
||||
name: "empty code",
|
||||
args: args{
|
||||
code: "",
|
||||
secret: secret,
|
||||
},
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "EVENT-8isk2", "Errors.User.MFA.OTP.InvalidCode"),
|
||||
},
|
||||
{
|
||||
name: "missing secret",
|
||||
args: args{
|
||||
code: codeAt(base),
|
||||
secret: nil,
|
||||
},
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "CRYPT-mNsQwe", "input value cannot be nil"),
|
||||
},
|
||||
{
|
||||
name: "unknown encryption key",
|
||||
args: args{
|
||||
code: codeAt(base),
|
||||
secret: unknownKeySecret,
|
||||
},
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "CRYPT-Kq12vn", "value was encrypted with a different key"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := VerifyTOTP(tt.args.code, tt.args.secret, cryptoAlg)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPHistory_AddRecent(t *testing.T) {
|
||||
// start of a window which is still open.
|
||||
start := time.Now().Add(-checkDuration)
|
||||
|
||||
hash1 := crypto.NewHMACValue("123456")
|
||||
hash2 := crypto.NewHMACValue("654321")
|
||||
|
||||
type args struct {
|
||||
ts time.Time
|
||||
value *crypto.HMACValue
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
history *TOTPHistory
|
||||
args args
|
||||
wantValues []*crypto.HMACValue
|
||||
}{
|
||||
{
|
||||
name: "zero start, timestamp inside the window",
|
||||
history: new(TOTPHistory),
|
||||
args: args{
|
||||
ts: time.Now(),
|
||||
value: hash1,
|
||||
},
|
||||
wantValues: []*crypto.HMACValue{hash1},
|
||||
},
|
||||
{
|
||||
name: "zero start, timestamp outside the window",
|
||||
history: new(TOTPHistory),
|
||||
args: args{
|
||||
ts: time.Now().Add(-checkDuration - time.Minute),
|
||||
value: hash1,
|
||||
},
|
||||
wantValues: nil,
|
||||
},
|
||||
{
|
||||
name: "timestamp exactly at the start of the window",
|
||||
history: &TOTPHistory{start: start},
|
||||
args: args{
|
||||
ts: start,
|
||||
value: hash1,
|
||||
},
|
||||
wantValues: nil,
|
||||
},
|
||||
{
|
||||
name: "appended after existing codes",
|
||||
history: &TOTPHistory{start: start, recentValues: []*crypto.HMACValue{hash1}},
|
||||
args: args{
|
||||
ts: time.Now(),
|
||||
value: hash2,
|
||||
},
|
||||
wantValues: []*crypto.HMACValue{hash1, hash2},
|
||||
},
|
||||
{
|
||||
name: "same value added twice",
|
||||
history: &TOTPHistory{start: start, recentValues: []*crypto.HMACValue{hash1}},
|
||||
args: args{
|
||||
ts: time.Now(),
|
||||
value: hash1,
|
||||
},
|
||||
wantValues: []*crypto.HMACValue{hash1, hash1},
|
||||
},
|
||||
{
|
||||
name: "nil value of a legacy event",
|
||||
history: &TOTPHistory{start: start},
|
||||
args: args{
|
||||
ts: time.Now(),
|
||||
value: nil,
|
||||
},
|
||||
wantValues: nil,
|
||||
},
|
||||
{
|
||||
name: "nil value of a legacy event, zero start",
|
||||
history: new(TOTPHistory),
|
||||
args: args{
|
||||
ts: time.Now(),
|
||||
value: nil,
|
||||
},
|
||||
wantValues: nil,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
startWasZero := tt.history.start.IsZero()
|
||||
startBefore := tt.history.start
|
||||
|
||||
tt.history.AddRecent(tt.args.ts, tt.args.value)
|
||||
|
||||
assert.Equal(t, tt.wantValues, tt.history.recentValues)
|
||||
switch {
|
||||
case tt.args.value == nil:
|
||||
assert.Equal(t, startBefore, tt.history.start, "a nil value must not set the start")
|
||||
case startWasZero:
|
||||
assert.WithinDuration(t, time.Now().Add(-checkDuration), tt.history.start, time.Second)
|
||||
default:
|
||||
assert.Equal(t, startBefore, tt.history.start, "an already set start must not be recomputed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPHistory_CheckReuse(t *testing.T) {
|
||||
recentValues := []*crypto.HMACValue{
|
||||
crypto.NewHMACValue("123456"),
|
||||
crypto.NewHMACValue("654321"),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
history *TOTPHistory
|
||||
code string
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "empty history",
|
||||
history: new(TOTPHistory),
|
||||
code: "123456",
|
||||
},
|
||||
{
|
||||
name: "code not used recently",
|
||||
history: &TOTPHistory{recentValues: recentValues},
|
||||
code: "111111",
|
||||
},
|
||||
{
|
||||
name: "first code reused",
|
||||
history: &TOTPHistory{recentValues: recentValues},
|
||||
code: "123456",
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "TOTP-Auw0a", "Errors.User.MFA.OTP.Reused"),
|
||||
},
|
||||
{
|
||||
name: "last code reused",
|
||||
history: &TOTPHistory{recentValues: recentValues},
|
||||
code: "654321",
|
||||
wantErr: zerrors.ThrowInvalidArgument(nil, "TOTP-Auw0a", "Errors.User.MFA.OTP.Reused"),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.history.CheckReuse(tt.code)
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTOTPHistory_addAndCheck(t *testing.T) {
|
||||
history := new(TOTPHistory)
|
||||
history.AddRecent(time.Now(), crypto.NewHMACValue("123456"))
|
||||
history.AddRecent(time.Now().Add(-checkDuration-time.Minute), crypto.NewHMACValue("654321"))
|
||||
history.AddRecent(time.Now(), nil)
|
||||
|
||||
require.Error(t, history.CheckReuse("123456"), "a code used inside the window must be reported as reused")
|
||||
require.NoError(t, history.CheckReuse("654321"), "a code used before the window must not be reported as reused")
|
||||
require.NoError(t, history.CheckReuse("111111"))
|
||||
}
|
||||
@@ -91,6 +91,7 @@ func init() {
|
||||
eventstore.RegisterFilterEventMapper(AggregateType, HumanMFAOTPRemovedType, HumanOTPRemovedEventMapper)
|
||||
eventstore.RegisterFilterEventMapper(AggregateType, HumanMFAOTPCheckSucceededType, HumanOTPCheckSucceededEventMapper)
|
||||
eventstore.RegisterFilterEventMapper(AggregateType, HumanMFAOTPCheckFailedType, HumanOTPCheckFailedEventMapper)
|
||||
eventstore.RegisterFilterEventMapper(AggregateType, HumanMFAOTPCheckReusedType, eventstore.GenericEventMapper[HumanOTPCheckReusedEvent])
|
||||
eventstore.RegisterFilterEventMapper(AggregateType, HumanOTPSMSAddedType, eventstore.GenericEventMapper[HumanOTPSMSAddedEvent])
|
||||
eventstore.RegisterFilterEventMapper(AggregateType, HumanOTPSMSRemovedType, eventstore.GenericEventMapper[HumanOTPSMSRemovedEvent])
|
||||
eventstore.RegisterFilterEventMapper(AggregateType, HumanOTPSMSCodeAddedType, eventstore.GenericEventMapper[HumanOTPSMSCodeAddedEvent])
|
||||
|
||||
@@ -18,6 +18,7 @@ const (
|
||||
HumanMFAOTPRemovedType = otpEventPrefix + "removed"
|
||||
HumanMFAOTPCheckSucceededType = otpEventPrefix + "check.succeeded"
|
||||
HumanMFAOTPCheckFailedType = otpEventPrefix + "check.failed"
|
||||
HumanMFAOTPCheckReusedType = otpEventPrefix + "check.reused"
|
||||
|
||||
otpSMSEventPrefix = otpEventPrefix + "sms."
|
||||
HumanOTPSMSAddedType = otpSMSEventPrefix + "added"
|
||||
@@ -145,6 +146,7 @@ func HumanOTPRemovedEventMapper(event eventstore.Event) (eventstore.Event, error
|
||||
type HumanOTPCheckSucceededEvent struct {
|
||||
eventstore.BaseEvent `json:"-"`
|
||||
*AuthRequestInfo
|
||||
CodeHash *crypto.HMACValue `json:"codeHash,omitempty"`
|
||||
}
|
||||
|
||||
func (e *HumanOTPCheckSucceededEvent) Payload() interface{} {
|
||||
@@ -159,6 +161,7 @@ func NewHumanOTPCheckSucceededEvent(
|
||||
ctx context.Context,
|
||||
aggregate *eventstore.Aggregate,
|
||||
info *AuthRequestInfo,
|
||||
codeHash *crypto.HMACValue,
|
||||
) *HumanOTPCheckSucceededEvent {
|
||||
return &HumanOTPCheckSucceededEvent{
|
||||
BaseEvent: *eventstore.NewBaseEventForPush(
|
||||
@@ -167,6 +170,7 @@ func NewHumanOTPCheckSucceededEvent(
|
||||
HumanMFAOTPCheckSucceededType,
|
||||
),
|
||||
AuthRequestInfo: info,
|
||||
CodeHash: codeHash,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +224,43 @@ func HumanOTPCheckFailedEventMapper(event eventstore.Event) (eventstore.Event, e
|
||||
return otpAdded, nil
|
||||
}
|
||||
|
||||
// HumanOTPCheckReusedEvent is pushed when a correct TOTP code was presented,
|
||||
// but had already been consumed within the current validation window.
|
||||
// It is deliberately distinct from [HumanOTPCheckFailedEvent]: a replay must be
|
||||
// observable to admins, but must not count towards the lockout policy.
|
||||
// Otherwise anyone who captured a code could replay it to lock the user out.
|
||||
type HumanOTPCheckReusedEvent struct {
|
||||
eventstore.BaseEvent `json:"-"`
|
||||
*AuthRequestInfo
|
||||
}
|
||||
|
||||
func (e *HumanOTPCheckReusedEvent) Payload() interface{} {
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *HumanOTPCheckReusedEvent) UniqueConstraints() []*eventstore.UniqueConstraint {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *HumanOTPCheckReusedEvent) SetBaseEvent(event *eventstore.BaseEvent) {
|
||||
e.BaseEvent = *event
|
||||
}
|
||||
|
||||
func NewHumanOTPCheckReusedEvent(
|
||||
ctx context.Context,
|
||||
aggregate *eventstore.Aggregate,
|
||||
info *AuthRequestInfo,
|
||||
) *HumanOTPCheckReusedEvent {
|
||||
return &HumanOTPCheckReusedEvent{
|
||||
BaseEvent: *eventstore.NewBaseEventForPush(
|
||||
ctx,
|
||||
aggregate,
|
||||
HumanMFAOTPCheckReusedType,
|
||||
),
|
||||
AuthRequestInfo: info,
|
||||
}
|
||||
}
|
||||
|
||||
type HumanOTPSMSAddedEvent struct {
|
||||
eventstore.BaseEvent `json:"-"`
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ Errors:
|
||||
NotExisting: "المصادقة متعددة العوامل OTP (كلمة مرور لمرة واحدة) غير موجودة"
|
||||
NotReady: "المصادقة متعددة العوامل OTP (كلمة مرور لمرة واحدة) ليست جاهزة"
|
||||
InvalidCode: "رمز غير صالح"
|
||||
Reused: "تم استخدام هذا الرمز بالفعل. يرجى انتظار رمز جديد"
|
||||
U2F:
|
||||
NotExisting: "U2F غير موجود"
|
||||
Passwordless:
|
||||
@@ -763,6 +764,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "نجح فحص OTP للمصادقة متعددة العوامل"
|
||||
failed: "فشل فحص OTP للمصادقة متعددة العوامل"
|
||||
reused: "أعيد استخدام رمز OTP للمصادقة متعددة العوامل"
|
||||
sms:
|
||||
added: "تمت إضافة OTP SMS للمصادقة متعددة العوامل"
|
||||
removed: "تمت إزالة OTP SMS للمصادقة متعددة العوامل"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "Многофакторният OTP (OneTimePassword) не съществува"
|
||||
NotReady: "Многофакторният OTP (OneTimePassword) не е готов"
|
||||
InvalidCode: "Невалиден код"
|
||||
Reused: "Този код вече е използван. Моля, изчакайте нов код"
|
||||
U2F:
|
||||
NotExisting: "U2F не съществува"
|
||||
Passwordless:
|
||||
@@ -763,6 +764,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Многофакторната OTP проверка е успешна"
|
||||
failed: "Многофакторната OTP проверка е неуспешна"
|
||||
reused: "Многофакторният OTP код е използван повторно"
|
||||
sms:
|
||||
added: "Добавен Многофакторната OTP SMS"
|
||||
removed: "Премахнато многофакторно OTP SMS"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "Vícefaktorové OTP (OneTimePassword) neexistuje"
|
||||
NotReady: "Vícefaktorové OTP (OneTimePassword) není připraveno"
|
||||
InvalidCode: "Neplatný kód"
|
||||
Reused: "Tento kód již byl použit. Počkejte prosím na nový kód"
|
||||
U2F:
|
||||
NotExisting: "U2F neexistuje"
|
||||
Passwordless:
|
||||
@@ -765,6 +766,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Kontrola OTP pro vícefaktorové ověření byla úspěšná"
|
||||
failed: "Kontrola OTP pro vícefaktorové ověření selhala"
|
||||
reused: "Kód OTP pro vícefaktorové ověření byl použit opakovaně"
|
||||
sms:
|
||||
added: "SMS pro vícefaktorové OTP přidána"
|
||||
removed: "SMS pro vícefaktorové OTP odstraněna"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "Multifaktor OTP (OneTimePassword) existiert nicht"
|
||||
NotReady: "Multifaktor OTP (OneTimePassword) ist nicht bereit"
|
||||
InvalidCode: "Code ist ungültig"
|
||||
Reused: "Dieser Code wurde bereits verwendet. Bitte warten Sie auf einen neuen Code"
|
||||
U2F:
|
||||
NotExisting: "U2F existiert nicht"
|
||||
Passwordless:
|
||||
@@ -767,6 +768,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Multifaktor OTP Verifikation erfolgreich"
|
||||
failed: "Multifaktor OTP Verifikation fehlgeschlagen"
|
||||
reused: "Multifaktor OTP Code wiederverwendet"
|
||||
sms:
|
||||
added: "Multifaktor OTP SMS hinzugefügt"
|
||||
removed: "Multifaktor OTP SMS entfernt"
|
||||
|
||||
@@ -178,6 +178,7 @@ Errors:
|
||||
NotExisting: "Multifactor OTP (OneTimePassword) doesn't exist"
|
||||
NotReady: "Multifactor OTP (OneTimePassword) isn't ready"
|
||||
InvalidCode: "Invalid code"
|
||||
Reused: "This code was already used. Please wait for a new code"
|
||||
U2F:
|
||||
NotExisting: "U2F does not exist"
|
||||
Passwordless:
|
||||
@@ -772,6 +773,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Multifactor OTP check succeeded"
|
||||
failed: "Multifactor OTP check failed"
|
||||
reused: "Multifactor OTP code reused"
|
||||
sms:
|
||||
added: "Multifactor OTP SMS added"
|
||||
removed: "Multifactor OTP SMS removed"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "Multifactor OTP (OneTimePassword) no existe"
|
||||
NotReady: "Multifactor OTP (OneTimePassword) no está listo"
|
||||
InvalidCode: "Código no válido"
|
||||
Reused: "Este código ya se ha utilizado. Espera a que se genere un nuevo código"
|
||||
U2F:
|
||||
NotExisting: "U2F no existe"
|
||||
Passwordless:
|
||||
@@ -767,6 +768,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Comprobación exitosa de Multifactor OTP"
|
||||
failed: "Comprobación fallida de Multifactor OTP"
|
||||
reused: "Código de Multifactor OTP reutilizado"
|
||||
sms:
|
||||
added: "Multifactor OTP SMS añadido"
|
||||
removed: "Multifactor OTP SMS elimonado"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "OTP multifactoriel (mot de passe à usage unique) n'existe pas."
|
||||
NotReady: "OTP multifactoriel (mot de passe à usage unique) n'est pas prêt."
|
||||
InvalidCode: "Code invalide"
|
||||
Reused: "Ce code a déjà été utilisé. Veuillez attendre un nouveau code"
|
||||
U2F:
|
||||
NotExisting: "L'U2F n'existe pas"
|
||||
Passwordless:
|
||||
@@ -765,6 +766,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Vérification de l'OTP multifactorielle réussie"
|
||||
failed: "La vérification de l'OTP multifactorielle a échoué"
|
||||
reused: "Code OTP multifactoriel réutilisé"
|
||||
sms:
|
||||
added: "Ajout de SMS OTP multifactoriels"
|
||||
removed: "Suppression des SMS OTP multifactoriels"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "A multifaktoros OTP (OneTimePassword) nem létezik"
|
||||
NotReady: "A multifaktoros OTP (OneTimePassword) nincs készen"
|
||||
InvalidCode: "Érvénytelen kód"
|
||||
Reused: "Ezt a kódot már felhasználták. Kérjük, várjon egy új kódra"
|
||||
U2F:
|
||||
NotExisting: "Az U2F nem létezik"
|
||||
Passwordless:
|
||||
@@ -767,6 +768,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "A multifaktoros OTP ellenőrzés sikeres"
|
||||
failed: "A multifaktoros OTP ellenőrzés sikertelen"
|
||||
reused: "A multifaktoros OTP kód újrafelhasználva"
|
||||
sms:
|
||||
added: "Multifaktoros OTP SMS hozzáadva"
|
||||
removed: "Multifaktoros OTP SMS eltávolítva"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "OTP multifaktor (OneTimePassword) tidak ada"
|
||||
NotReady: "OTP multifaktor (OneTimePassword) belum siap"
|
||||
InvalidCode: "Kode tidak valid"
|
||||
Reused: "Kode ini sudah digunakan. Silakan tunggu kode baru"
|
||||
U2F:
|
||||
NotExisting: "U2F tidak ada"
|
||||
Passwordless:
|
||||
@@ -762,6 +763,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Pemeriksaan OTP multifaktor berhasil"
|
||||
failed: "Pemeriksaan OTP multifaktor gagal"
|
||||
reused: "Kode OTP multifaktor digunakan kembali"
|
||||
sms:
|
||||
added: "OTP SMS multifaktor ditambahkan"
|
||||
removed: "OTP SMS multifaktor dihapus"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "Multifattore OTP (OneTimePassword) non esistente"
|
||||
NotReady: "Multifattore OTP (OneTimePassword) non è pronto"
|
||||
InvalidCode: "Codice non valido"
|
||||
Reused: "Questo codice è già stato utilizzato. Attendere un nuovo codice"
|
||||
U2F:
|
||||
NotExisting: "U2F non esistente"
|
||||
Passwordless:
|
||||
@@ -766,6 +767,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Controllo OTP riuscito"
|
||||
failed: "Controllo OTP fallito"
|
||||
reused: "Codice OTP riutilizzato"
|
||||
sms:
|
||||
added: "Aggiunta SMS OTP"
|
||||
removed: "SMS OTP rimossa"
|
||||
|
||||
@@ -177,6 +177,7 @@ Errors:
|
||||
NotExisting: "多要素OTP(ワンタイムパスワード)が存在しません"
|
||||
NotReady: "多要素OTP(ワンタイムパスワード)が利用可能でありません"
|
||||
InvalidCode: "無効なコードです"
|
||||
Reused: "このコードはすでに使用されています。新しいコードをお待ちください"
|
||||
U2F:
|
||||
NotExisting: "U2Fは存在しません"
|
||||
Passwordless:
|
||||
@@ -768,6 +769,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "MFA OTPチェックの成功"
|
||||
failed: "MFA OTPチェックの失敗"
|
||||
reused: "MFA OTPコードの再利用"
|
||||
sms:
|
||||
added: "多要素 OTP SMS を追加しました"
|
||||
removed: "多要素 OTP SMS を削除しました"
|
||||
|
||||
@@ -177,6 +177,7 @@ Errors:
|
||||
NotExisting: "다중 요소 OTP(일회용 비밀번호)가 존재하지 않습니다"
|
||||
NotReady: "다중 요소 OTP(일회용 비밀번호)가 준비되지 않았습니다"
|
||||
InvalidCode: "코드가 잘못되었습니다"
|
||||
Reused: "이 코드는 이미 사용되었습니다. 새 코드를 기다려 주세요"
|
||||
U2F:
|
||||
NotExisting: "U2F가 존재하지 않습니다"
|
||||
Passwordless:
|
||||
@@ -768,6 +769,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "다중인증 OTP 확인 성공"
|
||||
failed: "다중인증 OTP 확인 실패"
|
||||
reused: "다중인증 OTP 코드 재사용"
|
||||
sms:
|
||||
added: "다중인증 OTP SMS 추가됨"
|
||||
removed: "다중인증 OTP SMS 삭제됨"
|
||||
|
||||
@@ -175,6 +175,7 @@ Errors:
|
||||
NotExisting: "Мултифактор OTP (Еднократна Лозинка) не постои"
|
||||
NotReady: "Мултифактор OTP (Еднократна Лозинка) не е подготвен"
|
||||
InvalidCode: "Невалиден код"
|
||||
Reused: "Овој код е веќе искористен. Ве молиме почекајте нов код"
|
||||
U2F:
|
||||
NotExisting: "U2F не постои"
|
||||
Passwordless:
|
||||
@@ -762,6 +763,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Проверката на мултифактор OTP е успешна"
|
||||
failed: "Проверката на мултифактор OTP е неуспешна"
|
||||
reused: "Кодот за мултифактор OTP е повторно употребен"
|
||||
sms:
|
||||
added: "Додадена е мултифакторна OTP SMS"
|
||||
removed: "Отстранета мултифакторна OTP SMS"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "Multifactor OTP (OneTimePassword) bestaat niet"
|
||||
NotReady: "Multifactor OTP (OneTimePassword) is niet klaar"
|
||||
InvalidCode: "Ongeldige code"
|
||||
Reused: "Deze code is al gebruikt. Wacht op een nieuwe code"
|
||||
U2F:
|
||||
NotExisting: "U2F bestaat niet"
|
||||
Passwordless:
|
||||
@@ -767,6 +768,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Multifactor OTP controle geslaagd"
|
||||
failed: "Multifactor OTP controle mislukt"
|
||||
reused: "Multifactor OTP code hergebruikt"
|
||||
sms:
|
||||
added: "Multifactor OTP SMS toegevoegd"
|
||||
removed: "Multifactor OTP SMS verwijderd"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "Wieloskładnikowe OTP (OneTimePassword) nie istnieje"
|
||||
NotReady: "Wieloskładnikowe OTP (OneTimePassword) nie jest gotowe"
|
||||
InvalidCode: "Nieprawidłowy kod"
|
||||
Reused: "Ten kod został już użyty. Poczekaj na nowy kod"
|
||||
U2F:
|
||||
NotExisting: "U2F nie istnieje"
|
||||
Passwordless:
|
||||
@@ -767,6 +768,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Sprawdzenie wielofaktorowego OTP zakończone powodzeniem"
|
||||
failed: "Sprawdzenie wielofaktorowego OTP nie powiodło się"
|
||||
reused: "Kod wielofaktorowego OTP użyty ponownie"
|
||||
sms:
|
||||
added: "Dodano wieloskładnikowy OTP SMS"
|
||||
removed: "Usunięto wieloskładnikowy OTP SMS"
|
||||
|
||||
@@ -175,6 +175,7 @@ Errors:
|
||||
NotExisting: "OTP (OneTimePassword) de autenticação multifator não existe"
|
||||
NotReady: "OTP (OneTimePassword) de autenticação multifator não está pronto"
|
||||
InvalidCode: "Código inválido"
|
||||
Reused: "Este código já foi utilizado. Aguarde um novo código"
|
||||
U2F:
|
||||
NotExisting: "U2F não existe"
|
||||
Passwordless:
|
||||
@@ -762,6 +763,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Verificação de OTP de autenticação multifator bem-sucedida"
|
||||
failed: "Verificação de OTP de autenticação multifator falhou"
|
||||
reused: "Código de OTP de autenticação multifator reutilizado"
|
||||
sms:
|
||||
added: "Adicionado OTP SMS multifator"
|
||||
removed: "OTP SMS multifator removido"
|
||||
|
||||
@@ -177,6 +177,7 @@ Errors:
|
||||
NotExisting: "Multifactor OTP (Parolă Unică) nu există"
|
||||
NotReady: "Multifactor OTP (Parolă Unică) nu este pregătit"
|
||||
InvalidCode: "Cod invalid"
|
||||
Reused: "Acest cod a fost deja utilizat. Vă rugăm să așteptați un cod nou"
|
||||
U2F:
|
||||
NotExisting: "U2F nu există"
|
||||
Passwordless:
|
||||
@@ -763,6 +764,7 @@ Errors:
|
||||
check:
|
||||
succeeded: "Verificarea Multifactor OTP a reușit"
|
||||
failed: "Verificarea Multifactor OTP a eșuat"
|
||||
reused: "Cod Multifactor OTP reutilizat"
|
||||
sms:
|
||||
added: "Multifactor OTP SMS adăugat"
|
||||
removed: "Multifactor OTP SMS șters"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "Мультифактор OTP (OneTimePassword) не существует"
|
||||
NotReady: "Мультифактор OTP (OneTimePassword) не готов"
|
||||
InvalidCode: "Неверный код"
|
||||
Reused: "Этот код уже был использован. Пожалуйста, дождитесь нового кода"
|
||||
U2F:
|
||||
NotExisting: "Двухфакторная аутентификация не существует"
|
||||
Passwordless:
|
||||
@@ -756,6 +757,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Проверка мультифактора OTP прошла успешно"
|
||||
failed: "Проверка мультифактора OTP не удалась"
|
||||
reused: "Код мультифактора OTP использован повторно"
|
||||
sms:
|
||||
added: "Добавлен многофакторный OTP SMS"
|
||||
removed: "Удалены многофакторные OTP SMS"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "Tvåfaktor OTP (OneTimePassword) finns inte"
|
||||
NotReady: "Tvåfaktor OTP (OneTimePassword) är inte redo"
|
||||
InvalidCode: "Ogiltig kod"
|
||||
Reused: "Den här koden har redan använts. Vänta på en ny kod"
|
||||
U2F:
|
||||
NotExisting: "U2F finns inte"
|
||||
Passwordless:
|
||||
@@ -767,6 +768,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Tvåfaktor OTP-kontroll lyckades"
|
||||
failed: "Tvåfaktor OTP-kontroll misslyckades"
|
||||
reused: "Tvåfaktor OTP-kod återanvänd"
|
||||
sms:
|
||||
added: "Tvåfaktor OTP SMS tillagd"
|
||||
removed: "Tvåfaktor OTP SMS borttagen"
|
||||
|
||||
@@ -177,6 +177,7 @@ Errors:
|
||||
NotExisting: "Çok faktörlü OTP (Tek Kullanımlık Şifre) mevcut değil"
|
||||
NotReady: "Çok faktörlü OTP (Tek Kullanımlık Şifre) hazır değil"
|
||||
InvalidCode: "Geçersiz kod"
|
||||
Reused: "Bu kod zaten kullanıldı. Lütfen yeni bir kod bekleyin"
|
||||
U2F:
|
||||
NotExisting: "U2F mevcut değil"
|
||||
Passwordless:
|
||||
@@ -768,6 +769,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Çok faktörlü OTP kontrolü başarılı"
|
||||
failed: "Çok faktörlü OTP kontrolü başarısız"
|
||||
reused: "Çok faktörlü OTP kodu yeniden kullanıldı"
|
||||
sms:
|
||||
added: "Çok faktörlü OTP SMS eklendi"
|
||||
removed: "Çok faktörlü OTP SMS kaldırıldı"
|
||||
|
||||
@@ -176,6 +176,7 @@ Errors:
|
||||
NotExisting: "Багатофакторний OTP (одноразовий пароль) не існує"
|
||||
NotReady: "Багатофакторний OTP (одноразовий пароль) не готовий"
|
||||
InvalidCode: "Недійсний код"
|
||||
Reused: "Цей код вже було використано. Будь ласка, зачекайте на новий код"
|
||||
U2F:
|
||||
NotExisting: "U2F не існує"
|
||||
Passwordless:
|
||||
@@ -762,6 +763,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "Перевірка багатофакторного OTP успішна"
|
||||
failed: "Перевірка багатофакторного OTP не вдалася"
|
||||
reused: "Код багатофакторного OTP використано повторно"
|
||||
sms:
|
||||
added: "Багатофакторний OTP SMS доданий"
|
||||
removed: "Багатофакторний OTP SMS видалений"
|
||||
|
||||
@@ -177,6 +177,7 @@ Errors:
|
||||
NotExisting: "OTP (一次性密码) 不存在"
|
||||
NotReady: "OTP (一次性密码) 还没准备好"
|
||||
InvalidCode: "无效的验证码"
|
||||
Reused: "此验证码已被使用。请等待新的验证码"
|
||||
U2F:
|
||||
NotExisting: "U2F 不存在"
|
||||
Passwordless:
|
||||
@@ -768,6 +769,7 @@ EventTypes:
|
||||
check:
|
||||
succeeded: "验证 MFA OTP 成功"
|
||||
failed: "验证 MFA OTP 失败"
|
||||
reused: "重复使用 MFA OTP 验证码"
|
||||
sms:
|
||||
added: "添加了多因素 OTP 短信"
|
||||
removed: "删除了多因素 OTP 短信"
|
||||
|
||||
Reference in New Issue
Block a user