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:
Tim Möhlmann
2026-08-27 07:17:39 +02:00
committed by GitHub
co-authored by Livio Spring Copilot Autofix powered by AI copilot-swe-agent[bot] <198982749+[email protected]> muhlemmer
parent c9d747d407
commit 6128aeaa89
56 changed files with 850 additions and 8 deletions
@@ -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: 似乎出问题了
+150 -1
View File
@@ -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)
})
+13 -1
View File
@@ -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
+8
View File
@@ -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)
}
+49
View File
@@ -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)
}
+130
View File
@@ -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"))
}
+72 -4
View File
@@ -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
}
+293
View File
@@ -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"))
}
+1
View File
@@ -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])
+41
View File
@@ -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:"-"`
}
+2
View File
@@ -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 للمصادقة متعددة العوامل"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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 を削除しました"
+2
View File
@@ -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 삭제됨"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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"
+2
View File
@@ -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ı"
+2
View File
@@ -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 видалений"
+2
View File
@@ -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 短信"