diff --git a/backend/v3/domain/session_check_totp.go b/backend/v3/domain/session_check_totp.go index 15beaa61e..1ab8241e4 100644 --- a/backend/v3/domain/session_check_totp.go +++ b/backend/v3/domain/session_check_totp.go @@ -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) diff --git a/backend/v3/domain/session_check_totp_test.go b/backend/v3/domain/session_check_totp_test.go index ad19aa5e9..0fa954ea5 100644 --- a/backend/v3/domain/session_check_totp_test.go +++ b/backend/v3/domain/session_check_totp_test.go @@ -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()), }, }, diff --git a/internal/api/ui/login/static/i18n/ar.yaml b/internal/api/ui/login/static/i18n/ar.yaml index 439ecfeaa..b8b75a216 100644 --- a/internal/api/ui/login/static/i18n/ar.yaml +++ b/internal/api/ui/login/static/i18n/ar.yaml @@ -500,6 +500,7 @@ Errors: AlreadyReady: المصادقة متعددة العوامل OTP (كلمة مرور لمرة واحدة) معدة بالفعل NotExisting: المصادقة متعددة العوامل OTP (كلمة مرور لمرة واحدة) غير موجودة InvalidCode: رمز غير صالح + Reused: تم استخدام هذا الرمز بالفعل. يرجى انتظار رمز جديد NotReady: المصادقة متعددة العوامل OTP (كلمة مرور لمرة واحدة) ليست جاهزة Locked: المستخدم مقفل SomethingWentWrong: حدث خطأ ما diff --git a/internal/api/ui/login/static/i18n/bg.yaml b/internal/api/ui/login/static/i18n/bg.yaml index 0cd553b72..74672fe61 100644 --- a/internal/api/ui/login/static/i18n/bg.yaml +++ b/internal/api/ui/login/static/i18n/bg.yaml @@ -492,6 +492,7 @@ Errors: AlreadyReady: Многофакторният OTP (OneTimePassword) вече е настроен NotExisting: Многофакторният OTP (OneTimePassword) не съществува InvalidCode: Невалиден код + Reused: Този код вече е използван. Моля, изчакайте нов код NotReady: Многофакторният OTP (OneTimePassword) не е готов Locked: Потребителят е заключен SomethingWentWrong: Нещо се обърка diff --git a/internal/api/ui/login/static/i18n/cs.yaml b/internal/api/ui/login/static/i18n/cs.yaml index 03acc48e0..759b2f23e 100644 --- a/internal/api/ui/login/static/i18n/cs.yaml +++ b/internal/api/ui/login/static/i18n/cs.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/de.yaml b/internal/api/ui/login/static/i18n/de.yaml index 183d5c04c..b41628985 100644 --- a/internal/api/ui/login/static/i18n/de.yaml +++ b/internal/api/ui/login/static/i18n/de.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/en.yaml b/internal/api/ui/login/static/i18n/en.yaml index a93452c85..6dd9adb4d 100644 --- a/internal/api/ui/login/static/i18n/en.yaml +++ b/internal/api/ui/login/static/i18n/en.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/es.yaml b/internal/api/ui/login/static/i18n/es.yaml index f6194d187..cc427ae88 100644 --- a/internal/api/ui/login/static/i18n/es.yaml +++ b/internal/api/ui/login/static/i18n/es.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/fr.yaml b/internal/api/ui/login/static/i18n/fr.yaml index 79ed137c7..d895e9477 100644 --- a/internal/api/ui/login/static/i18n/fr.yaml +++ b/internal/api/ui/login/static/i18n/fr.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/hu.yaml b/internal/api/ui/login/static/i18n/hu.yaml index f5a0a4a43..322e1ba5e 100644 --- a/internal/api/ui/login/static/i18n/hu.yaml +++ b/internal/api/ui/login/static/i18n/hu.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/id.yaml b/internal/api/ui/login/static/i18n/id.yaml index eab588f62..1a08a7eba 100644 --- a/internal/api/ui/login/static/i18n/id.yaml +++ b/internal/api/ui/login/static/i18n/id.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/it.yaml b/internal/api/ui/login/static/i18n/it.yaml index 8c95da391..73c19c97d 100644 --- a/internal/api/ui/login/static/i18n/it.yaml +++ b/internal/api/ui/login/static/i18n/it.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/ja.yaml b/internal/api/ui/login/static/i18n/ja.yaml index 03778080b..0363c19f8 100644 --- a/internal/api/ui/login/static/i18n/ja.yaml +++ b/internal/api/ui/login/static/i18n/ja.yaml @@ -505,6 +505,7 @@ Errors: AlreadyReady: 多要素OTP(ワンタイムパスワード)は設定済みです NotExisting: 多要素OTP(ワンタイムパスワード)が存在しません InvalidCode: 無効なコード + Reused: このコードはすでに使用されています。新しいコードをお待ちください NotReady: 多要素OTP(ワンタイムパスワード)は利用可能でありません Locked: ユーザーはロックされています SomethingWentWrong: エラーが発生しました diff --git a/internal/api/ui/login/static/i18n/ko.yaml b/internal/api/ui/login/static/i18n/ko.yaml index e834bd7be..f08bf1e88 100644 --- a/internal/api/ui/login/static/i18n/ko.yaml +++ b/internal/api/ui/login/static/i18n/ko.yaml @@ -504,6 +504,7 @@ Errors: AlreadyReady: 다중 인증 OTP(일회용 비밀번호)가 이미 설정되었습니다 NotExisting: 다중 인증 OTP(일회용 비밀번호)가 존재하지 않습니다 InvalidCode: 잘못된 코드입니다 + Reused: 이 코드는 이미 사용되었습니다. 새 코드를 기다려 주세요 NotReady: 다중 인증 OTP(일회용 비밀번호)가 준비되지 않았습니다 Locked: 사용자가 잠겼습니다 SomethingWentWrong: 문제가 발생했습니다 diff --git a/internal/api/ui/login/static/i18n/mk.yaml b/internal/api/ui/login/static/i18n/mk.yaml index f96dece1f..e52b2ad52 100644 --- a/internal/api/ui/login/static/i18n/mk.yaml +++ b/internal/api/ui/login/static/i18n/mk.yaml @@ -505,6 +505,7 @@ Errors: AlreadyReady: Мултифактор OTP (Еднократна Лозинка) е веќе подесена NotExisting: Мултифактор OTP (Еднократна Лозинка) не постои InvalidCode: Невалиден код + Reused: Овој код е веќе искористен. Ве молиме почекајте нов код NotReady: Мултифактор OTP (Еднократна Лозинка) не е подготвена Locked: Корисникот е заклучен SomethingWentWrong: Се случи нешто неочекувано diff --git a/internal/api/ui/login/static/i18n/nl.yaml b/internal/api/ui/login/static/i18n/nl.yaml index 4a189a0c2..4fb6d56e3 100644 --- a/internal/api/ui/login/static/i18n/nl.yaml +++ b/internal/api/ui/login/static/i18n/nl.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/pl.yaml b/internal/api/ui/login/static/i18n/pl.yaml index 4bbfdaad0..db0acdf6e 100644 --- a/internal/api/ui/login/static/i18n/pl.yaml +++ b/internal/api/ui/login/static/i18n/pl.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/pt.yaml b/internal/api/ui/login/static/i18n/pt.yaml index ecbb29441..99472f594 100644 --- a/internal/api/ui/login/static/i18n/pt.yaml +++ b/internal/api/ui/login/static/i18n/pt.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/ro.yaml b/internal/api/ui/login/static/i18n/ro.yaml index 4233054b7..141f4b16f 100644 --- a/internal/api/ui/login/static/i18n/ro.yaml +++ b/internal/api/ui/login/static/i18n/ro.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/ru.yaml b/internal/api/ui/login/static/i18n/ru.yaml index 70ced9f44..5c4fd6ec7 100644 --- a/internal/api/ui/login/static/i18n/ru.yaml +++ b/internal/api/ui/login/static/i18n/ru.yaml @@ -505,6 +505,7 @@ Errors: AlreadyReady: OTP (OneTimePassword) уже настроен NotExisting: OTP (OneTimePassword) не существует InvalidCode: Неверный код + Reused: Этот код уже был использован. Пожалуйста, дождитесь нового кода NotReady: OTP (OneTimePassword) не готов Locked: Пользователь заблокирован SomethingWentWrong: Что-то пошло не так diff --git a/internal/api/ui/login/static/i18n/sv.yaml b/internal/api/ui/login/static/i18n/sv.yaml index 981405ef0..768d3bd52 100644 --- a/internal/api/ui/login/static/i18n/sv.yaml +++ b/internal/api/ui/login/static/i18n/sv.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/tr.yaml b/internal/api/ui/login/static/i18n/tr.yaml index efe363a95..5980cb0e1 100644 --- a/internal/api/ui/login/static/i18n/tr.yaml +++ b/internal/api/ui/login/static/i18n/tr.yaml @@ -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 diff --git a/internal/api/ui/login/static/i18n/uk.yaml b/internal/api/ui/login/static/i18n/uk.yaml index 4af0d4067..ca360ef9a 100644 --- a/internal/api/ui/login/static/i18n/uk.yaml +++ b/internal/api/ui/login/static/i18n/uk.yaml @@ -505,6 +505,7 @@ Errors: AlreadyReady: Багатофакторний OTP (одноразовий пароль) уже налаштовано NotExisting: Багатофакторний OTP (одноразовий пароль) не існує InvalidCode: Недійсний код + Reused: Цей код вже було використано. Будь ласка, зачекайте на новий код NotReady: Багатофакторний OTP (одноразовий пароль) не готовий Locked: Користувача заблоковано SomethingWentWrong: Щось пішло не так diff --git a/internal/api/ui/login/static/i18n/zh.yaml b/internal/api/ui/login/static/i18n/zh.yaml index a36b1fb46..02cd7c5ec 100644 --- a/internal/api/ui/login/static/i18n/zh.yaml +++ b/internal/api/ui/login/static/i18n/zh.yaml @@ -504,6 +504,7 @@ Errors: AlreadyReady: OTP (一次性密码) 已设置 NotExisting: OTP (一次性密码) 不存在 InvalidCode: 无效的验证码 + Reused: 此验证码已被使用。请等待新的验证码 NotReady: OTP (一次性密码) 还没准备好 Locked: 用户被锁定 SomethingWentWrong: 似乎出问题了 diff --git a/internal/command/session_test.go b/internal/command/session_test.go index 2ba718336..a883493a6 100644 --- a/internal/command/session_test.go +++ b/internal/command/session_test.go @@ -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) }) diff --git a/internal/command/user_human_otp.go b/internal/command/user_human_otp.go index 714e917cc..4eda15e03 100644 --- a/internal/command/user_human_otp.go +++ b/internal/command/user_human_otp.go @@ -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 diff --git a/internal/command/user_human_otp_model.go b/internal/command/user_human_otp_model.go index 6bbc47110..9f747fe5d 100644 --- a/internal/command/user_human_otp_model.go +++ b/internal/command/user_human_otp_model.go @@ -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 } diff --git a/internal/command/user_human_otp_model_test.go b/internal/command/user_human_otp_model_test.go new file mode 100644 index 000000000..f77815221 --- /dev/null +++ b/internal/command/user_human_otp_model_test.go @@ -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) +} diff --git a/internal/crypto/hmac.go b/internal/crypto/hmac.go new file mode 100644 index 000000000..f1215108e --- /dev/null +++ b/internal/crypto/hmac.go @@ -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) +} diff --git a/internal/crypto/hmac_test.go b/internal/crypto/hmac_test.go new file mode 100644 index 000000000..47fa0f2ae --- /dev/null +++ b/internal/crypto/hmac_test.go @@ -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")) +} diff --git a/internal/domain/human_otp.go b/internal/domain/human_otp.go index fcadb32a7..a0d496bab 100644 --- a/internal/domain/human_otp.go +++ b/internal/domain/human_otp.go @@ -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 } diff --git a/internal/domain/human_otp_test.go b/internal/domain/human_otp_test.go new file mode 100644 index 000000000..545147e13 --- /dev/null +++ b/internal/domain/human_otp_test.go @@ -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")) +} diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go index 10e811275..d962251d6 100644 --- a/internal/repository/user/eventstore.go +++ b/internal/repository/user/eventstore.go @@ -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]) diff --git a/internal/repository/user/human_mfa_otp.go b/internal/repository/user/human_mfa_otp.go index 60b0e7270..ece239889 100644 --- a/internal/repository/user/human_mfa_otp.go +++ b/internal/repository/user/human_mfa_otp.go @@ -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:"-"` } diff --git a/internal/static/i18n/ar.yaml b/internal/static/i18n/ar.yaml index 42cf8c75b..0f0bb4cb1 100644 --- a/internal/static/i18n/ar.yaml +++ b/internal/static/i18n/ar.yaml @@ -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 للمصادقة متعددة العوامل" diff --git a/internal/static/i18n/bg.yaml b/internal/static/i18n/bg.yaml index 1dc561d47..c52a0894f 100644 --- a/internal/static/i18n/bg.yaml +++ b/internal/static/i18n/bg.yaml @@ -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" diff --git a/internal/static/i18n/cs.yaml b/internal/static/i18n/cs.yaml index b1d23fae2..5e38cda79 100644 --- a/internal/static/i18n/cs.yaml +++ b/internal/static/i18n/cs.yaml @@ -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" diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml index ab01057cb..0abc6c510 100644 --- a/internal/static/i18n/de.yaml +++ b/internal/static/i18n/de.yaml @@ -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" diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml index 204f797d8..90da0cd82 100644 --- a/internal/static/i18n/en.yaml +++ b/internal/static/i18n/en.yaml @@ -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" diff --git a/internal/static/i18n/es.yaml b/internal/static/i18n/es.yaml index b531c9b73..086e4a950 100644 --- a/internal/static/i18n/es.yaml +++ b/internal/static/i18n/es.yaml @@ -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" diff --git a/internal/static/i18n/fr.yaml b/internal/static/i18n/fr.yaml index d7c67bbda..708b5548d 100644 --- a/internal/static/i18n/fr.yaml +++ b/internal/static/i18n/fr.yaml @@ -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" diff --git a/internal/static/i18n/hu.yaml b/internal/static/i18n/hu.yaml index cfa10a862..c5237f5e4 100644 --- a/internal/static/i18n/hu.yaml +++ b/internal/static/i18n/hu.yaml @@ -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" diff --git a/internal/static/i18n/id.yaml b/internal/static/i18n/id.yaml index a9d21bb1a..401f70f02 100644 --- a/internal/static/i18n/id.yaml +++ b/internal/static/i18n/id.yaml @@ -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" diff --git a/internal/static/i18n/it.yaml b/internal/static/i18n/it.yaml index 5b0a0ec70..4fa2df1c0 100644 --- a/internal/static/i18n/it.yaml +++ b/internal/static/i18n/it.yaml @@ -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" diff --git a/internal/static/i18n/ja.yaml b/internal/static/i18n/ja.yaml index 6444671fd..26eb2cc3e 100644 --- a/internal/static/i18n/ja.yaml +++ b/internal/static/i18n/ja.yaml @@ -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 を削除しました" diff --git a/internal/static/i18n/ko.yaml b/internal/static/i18n/ko.yaml index 1e97edc7a..d841455ac 100644 --- a/internal/static/i18n/ko.yaml +++ b/internal/static/i18n/ko.yaml @@ -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 삭제됨" diff --git a/internal/static/i18n/mk.yaml b/internal/static/i18n/mk.yaml index 103445160..fdb855e59 100644 --- a/internal/static/i18n/mk.yaml +++ b/internal/static/i18n/mk.yaml @@ -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" diff --git a/internal/static/i18n/nl.yaml b/internal/static/i18n/nl.yaml index fcd953929..f5dc7fbfa 100644 --- a/internal/static/i18n/nl.yaml +++ b/internal/static/i18n/nl.yaml @@ -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" diff --git a/internal/static/i18n/pl.yaml b/internal/static/i18n/pl.yaml index 7d8b6dba2..5a6ac28a8 100644 --- a/internal/static/i18n/pl.yaml +++ b/internal/static/i18n/pl.yaml @@ -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" diff --git a/internal/static/i18n/pt.yaml b/internal/static/i18n/pt.yaml index dbeb857c4..fddfc1900 100644 --- a/internal/static/i18n/pt.yaml +++ b/internal/static/i18n/pt.yaml @@ -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" diff --git a/internal/static/i18n/ro.yaml b/internal/static/i18n/ro.yaml index 79f4d6fa6..7a719f05a 100644 --- a/internal/static/i18n/ro.yaml +++ b/internal/static/i18n/ro.yaml @@ -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" diff --git a/internal/static/i18n/ru.yaml b/internal/static/i18n/ru.yaml index 36c3ad8d7..0067eafe7 100644 --- a/internal/static/i18n/ru.yaml +++ b/internal/static/i18n/ru.yaml @@ -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" diff --git a/internal/static/i18n/sv.yaml b/internal/static/i18n/sv.yaml index c61b03fba..3ea9d6a82 100644 --- a/internal/static/i18n/sv.yaml +++ b/internal/static/i18n/sv.yaml @@ -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" diff --git a/internal/static/i18n/tr.yaml b/internal/static/i18n/tr.yaml index 0653bca04..1e5f497bb 100644 --- a/internal/static/i18n/tr.yaml +++ b/internal/static/i18n/tr.yaml @@ -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ı" diff --git a/internal/static/i18n/uk.yaml b/internal/static/i18n/uk.yaml index 07302bc0f..e5177af2b 100644 --- a/internal/static/i18n/uk.yaml +++ b/internal/static/i18n/uk.yaml @@ -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 видалений" diff --git a/internal/static/i18n/zh.yaml b/internal/static/i18n/zh.yaml index e539456c9..4d0138484 100644 --- a/internal/static/i18n/zh.yaml +++ b/internal/static/i18n/zh.yaml @@ -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 短信"