From 5dc6192da44ca9b68b6ba0845ae4cb89130f887b Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 19 Aug 2026 17:44:04 +0900 Subject: [PATCH 1/7] =?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/7] =?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/7] =?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(); From cc9b5ef7fa0807b666e818cb13241f66731014e5 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 19 Aug 2026 19:13:38 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(security):=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=97=B0=EB=9D=BD=EC=B2=98=20=ED=95=84=EB=93=9C=20=EC=95=94?= =?UTF-8?q?=ED=98=B8=ED=99=94=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- compose.demo.yml | 4 + .../crypto/AccountPiiCipher.java | 21 +++ .../crypto/AccountPiiCryptoConfiguration.java | 65 +++++++ .../crypto/AccountPiiProperties.java | 62 +++++++ .../crypto/AesGcmAccountPiiCipher.java | 130 ++++++++++++++ .../crypto/DisabledAccountPiiCipher.java | 27 +++ .../persistence/JpaUserAccountRepository.java | 15 +- .../persistence/UserAccountJpaEntity.java | 78 +++++++- src/main/resources/application.yaml | 7 + .../V61__encrypt_user_account_phone.sql | 17 ++ .../server/PostgreSqlMigrationTests.java | 26 +++ .../AccountPiiEncryptionIntegrationTest.java | 170 ++++++++++++++++++ .../crypto/AesGcmAccountPiiCipherTest.java | 110 ++++++++++++ 13 files changed, 719 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCipher.java create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCryptoConfiguration.java create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiProperties.java create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipher.java create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/crypto/DisabledAccountPiiCipher.java create mode 100644 src/main/resources/db/migration/V61__encrypt_user_account_phone.sql create mode 100644 src/test/java/com/fowoco/server/auth/AccountPiiEncryptionIntegrationTest.java create mode 100644 src/test/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipherTest.java diff --git a/compose.demo.yml b/compose.demo.yml index afb2a262..8a021d40 100644 --- a/compose.demo.yml +++ b/compose.demo.yml @@ -65,6 +65,10 @@ services: DOCUMENT_OCR_ENABLED: ${DOCUMENT_OCR_ENABLED:-false} OCR_RESULT_ENCRYPTION_KEY_BASE64: ${OCR_RESULT_ENCRYPTION_KEY_BASE64:-} OCR_RESULT_KEY_VERSION: ${OCR_RESULT_KEY_VERSION:-demo-v1} + PII_ENCRYPTION_ENABLED: ${PII_ENCRYPTION_ENABLED:-false} + PII_ENCRYPTION_KEY_BASE64: ${PII_ENCRYPTION_KEY_BASE64:-} + PII_ENCRYPTION_KEY_VERSION: ${PII_ENCRYPTION_KEY_VERSION:-demo-v1} + PII_DECRYPTION_KEYS: ${PII_DECRYPTION_KEYS:-} FILE_STORAGE_LOCAL_PATH: /app/data/files DEMO_SEED_ENABLED: ${DEMO_SEED_ENABLED:-false} DEMO_SEED_ADMIN_PASSWORD: ${DEMO_SEED_ADMIN_PASSWORD:-} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCipher.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCipher.java new file mode 100644 index 00000000..18d98bd5 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCipher.java @@ -0,0 +1,21 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import java.util.UUID; + +public interface AccountPiiCipher { + + boolean isAvailable(); + + EncryptedValue encrypt(String plaintext, UUID companyId, UUID userId, String fieldName); + + String decrypt( + String ciphertext, + String keyVersion, + UUID companyId, + UUID userId, + String fieldName + ); + + record EncryptedValue(String ciphertext, String keyVersion) { + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCryptoConfiguration.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCryptoConfiguration.java new file mode 100644 index 00000000..330b9f6a --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCryptoConfiguration.java @@ -0,0 +1,65 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +@EnableConfigurationProperties(AccountPiiProperties.class) +public class AccountPiiCryptoConfiguration { + + @Bean + public AccountPiiCipher accountPiiCipher(AccountPiiProperties properties) { + if (!properties.isEnabled()) { + return new DisabledAccountPiiCipher(); + } + properties.validateEnabledConfiguration(); + Map keys = parsePreviousKeys(properties.getDecryptionKeys()); + String currentVersion = properties.getCurrentKeyVersion().strip(); + byte[] currentKey = decodeKey(properties.getCurrentKeyBase64(), "PII_ENCRYPTION_KEY_BASE64"); + byte[] conflictingKey = keys.put(currentVersion, currentKey); + if (conflictingKey != null && !Arrays.equals(conflictingKey, currentKey)) { + throw new IllegalStateException("current PII key version conflicts with PII_DECRYPTION_KEYS"); + } + return new AesGcmAccountPiiCipher(keys, currentVersion, new SecureRandom()); + } + + private Map parsePreviousKeys(String configuredKeys) { + Map keys = new LinkedHashMap<>(); + if (configuredKeys == null || configuredKeys.isBlank()) { + return keys; + } + for (String entry : configuredKeys.split(",")) { + String[] pair = entry.strip().split("=", 2); + if (pair.length != 2) { + throw new IllegalStateException( + "PII_DECRYPTION_KEYS must use version=base64 entries separated by commas" + ); + } + String version = pair[0].strip(); + AccountPiiProperties.validateKeyVersion(version, "PII_DECRYPTION_KEYS version"); + byte[] previous = keys.put(version, decodeKey(pair[1], "PII_DECRYPTION_KEYS")); + if (previous != null) { + throw new IllegalStateException("PII_DECRYPTION_KEYS contains a duplicate version"); + } + } + return keys; + } + + private byte[] decodeKey(String value, String fieldName) { + try { + byte[] decoded = Base64.getDecoder().decode(value.strip()); + if (decoded.length != 32) { + throw new IllegalStateException(fieldName + " must decode to 32 bytes"); + } + return decoded; + } catch (IllegalArgumentException exception) { + throw new IllegalStateException(fieldName + " is not valid Base64", exception); + } + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiProperties.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiProperties.java new file mode 100644 index 00000000..bba9a45f --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiProperties.java @@ -0,0 +1,62 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.auth.pii") +public final class AccountPiiProperties { + + private boolean enabled; + private String currentKeyBase64; + private String currentKeyVersion = "local-v1"; + private String decryptionKeys = ""; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getCurrentKeyBase64() { + return currentKeyBase64; + } + + public void setCurrentKeyBase64(String currentKeyBase64) { + this.currentKeyBase64 = currentKeyBase64; + } + + public String getCurrentKeyVersion() { + return currentKeyVersion; + } + + public void setCurrentKeyVersion(String currentKeyVersion) { + this.currentKeyVersion = currentKeyVersion; + } + + public String getDecryptionKeys() { + return decryptionKeys; + } + + public void setDecryptionKeys(String decryptionKeys) { + this.decryptionKeys = decryptionKeys; + } + + void validateEnabledConfiguration() { + if (!enabled) { + return; + } + if (currentKeyBase64 == null || currentKeyBase64.isBlank()) { + throw new IllegalStateException( + "PII_ENCRYPTION_KEY_BASE64 must be configured when account PII encryption is enabled" + ); + } + validateKeyVersion(currentKeyVersion, "PII_ENCRYPTION_KEY_VERSION"); + } + + static void validateKeyVersion(String value, String fieldName) { + if (value == null || !value.matches("[A-Za-z0-9._-]{1,60}")) { + throw new IllegalStateException(fieldName + " must match [A-Za-z0-9._-]{1,60}"); + } + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipher.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipher.java new file mode 100644 index 00000000..40686f70 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipher.java @@ -0,0 +1,130 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.stream.Collectors; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +final class AesGcmAccountPiiCipher implements AccountPiiCipher { + + private static final int IV_BYTES = 12; + private static final int TAG_BITS = 128; + + private final Map keys; + private final String currentKeyVersion; + private final SecureRandom secureRandom; + + AesGcmAccountPiiCipher( + Map keyBytesByVersion, + String currentKeyVersion, + SecureRandom secureRandom + ) { + Objects.requireNonNull(keyBytesByVersion, "keyBytesByVersion must not be null"); + this.currentKeyVersion = requireText(currentKeyVersion, "currentKeyVersion"); + this.secureRandom = Objects.requireNonNull(secureRandom, "secureRandom must not be null"); + this.keys = keyBytesByVersion.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + entry -> requireText(entry.getKey(), "key version"), + entry -> secretKey(entry.getValue()) + )); + if (!keys.containsKey(this.currentKeyVersion)) { + throw new IllegalStateException("current PII encryption key version is missing from the keyring"); + } + } + + @Override + public boolean isAvailable() { + return true; + } + + @Override + public EncryptedValue encrypt( + String plaintext, + UUID companyId, + UUID userId, + String fieldName + ) { + String value = Objects.requireNonNull(plaintext, "plaintext must not be null"); + byte[] iv = new byte[IV_BYTES]; + secureRandom.nextBytes(iv); + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.ENCRYPT_MODE, + keys.get(currentKeyVersion), + new GCMParameterSpec(TAG_BITS, iv) + ); + cipher.updateAAD(aad(companyId, userId, fieldName)); + byte[] encrypted = cipher.doFinal(value.getBytes(StandardCharsets.UTF_8)); + String ciphertext = "v1." + + Base64.getUrlEncoder().withoutPadding().encodeToString(iv) + + "." + + Base64.getUrlEncoder().withoutPadding().encodeToString(encrypted); + return new EncryptedValue(ciphertext, currentKeyVersion); + } catch (GeneralSecurityException exception) { + throw new IllegalStateException("account PII encryption failed", exception); + } + } + + @Override + public String decrypt( + String ciphertext, + String keyVersion, + UUID companyId, + UUID userId, + String fieldName + ) { + SecretKeySpec key = keys.get(requireText(keyVersion, "keyVersion")); + if (key == null) { + throw new IllegalStateException("account PII decryption key version is unavailable"); + } + String[] parts = requireText(ciphertext, "ciphertext").split("\\.", -1); + if (parts.length != 3 || !"v1".equals(parts[0])) { + throw new IllegalStateException("account PII ciphertext format is invalid"); + } + try { + byte[] iv = Base64.getUrlDecoder().decode(parts[1]); + byte[] encrypted = Base64.getUrlDecoder().decode(parts[2]); + if (iv.length != IV_BYTES) { + throw new IllegalStateException("account PII ciphertext IV is invalid"); + } + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + cipher.updateAAD(aad(companyId, userId, fieldName)); + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } catch (GeneralSecurityException | IllegalArgumentException exception) { + throw new IllegalStateException("account PII decryption failed", exception); + } + } + + private byte[] aad(UUID companyId, UUID userId, String fieldName) { + return (Objects.requireNonNull(companyId, "companyId must not be null") + + ":" + + Objects.requireNonNull(userId, "userId must not be null") + + ":user_account:" + + requireText(fieldName, "fieldName")) + .getBytes(StandardCharsets.UTF_8); + } + + private SecretKeySpec secretKey(byte[] keyBytes) { + Objects.requireNonNull(keyBytes, "keyBytes must not be null"); + if (keyBytes.length != 32) { + throw new IllegalStateException("account PII encryption keys must decode to 32 bytes"); + } + return new SecretKeySpec(keyBytes.clone(), "AES"); + } + + private static String requireText(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalStateException(fieldName + " must not be blank"); + } + return value.strip(); + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/DisabledAccountPiiCipher.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/DisabledAccountPiiCipher.java new file mode 100644 index 00000000..00f8008b --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/DisabledAccountPiiCipher.java @@ -0,0 +1,27 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import java.util.UUID; + +final class DisabledAccountPiiCipher implements AccountPiiCipher { + + @Override + public boolean isAvailable() { + return false; + } + + @Override + public EncryptedValue encrypt(String plaintext, UUID companyId, UUID userId, String fieldName) { + throw new IllegalStateException("account PII encryption is disabled"); + } + + @Override + public String decrypt( + String ciphertext, + String keyVersion, + UUID companyId, + UUID userId, + String fieldName + ) { + throw new IllegalStateException("account PII encryption is disabled"); + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java index 63faeba8..44414110 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/persistence/JpaUserAccountRepository.java @@ -1,6 +1,7 @@ package com.fowoco.server.auth.infrastructure.persistence; import com.fowoco.server.auth.domain.UserAccount; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiCipher; import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; import java.util.Objects; @@ -13,15 +14,17 @@ public class JpaUserAccountRepository implements com.fowoco.server.auth.application.port.UserAccountRepository { private final EntityManager entityManager; + private final AccountPiiCipher piiCipher; - public JpaUserAccountRepository(EntityManager entityManager) { + public JpaUserAccountRepository(EntityManager entityManager, AccountPiiCipher piiCipher) { this.entityManager = entityManager; + this.piiCipher = Objects.requireNonNull(piiCipher, "piiCipher must not be null"); } @Override public void insert(UserAccount userAccount) { Objects.requireNonNull(userAccount, "userAccount must not be null"); - entityManager.persist(UserAccountJpaEntity.fromDomain(userAccount)); + entityManager.persist(UserAccountJpaEntity.fromDomain(userAccount, piiCipher)); entityManager.flush(); } @@ -36,7 +39,7 @@ public void update(UserAccount userAccount) { if (entity == null) { throw new IllegalStateException("user account to update was not found"); } - entity.applyState(userAccount); + entity.applyState(userAccount, piiCipher); entityManager.flush(); } @@ -70,7 +73,7 @@ public Optional findByNormalizedEmail(String normalizedEmail) { .setMaxResults(1) .getResultStream() .findFirst() - .map(UserAccountJpaEntity::toDomain); + .map(entity -> entity.toDomain(piiCipher)); } @Override @@ -89,7 +92,7 @@ public Optional findByNormalizedEmailWithLock(String normalizedEmai .setMaxResults(1) .getResultStream() .findFirst() - .map(UserAccountJpaEntity::toDomain); + .map(entity -> entity.toDomain(piiCipher)); } @Override @@ -124,6 +127,6 @@ private Optional findByUserIdAndCompanyId( return query .getResultStream() .findFirst() - .map(UserAccountJpaEntity::toDomain); + .map(entity -> entity.toDomain(piiCipher)); } } 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 802163b3..5094bbee 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 @@ -3,6 +3,7 @@ import com.fowoco.server.auth.domain.AccountStatus; import com.fowoco.server.auth.domain.UserAccount; import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiCipher; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -31,6 +32,8 @@ ) public class UserAccountJpaEntity { + private static final String PHONE_FIELD = "phone"; + @Id @Column(name = "user_id", nullable = false, updatable = false) private UUID userId; @@ -44,6 +47,12 @@ public class UserAccountJpaEntity { @Column(name = "phone", length = 30) private String phone; + @Column(name = "phone_ciphertext") + private String phoneCiphertext; + + @Column(name = "phone_key_version", length = 60) + private String phoneKeyVersion; + @Column(name = "email", nullable = false, length = 254) private String email; @@ -91,6 +100,8 @@ private UserAccountJpaEntity( UUID companyId, String displayName, String phone, + String phoneCiphertext, + String phoneKeyVersion, String email, String normalizedEmail, String passwordHash, @@ -108,6 +119,8 @@ private UserAccountJpaEntity( this.companyId = companyId; this.displayName = displayName; this.phone = phone; + this.phoneCiphertext = phoneCiphertext; + this.phoneKeyVersion = phoneKeyVersion; this.email = email; this.normalizedEmail = normalizedEmail; this.passwordHash = passwordHash; @@ -122,13 +135,18 @@ private UserAccountJpaEntity( this.version = version; } - public static UserAccountJpaEntity fromDomain(UserAccount userAccount) { + public static UserAccountJpaEntity fromDomain( + UserAccount userAccount, + AccountPiiCipher piiCipher + ) { Objects.requireNonNull(userAccount, "userAccount must not be null"); - return new UserAccountJpaEntity( + UserAccountJpaEntity entity = new UserAccountJpaEntity( userAccount.userId(), userAccount.companyId(), userAccount.displayName(), - userAccount.phone(), + null, + null, + null, userAccount.email(), userAccount.normalizedEmail(), userAccount.passwordHash(), @@ -142,14 +160,16 @@ public static UserAccountJpaEntity fromDomain(UserAccount userAccount) { userAccount.lastFailedLoginAt(), userAccount.version() ); + entity.storePhone(userAccount.phone(), piiCipher); + return entity; } - public UserAccount toDomain() { + public UserAccount toDomain(AccountPiiCipher piiCipher) { return new UserAccount( userId, companyId, displayName, - phone, + readPhone(piiCipher), email, normalizedEmail, passwordHash, @@ -165,13 +185,13 @@ public UserAccount toDomain() { ); } - void applyState(UserAccount userAccount) { + void applyState(UserAccount userAccount, AccountPiiCipher piiCipher) { Objects.requireNonNull(userAccount, "userAccount must not be null"); if (!userId.equals(userAccount.userId()) || version + 1 != userAccount.version()) { throw new IllegalArgumentException("user account version transition is invalid"); } displayName = userAccount.displayName(); - phone = userAccount.phone(); + storePhone(userAccount.phone(), piiCipher); passwordHash = userAccount.passwordHash(); updatedAt = userAccount.updatedAt(); passwordChangedAt = userAccount.passwordChangedAt(); @@ -180,4 +200,48 @@ void applyState(UserAccount userAccount) { lastFailedLoginAt = userAccount.lastFailedLoginAt(); } + private String readPhone(AccountPiiCipher piiCipher) { + Objects.requireNonNull(piiCipher, "piiCipher must not be null"); + if (phoneCiphertext != null) { + return piiCipher.decrypt( + phoneCiphertext, + phoneKeyVersion, + companyId, + userId, + PHONE_FIELD + ); + } + if (phone != null && piiCipher.isAvailable()) { + String legacyPhone = phone; + storePhone(legacyPhone, piiCipher); + return legacyPhone; + } + return phone; + } + + private void storePhone(String value, AccountPiiCipher piiCipher) { + Objects.requireNonNull(piiCipher, "piiCipher must not be null"); + if (value == null) { + phone = null; + phoneCiphertext = null; + phoneKeyVersion = null; + return; + } + if (!piiCipher.isAvailable()) { + phone = value; + phoneCiphertext = null; + phoneKeyVersion = null; + return; + } + AccountPiiCipher.EncryptedValue encrypted = piiCipher.encrypt( + value, + companyId, + userId, + PHONE_FIELD + ); + phone = null; + phoneCiphertext = encrypted.ciphertext(); + phoneKeyVersion = encrypted.keyVersion(); + } + } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index d54ff096..53457ede 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -155,6 +155,11 @@ app: demo-document-data: command: ${DEMO_DOCUMENT_DATA_COMMAND:none} auth: + pii: + enabled: ${PII_ENCRYPTION_ENABLED:false} + current-key-base64: ${PII_ENCRYPTION_KEY_BASE64:} + current-key-version: ${PII_ENCRYPTION_KEY_VERSION:local-v1} + decryption-keys: ${PII_DECRYPTION_KEYS:} jwt: issuer: ${JWT_ISSUER:fowoco-server} audience: ${JWT_AUDIENCE:fowoco-client} @@ -320,6 +325,8 @@ app: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS} auth: + pii: + enabled: ${PII_ENCRYPTION_ENABLED:true} jwt: issuer: ${JWT_ISSUER} audience: ${JWT_AUDIENCE} diff --git a/src/main/resources/db/migration/V61__encrypt_user_account_phone.sql b/src/main/resources/db/migration/V61__encrypt_user_account_phone.sql new file mode 100644 index 00000000..dc82f8cb --- /dev/null +++ b/src/main/resources/db/migration/V61__encrypt_user_account_phone.sql @@ -0,0 +1,17 @@ +ALTER TABLE user_account + ADD COLUMN phone_ciphertext TEXT; + +ALTER TABLE user_account + ADD COLUMN phone_key_version VARCHAR(60); + +ALTER TABLE user_account + ADD CONSTRAINT ck_user_account_phone_cipher_pair + CHECK ( + (phone_ciphertext IS NULL AND phone_key_version IS NULL) + OR + (phone_ciphertext IS NOT NULL AND phone_key_version IS NOT NULL) + ); + +ALTER TABLE user_account + ADD CONSTRAINT ck_user_account_phone_single_storage + CHECK (phone IS NULL OR phone_ciphertext IS NULL); diff --git a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java index 852eb40a..242ab20a 100644 --- a/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java +++ b/src/test/java/com/fowoco/server/PostgreSqlMigrationTests.java @@ -176,6 +176,9 @@ private void assertSchemaContract(Connection connection) throws SQLException { .containsEntry("user_id", new ColumnSpec("uuid", false)) .containsEntry("company_id", new ColumnSpec("uuid", false)) .containsEntry("display_name", new ColumnSpec("varchar", false)) + .containsEntry("phone", new ColumnSpec("varchar", true)) + .containsEntry("phone_ciphertext", new ColumnSpec("text", true)) + .containsEntry("phone_key_version", new ColumnSpec("varchar", true)) .containsEntry("normalized_email", new ColumnSpec("varchar", false)) .containsEntry("password_hash", new ColumnSpec("varchar", false)) .containsEntry("role", new ColumnSpec("varchar", false)) @@ -423,6 +426,8 @@ private void assertSchemaContract(Connection connection) throws SQLException { "fk_user_account_company", "uq_user_account_normalized_email", "uq_user_account_user_company", + "ck_user_account_phone_cipher_pair", + "ck_user_account_phone_single_storage", "pk_refresh_token", "uq_refresh_token_hash", "fk_refresh_token_user_company", @@ -965,6 +970,27 @@ INSERT INTO user_account ( 'test-password-hash', 'HR', 'ACTIVE' ) """); + assertSqlState(connection, "23514", """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, + password_hash, role, status, phone, phone_ciphertext, phone_key_version + ) VALUES ( + '45000000-0000-0000-0000-000000000001', '%s', + 'double.phone@example.com', 'double.phone@example.com', + 'test-password-hash', 'HR', 'ACTIVE', + '010-1234-5678', 'v1.example', 'test-v1' + ) + """.formatted(COMPANY_A)); + assertSqlState(connection, "23514", """ + INSERT INTO user_account ( + user_id, company_id, email, normalized_email, + password_hash, role, status, phone_ciphertext + ) VALUES ( + '46000000-0000-0000-0000-000000000001', '%s', + 'missing.key@example.com', 'missing.key@example.com', + 'test-password-hash', 'HR', 'ACTIVE', 'v1.example' + ) + """.formatted(COMPANY_A)); assertSqlState(connection, "23503", """ INSERT INTO refresh_token ( refresh_token_id, user_id, company_id, diff --git a/src/test/java/com/fowoco/server/auth/AccountPiiEncryptionIntegrationTest.java b/src/test/java/com/fowoco/server/auth/AccountPiiEncryptionIntegrationTest.java new file mode 100644 index 00000000..3ca2504e --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/AccountPiiEncryptionIntegrationTest.java @@ -0,0 +1,170 @@ +package com.fowoco.server.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.jayway.jsonpath.JsonPath; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.HttpHeaders; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +@ActiveProfiles("test") +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + properties = { + "spring.datasource.url=jdbc:h2:mem:fowoco-account-pii-test;" + + "MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;" + + "DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1", + "app.auth.pii.enabled=true", + "app.auth.pii.current-key-base64=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "app.auth.pii.current-key-version=test-v1" + } +) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AccountPiiEncryptionIntegrationTest { + + private static final String PASSWORD = "Signup-password-1!"; + private static final String PHONE = "010-1234-5678"; + + @LocalServerPort + private int port; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private final HttpClient httpClient = HttpClient.newHttpClient(); + + @BeforeEach + void cleanData() { + jdbcTemplate.update("DELETE FROM event_consumption"); + jdbcTemplate.update("DELETE FROM event_publication"); + jdbcTemplate.update("DELETE FROM audit_event"); + jdbcTemplate.update("DELETE FROM password_reset_token"); + jdbcTemplate.update("DELETE FROM user_agreement_consent"); + jdbcTemplate.update("DELETE FROM refresh_token"); + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company_settings"); + jdbcTemplate.update("DELETE FROM company"); + } + + @Test + void profileUpdateStoresPhoneOnlyAsCiphertextAndReturnsDecryptedValue() throws Exception { + HttpResponse signup = postJson("/api/v1/auth/signup", signupBody()); + assertThat(signup.statusCode()).isEqualTo(201); + HttpResponse login = postJson("/api/v1/auth/login", """ + {"email":"owner@example.com","password":"Signup-password-1!"} + """); + String accessToken = JsonPath.read(login.body(), "$.access_token"); + String userId = JsonPath.read(login.body(), "$.user_id"); + + HttpResponse updated = patchJson( + "/api/v1/auth/me/profile", + """ + {"display_name":"담당자","phone":"010-1234-5678"} + """, + accessToken + ); + + assertThat(updated.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(updated.body(), "$.phone")).isEqualTo(PHONE); + assertThat(jdbcTemplate.queryForObject( + "SELECT phone FROM user_account WHERE user_id = ?", + String.class, + userId + )).isNull(); + String ciphertext = jdbcTemplate.queryForObject( + "SELECT phone_ciphertext FROM user_account WHERE user_id = ?", + String.class, + userId + ); + assertThat(ciphertext).startsWith("v1.").doesNotContain(PHONE); + assertThat(jdbcTemplate.queryForObject( + "SELECT phone_key_version FROM user_account WHERE user_id = ?", + String.class, + userId + )).isEqualTo("test-v1"); + + HttpResponse profile = get("/api/v1/auth/me/profile", accessToken); + assertThat(profile.statusCode()).isEqualTo(200); + assertThat(JsonPath.read(profile.body(), "$.phone")).isEqualTo(PHONE); + } + + @Test + void legacyPlaintextPhoneIsMigratedOnAuthenticatedAccountAccess() throws Exception { + HttpResponse signup = postJson("/api/v1/auth/signup", signupBody()); + String userId = JsonPath.read(signup.body(), "$.user_id"); + jdbcTemplate.update( + "UPDATE user_account SET phone = ?, phone_ciphertext = NULL, phone_key_version = NULL WHERE user_id = ?", + PHONE, + userId + ); + + HttpResponse login = postJson("/api/v1/auth/login", """ + {"email":"owner@example.com","password":"Signup-password-1!"} + """); + + assertThat(login.statusCode()).isEqualTo(200); + assertThat(jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM user_account WHERE user_id = ? AND phone IS NULL " + + "AND phone_ciphertext IS NOT NULL AND phone_key_version = 'test-v1'", + Integer.class, + userId + )).isEqualTo(1); + } + + private String signupBody() { + return """ + { + "company_name":"한빛정밀", + "display_name":"담당자", + "email":"owner@example.com", + "password":"Signup-password-1!", + "agreements":{ + "service_terms":{"agreed":true,"version":"1.0"}, + "privacy_policy":{"agreed":true,"version":"1.0"}, + "marketing":{"agreed":false,"version":"1.0"} + } + } + """; + } + + private HttpResponse postJson(String path, String body) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private HttpResponse patchJson(String path, String body, String accessToken) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .header(HttpHeaders.CONTENT_TYPE, "application/json") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .method("PATCH", HttpRequest.BodyPublishers.ofString(body)) + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } + + private HttpResponse get(String path, String accessToken) throws Exception { + return httpClient.send( + HttpRequest.newBuilder(URI.create("http://localhost:" + port + path)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString() + ); + } +} diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipherTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipherTest.java new file mode 100644 index 00000000..9e7f699d --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipherTest.java @@ -0,0 +1,110 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.security.SecureRandom; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class AesGcmAccountPiiCipherTest { + + private static final UUID COMPANY_ID = UUID.fromString("10000000-0000-0000-0000-000000000001"); + private static final UUID USER_ID = UUID.fromString("11000000-0000-0000-0000-000000000001"); + + @Test + void encryptsWithTenantUserAndFieldBoundAuthenticatedData() { + AesGcmAccountPiiCipher cipher = cipher(Map.of("test-v1", new byte[32]), "test-v1"); + + AccountPiiCipher.EncryptedValue encrypted = cipher.encrypt( + "010-1234-5678", + COMPANY_ID, + USER_ID, + "phone" + ); + + assertThat(encrypted.ciphertext()).startsWith("v1.").doesNotContain("010-1234-5678"); + assertThat(encrypted.keyVersion()).isEqualTo("test-v1"); + assertThat(cipher.decrypt( + encrypted.ciphertext(), + encrypted.keyVersion(), + COMPANY_ID, + USER_ID, + "phone" + )).isEqualTo("010-1234-5678"); + assertThatThrownBy(() -> cipher.decrypt( + encrypted.ciphertext(), + encrypted.keyVersion(), + UUID.randomUUID(), + USER_ID, + "phone" + )).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> cipher.decrypt( + encrypted.ciphertext(), + encrypted.keyVersion(), + COMPANY_ID, + USER_ID, + "email" + )).isInstanceOf(IllegalStateException.class); + } + + @Test + void decryptsPreviousKeyVersionDuringRotation() { + byte[] previousKey = new byte[32]; + byte[] currentKey = new byte[32]; + currentKey[0] = 1; + AesGcmAccountPiiCipher previous = cipher(Map.of("pii-v1", previousKey), "pii-v1"); + AccountPiiCipher.EncryptedValue encrypted = previous.encrypt( + "010-9999-0000", + COMPANY_ID, + USER_ID, + "phone" + ); + AesGcmAccountPiiCipher rotated = cipher( + Map.of("pii-v1", previousKey, "pii-v2", currentKey), + "pii-v2" + ); + + assertThat(rotated.decrypt( + encrypted.ciphertext(), + encrypted.keyVersion(), + COMPANY_ID, + USER_ID, + "phone" + )).isEqualTo("010-9999-0000"); + assertThat(rotated.encrypt("010-9999-0000", COMPANY_ID, USER_ID, "phone").keyVersion()) + .isEqualTo("pii-v2"); + } + + @Test + void rejectsTamperedCiphertextAndUnknownKeyVersion() { + AesGcmAccountPiiCipher cipher = cipher(Map.of("test-v1", new byte[32]), "test-v1"); + AccountPiiCipher.EncryptedValue encrypted = cipher.encrypt( + "010-1234-5678", + COMPANY_ID, + USER_ID, + "phone" + ); + String tampered = encrypted.ciphertext().substring(0, encrypted.ciphertext().length() - 1) + "A"; + + assertThatThrownBy(() -> cipher.decrypt( + tampered, + encrypted.keyVersion(), + COMPANY_ID, + USER_ID, + "phone" + )).isInstanceOf(IllegalStateException.class); + assertThatThrownBy(() -> cipher.decrypt( + encrypted.ciphertext(), + "unknown-v1", + COMPANY_ID, + USER_ID, + "phone" + )).isInstanceOf(IllegalStateException.class); + } + + private AesGcmAccountPiiCipher cipher(Map keys, String currentVersion) { + return new AesGcmAccountPiiCipher(keys, currentVersion, new SecureRandom()); + } +} From 27648ab9cda65afb520363b39a1ef7322335d3f6 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 19 Aug 2026 19:13:51 +0900 Subject: [PATCH 5/7] =?UTF-8?q?docs(security):=20=EA=B0=9C=EC=9D=B8?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=ED=82=A4=20=EC=9A=B4=EC=98=81=20=EC=A0=88?= =?UTF-8?q?=EC=B0=A8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/deployment-runbook.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index 460781c5..e028fe8c 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -39,6 +39,10 @@ Secret은 Git과 Actions 로그에 값을 남기지 않고 `kubectl create secre | DB | `DB_RUNTIME_USERNAME`, `DB_RUNTIME_PASSWORD` | 애플리케이션 실행 계정 | | DB | `DB_MIGRATION_USERNAME`, `DB_MIGRATION_PASSWORD` | Flyway 전용 계정 | | Auth | `JWT_SECRET_BASE64`, `JWT_ISSUER`, `JWT_AUDIENCE` | Access Token 발급·검증 | +| PII | `PII_ENCRYPTION_ENABLED=true` | 계정 연락처 AES-256-GCM 암호화 활성화 | +| PII | `PII_ENCRYPTION_KEY_BASE64` | 현재 32바이트 연락처 암호화 키의 Base64 | +| PII | `PII_ENCRYPTION_KEY_VERSION` | 현재 키 식별 version, 예: `pii-2026-08-v1` | +| PII | `PII_DECRYPTION_KEYS` | 회전 이전 키 목록, `version=base64`를 쉼표로 구분 | | Web | `CORS_ALLOWED_ORIGINS` | 실제 Client HTTPS origin만 허용 | | Catalog | `WORKFLOW_CATALOG_LOCATION` | 검증된 `RELEASED` projection 위치 | | AI | `AI_RUNTIME_ENABLED=true` | 실제 Runtime 연동 활성화 | @@ -53,6 +57,28 @@ Secret은 Git과 Actions 로그에 값을 남기지 않고 `kubectl create secre | OCR | `OCR_RESULT_ENCRYPTION_KEY_BASE64` | 32바이트 OCR 결과 암호화 키의 Base64 | | OCR | `OCR_RESULT_KEY_VERSION` | 암호화 키 식별 version | +`PII_ENCRYPTION_KEY_BASE64`와 `PII_DECRYPTION_KEYS`는 Git, DB, 이미지, Issue, 로그에 +기록하지 않고 `server-env` Secret으로 주입합니다. 운영에서는 `prod` profile이 PII 암호화를 +기본 활성화하므로 현재 키가 없으면 Server가 기동하지 않습니다. 현재 Infra는 Kubernetes +Secret 주입까지 지원하며 AWS KMS·Secrets Manager 자동 동기화는 별도 고도화 범위입니다. + +키 회전은 새 키와 새 version을 현재 값으로 배포하되, 기존 version과 키를 +`PII_DECRYPTION_KEYS`에 유지한 상태에서 수행합니다. 기존 평문 연락처는 로그인·프로필 수정 등 +계정이 쓰기 transaction에서 조회될 때 암호문으로 점진 전환됩니다. 이전 키 제거 전에는 +DB에서 해당 `phone_key_version` 잔여 건수가 0인지 확인해야 합니다. + +```sql +SELECT COUNT(*) AS legacy_plaintext_phone_count +FROM user_account +WHERE phone IS NOT NULL; + +SELECT phone_key_version, COUNT(*) AS encrypted_phone_count +FROM user_account +WHERE phone_ciphertext IS NOT NULL +GROUP BY phone_key_version +ORDER BY phone_key_version; +``` + 비밀번호 재설정 메일을 실제로 발송할 때만 다음 값을 `server-env`에 추가합니다. 기본 `PASSWORD_RESET_NOTIFICATION_PROVIDER=none`에서는 메일을 발송하지 않습니다. @@ -116,6 +142,9 @@ Swagger를 읽기 전용으로 유지합니다. HTTP 주소를 임시로 넣어 ```bash export DEMO_DB_PASSWORD='local-demo-password' export JWT_SECRET_BASE64="$(openssl rand -base64 32)" +export PII_ENCRYPTION_ENABLED=true +export PII_ENCRYPTION_KEY_BASE64="$(openssl rand -base64 32)" +export PII_ENCRYPTION_KEY_VERSION='local-pii-v1' export DEMO_SEED_ENABLED=true export DEMO_SEED_ADMIN_PASSWORD='로컬 전용 12자 이상 값' docker compose -f compose.demo.yml up --build From 5b21ca2dab453bdced97060a19d57517bb0922da Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 19 Aug 2026 22:22:13 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat(security):=20=EC=97=B0=EB=9D=BD?= =?UTF-8?q?=EC=B2=98=20=EC=95=94=ED=98=B8=ED=99=94=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=EA=B3=BC=20=ED=82=A4=20=EA=B5=90=EC=B2=B4=20=EB=AA=85=EB=A0=B9?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 11 + compose.demo.yml | 2 + .../crypto/AccountPiiCipher.java | 6 + .../AccountPiiMaintenanceCommandRunner.java | 113 ++++++ .../crypto/AccountPiiMaintenanceService.java | 342 ++++++++++++++++++ .../crypto/AesGcmAccountPiiCipher.java | 5 + .../crypto/DisabledAccountPiiCipher.java | 5 + .../persistence/UserAccountJpaEntity.java | 6 +- src/main/resources/application.yaml | 2 + ...tPiiMaintenanceServiceIntegrationTest.java | 184 ++++++++++ .../UserAccountJpaEntityPiiRotationTest.java | 68 ++++ 11 files changed, 743 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceCommandRunner.java create mode 100644 src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceService.java create mode 100644 src/test/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceServiceIntegrationTest.java create mode 100644 src/test/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntityPiiRotationTest.java diff --git a/.env.example b/.env.example index 0bae487e..8485d41b 100644 --- a/.env.example +++ b/.env.example @@ -120,6 +120,17 @@ REFRESH_TOKEN_COOKIE_SAME_SITE=Strict # local=false, dev/prod=true가 기본입니다. HTTPS 정책을 바꿀 때만 명시하세요. # REFRESH_TOKEN_COOKIE_SECURE=true +# 계정 연락처 암호화입니다. 실제 키는 Git이 아닌 배포 Secret으로만 주입합니다. +# PII_ENCRYPTION_ENABLED=true +# PII_ENCRYPTION_KEY_BASE64= +PII_ENCRYPTION_KEY_VERSION=local-pii-v1 +# 키 교체 중에만 이전 키를 version=base64 형식으로 추가합니다. +# PII_DECRYPTION_KEYS=local-pii-v0=... +# 아래 명령은 one-off 유지보수 프로세스 전용입니다. 일반 Server는 항상 none입니다. +# migrate | verify | restore-plaintext +PII_MAINTENANCE_COMMAND=none +PII_MAINTENANCE_BATCH_SIZE=100 + # 비밀번호 재설정 token은 알림 Provider에만 원문을 전달하고 DB에는 SHA-256 hash만 저장합니다. PASSWORD_RESET_TTL=30m PASSWORD_RESET_COOLDOWN=1m diff --git a/compose.demo.yml b/compose.demo.yml index 8a021d40..5767e555 100644 --- a/compose.demo.yml +++ b/compose.demo.yml @@ -69,6 +69,8 @@ services: PII_ENCRYPTION_KEY_BASE64: ${PII_ENCRYPTION_KEY_BASE64:-} PII_ENCRYPTION_KEY_VERSION: ${PII_ENCRYPTION_KEY_VERSION:-demo-v1} PII_DECRYPTION_KEYS: ${PII_DECRYPTION_KEYS:-} + PII_MAINTENANCE_COMMAND: ${PII_MAINTENANCE_COMMAND:-none} + PII_MAINTENANCE_BATCH_SIZE: ${PII_MAINTENANCE_BATCH_SIZE:-100} FILE_STORAGE_LOCAL_PATH: /app/data/files DEMO_SEED_ENABLED: ${DEMO_SEED_ENABLED:-false} DEMO_SEED_ADMIN_PASSWORD: ${DEMO_SEED_ADMIN_PASSWORD:-} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCipher.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCipher.java index 18d98bd5..7bc89092 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCipher.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiCipher.java @@ -6,6 +6,12 @@ public interface AccountPiiCipher { boolean isAvailable(); + String currentKeyVersion(); + + default boolean requiresReEncryption(String keyVersion) { + return isAvailable() && !currentKeyVersion().equals(keyVersion); + } + EncryptedValue encrypt(String plaintext, UUID companyId, UUID userId, String fieldName); String decrypt( diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceCommandRunner.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceCommandRunner.java new file mode 100644 index 00000000..778d0fb7 --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceCommandRunner.java @@ -0,0 +1,113 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiMaintenanceService.EncryptionInventory; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiMaintenanceService.MaintenanceResult; +import java.util.Locale; +import java.util.Objects; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +final class AccountPiiMaintenanceCommandRunner implements ApplicationRunner { + + private static final Logger LOGGER = LoggerFactory.getLogger( + AccountPiiMaintenanceCommandRunner.class + ); + + private final AccountPiiMaintenanceService service; + private final ConfigurableApplicationContext applicationContext; + private final String command; + private final int batchSize; + + AccountPiiMaintenanceCommandRunner( + AccountPiiMaintenanceService service, + ConfigurableApplicationContext applicationContext, + @Value("${app.auth.pii.maintenance-command:none}") String command, + @Value("${app.auth.pii.maintenance-batch-size:100}") int batchSize + ) { + this.service = Objects.requireNonNull(service, "service must not be null"); + this.applicationContext = Objects.requireNonNull( + applicationContext, + "applicationContext must not be null" + ); + this.command = Objects.requireNonNull(command, "command must not be null"); + this.batchSize = batchSize; + } + + @Override + public void run(ApplicationArguments arguments) { + Command parsed = Command.parse(command); + if (parsed == Command.NONE) { + return; + } + switch (parsed) { + case MIGRATE -> logResult(parsed, service.migrateToCurrentKey(batchSize)); + case VERIFY -> logInventory(parsed, service.verifyCurrentKey(), 0); + case RESTORE_PLAINTEXT -> logResult(parsed, service.restorePlaintext(batchSize)); + case NONE -> throw new IllegalStateException("unreachable account PII command"); + } + applicationContext.close(); + } + + private void logResult(Command command, MaintenanceResult result) { + logInventory(command, result.inventory(), result.processedCount()); + } + + private void logInventory( + Command command, + EncryptionInventory inventory, + int processedCount + ) { + LOGGER.info( + "account_pii_maintenance command={} processed_count={} account_count={} " + + "plaintext_count={} encrypted_count={} current_key_count={} " + + "stale_key_count={} current_key_version={}", + command.externalName(), + processedCount, + inventory.accountCount(), + inventory.plaintextCount(), + inventory.encryptedCount(), + inventory.currentKeyCount(), + inventory.staleKeyCount(), + inventory.currentKeyVersion() + ); + } + + private enum Command { + NONE("none"), + MIGRATE("migrate"), + VERIFY("verify"), + RESTORE_PLAINTEXT("restore-plaintext"); + + private final String externalName; + + Command(String externalName) { + this.externalName = externalName; + } + + String externalName() { + return externalName; + } + + static Command parse(String value) { + String normalized = value.strip().toLowerCase(Locale.ROOT); + for (Command command : values()) { + if (command.externalName.equals(normalized)) { + return command; + } + } + throw new IllegalStateException( + "app.auth.pii.maintenance-command must be one of " + + "none, migrate, verify, restore-plaintext" + ); + } + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceService.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceService.java new file mode 100644 index 00000000..d2a13c7a --- /dev/null +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceService.java @@ -0,0 +1,342 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +@Component +final class AccountPiiMaintenanceService { + + private static final String PHONE_FIELD = "phone"; + + private final JdbcTemplate jdbcTemplate; + private final TransactionTemplate transactionTemplate; + private final AccountPiiCipher piiCipher; + + AccountPiiMaintenanceService( + JdbcTemplate jdbcTemplate, + PlatformTransactionManager transactionManager, + AccountPiiCipher piiCipher + ) { + this.jdbcTemplate = Objects.requireNonNull(jdbcTemplate, "jdbcTemplate must not be null"); + this.transactionTemplate = new TransactionTemplate(Objects.requireNonNull( + transactionManager, + "transactionManager must not be null" + )); + this.piiCipher = Objects.requireNonNull(piiCipher, "piiCipher must not be null"); + } + + MaintenanceResult migrateToCurrentKey(int batchSize) { + requireReady(batchSize); + requireMaintenanceDatabaseRole(); + int migrated = processBatches(batchSize, this::migrateBatch); + EncryptionInventory inventory = inspect(); + if (inventory.plaintextCount() != 0 || inventory.staleKeyCount() != 0) { + throw new IllegalStateException( + "account PII migration finished with plaintext or stale-key rows remaining" + ); + } + return new MaintenanceResult(migrated, inventory); + } + + MaintenanceResult restorePlaintext(int batchSize) { + requireReady(batchSize); + requireMaintenanceDatabaseRole(); + int restored = processBatches(batchSize, this::restoreBatch); + EncryptionInventory inventory = inspect(); + if (inventory.encryptedCount() != 0) { + throw new IllegalStateException( + "account PII plaintext restore finished with encrypted rows remaining" + ); + } + return new MaintenanceResult(restored, inventory); + } + + EncryptionInventory verifyCurrentKey() { + requireReady(1); + requireMaintenanceDatabaseRole(); + EncryptionInventory inventory = inspect(); + if (inventory.plaintextCount() != 0 || inventory.staleKeyCount() != 0) { + throw new IllegalStateException( + "account PII verification failed because plaintext or stale-key rows remain" + ); + } + return inventory; + } + + private int processBatches(int batchSize, BatchOperation operation) { + int processed = 0; + while (true) { + Integer batchProcessed = transactionTemplate.execute(status -> operation.run(batchSize)); + int changed = batchProcessed == null ? 0 : batchProcessed; + if (changed == 0) { + return processed; + } + processed += changed; + } + } + + private int migrateBatch(int batchSize) { + List rows = jdbcTemplate.query( + """ + SELECT user_id, company_id, phone, phone_ciphertext, phone_key_version + FROM user_account + WHERE phone IS NOT NULL + OR (phone_ciphertext IS NOT NULL AND phone_key_version <> ?) + ORDER BY user_id + LIMIT ? + """, + (resultSet, rowNumber) -> new PhoneRow( + resultSet.getObject("user_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + resultSet.getString("phone"), + resultSet.getString("phone_ciphertext"), + resultSet.getString("phone_key_version") + ), + piiCipher.currentKeyVersion(), + batchSize + ); + int updated = 0; + for (PhoneRow row : rows) { + String plaintext = row.phone() != null + ? row.phone() + : piiCipher.decrypt( + row.phoneCiphertext(), + row.phoneKeyVersion(), + row.companyId(), + row.userId(), + PHONE_FIELD + ); + AccountPiiCipher.EncryptedValue encrypted = piiCipher.encrypt( + plaintext, + row.companyId(), + row.userId(), + PHONE_FIELD + ); + updated += row.phone() != null + ? replacePlaintext(row, encrypted) + : replaceStaleCiphertext(row, encrypted); + } + ensureBatchMadeProgress(rows, updated); + return updated; + } + + private int restoreBatch(int batchSize) { + List rows = jdbcTemplate.query( + """ + SELECT user_id, company_id, phone, phone_ciphertext, phone_key_version + FROM user_account + WHERE phone_ciphertext IS NOT NULL + ORDER BY user_id + LIMIT ? + """, + (resultSet, rowNumber) -> new PhoneRow( + resultSet.getObject("user_id", UUID.class), + resultSet.getObject("company_id", UUID.class), + resultSet.getString("phone"), + resultSet.getString("phone_ciphertext"), + resultSet.getString("phone_key_version") + ), + batchSize + ); + int updated = 0; + for (PhoneRow row : rows) { + String plaintext = piiCipher.decrypt( + row.phoneCiphertext(), + row.phoneKeyVersion(), + row.companyId(), + row.userId(), + PHONE_FIELD + ); + updated += jdbcTemplate.update( + """ + UPDATE user_account + SET phone = ?, + phone_ciphertext = NULL, + phone_key_version = NULL, + updated_at = CURRENT_TIMESTAMP, + version = version + 1 + WHERE user_id = ? + AND company_id = ? + AND phone IS NULL + AND phone_ciphertext = ? + AND phone_key_version = ? + """, + plaintext, + row.userId(), + row.companyId(), + row.phoneCiphertext(), + row.phoneKeyVersion() + ); + } + ensureBatchMadeProgress(rows, updated); + return updated; + } + + private int replacePlaintext( + PhoneRow row, + AccountPiiCipher.EncryptedValue encrypted + ) { + return jdbcTemplate.update( + """ + UPDATE user_account + SET phone = NULL, + phone_ciphertext = ?, + phone_key_version = ?, + updated_at = CURRENT_TIMESTAMP, + version = version + 1 + WHERE user_id = ? + AND company_id = ? + AND phone = ? + AND phone_ciphertext IS NULL + AND phone_key_version IS NULL + """, + encrypted.ciphertext(), + encrypted.keyVersion(), + row.userId(), + row.companyId(), + row.phone() + ); + } + + private int replaceStaleCiphertext( + PhoneRow row, + AccountPiiCipher.EncryptedValue encrypted + ) { + return jdbcTemplate.update( + """ + UPDATE user_account + SET phone_ciphertext = ?, + phone_key_version = ?, + updated_at = CURRENT_TIMESTAMP, + version = version + 1 + WHERE user_id = ? + AND company_id = ? + AND phone IS NULL + AND phone_ciphertext = ? + AND phone_key_version = ? + """, + encrypted.ciphertext(), + encrypted.keyVersion(), + row.userId(), + row.companyId(), + row.phoneCiphertext(), + row.phoneKeyVersion() + ); + } + + private EncryptionInventory inspect() { + return jdbcTemplate.queryForObject( + """ + SELECT COUNT(*) AS account_count, + SUM(CASE WHEN phone IS NOT NULL THEN 1 ELSE 0 END) AS plaintext_count, + SUM(CASE WHEN phone_ciphertext IS NOT NULL THEN 1 ELSE 0 END) AS encrypted_count, + SUM(CASE + WHEN phone_ciphertext IS NOT NULL AND phone_key_version = ? THEN 1 + ELSE 0 + END) AS current_key_count, + SUM(CASE + WHEN phone_ciphertext IS NOT NULL AND phone_key_version <> ? THEN 1 + ELSE 0 + END) AS stale_key_count + FROM user_account + """, + (resultSet, rowNumber) -> new EncryptionInventory( + resultSet.getLong("account_count"), + resultSet.getLong("plaintext_count"), + resultSet.getLong("encrypted_count"), + resultSet.getLong("current_key_count"), + resultSet.getLong("stale_key_count"), + piiCipher.currentKeyVersion() + ), + piiCipher.currentKeyVersion(), + piiCipher.currentKeyVersion() + ); + } + + private void requireReady(int batchSize) { + if (!piiCipher.isAvailable()) { + throw new IllegalStateException( + "account PII encryption must be enabled for maintenance commands" + ); + } + if (batchSize < 1 || batchSize > 1_000) { + throw new IllegalStateException("account PII maintenance batch size must be 1 to 1000"); + } + } + + private void requireMaintenanceDatabaseRole() { + String databaseProduct = jdbcTemplate.execute( + (ConnectionCallback) connection -> databaseProductName(connection) + ); + if (!"PostgreSQL".equalsIgnoreCase(databaseProduct)) { + return; + } + Boolean allowed = jdbcTemplate.queryForObject( + """ + SELECT role.rolsuper + OR role.rolbypassrls + OR account_table.relowner = role.oid + FROM pg_catalog.pg_roles AS role + JOIN pg_catalog.pg_class AS account_table + ON account_table.relname = 'user_account' + JOIN pg_catalog.pg_namespace AS namespace + ON namespace.oid = account_table.relnamespace + AND namespace.nspname = 'public' + WHERE role.rolname = CURRENT_USER + """, + Boolean.class + ); + if (!Boolean.TRUE.equals(allowed)) { + throw new IllegalStateException( + "account PII maintenance requires the migration owner or a BYPASSRLS role" + ); + } + } + + private String databaseProductName(Connection connection) throws SQLException { + return connection.getMetaData().getDatabaseProductName(); + } + + private void ensureBatchMadeProgress(List rows, int updated) { + if (!rows.isEmpty() && updated == 0) { + throw new IllegalStateException( + "account PII maintenance made no progress because rows changed concurrently" + ); + } + } + + record MaintenanceResult(int processedCount, EncryptionInventory inventory) { + } + + record EncryptionInventory( + long accountCount, + long plaintextCount, + long encryptedCount, + long currentKeyCount, + long staleKeyCount, + String currentKeyVersion + ) { + } + + private record PhoneRow( + UUID userId, + UUID companyId, + String phone, + String phoneCiphertext, + String phoneKeyVersion + ) { + } + + @FunctionalInterface + private interface BatchOperation { + int run(int batchSize); + } +} diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipher.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipher.java index 40686f70..0eefefcb 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipher.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/AesGcmAccountPiiCipher.java @@ -44,6 +44,11 @@ public boolean isAvailable() { return true; } + @Override + public String currentKeyVersion() { + return currentKeyVersion; + } + @Override public EncryptedValue encrypt( String plaintext, diff --git a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/DisabledAccountPiiCipher.java b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/DisabledAccountPiiCipher.java index 00f8008b..bd7e915a 100644 --- a/src/main/java/com/fowoco/server/auth/infrastructure/crypto/DisabledAccountPiiCipher.java +++ b/src/main/java/com/fowoco/server/auth/infrastructure/crypto/DisabledAccountPiiCipher.java @@ -9,6 +9,11 @@ public boolean isAvailable() { return false; } + @Override + public String currentKeyVersion() { + throw new IllegalStateException("account PII encryption is disabled"); + } + @Override public EncryptedValue encrypt(String plaintext, UUID companyId, UUID userId, String fieldName) { throw new IllegalStateException("account PII encryption is disabled"); 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 5094bbee..d1e42146 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 @@ -203,13 +203,17 @@ void applyState(UserAccount userAccount, AccountPiiCipher piiCipher) { private String readPhone(AccountPiiCipher piiCipher) { Objects.requireNonNull(piiCipher, "piiCipher must not be null"); if (phoneCiphertext != null) { - return piiCipher.decrypt( + String decrypted = piiCipher.decrypt( phoneCiphertext, phoneKeyVersion, companyId, userId, PHONE_FIELD ); + if (piiCipher.requiresReEncryption(phoneKeyVersion)) { + storePhone(decrypted, piiCipher); + } + return decrypted; } if (phone != null && piiCipher.isAvailable()) { String legacyPhone = phone; diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 13961bcb..42fa4819 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -160,6 +160,8 @@ app: current-key-base64: ${PII_ENCRYPTION_KEY_BASE64:} current-key-version: ${PII_ENCRYPTION_KEY_VERSION:local-v1} decryption-keys: ${PII_DECRYPTION_KEYS:} + maintenance-command: ${PII_MAINTENANCE_COMMAND:none} + maintenance-batch-size: ${PII_MAINTENANCE_BATCH_SIZE:100} jwt: issuer: ${JWT_ISSUER:fowoco-server} audience: ${JWT_AUDIENCE:fowoco-client} diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceServiceIntegrationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceServiceIntegrationTest.java new file mode 100644 index 00000000..ae4632de --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/infrastructure/crypto/AccountPiiMaintenanceServiceIntegrationTest.java @@ -0,0 +1,184 @@ +package com.fowoco.server.auth.infrastructure.crypto; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiMaintenanceService.MaintenanceResult; +import java.util.Base64; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.annotation.DirtiesContext; + +@ActiveProfiles("test") +@SpringBootTest(properties = { + "spring.datasource.url=jdbc:h2:mem:fowoco-account-pii-maintenance-test;" + + "MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;" + + "DEFAULT_NULL_ORDERING=HIGH;DB_CLOSE_DELAY=-1", + "app.auth.pii.enabled=true", + "app.auth.pii.current-key-base64=AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "app.auth.pii.current-key-version=pii-v2", + "app.auth.pii.decryption-keys=pii-v1=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class AccountPiiMaintenanceServiceIntegrationTest { + + private static final UUID COMPANY_ID = UUID.fromString( + "10000000-0000-0000-0000-000000000001" + ); + private static final UUID PLAINTEXT_USER_ID = UUID.fromString( + "11000000-0000-0000-0000-000000000001" + ); + private static final UUID OLD_KEY_USER_ID = UUID.fromString( + "11000000-0000-0000-0000-000000000002" + ); + private static final String PLAINTEXT_PHONE = "010-1111-2222"; + private static final String OLD_KEY_PHONE = "010-3333-4444"; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private AccountPiiMaintenanceService service; + + @Autowired + private AccountPiiCipher currentCipher; + + @BeforeEach + void prepareRows() { + jdbcTemplate.update("DELETE FROM user_account"); + jdbcTemplate.update("DELETE FROM company"); + jdbcTemplate.update( + """ + INSERT INTO company ( + company_id, name, status, created_at, updated_at, version + ) VALUES (?, '테스트 사업장', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0) + """, + COMPANY_ID + ); + insertAccount(PLAINTEXT_USER_ID, "plain@example.com", PLAINTEXT_PHONE, null, null); + + AccountPiiCipher oldCipher = oldCipher(); + AccountPiiCipher.EncryptedValue oldEncrypted = oldCipher.encrypt( + OLD_KEY_PHONE, + COMPANY_ID, + OLD_KEY_USER_ID, + "phone" + ); + insertAccount( + OLD_KEY_USER_ID, + "old-key@example.com", + null, + oldEncrypted.ciphertext(), + oldEncrypted.keyVersion() + ); + } + + @Test + void migratesPlaintextAndOldKeyRowsThenRestoresPlaintextForRollback() { + MaintenanceResult migrated = service.migrateToCurrentKey(1); + + assertThat(migrated.processedCount()).isEqualTo(2); + assertThat(migrated.inventory().plaintextCount()).isZero(); + assertThat(migrated.inventory().currentKeyCount()).isEqualTo(2); + assertThat(migrated.inventory().staleKeyCount()).isZero(); + assertThat(service.migrateToCurrentKey(1).processedCount()).isZero(); + assertStoredPhone(PLAINTEXT_USER_ID, PLAINTEXT_PHONE); + assertStoredPhone(OLD_KEY_USER_ID, OLD_KEY_PHONE); + + MaintenanceResult restored = service.restorePlaintext(1); + + assertThat(restored.processedCount()).isEqualTo(2); + assertThat(restored.inventory().encryptedCount()).isZero(); + assertThat(jdbcTemplate.queryForObject( + "SELECT phone FROM user_account WHERE user_id = ?", + String.class, + PLAINTEXT_USER_ID + )).isEqualTo(PLAINTEXT_PHONE); + assertThat(jdbcTemplate.queryForObject( + "SELECT phone FROM user_account WHERE user_id = ?", + String.class, + OLD_KEY_USER_ID + )).isEqualTo(OLD_KEY_PHONE); + } + + @Test + void verificationFailsWhilePlaintextOrOldKeyRowsRemain() { + assertThatThrownBy(service::verifyCurrentKey) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("plaintext or stale-key rows remain"); + } + + private void insertAccount( + UUID userId, + String email, + String phone, + String ciphertext, + String keyVersion + ) { + jdbcTemplate.update( + """ + INSERT INTO user_account ( + user_id, company_id, display_name, phone, phone_ciphertext, + phone_key_version, email, normalized_email, password_hash, + role, status, created_at, updated_at, password_changed_at, + failed_login_attempts, version + ) VALUES ( + ?, ?, '담당자', ?, ?, ?, ?, ?, 'password-hash', + 'HR', 'ACTIVE', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, + CURRENT_TIMESTAMP, 0, 0 + ) + """, + userId, + COMPANY_ID, + phone, + ciphertext, + keyVersion, + email, + email + ); + } + + private void assertStoredPhone(UUID userId, String expected) { + StoredPhone stored = jdbcTemplate.queryForObject( + """ + SELECT phone, phone_ciphertext, phone_key_version + FROM user_account + WHERE user_id = ? + """, + (resultSet, rowNumber) -> new StoredPhone( + resultSet.getString("phone"), + resultSet.getString("phone_ciphertext"), + resultSet.getString("phone_key_version") + ), + userId + ); + assertThat(stored).isNotNull(); + assertThat(stored.plaintext()).isNull(); + assertThat(stored.keyVersion()).isEqualTo("pii-v2"); + assertThat(currentCipher.decrypt( + stored.ciphertext(), + stored.keyVersion(), + COMPANY_ID, + userId, + "phone" + )).isEqualTo(expected); + } + + private AccountPiiCipher oldCipher() { + AccountPiiProperties properties = new AccountPiiProperties(); + properties.setEnabled(true); + properties.setCurrentKeyVersion("pii-v1"); + properties.setCurrentKeyBase64( + Base64.getEncoder().encodeToString(new byte[32]) + ); + return new AccountPiiCryptoConfiguration().accountPiiCipher(properties); + } + + private record StoredPhone(String plaintext, String ciphertext, String keyVersion) { + } +} diff --git a/src/test/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntityPiiRotationTest.java b/src/test/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntityPiiRotationTest.java new file mode 100644 index 00000000..754eecc2 --- /dev/null +++ b/src/test/java/com/fowoco/server/auth/infrastructure/persistence/UserAccountJpaEntityPiiRotationTest.java @@ -0,0 +1,68 @@ +package com.fowoco.server.auth.infrastructure.persistence; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fowoco.server.auth.domain.UserAccount; +import com.fowoco.server.auth.domain.UserRole; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiCipher; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiCryptoConfiguration; +import com.fowoco.server.auth.infrastructure.crypto.AccountPiiProperties; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class UserAccountJpaEntityPiiRotationTest { + + private static final UUID COMPANY_ID = UUID.fromString( + "10000000-0000-0000-0000-000000000001" + ); + private static final UUID USER_ID = UUID.fromString( + "11000000-0000-0000-0000-000000000001" + ); + private static final String PHONE = "010-1234-5678"; + + @Test + void reEncryptsCiphertextWithCurrentKeyWhenAccountIsRead() { + byte[] oldKey = new byte[32]; + byte[] currentKey = new byte[32]; + currentKey[0] = 1; + AccountPiiCipher oldCipher = cipher("pii-v1", oldKey, ""); + UserAccountJpaEntity entity = UserAccountJpaEntity.fromDomain( + UserAccount.create( + USER_ID, + COMPANY_ID, + "담당자", + PHONE, + "owner@example.com", + "password-hash", + UserRole.ADMIN, + Instant.parse("2026-08-19T00:00:00Z") + ), + oldCipher + ); + + AccountPiiCipher rotatingCipher = cipher( + "pii-v2", + currentKey, + "pii-v1=" + Base64.getEncoder().encodeToString(oldKey) + ); + assertThat(entity.toDomain(rotatingCipher).phone()).isEqualTo(PHONE); + + AccountPiiCipher currentOnlyCipher = cipher("pii-v2", currentKey, ""); + assertThat(entity.toDomain(currentOnlyCipher).phone()).isEqualTo(PHONE); + } + + private AccountPiiCipher cipher( + String currentVersion, + byte[] currentKey, + String previousKeys + ) { + AccountPiiProperties properties = new AccountPiiProperties(); + properties.setEnabled(true); + properties.setCurrentKeyVersion(currentVersion); + properties.setCurrentKeyBase64(Base64.getEncoder().encodeToString(currentKey)); + properties.setDecryptionKeys(previousKeys); + return new AccountPiiCryptoConfiguration().accountPiiCipher(properties); + } +} From acafd6780dbeb3b181327e7fa3b13dffd33bdd12 Mon Sep 17 00:00:00 2001 From: hywznn Date: Wed, 19 Aug 2026 22:22:27 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs(security):=20=EC=97=B0=EB=9D=BD?= =?UTF-8?q?=EC=B2=98=20=EC=95=94=ED=98=B8=ED=99=94=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=EA=B3=BC=20=EB=A1=A4=EB=B0=B1=20=EC=A0=88=EC=B0=A8=20=EB=B3=B4?= =?UTF-8?q?=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/deployment-runbook.md | 61 +++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md index e028fe8c..c899afeb 100644 --- a/docs/deployment-runbook.md +++ b/docs/deployment-runbook.md @@ -43,6 +43,8 @@ Secret은 Git과 Actions 로그에 값을 남기지 않고 `kubectl create secre | PII | `PII_ENCRYPTION_KEY_BASE64` | 현재 32바이트 연락처 암호화 키의 Base64 | | PII | `PII_ENCRYPTION_KEY_VERSION` | 현재 키 식별 version, 예: `pii-2026-08-v1` | | PII | `PII_DECRYPTION_KEYS` | 회전 이전 키 목록, `version=base64`를 쉼표로 구분 | +| PII | `PII_MAINTENANCE_COMMAND=none` | 일반 Server의 고정값. 일회성 작업에서만 변경 | +| PII | `PII_MAINTENANCE_BATCH_SIZE=100` | 연락처 전환 작업의 transaction당 처리 건수 | | Web | `CORS_ALLOWED_ORIGINS` | 실제 Client HTTPS origin만 허용 | | Catalog | `WORKFLOW_CATALOG_LOCATION` | 검증된 `RELEASED` projection 위치 | | AI | `AI_RUNTIME_ENABLED=true` | 실제 Runtime 연동 활성화 | @@ -62,10 +64,57 @@ Secret은 Git과 Actions 로그에 값을 남기지 않고 `kubectl create secre 기본 활성화하므로 현재 키가 없으면 Server가 기동하지 않습니다. 현재 Infra는 Kubernetes Secret 주입까지 지원하며 AWS KMS·Secrets Manager 자동 동기화는 별도 고도화 범위입니다. -키 회전은 새 키와 새 version을 현재 값으로 배포하되, 기존 version과 키를 -`PII_DECRYPTION_KEYS`에 유지한 상태에서 수행합니다. 기존 평문 연락처는 로그인·프로필 수정 등 -계정이 쓰기 transaction에서 조회될 때 암호문으로 점진 전환됩니다. 이전 키 제거 전에는 -DB에서 해당 `phone_key_version` 잔여 건수가 0인지 확인해야 합니다. +로그인·프로필 수정 시 평문 또는 이전 키 암호문을 현재 키로 다시 암호화하는 방어 로직이 +있지만, 계정 접근 빈도에 의존하는 점진 전환을 배포 완료 기준으로 사용하지 않습니다. 초기 +전환과 키 회전은 아래 일회성 유지보수 명령으로 모든 행을 명시적으로 처리합니다. + +| 명령 | 목적 | 완료 조건 | +| --- | --- | --- | +| `migrate` | 평문과 이전 키 암호문을 현재 키로 전환 | 처리 후 오류 없이 종료 | +| `verify` | 전환 완료 여부 검사 | 평문 0건, 이전 키 0건 | +| `restore-plaintext` | 구버전 애플리케이션 롤백 전 평문 복원 | 암호문 0건 | + +유지보수 명령은 PostgreSQL RLS를 우회해야 하므로 일반 Runtime 계정으로 실행되지 않습니다. +`user_account` 소유자, `BYPASSRLS` 또는 Superuser 권한을 가진 **Flyway 전용 계정**을 일회성 +프로세스에만 주입합니다. 정상 Deployment의 `PII_MAINTENANCE_COMMAND`는 항상 `none`입니다. + +### 최초 암호화 전환 + +1. DB 백업과 복구 절차를 확인합니다. +2. 새 컬럼과 암호화 코드를 먼저 배포하고 현재 키를 Secret으로 주입합니다. +3. 쓰기 트래픽을 통제한 뒤 일회성 프로세스에서 `migrate`를 실행합니다. +4. 같은 키로 `verify`를 실행해 평문과 이전 키 잔여 건수가 0인지 확인합니다. +5. 정상 Server의 로그인·프로필 조회 Smoke를 수행합니다. + +```bash +export SPRING_MAIN_WEB_APPLICATION_TYPE=none +export PII_MAINTENANCE_COMMAND=migrate +export PII_MAINTENANCE_BATCH_SIZE=100 +export DB_RUNTIME_USERNAME="$DB_MIGRATION_USERNAME" +export DB_RUNTIME_PASSWORD="$DB_MIGRATION_PASSWORD" +java -jar server.jar + +export PII_MAINTENANCE_COMMAND=verify +java -jar server.jar +``` + +Kubernetes에서는 동일 환경변수를 가진 일회성 Job으로 실행합니다. 일반 Deployment의 Secret을 +`migrate`로 바꾸지 않으며, 로그에는 원문·암호문 대신 처리 건수와 키 version만 남습니다. + +### 키 회전 + +1. 새 키와 새 version을 현재 값으로 설정합니다. +2. 직전 키를 `PII_DECRYPTION_KEYS=old-version=old-base64`에 유지합니다. +3. 새 설정을 배포한 뒤 `migrate`, `verify`를 순서대로 실행합니다. +4. DB와 애플리케이션 Smoke를 확인한 뒤 이전 version 잔여 건수가 0일 때만 이전 키를 제거합니다. + +### 구버전 애플리케이션 롤백 + +암호화 도입 이전 버전은 `phone_ciphertext`를 읽지 못하므로 이미지를 먼저 되돌리면 연락처가 +빈 값으로 보입니다. 반드시 모든 복호화 키를 유지한 상태에서 쓰기 트래픽을 통제하고 +`restore-plaintext`를 먼저 실행합니다. 아래 조회에서 암호문 0건을 확인한 다음에만 구버전 +이미지를 배포합니다. 장애 수정 후에는 다시 `migrate`, `verify`를 수행하는 전진 복구를 +우선합니다. ```sql SELECT COUNT(*) AS legacy_plaintext_phone_count @@ -77,6 +126,10 @@ FROM user_account WHERE phone_ciphertext IS NOT NULL GROUP BY phone_key_version ORDER BY phone_key_version; + +SELECT COUNT(*) AS remaining_encrypted_phone_count +FROM user_account +WHERE phone_ciphertext IS NOT NULL; ``` 비밀번호 재설정 메일을 실제로 발송할 때만 다음 값을 `server-env`에 추가합니다. 기본