Закрыты задачи бэклога по неочевидному поведению UI при ошибках отправки сообщений и блокировках со стороны Message-safety + добалено ограничение на размер сообщения

This commit is contained in:
mi
2026-07-29 16:45:19 +03:00
parent 41e19005fb
commit bda3ff39d7
36 changed files with 486 additions and 141 deletions
@@ -100,27 +100,31 @@ final class OtpStore {
"failure", "order_failed", device);
}
boolean consume(String challengeId, String suppliedCode, DeviceMetadata device) {
VerifyResult consume(String challengeId, String suppliedCode, DeviceMetadata device) {
OtpChallengeEntity challenge = entityManager.find(
OtpChallengeEntity.class, challengeId, LockModeType.PESSIMISTIC_WRITE);
Instant now = Instant.now();
if (challenge == null) return false;
if (challenge == null) return VerifyResult.INVALID;
if (!"active".equals(challenge.challengeStatus)) {
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"already_used", challenge.challengeStatus, device);
return false;
return switch (challenge.challengeStatus) {
case "limited" -> VerifyResult.VERIFY_LIMITED;
case "expired" -> VerifyResult.EXPIRED;
default -> VerifyResult.ALREADY_USED;
};
}
if (!challenge.expiresAt.isAfter(now)) {
challenge.challengeStatus = "expired";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"expired", "ttl", device);
return false;
return VerifyResult.EXPIRED;
}
if (challenge.verifyAttempts >= challenge.maxVerifyAttempts) {
challenge.challengeStatus = "limited";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"limited", "attempt_limit", device);
return false;
return VerifyResult.VERIFY_LIMITED;
}
challenge.verifyAttempts++;
boolean valid = suppliedCode != null && Crypto.constantTimeEquals(
@@ -130,13 +134,13 @@ final class OtpStore {
if (limited) challenge.challengeStatus = "limited";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
limited ? "limited" : "failure", limited ? "attempt_limit" : "invalid", device);
return false;
return limited ? VerifyResult.VERIFY_LIMITED : VerifyResult.INVALID;
}
challenge.consumedAt = now;
challenge.challengeStatus = "consumed";
event("otp_verify", challenge.phoneHmac, challengeId, challenge.smsMessageId,
"success", "verified", device);
return true;
return VerifyResult.VERIFIED;
}
OtpChallengeEntity get(String challengeId) {
@@ -193,6 +197,14 @@ final class OtpStore {
record Reservation(OtpChallengeEntity challenge, String otp) {}
enum VerifyResult {
VERIFIED,
INVALID,
EXPIRED,
ALREADY_USED,
VERIFY_LIMITED
}
static final class OtpLimitException extends RuntimeException {
OtpLimitException(String message) { super(message); }
@@ -66,8 +66,13 @@ public final class PhoneOtpAuthenticator implements Authenticator {
}
return;
}
if (!new OtpStore(context.getSession()).consume(challengeId, code, device)) {
Response response = otpForm(context, PhoneNormalizer.mask(phone), "otpInvalid");
OtpStore.VerifyResult result =
new OtpStore(context.getSession()).consume(challengeId, code, device);
if (result != OtpStore.VerifyResult.VERIFIED) {
String messageKey = result == OtpStore.VerifyResult.VERIFY_LIMITED
? "otpVerifyLimited"
: "otpInvalid";
Response response = otpForm(context, PhoneNormalizer.mask(phone), messageKey);
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response);
return;
}
@@ -28,6 +28,10 @@ class SmsLifecycleContractTest {
void otpThemeUsesSnapshotLengthExpiryAndRealResendAction() throws Exception {
String template = Files.readString(Path.of("themes/han-phone/login/otp.ftl"));
String script = Files.readString(Path.of("themes/han-phone/login/resources/js/han-login.js"));
String messages = Files.readString(
Path.of("themes/han-phone/login/messages/messages_ru.properties"));
String verifier = Files.readString(Path.of(
"src/main/java/ru/han/chat/keycloak/PhoneOtpAuthenticator.java"));
assertTrue(template.contains("otpCodeLength"));
assertTrue(template.contains("otpExpiresAt"));
@@ -37,6 +41,18 @@ class SmsLifecycleContractTest {
assertTrue(script.contains("han_device_id"));
assertTrue(script.contains("expiresAt - Date.now()"));
assertTrue(script.contains("Number.isFinite(expiresAt)"));
assertTrue(messages.contains("otpVerifyLimited="));
assertTrue(messages.contains("Попробуйте через 24 часа"));
assertTrue(verifier.contains("VerifyResult.VERIFY_LIMITED"));
}
@Test
void phoneThemeDoesNotRepeatConsentText() throws Exception {
String phone = Files.readString(Path.of("themes/han-phone/login/phone.ftl"));
assertTrue(!phone.contains("phoneLegalPrefix"));
assertTrue(!phone.contains("termsOfUse"));
assertTrue(!phone.contains("privacyPolicy"));
}
@Test
@@ -20,8 +20,9 @@ verifyOtp=Подтвердить
mockMode=Тестовый режим отправки кода
phoneInvalid=Проверьте формат номера телефона.
otpInvalid=Код неверен, истёк или уже использован.
otpVerifyLimited=Превышен лимит неуспешных авторизаций. Начните процедуру заново.
otpCooldown=Повторно отправить СМС можно после обнуления таймера.
otpLimited=Слишком много попыток. Повторите позже.
otpLimited=Превышен лимит попыток авторизации. Попробуйте через 24 часа.
otpUnavailable=Сервис подтверждения временно недоступен. Повторите позже.
captchaInvalid=Не удалось подтвердить, что запрос отправил человек. Пройдите проверку ещё раз.
captchaUnavailable=Проверка пока не загрузилась. Проверьте соединение и повторите.
@@ -57,12 +57,6 @@
</form>
</div>
<p class="han-legal">
${msg("phoneLegalPrefix")}
<span>${msg("termsOfUse")}</span>
${msg("phoneLegalAnd")}
<span>${msg("privacyPolicy")}</span>
</p>
</div>
<script src="${url.resourcesPath}/js/han-login.js?v=5"></script>
<#if captchaEnabled!false>
@@ -246,20 +246,6 @@ body.login-pf {
margin-top: 16px;
}
.han-legal {
margin: 32px 0 0;
color: var(--han-muted);
font-size: 12px;
line-height: 1.5;
text-align: center;
}
.han-legal span {
color: rgba(37, 37, 37, 0.7);
text-decoration: underline;
text-underline-offset: 2px;
}
.han-error {
display: flex;
align-items: flex-start;