Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/main/java/com/fowoco/server/auth/api/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand All @@ -56,13 +58,15 @@ public AuthController(
SignupService signupService,
PasswordResetService passwordResetService,
AgreementPolicyProperties agreementPolicy,
LoginProtectionProperties loginProtectionPolicy,
RefreshTokenCookieFactory refreshTokenCookieFactory,
ActorContextProvider actorContextProvider
) {
this.authService = authService;
this.signupService = signupService;
this.passwordResetService = passwordResetService;
this.agreementPolicy = agreementPolicy;
this.loginProtectionPolicy = loginProtectionPolicy;
this.refreshTokenCookieFactory = refreshTokenCookieFactory;
this.actorContextProvider = actorContextProvider;
}
Expand All @@ -85,7 +89,7 @@ public ResponseEntity<SignupPolicyResponse> getSignupPolicy() {
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header(HttpHeaders.PRAGMA, "no-cache")
.body(SignupPolicyResponse.from(agreementPolicy));
.body(SignupPolicyResponse.from(agreementPolicy, loginProtectionPolicy));
}

@Operation(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,35 @@
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,
PasswordPolicy.MAX_LENGTH,
true,
true
),
new AccountProtectionResponse(
loginProtection.maxFailedAttempts(),
loginProtection.lockDuration().toSeconds(),
loginProtection.passwordMaxAge().toDays()
),
new AgreementsPolicyResponse(
new AgreementPolicyResponse(
policy.serviceTermsVersion(),
Expand All @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions src/main/java/com/fowoco/server/auth/application/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -59,6 +60,7 @@ public AuthService(
RefreshTokenHashPort refreshTokenHashPort,
RefreshTokenRotationTransaction refreshTokenRotationTransaction,
RefreshTokenLogoutTransaction refreshTokenLogoutTransaction,
LoginProtectionTransaction loginProtectionTransaction,
AuthAuditPort authAuditPort,
UserLoginEventRepository userLoginEventRepository,
UuidGenerator uuidGenerator,
Expand All @@ -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<UUID> companyIdCandidate =
Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
@@ -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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
96 changes: 96 additions & 0 deletions src/main/java/com/fowoco/server/auth/domain/UserAccount.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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(
Expand All @@ -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");
Expand All @@ -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");
}
Expand Down Expand Up @@ -93,6 +106,9 @@ public static UserAccount create(
now,
now,
now,
0,
null,
null,
0L
);
}
Expand Down Expand Up @@ -127,6 +143,9 @@ public UserAccount changePassword(String newPasswordHash, Instant now) {
createdAt,
now,
now,
0,
null,
null,
version + 1
);
}
Expand All @@ -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
);
}
Expand Down Expand Up @@ -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;
}
Expand Down
Loading
Loading