From 5dc6192da44ca9b68b6ba0845ae4cb89130f887b Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 19 Aug 2026 17:44:04 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(auth):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=9E=A0=EA=B8=88=EA=B3=BC=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=A7=8C=EB=A3=8C=20=EC=A0=95=EC=B1=85=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/auth/api/AuthController.java | 6 +- .../server/auth/api/SignupPolicyResponse.java | 20 +++- .../server/auth/application/AuthService.java | 10 +- .../LoginProtectionTransaction.java | 67 +++++++++++++ .../auth/application/error/AuthErrorCode.java | 2 + .../server/auth/domain/UserAccount.java | 96 +++++++++++++++++++ .../persistence/UserAccountJpaEntity.java | 24 +++++ .../security/AuthSecurityConfig.java | 3 +- .../security/LoginProtectionProperties.java | 46 +++++++++ src/main/resources/application.yaml | 4 + ...__add_login_protection_to_user_account.sql | 12 +++ .../auth/AuthSecurityIntegrationTest.java | 71 +++++++++++++- .../server/auth/SignupIntegrationTest.java | 6 ++ 13 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/security/LoginProtectionProperties.java create mode 100644 src/main/resources/db/migration/V60__add_login_protection_to_user_account.sql 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..68079fbb 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,6 +77,7 @@ public AuthService( this.refreshTokenHashPort = refreshTokenHashPort; this.refreshTokenRotationTransaction = refreshTokenRotationTransaction; this.refreshTokenLogoutTransaction = refreshTokenLogoutTransaction; + this.loginProtectionTransaction = loginProtectionTransaction; this.authAuditPort = authAuditPort; this.userLoginEventRepository = userLoginEventRepository; this.uuidGenerator = uuidGenerator; @@ -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..b9598b4b --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java @@ -0,0 +1,67 @@ +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.Propagation; +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(propagation = Propagation.REQUIRES_NEW) + 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(propagation = Propagation.REQUIRES_NEW) + 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 a82c6930..d54ff096 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")) From 368e305da93eb054426718aca602426109d3f8f6 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 19 Aug 2026 18:00:37 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(auth):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=20=EA=B2=80=EC=A6=9D=EC=9D=98=20=EC=A4=91?= =?UTF-8?q?=EC=B2=A9=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/auth/application/LoginProtectionTransaction.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java b/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java index b9598b4b..71a1e904 100644 --- a/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java +++ b/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java @@ -44,7 +44,7 @@ public void recordFailure(UUID userId, UUID companyId) { userAccountRepository.update(updated); } - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public void verifyAndClear(UUID userId, UUID companyId) { UserAccount account = lockedAccount(userId, companyId); Instant now = clock.instant(); From b0312a53a4f09dbbf6c7d4aa4bab4e05f5866700 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 19 Aug 2026 18:13:44 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(auth):=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=83=81=ED=83=9C=EB=A5=BC=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=ED=8A=B8=EB=9E=9C=EC=9E=AD=EC=85=98=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/fowoco/server/auth/application/AuthService.java | 2 +- .../server/auth/application/LoginProtectionTransaction.java | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) 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 68079fbb..96122e34 100644 --- a/src/main/java/com/fowoco/server/auth/application/AuthService.java +++ b/src/main/java/com/fowoco/server/auth/application/AuthService.java @@ -84,7 +84,7 @@ public AuthService( this.clock = clock; } - @Transactional + @Transactional(noRollbackFor = ApiException.class) public LoginResult login(LoginCommand command) { String normalizedEmail = UserAccount.normalizeEmail(command.email()); Optional companyIdCandidate = diff --git a/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java b/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java index 71a1e904..e73efef9 100644 --- a/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java +++ b/src/main/java/com/fowoco/server/auth/application/LoginProtectionTransaction.java @@ -10,7 +10,6 @@ import java.time.Instant; import java.util.UUID; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -33,7 +32,7 @@ public LoginProtectionTransaction( this.clock = clock; } - @Transactional(propagation = Propagation.REQUIRES_NEW) + @Transactional public void recordFailure(UUID userId, UUID companyId) { UserAccount account = lockedAccount(userId, companyId); UserAccount updated = account.recordFailedLogin( @@ -44,7 +43,7 @@ public void recordFailure(UUID userId, UUID companyId) { userAccountRepository.update(updated); } - @Transactional + @Transactional(noRollbackFor = ApiException.class) public void verifyAndClear(UUID userId, UUID companyId) { UserAccount account = lockedAccount(userId, companyId); Instant now = clock.instant();