diff --git a/src/main/java/com/fowoco/server/auth/api/AuthController.java b/src/main/java/com/fowoco/server/auth/api/AuthController.java index 63191dd1..43e21c90 100644 --- a/src/main/java/com/fowoco/server/auth/api/AuthController.java +++ b/src/main/java/com/fowoco/server/auth/api/AuthController.java @@ -9,6 +9,7 @@ import com.fowoco.server.auth.application.error.InvalidRefreshTokenException; import com.fowoco.server.auth.application.port.ActorContextProvider; import com.fowoco.server.auth.infrastructure.security.AgreementPolicyProperties; +import com.fowoco.server.auth.infrastructure.security.LoginProtectionProperties; import com.fowoco.server.auth.infrastructure.web.UserAgentDeviceSummarizer; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -48,6 +49,7 @@ public class AuthController { private final SignupService signupService; private final PasswordResetService passwordResetService; private final AgreementPolicyProperties agreementPolicy; + private final LoginProtectionProperties loginProtectionPolicy; private final RefreshTokenCookieFactory refreshTokenCookieFactory; private final ActorContextProvider actorContextProvider; @@ -56,6 +58,7 @@ public AuthController( SignupService signupService, PasswordResetService passwordResetService, AgreementPolicyProperties agreementPolicy, + LoginProtectionProperties loginProtectionPolicy, RefreshTokenCookieFactory refreshTokenCookieFactory, ActorContextProvider actorContextProvider ) { @@ -63,6 +66,7 @@ public AuthController( this.signupService = signupService; this.passwordResetService = passwordResetService; this.agreementPolicy = agreementPolicy; + this.loginProtectionPolicy = loginProtectionPolicy; this.refreshTokenCookieFactory = refreshTokenCookieFactory; this.actorContextProvider = actorContextProvider; } @@ -85,7 +89,7 @@ public ResponseEntity getSignupPolicy() { return ResponseEntity.ok() .cacheControl(CacheControl.noStore()) .header(HttpHeaders.PRAGMA, "no-cache") - .body(SignupPolicyResponse.from(agreementPolicy)); + .body(SignupPolicyResponse.from(agreementPolicy, loginProtectionPolicy)); } @Operation( diff --git a/src/main/java/com/fowoco/server/auth/api/SignupPolicyResponse.java b/src/main/java/com/fowoco/server/auth/api/SignupPolicyResponse.java index 841601e1..78ce2cf2 100644 --- a/src/main/java/com/fowoco/server/auth/api/SignupPolicyResponse.java +++ b/src/main/java/com/fowoco/server/auth/api/SignupPolicyResponse.java @@ -3,18 +3,23 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fowoco.server.auth.api.validation.PasswordPolicy; import com.fowoco.server.auth.infrastructure.security.AgreementPolicyProperties; +import com.fowoco.server.auth.infrastructure.security.LoginProtectionProperties; import io.swagger.v3.oas.annotations.media.Schema; @Schema(name = "SignupPolicyResponse", description = "회원가입 화면이 적용할 현재 정책") public record SignupPolicyResponse( @JsonProperty("password_policy") PasswordPolicyResponse passwordPolicy, + @JsonProperty("account_protection") AccountProtectionResponse accountProtection, AgreementsPolicyResponse agreements ) { private static final String SERVICE_TERMS_PATH = "/legal/terms"; private static final String PRIVACY_POLICY_PATH = "/legal/privacy"; - public static SignupPolicyResponse from(AgreementPolicyProperties policy) { + public static SignupPolicyResponse from( + AgreementPolicyProperties policy, + LoginProtectionProperties loginProtection + ) { return new SignupPolicyResponse( new PasswordPolicyResponse( PasswordPolicy.MIN_LENGTH, @@ -22,6 +27,11 @@ public static SignupPolicyResponse from(AgreementPolicyProperties policy) { true, true ), + new AccountProtectionResponse( + loginProtection.maxFailedAttempts(), + loginProtection.lockDuration().toSeconds(), + loginProtection.passwordMaxAge().toDays() + ), new AgreementsPolicyResponse( new AgreementPolicyResponse( policy.serviceTermsVersion(), @@ -42,6 +52,14 @@ public static SignupPolicyResponse from(AgreementPolicyProperties policy) { ); } + @Schema(name = "AccountProtectionResponse", description = "로그인 잠금과 비밀번호 갱신 정책") + public record AccountProtectionResponse( + @JsonProperty("max_failed_attempts") int maxFailedAttempts, + @JsonProperty("lock_duration_seconds") long lockDurationSeconds, + @JsonProperty("password_max_age_days") long passwordMaxAgeDays + ) { + } + @Schema(name = "PasswordPolicyResponse", description = "비밀번호 생성 규칙") public record PasswordPolicyResponse( @JsonProperty("min_length") int minLength, diff --git a/src/main/java/com/fowoco/server/auth/application/AuthService.java b/src/main/java/com/fowoco/server/auth/application/AuthService.java index cef4d933..96122e34 100644 --- a/src/main/java/com/fowoco/server/auth/application/AuthService.java +++ b/src/main/java/com/fowoco/server/auth/application/AuthService.java @@ -40,6 +40,7 @@ public class AuthService { private final RefreshTokenHashPort refreshTokenHashPort; private final RefreshTokenRotationTransaction refreshTokenRotationTransaction; private final RefreshTokenLogoutTransaction refreshTokenLogoutTransaction; + private final LoginProtectionTransaction loginProtectionTransaction; private final AuthAuditPort authAuditPort; private final UserLoginEventRepository userLoginEventRepository; private final UuidGenerator uuidGenerator; @@ -59,6 +60,7 @@ public AuthService( RefreshTokenHashPort refreshTokenHashPort, RefreshTokenRotationTransaction refreshTokenRotationTransaction, RefreshTokenLogoutTransaction refreshTokenLogoutTransaction, + LoginProtectionTransaction loginProtectionTransaction, AuthAuditPort authAuditPort, UserLoginEventRepository userLoginEventRepository, UuidGenerator uuidGenerator, @@ -75,13 +77,14 @@ public AuthService( this.refreshTokenHashPort = refreshTokenHashPort; this.refreshTokenRotationTransaction = refreshTokenRotationTransaction; this.refreshTokenLogoutTransaction = refreshTokenLogoutTransaction; + this.loginProtectionTransaction = loginProtectionTransaction; this.authAuditPort = authAuditPort; this.userLoginEventRepository = userLoginEventRepository; this.uuidGenerator = uuidGenerator; this.clock = clock; } - @Transactional + @Transactional(noRollbackFor = ApiException.class) public LoginResult login(LoginCommand command) { String normalizedEmail = UserAccount.normalizeEmail(command.email()); Optional companyIdCandidate = @@ -104,9 +107,14 @@ public LoginResult login(LoginCommand command) { UserAccount userAccount = userAccountCandidate.orElseThrow(); boolean passwordMatches = passwordVerifier.matches(command.password(), userAccount.passwordHash()); - if (!passwordMatches || !userAccount.canLogin()) { + if (!passwordMatches) { + loginProtectionTransaction.recordFailure(userAccount.userId(), userAccount.companyId()); + throw invalidCredentialsWithAudit(); + } + if (!userAccount.canLogin()) { throw invalidCredentialsWithAudit(); } + loginProtectionTransaction.verifyAndClear(userAccount.userId(), userAccount.companyId()); CompanyAuthenticationSnapshot company = companyAuthenticationReader .findByCompanyId(userAccount.companyId()) diff --git a/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java b/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java new file mode 100644 index 00000000..e73efef9 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java @@ -0,0 +1,66 @@ +package com.fowoco.server.auth.application; + +import com.fowoco.server.auth.application.error.AuthErrorCode; +import com.fowoco.server.auth.application.port.UserAccountRepository; +import com.fowoco.server.auth.domain.UserAccount; +import com.fowoco.server.auth.infrastructure.security.LoginProtectionProperties; +import com.fowoco.server.common.error.ApiException; +import com.fowoco.server.common.security.TenantDatabaseContext; +import java.time.Clock; +import java.time.Instant; +import java.util.UUID; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class LoginProtectionTransaction { + + private final UserAccountRepository userAccountRepository; + private final TenantDatabaseContext tenantDatabaseContext; + private final LoginProtectionProperties properties; + private final Clock clock; + + public LoginProtectionTransaction( + UserAccountRepository userAccountRepository, + TenantDatabaseContext tenantDatabaseContext, + LoginProtectionProperties properties, + Clock clock + ) { + this.userAccountRepository = userAccountRepository; + this.tenantDatabaseContext = tenantDatabaseContext; + this.properties = properties; + this.clock = clock; + } + + @Transactional + public void recordFailure(UUID userId, UUID companyId) { + UserAccount account = lockedAccount(userId, companyId); + UserAccount updated = account.recordFailedLogin( + properties.maxFailedAttempts(), + properties.lockDuration(), + clock.instant() + ); + userAccountRepository.update(updated); + } + + @Transactional(noRollbackFor = ApiException.class) + public void verifyAndClear(UUID userId, UUID companyId) { + UserAccount account = lockedAccount(userId, companyId); + Instant now = clock.instant(); + if (account.isTemporarilyLocked(now)) { + throw new ApiException(AuthErrorCode.ACCOUNT_TEMPORARILY_LOCKED); + } + if (account.isPasswordExpired(properties.passwordMaxAge(), now)) { + throw new ApiException(AuthErrorCode.PASSWORD_EXPIRED); + } + if (account.hasLoginFailures()) { + userAccountRepository.update(account.clearLoginFailures(now)); + } + } + + private UserAccount lockedAccount(UUID userId, UUID companyId) { + tenantDatabaseContext.setCompanyIdForCurrentTransaction(companyId); + return userAccountRepository.findByUserIdAndCompanyIdWithLock(userId, companyId) + .orElseThrow(() -> new IllegalStateException("user account was not found")); + } +} diff --git a/src/main/java/com/fowoco/server/auth/application/error/AuthErrorCode.java b/src/main/java/com/fowoco/server/auth/application/error/AuthErrorCode.java index 355a9b6e..cdc3678d 100644 --- a/src/main/java/com/fowoco/server/auth/application/error/AuthErrorCode.java +++ b/src/main/java/com/fowoco/server/auth/application/error/AuthErrorCode.java @@ -8,6 +8,8 @@ public enum AuthErrorCode implements ApiErrorCode { INVALID_AGREEMENT_CONSENT(HttpStatus.UNPROCESSABLE_ENTITY, "필수 약관 동의와 약관 버전을 확인해 주세요."), INVALID_PASSWORD_RESET_TOKEN(HttpStatus.BAD_REQUEST, "비밀번호 재설정 링크가 유효하지 않습니다."), INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "이메일 또는 비밀번호를 확인해 주세요."), + ACCOUNT_TEMPORARILY_LOCKED(HttpStatus.LOCKED, "로그인 시도가 반복되어 계정이 잠시 잠겼습니다."), + PASSWORD_EXPIRED(HttpStatus.FORBIDDEN, "비밀번호 사용기간이 만료되었습니다. 비밀번호를 재설정해 주세요."), INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "로그인 정보를 갱신할 수 없습니다. 다시 로그인해 주세요."); private final HttpStatus status; diff --git a/src/main/java/com/fowoco/server/auth/domain/UserAccount.java b/src/main/java/com/fowoco/server/auth/domain/UserAccount.java index 48674e4d..b31cbf3c 100644 --- a/src/main/java/com/fowoco/server/auth/domain/UserAccount.java +++ b/src/main/java/com/fowoco/server/auth/domain/UserAccount.java @@ -1,5 +1,6 @@ package com.fowoco.server.auth.domain; +import java.time.Duration; import java.time.Instant; import java.util.Locale; import java.util.Objects; @@ -24,6 +25,9 @@ public final class UserAccount { private final Instant createdAt; private final Instant updatedAt; private final Instant passwordChangedAt; + private final int failedLoginAttempts; + private final Instant lockedUntil; + private final Instant lastFailedLoginAt; private final long version; public UserAccount( @@ -39,6 +43,9 @@ public UserAccount( Instant createdAt, Instant updatedAt, Instant passwordChangedAt, + int failedLoginAttempts, + Instant lockedUntil, + Instant lastFailedLoginAt, long version ) { this.userId = Objects.requireNonNull(userId, "userId must not be null"); @@ -63,6 +70,12 @@ public UserAccount( if (passwordChangedAt.isBefore(createdAt)) { throw new IllegalArgumentException("passwordChangedAt must not be before createdAt"); } + if (failedLoginAttempts < 0) { + throw new IllegalArgumentException("failedLoginAttempts must not be negative"); + } + this.failedLoginAttempts = failedLoginAttempts; + this.lockedUntil = lockedUntil; + this.lastFailedLoginAt = lastFailedLoginAt; if (version < 0) { throw new IllegalArgumentException("version must not be negative"); } @@ -93,6 +106,9 @@ public static UserAccount create( now, now, now, + 0, + null, + null, 0L ); } @@ -127,6 +143,9 @@ public UserAccount changePassword(String newPasswordHash, Instant now) { createdAt, now, now, + 0, + null, + null, version + 1 ); } @@ -149,6 +168,71 @@ public UserAccount updateProfile(String newDisplayName, String newPhone, Instant createdAt, now, passwordChangedAt, + failedLoginAttempts, + lockedUntil, + lastFailedLoginAt, + version + 1 + ); + } + + public UserAccount recordFailedLogin(int maxFailedAttempts, Duration lockDuration, Instant now) { + Objects.requireNonNull(lockDuration, "lockDuration must not be null"); + Objects.requireNonNull(now, "now must not be null"); + if (maxFailedAttempts < 1 || lockDuration.isZero() || lockDuration.isNegative()) { + throw new IllegalArgumentException("login protection policy is invalid"); + } + int nextAttempts = lockedUntil != null && !now.isBefore(lockedUntil) + ? 1 + : failedLoginAttempts + 1; + Instant nextLockedUntil = nextAttempts >= maxFailedAttempts ? now.plus(lockDuration) : null; + return copyWithLoginProtection(nextAttempts, nextLockedUntil, now, now); + } + + public UserAccount clearLoginFailures(Instant now) { + Objects.requireNonNull(now, "now must not be null"); + return copyWithLoginProtection(0, null, null, now); + } + + public boolean isTemporarilyLocked(Instant now) { + Objects.requireNonNull(now, "now must not be null"); + return lockedUntil != null && now.isBefore(lockedUntil); + } + + public boolean isPasswordExpired(Duration maxAge, Instant now) { + Objects.requireNonNull(maxAge, "maxAge must not be null"); + Objects.requireNonNull(now, "now must not be null"); + return passwordChangedAt.plus(maxAge).isBefore(now); + } + + public boolean hasLoginFailures() { + return failedLoginAttempts > 0 || lockedUntil != null || lastFailedLoginAt != null; + } + + private UserAccount copyWithLoginProtection( + int nextFailedAttempts, + Instant nextLockedUntil, + Instant nextLastFailedLoginAt, + Instant now + ) { + if (now.isBefore(updatedAt)) { + throw new IllegalArgumentException("now must not be before updatedAt"); + } + return new UserAccount( + userId, + companyId, + displayName, + phone, + email, + normalizedEmail, + passwordHash, + role, + status, + createdAt, + now, + passwordChangedAt, + nextFailedAttempts, + nextLockedUntil, + nextLastFailedLoginAt, version + 1 ); } @@ -201,6 +285,18 @@ public Instant passwordChangedAt() { return passwordChangedAt; } + public int failedLoginAttempts() { + return failedLoginAttempts; + } + + public Instant lockedUntil() { + return lockedUntil; + } + + public Instant lastFailedLoginAt() { + return lastFailedLoginAt; + } + public long version() { return version; } diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java index 2dcb00dd..802163b3 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntity.java @@ -70,6 +70,15 @@ public class UserAccountJpaEntity { @Column(name = "password_changed_at", nullable = false) private Instant passwordChangedAt; + @Column(name = "failed_login_attempts", nullable = false) + private int failedLoginAttempts; + + @Column(name = "locked_until") + private Instant lockedUntil; + + @Column(name = "last_failed_login_at") + private Instant lastFailedLoginAt; + @Version @Column(name = "version", nullable = false) private long version; @@ -90,6 +99,9 @@ private UserAccountJpaEntity( Instant createdAt, Instant updatedAt, Instant passwordChangedAt, + int failedLoginAttempts, + Instant lockedUntil, + Instant lastFailedLoginAt, long version ) { this.userId = userId; @@ -104,6 +116,9 @@ private UserAccountJpaEntity( this.createdAt = createdAt; this.updatedAt = updatedAt; this.passwordChangedAt = passwordChangedAt; + this.failedLoginAttempts = failedLoginAttempts; + this.lockedUntil = lockedUntil; + this.lastFailedLoginAt = lastFailedLoginAt; this.version = version; } @@ -122,6 +137,9 @@ public static UserAccountJpaEntity fromDomain(UserAccount userAccount) { userAccount.createdAt(), userAccount.updatedAt(), userAccount.passwordChangedAt(), + userAccount.failedLoginAttempts(), + userAccount.lockedUntil(), + userAccount.lastFailedLoginAt(), userAccount.version() ); } @@ -140,6 +158,9 @@ public UserAccount toDomain() { createdAt, updatedAt, passwordChangedAt, + failedLoginAttempts, + lockedUntil, + lastFailedLoginAt, version ); } @@ -154,6 +175,9 @@ void applyState(UserAccount userAccount) { passwordHash = userAccount.passwordHash(); updatedAt = userAccount.updatedAt(); passwordChangedAt = userAccount.passwordChangedAt(); + failedLoginAttempts = userAccount.failedLoginAttempts(); + lockedUntil = userAccount.lockedUntil(); + lastFailedLoginAt = userAccount.lastFailedLoginAt(); } } diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/security/AuthSecurityConfig.java b/src/main/java/com/fowoco/server/auth/infrastructure/security/AuthSecurityConfig.java index 8f6c7525..e60a9940 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/security/AuthSecurityConfig.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/security/AuthSecurityConfig.java @@ -32,7 +32,8 @@ RefreshTokenProperties.class, PasswordResetProperties.class, PasswordResetRateLimitProperties.class, - AgreementPolicyProperties.class + AgreementPolicyProperties.class, + LoginProtectionProperties.class }) public class AuthSecurityConfig { diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/security/LoginProtectionProperties.java b/src/main/java/com/fowoco/server/auth/infrastructure/security/LoginProtectionProperties.java new file mode 100644 index 00000000..ac647938 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/security/LoginProtectionProperties.java @@ -0,0 +1,46 @@ +package com.fowoco.server.auth.infrastructure.security; + +import java.time.Duration; +import java.util.Objects; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.auth.login-protection") +public final class LoginProtectionProperties { + + private final int maxFailedAttempts; + private final Duration lockDuration; + private final Duration passwordMaxAge; + + public LoginProtectionProperties( + int maxFailedAttempts, + Duration lockDuration, + Duration passwordMaxAge + ) { + if (maxFailedAttempts < 1 || maxFailedAttempts > 20) { + throw new IllegalArgumentException("maxFailedAttempts must be between 1 and 20"); + } + this.maxFailedAttempts = maxFailedAttempts; + this.lockDuration = requirePositive(lockDuration, "lockDuration"); + this.passwordMaxAge = requirePositive(passwordMaxAge, "passwordMaxAge"); + } + + public int maxFailedAttempts() { + return maxFailedAttempts; + } + + public Duration lockDuration() { + return lockDuration; + } + + public Duration passwordMaxAge() { + return passwordMaxAge; + } + + private static Duration requirePositive(Duration value, String name) { + Objects.requireNonNull(value, name + " must not be null"); + if (value.isZero() || value.isNegative()) { + throw new IllegalArgumentException(name + " must be positive"); + } + return value; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index cfb0a20d..27475ef9 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -177,6 +177,10 @@ app: rate-limit: max-requests: ${PASSWORD_RESET_RATE_LIMIT_MAX_REQUESTS:10} window: ${PASSWORD_RESET_RATE_LIMIT_WINDOW:10m} + login-protection: + max-failed-attempts: ${LOGIN_MAX_FAILED_ATTEMPTS:5} + lock-duration: ${LOGIN_LOCK_DURATION:15m} + password-max-age: ${PASSWORD_MAX_AGE:180d} agreements: service-terms-version: ${SERVICE_TERMS_VERSION:1.0} privacy-policy-version: ${PRIVACY_POLICY_VERSION:1.0} diff --git a/src/main/resources/db/migration/V60__add_login_protection_to_user_account.sql b/src/main/resources/db/migration/V60__add_login_protection_to_user_account.sql new file mode 100644 index 00000000..79081319 --- /dev/null +++ b/src/main/resources/db/migration/V60__add_login_protection_to_user_account.sql @@ -0,0 +1,12 @@ +ALTER TABLE user_account + ADD COLUMN failed_login_attempts INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE user_account + ADD COLUMN locked_until TIMESTAMP(6) WITH TIME ZONE; + +ALTER TABLE user_account + ADD COLUMN last_failed_login_at TIMESTAMP(6) WITH TIME ZONE; + +ALTER TABLE user_account + ADD CONSTRAINT ck_user_account_failed_login_attempts + CHECK (failed_login_attempts >= 0); diff --git a/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java index 46eeea57..0d05b064 100644 --- a/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/AuthSecurityIntegrationTest.java @@ -12,6 +12,7 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.time.Instant; +import java.time.temporal.ChronoUnit; import java.util.UUID; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -87,7 +88,16 @@ void resetAuthenticationState() { jdbcTemplate.update("DELETE FROM refresh_token"); jdbcTemplate.update("DELETE FROM user_login_event"); jdbcTemplate.update( - "UPDATE user_account SET status = 'ACTIVE', updated_at = CURRENT_TIMESTAMP, version = version + 1" + """ + UPDATE user_account + SET status = 'ACTIVE', + failed_login_attempts = 0, + locked_until = NULL, + last_failed_login_at = NULL, + password_changed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP, + version = version + 1 + """ ); jdbcTemplate.update( "UPDATE company SET status = 'ACTIVE', updated_at = CURRENT_TIMESTAMP, version = version + 1" @@ -366,6 +376,65 @@ void unknownEmailAndWrongPasswordUseTheSameResponse() throws Exception { assertThat(refreshTokenCount()).isZero(); } + @Test + void repeatedPasswordFailuresTemporarilyLockTheAccount() throws Exception { + for (int attempt = 0; attempt < 5; attempt++) { + assertThat(login(VIEWER_A_EMAIL, "Wrong-password-1!").statusCode()).isEqualTo(401); + } + + HttpResponse lockedResponse = login(VIEWER_A_EMAIL, PASSWORD); + + assertThat(lockedResponse.statusCode()).isEqualTo(423); + assertThat(JsonPath.read(lockedResponse.body(), "$.code")) + .isEqualTo("ACCOUNT_TEMPORARILY_LOCKED"); + assertThat(jdbcTemplate.queryForObject( + "SELECT failed_login_attempts FROM user_account WHERE user_id = ?", + Integer.class, + VIEWER_A + )).isEqualTo(5); + assertThat(refreshTokenCount()).isZero(); + } + + @Test + void successfulLoginClearsPreviousFailureCount() throws Exception { + assertThat(login(VIEWER_A_EMAIL, "Wrong-password-1!").statusCode()).isEqualTo(401); + + assertThat(login(VIEWER_A_EMAIL, PASSWORD).statusCode()).isEqualTo(200); + assertThat(jdbcTemplate.queryForObject( + "SELECT failed_login_attempts FROM user_account WHERE user_id = ?", + Integer.class, + VIEWER_A + )).isZero(); + assertThat(jdbcTemplate.queryForObject( + "SELECT locked_until IS NULL FROM user_account WHERE user_id = ?", + Boolean.class, + VIEWER_A + )).isTrue(); + } + + @Test + void expiredPasswordRequiresResetBeforeLogin() throws Exception { + Instant now = Instant.now(); + jdbcTemplate.update( + """ + UPDATE user_account + SET created_at = ?, + password_changed_at = ?, + version = version + 1 + WHERE user_id = ? + """, + now.minus(365, ChronoUnit.DAYS), + now.minus(181, ChronoUnit.DAYS), + VIEWER_A + ); + + HttpResponse response = login(VIEWER_A_EMAIL, PASSWORD); + + assertThat(response.statusCode()).isEqualTo(403); + assertThat(JsonPath.read(response.body(), "$.code")).isEqualTo("PASSWORD_EXPIRED"); + assertThat(refreshTokenCount()).isZero(); + } + @Test void tamperedAccessTokenIsRejected() throws Exception { String accessToken = accessToken(login(VIEWER_A_EMAIL, PASSWORD)); diff --git a/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java b/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java index ec526ad2..13ee97ab 100644 --- a/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java +++ b/src/test/java/com/fowoco/server/auth/SignupIntegrationTest.java @@ -176,6 +176,12 @@ void signupPolicyIsPublicAndUsesConfiguredAgreementVersions() throws Exception { .isTrue(); assertThat(JsonPath.read(response.body(), "$.password_policy.require_digit")) .isTrue(); + assertThat(JsonPath.read(response.body(), "$.account_protection.max_failed_attempts")) + .isEqualTo(5); + assertThat(JsonPath.read(response.body(), "$.account_protection.lock_duration_seconds")) + .isEqualTo(900); + assertThat(JsonPath.read(response.body(), "$.account_protection.password_max_age_days")) + .isEqualTo(180); assertThat(JsonPath.read(response.body(), "$.agreements.service_terms.version")) .isEqualTo("1.0"); assertThat(JsonPath.read(response.body(), "$.agreements.service_terms.required"))