init: 클린 기반 auth 서버 설계

This commit is contained in:
DongHyeonka
2026-07-24 14:30:18 +09:00
parent 471db0203d
commit 8a1ac1e769
3642 changed files with 275893 additions and 1 deletions
@@ -0,0 +1,11 @@
package com.project.auth.application.auth.exception;
import com.project.auth.application.support.exception.AuthErrorCode;
import com.project.auth.application.support.exception.BusinessException;
public class InvalidKeycloakClaimsException extends BusinessException {
public InvalidKeycloakClaimsException() {
super(AuthErrorCode.KEYCLOAK_CLAIMS_INVALID);
}
}
@@ -0,0 +1,11 @@
package com.project.auth.application.auth.exception;
import com.project.auth.application.support.exception.AuthErrorCode;
import com.project.auth.application.support.exception.BusinessException;
public class KeycloakAccountConflictException extends BusinessException {
public KeycloakAccountConflictException() {
super(AuthErrorCode.KEYCLOAK_ACCOUNT_CONFLICT);
}
}
@@ -0,0 +1,11 @@
package com.project.auth.application.auth.exception;
import com.project.auth.application.support.exception.AuthErrorCode;
import com.project.auth.application.support.exception.BusinessException;
public class KeycloakUserNotFoundException extends BusinessException {
public KeycloakUserNotFoundException() {
super(AuthErrorCode.KEYCLOAK_USER_NOT_FOUND);
}
}
@@ -0,0 +1,8 @@
package com.project.auth.application.auth.identity;
public record KeycloakUserClaims(
String subject,
String email,
String name
) {
}
@@ -0,0 +1,6 @@
package com.project.auth.application.auth.identity;
public interface LoadKeycloakUserUseCase {
LoadedKeycloakUser load(KeycloakUserClaims claims);
}
@@ -0,0 +1,24 @@
package com.project.auth.application.auth.identity;
import com.project.auth.domain.user.model.User;
import java.util.UUID;
public record LoadedKeycloakUser(
UUID userId,
String email,
String name,
String provider,
String providerSubject
) {
public static LoadedKeycloakUser from(User user) {
return new LoadedKeycloakUser(
user.getId(),
user.getEmail(),
user.getName(),
user.getProvider().name(),
user.getProviderSubject()
);
}
}
@@ -0,0 +1,36 @@
package com.project.auth.application.auth.identity.internal;
import com.project.auth.application.auth.exception.InvalidKeycloakClaimsException;
import com.project.auth.application.auth.identity.KeycloakUserClaims;
import com.project.auth.domain.user.exception.DomainException;
import com.project.auth.domain.user.model.UserEmail;
import com.project.auth.domain.user.model.UserName;
final class KeycloakUserClaimsValidator {
private KeycloakUserClaimsValidator() {
}
static ValidatedKeycloakUserClaims validate(KeycloakUserClaims claims) {
if (claims == null
|| isBlank(claims.subject())
|| isBlank(claims.email())
|| isBlank(claims.name())) {
throw new InvalidKeycloakClaimsException();
}
try {
return new ValidatedKeycloakUserClaims(
claims.subject().trim(),
UserEmail.from(claims.email()),
UserName.from(claims.name())
);
} catch (DomainException exception) {
throw new InvalidKeycloakClaimsException();
}
}
private static boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,78 @@
package com.project.auth.application.auth.identity.internal;
import com.project.auth.application.auth.exception.KeycloakAccountConflictException;
import com.project.auth.application.auth.identity.KeycloakUserClaims;
import com.project.auth.application.auth.identity.LoadKeycloakUserUseCase;
import com.project.auth.application.auth.identity.LoadedKeycloakUser;
import com.project.auth.application.auth.identity.port.out.LoadKeycloakUserPort;
import com.project.auth.application.auth.identity.port.out.RegisterKeycloakUserPort;
import com.project.auth.application.support.audit.AuthAuditEvent;
import com.project.auth.application.support.audit.AuthAuditEventPublisher;
import com.project.auth.application.support.audit.AuthAuditEventType;
import com.project.auth.application.support.audit.AuthAuditFields;
import com.project.auth.domain.user.model.AuthProvider;
import com.project.auth.domain.user.model.User;
import org.springframework.transaction.annotation.Transactional;
import java.time.Clock;
import java.time.Instant;
import java.util.Objects;
import java.util.UUID;
public class KeycloakUserLoader implements LoadKeycloakUserUseCase {
private final LoadKeycloakUserPort loadKeycloakUserPort;
private final RegisterKeycloakUserPort registerKeycloakUserPort;
private final AuthAuditEventPublisher authAuditEventPublisher;
private final Clock clock;
public KeycloakUserLoader(
LoadKeycloakUserPort loadKeycloakUserPort,
RegisterKeycloakUserPort registerKeycloakUserPort,
AuthAuditEventPublisher authAuditEventPublisher,
Clock clock
) {
this.loadKeycloakUserPort = Objects.requireNonNull(loadKeycloakUserPort, "loadKeycloakUserPort must not be null");
this.registerKeycloakUserPort = Objects.requireNonNull(
registerKeycloakUserPort,
"registerKeycloakUserPort must not be null"
);
this.authAuditEventPublisher = Objects.requireNonNull(
authAuditEventPublisher,
"authAuditEventPublisher must not be null"
);
this.clock = Objects.requireNonNull(clock, "clock must not be null");
}
@Override
@Transactional
public LoadedKeycloakUser load(KeycloakUserClaims claims) {
ValidatedKeycloakUserClaims validatedClaims = KeycloakUserClaimsValidator.validate(claims);
User user = loadKeycloakUserPort.findByProviderAndProviderSubject(AuthProvider.KEYCLOAK, validatedClaims.subject())
.orElseGet(() -> autoRegister(validatedClaims));
return LoadedKeycloakUser.from(user);
}
private User autoRegister(ValidatedKeycloakUserClaims claims) {
if (loadKeycloakUserPort.existsByEmail(claims.email())) {
throw new KeycloakAccountConflictException();
}
User newUser = User.registerKeycloak(
UUID.randomUUID(),
claims.email(),
claims.name(),
claims.subject(),
Instant.now(clock)
);
User saved = registerKeycloakUserPort.register(newUser);
authAuditEventPublisher.publish(AuthAuditEvent.info(
AuthAuditEventType.KEYCLOAK_USER_AUTO_REGISTERED.code(),
AuthAuditFields.PROVIDER, AuthProvider.KEYCLOAK,
AuthAuditFields.SUBJECT, claims.subject()
));
return saved;
}
}
@@ -0,0 +1,11 @@
package com.project.auth.application.auth.identity.internal;
import com.project.auth.domain.user.model.UserEmail;
import com.project.auth.domain.user.model.UserName;
record ValidatedKeycloakUserClaims(
String subject,
UserEmail email,
UserName name
) {
}
@@ -0,0 +1,14 @@
package com.project.auth.application.auth.identity.port.out;
import com.project.auth.domain.user.model.AuthProvider;
import com.project.auth.domain.user.model.User;
import com.project.auth.domain.user.model.UserEmail;
import java.util.Optional;
public interface LoadKeycloakUserPort {
Optional<User> findByProviderAndProviderSubject(AuthProvider provider, String providerSubject);
boolean existsByEmail(UserEmail email);
}
@@ -0,0 +1,8 @@
package com.project.auth.application.auth.identity.port.out;
import com.project.auth.domain.user.model.User;
public interface RegisterKeycloakUserPort {
User register(User user);
}
@@ -0,0 +1,6 @@
package com.project.auth.application.support.audit;
public enum AuditLevel {
INFO,
WARN
}
@@ -0,0 +1,38 @@
package com.project.auth.application.support.audit;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
public record AuthAuditEvent(AuditLevel level, String eventType, Map<String, String> fields) {
public AuthAuditEvent {
Objects.requireNonNull(level, "level must not be null");
Objects.requireNonNull(eventType, "eventType must not be null");
Objects.requireNonNull(fields, "fields must not be null");
fields = Collections.unmodifiableMap(new LinkedHashMap<>(fields));
}
public static AuthAuditEvent info(String eventType, Object... keyValues) {
return new AuthAuditEvent(AuditLevel.INFO, eventType, toFields(keyValues));
}
public static AuthAuditEvent warn(String eventType, Object... keyValues) {
return new AuthAuditEvent(AuditLevel.WARN, eventType, toFields(keyValues));
}
private static Map<String, String> toFields(Object... keyValues) {
if (keyValues.length % 2 != 0) {
throw new IllegalArgumentException("keyValues must contain an even number of elements");
}
LinkedHashMap<String, String> fields = new LinkedHashMap<>();
for (int index = 0; index < keyValues.length; index += 2) {
Object key = Objects.requireNonNull(keyValues[index], "field key must not be null");
Object value = Objects.requireNonNull(keyValues[index + 1], "field value must not be null");
fields.put(key.toString(), value.toString());
}
return fields;
}
}
@@ -0,0 +1,6 @@
package com.project.auth.application.support.audit;
public interface AuthAuditEventPublisher {
void publish(AuthAuditEvent event);
}
@@ -0,0 +1,18 @@
package com.project.auth.application.support.audit;
public enum AuthAuditEventType {
KEYCLOAK_USER_NOT_FOUND("KEYCLOAK_USER_NOT_FOUND"),
KEYCLOAK_USER_AUTO_REGISTERED("KEYCLOAK_USER_AUTO_REGISTERED"),
AUTHENTICATION_REQUIRED("AUTHENTICATION_REQUIRED"),
ACCESS_DENIED("ACCESS_DENIED");
private final String code;
AuthAuditEventType(String code) {
this.code = code;
}
public String code() {
return code;
}
}
@@ -0,0 +1,16 @@
package com.project.auth.application.support.audit;
public enum AuthAuditFailureReason {
EMAIL_CONFLICT("email_conflict"),
DUPLICATE_EMAIL_RACE_CONDITION("duplicate_email_race_condition");
private final String code;
AuthAuditFailureReason(String code) {
this.code = code;
}
public String code() {
return code;
}
}
@@ -0,0 +1,69 @@
package com.project.auth.application.support.audit;
import com.project.auth.domain.user.model.UserEmail;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
import java.util.UUID;
public final class AuthAuditFields {
public static final String EVENT_TYPE = "eventType";
public static final String ACTOR_ID = "actorId";
public static final String USER_ID_HASH = "userIdHash";
public static final String EMAIL_MASKED = "emailMasked";
public static final String PROVIDER = "provider";
public static final String REASON = "reason";
public static final String METHOD = "method";
public static final String REQUEST_PATH = "requestPath";
public static final String KEY_ID = "keyId";
public static final String EXPIRES_IN_SECONDS = "expiresInSeconds";
public static final String SUBJECT = "subject";
private static final String HASH_PREFIX = "sha256:";
private static final int HASH_HEX_LENGTH = 16;
private static final HexFormat HEX_FORMAT = HexFormat.of();
private AuthAuditFields() {
}
public static String maskedEmail(UserEmail email) {
Objects.requireNonNull(email, "email must not be null");
return maskEmail(email.value());
}
public static String userIdHash(UUID userId) {
Objects.requireNonNull(userId, "userId must not be null");
return HASH_PREFIX + sha256Hex(userId.toString()).substring(0, HASH_HEX_LENGTH);
}
private static String maskEmail(String email) {
int atIndex = email.indexOf('@');
if (atIndex <= 0 || atIndex == email.length() - 1) {
return "***";
}
String localPart = email.substring(0, atIndex);
String domain = email.substring(atIndex + 1);
return localPartPrefix(localPart) + "***@" + domain;
}
private static String localPartPrefix(String localPart) {
if (localPart.length() == 1) {
return localPart;
}
return localPart.substring(0, Math.min(localPart.length(), 2));
}
private static String sha256Hex(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HEX_FORMAT.formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 digest is not available.", exception);
}
}
}
@@ -0,0 +1,27 @@
package com.project.auth.application.support.exception;
public enum AuthErrorCode implements ClientFacingErrorCode {
AUTHENTICATION_REQUIRED("AUTH-001", "인증이 필요합니다."),
ACCESS_DENIED("AUTH-002", "접근 권한이 없습니다."),
KEYCLOAK_CLAIMS_INVALID("AUTH-003", "Keycloak 토큰 클레임이 올바르지 않습니다."),
KEYCLOAK_USER_NOT_FOUND("AUTH-004", "연결된 내부 Keycloak 사용자를 찾을 수 없습니다."),
KEYCLOAK_ACCOUNT_CONFLICT("AUTH-005", "Keycloak 계정과 연결할 수 없는 내부 사용자 정보입니다.");
private final String code;
private final String message;
AuthErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
@Override
public String code() {
return code;
}
@Override
public String message() {
return message;
}
}
@@ -0,0 +1,30 @@
package com.project.auth.application.support.exception;
public abstract class BusinessException extends RuntimeException {
private final ClientFacingErrorCode errorCode;
protected BusinessException(ClientFacingErrorCode errorCode) {
super(errorCode.message());
this.errorCode = errorCode;
}
protected BusinessException(ClientFacingErrorCode errorCode, String detailMessage) {
super(detailMessage);
this.errorCode = errorCode;
}
protected BusinessException(ClientFacingErrorCode errorCode, Throwable cause) {
super(errorCode.message(), cause);
this.errorCode = errorCode;
}
protected BusinessException(ClientFacingErrorCode errorCode, String detailMessage, Throwable cause) {
super(detailMessage, cause);
this.errorCode = errorCode;
}
public ClientFacingErrorCode getErrorCode() {
return errorCode;
}
}
@@ -0,0 +1,15 @@
package com.project.auth.application.support.exception;
/**
* 클라이언트에 노출되는 ErrorCode 마커 인터페이스.
*
* ApiErrorHttpStatusMapper.map(...)의 시그니처를 이 타입으로 좁혀, 내부 전용인
* ExternalErrorCode(InfrastructureErrorCode 등)이 클라이언트 응답 경로로 흘러들어가는
* 것을 컴파일 단계에서 차단한다.
*
* TODO(i18n): 현재 message()는 한국어 하드코딩 문자열을 반환한다. MessageSource를 도입할 때는
* 기존 message()를 기본 메시지 키로 취급하고, 실제 렌더링 문자열은 응답을 만들 때 요청 로케일에
* 맞추어 MessageSource로 해석하도록 변경한다.
*/
public non-sealed interface ClientFacingErrorCode extends ErrorCode {
}
@@ -0,0 +1,23 @@
package com.project.auth.application.support.exception;
public enum CommonErrorCode implements ClientFacingErrorCode {
INTERNAL_SERVER_ERROR("COMMON-999", "예상하지 못한 오류가 발생했습니다.");
private final String code;
private final String message;
CommonErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
@Override
public String code() {
return code;
}
@Override
public String message() {
return message;
}
}
@@ -0,0 +1,9 @@
package com.project.auth.application.support.exception;
public sealed interface ErrorCode
permits ClientFacingErrorCode, ExternalErrorCode {
String code();
String message();
}
@@ -0,0 +1,4 @@
package com.project.auth.application.support.exception;
public non-sealed interface ExternalErrorCode extends ErrorCode {
}
@@ -0,0 +1,128 @@
package com.project.auth.application.support.logging;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
public final class LogSanitizer {
private static final String UNAVAILABLE_VALUE = "-";
private static final String ANONYMOUS_VALUE = "anonymous";
private static final String HASH_PREFIX = "sha256:";
private static final int HASH_HEX_LENGTH = 16;
private static final int DEFAULT_MAX_LENGTH = 160;
private static final int USER_AGENT_MAX_LENGTH = 120;
private static final int REQUEST_PATH_MAX_LENGTH = 200;
private static final HexFormat HEX_FORMAT = HexFormat.of();
private LogSanitizer() {
}
public static String userAgent(String value) {
return normalize(value, USER_AGENT_MAX_LENGTH);
}
public static String clientIp(String value) {
if (value == null || value.isBlank()) {
return UNAVAILABLE_VALUE;
}
String normalized = normalize(value, DEFAULT_MAX_LENGTH);
String[] octets = normalized.split("\\.", -1);
if (octets.length == 4 && isIpv4Octets(octets)) {
return octets[0] + "." + octets[1] + "." + octets[2] + ".0";
}
return HASH_PREFIX + sha256Hex(normalized).substring(0, HASH_HEX_LENGTH);
}
public static String requestPath(String value) {
return normalize(value, REQUEST_PATH_MAX_LENGTH);
}
public static String actorId(String value) {
if (value == null || value.isBlank() || ANONYMOUS_VALUE.equals(value)) {
return ANONYMOUS_VALUE;
}
String normalized = normalize(value, DEFAULT_MAX_LENGTH);
if (normalized.contains("@")) {
return maskEmail(normalized);
}
return HASH_PREFIX + sha256Hex(normalized).substring(0, HASH_HEX_LENGTH);
}
public static String normalize(String value) {
return normalize(value, DEFAULT_MAX_LENGTH);
}
private static String normalize(String value, int maxLength) {
if (value == null || value.isBlank()) {
return UNAVAILABLE_VALUE;
}
StringBuilder builder = new StringBuilder(Math.min(value.length(), maxLength));
for (int index = 0; index < value.length() && builder.length() < maxLength; index++) {
char character = value.charAt(index);
if (Character.isISOControl(character) || Character.isWhitespace(character) || character == '='
|| character == '|') {
builder.append('_');
} else {
builder.append(character);
}
}
if (value.length() > maxLength) {
builder.append("...");
}
return builder.toString();
}
private static String maskEmail(String email) {
int atIndex = email.indexOf('@');
if (atIndex <= 0 || atIndex == email.length() - 1) {
return "***";
}
String localPart = email.substring(0, atIndex);
String domain = email.substring(atIndex + 1);
return localPartPrefix(localPart) + "***@" + domain;
}
private static String localPartPrefix(String localPart) {
if (localPart.length() == 1) {
return localPart;
}
return localPart.substring(0, Math.min(localPart.length(), 2));
}
private static boolean isIpv4Octets(String[] octets) {
for (String octet : octets) {
if (!isIpv4Octet(octet)) {
return false;
}
}
return true;
}
private static boolean isIpv4Octet(String octet) {
if (octet.isEmpty() || octet.length() > 3) {
return false;
}
int value = 0;
for (int index = 0; index < octet.length(); index++) {
char character = octet.charAt(index);
if (!Character.isDigit(character)) {
return false;
}
value = value * 10 + Character.digit(character, 10);
}
return value <= 255;
}
private static String sha256Hex(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HEX_FORMAT.formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 digest is not available.", exception);
}
}
}
@@ -0,0 +1,166 @@
package com.project.auth.application.auth.identity.internal;
import com.project.auth.application.auth.exception.InvalidKeycloakClaimsException;
import com.project.auth.application.auth.exception.KeycloakAccountConflictException;
import com.project.auth.application.auth.identity.KeycloakUserClaims;
import com.project.auth.application.auth.identity.LoadedKeycloakUser;
import com.project.auth.application.auth.identity.port.out.LoadKeycloakUserPort;
import com.project.auth.application.auth.identity.port.out.RegisterKeycloakUserPort;
import com.project.auth.application.support.audit.AuthAuditEvent;
import com.project.auth.application.support.audit.AuthAuditEventPublisher;
import com.project.auth.domain.user.model.AuthProvider;
import com.project.auth.domain.user.model.User;
import com.project.auth.domain.user.model.UserEmail;
import com.project.auth.domain.user.model.UserName;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class KeycloakUserLoaderTest {
private static final Instant FIXED_NOW = Instant.parse("2026-04-17T00:00:00Z");
private FakeKeycloakUserRepository userRepository;
private RecordingAuthAuditEventPublisher auditEventPublisher;
private KeycloakUserLoader loader;
@BeforeEach
void setUp() {
userRepository = new FakeKeycloakUserRepository();
auditEventPublisher = new RecordingAuthAuditEventPublisher();
loader = new KeycloakUserLoader(
userRepository,
userRepository,
auditEventPublisher,
Clock.fixed(FIXED_NOW, ZoneOffset.UTC)
);
}
@Test
void loadReturnsExistingUserByKeycloakSubject() {
userRepository.usersBySubject.put("keycloak-subject-1", User.registerKeycloak(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
UserEmail.from("tester@example.com"),
UserName.from("테스터"),
"keycloak-subject-1",
Instant.parse("2026-04-17T00:00:00Z")
));
LoadedKeycloakUser user = loader.load(new KeycloakUserClaims(
"keycloak-subject-1",
"changed@example.com",
"변경"
));
assertThat(user.userId()).isEqualTo(UUID.fromString("11111111-1111-1111-1111-111111111111"));
assertThat(user.email()).isEqualTo("tester@example.com");
assertThat(user.name()).isEqualTo("테스터");
assertThat(user.provider()).isEqualTo(AuthProvider.KEYCLOAK.name());
assertThat(user.providerSubject()).isEqualTo("keycloak-subject-1");
assertThat(auditEventPublisher.events).isEmpty();
}
@Test
void loadAutoRegistersMissingInternalUser() {
LoadedKeycloakUser user = loader.load(new KeycloakUserClaims(
"keycloak-subject-1",
"tester@example.com",
"테스터"
));
assertThat(user.userId()).isNotNull();
assertThat(user.email()).isEqualTo("tester@example.com");
assertThat(user.name()).isEqualTo("테스터");
assertThat(user.provider()).isEqualTo(AuthProvider.KEYCLOAK.name());
assertThat(user.providerSubject()).isEqualTo("keycloak-subject-1");
assertThat(userRepository.usersBySubject.get("keycloak-subject-1").getCreatedAt()).isEqualTo(FIXED_NOW);
assertThat(auditEventPublisher.events)
.singleElement()
.satisfies(event -> {
assertThat(event.eventType()).isEqualTo("KEYCLOAK_USER_AUTO_REGISTERED");
assertThat(event.fields()).containsEntry("subject", "keycloak-subject-1");
});
}
@Test
void loadRejectsBlankClaimsAsInvalid() {
assertThatThrownBy(() -> loader.load(new KeycloakUserClaims(
"keycloak-subject-1",
"",
"테스터"
))).isInstanceOf(InvalidKeycloakClaimsException.class);
}
@Test
void loadRejectsMalformedEmailClaimAsInvalid() {
assertThatThrownBy(() -> loader.load(new KeycloakUserClaims(
"keycloak-subject-1",
"not-an-email",
"테스터"
))).isInstanceOf(InvalidKeycloakClaimsException.class);
}
@Test
void loadRejectsMissingSubjectWhenEmailAlreadyBelongsToAnotherUser() {
userRepository.usersBySubject.put("other-subject", User.registerKeycloak(
UUID.fromString("11111111-1111-1111-1111-111111111111"),
UserEmail.from("tester@example.com"),
UserName.from("테스터"),
"other-subject",
FIXED_NOW
));
assertThatThrownBy(() -> loader.load(new KeycloakUserClaims(
"keycloak-subject-1",
"tester@example.com",
"테스터"
))).isInstanceOf(KeycloakAccountConflictException.class);
}
private static final class FakeKeycloakUserRepository implements LoadKeycloakUserPort, RegisterKeycloakUserPort {
private final Map<String, User> usersBySubject = new ConcurrentHashMap<>();
@Override
public Optional<User> findByProviderAndProviderSubject(AuthProvider provider, String providerSubject) {
if (provider != AuthProvider.KEYCLOAK) {
return Optional.empty();
}
return Optional.ofNullable(usersBySubject.get(providerSubject));
}
@Override
public boolean existsByEmail(UserEmail email) {
return usersBySubject.values().stream()
.anyMatch(user -> user.getEmail().equals(email.value()));
}
@Override
public User register(User user) {
usersBySubject.put(user.getProviderSubject(), user);
return user;
}
}
private static final class RecordingAuthAuditEventPublisher implements AuthAuditEventPublisher {
private final List<AuthAuditEvent> events = new ArrayList<>();
@Override
public void publish(AuthAuditEvent event) {
events.add(event);
}
}
}
@@ -0,0 +1,66 @@
package com.project.auth.application.support.exception;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/**
* BusinessException 4개 protected 생성자 + getErrorCode 단위 테스트.
* 다른 모듈(presentation 핸들러)에서 BusinessException을 instantiate해도 모듈 격리 측정에서는
* application 모듈 라인이 카운트되지 않으므로 본 테스트로 직접 호출.
*/
class BusinessExceptionTest {
@Test
void single_arg_constructor_uses_error_code_message() {
BusinessException ex = new TestException(AuthErrorCode.AUTHENTICATION_REQUIRED);
assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.AUTHENTICATION_REQUIRED);
assertThat(ex.getMessage()).isEqualTo(AuthErrorCode.AUTHENTICATION_REQUIRED.message());
assertThat(ex.getCause()).isNull();
}
@Test
void detail_message_constructor_overrides_message() {
BusinessException ex = new TestException(AuthErrorCode.ACCESS_DENIED, "specific detail");
assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.ACCESS_DENIED);
assertThat(ex.getMessage()).isEqualTo("specific detail");
assertThat(ex.getCause()).isNull();
}
@Test
void cause_constructor_preserves_cause_and_uses_error_code_message() {
Throwable cause = new IllegalStateException("inner");
BusinessException ex = new TestException(AuthErrorCode.KEYCLOAK_USER_NOT_FOUND, cause);
assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.KEYCLOAK_USER_NOT_FOUND);
assertThat(ex.getMessage()).isEqualTo(AuthErrorCode.KEYCLOAK_USER_NOT_FOUND.message());
assertThat(ex.getCause()).isSameAs(cause);
}
@Test
void detail_message_and_cause_constructor_preserves_both() {
Throwable cause = new IllegalStateException("inner");
BusinessException ex = new TestException(AuthErrorCode.KEYCLOAK_CLAIMS_INVALID, "detail", cause);
assertThat(ex.getErrorCode()).isEqualTo(AuthErrorCode.KEYCLOAK_CLAIMS_INVALID);
assertThat(ex.getMessage()).isEqualTo("detail");
assertThat(ex.getCause()).isSameAs(cause);
}
private static final class TestException extends BusinessException {
TestException(ClientFacingErrorCode errorCode) {
super(errorCode);
}
TestException(ClientFacingErrorCode errorCode, String detailMessage) {
super(errorCode, detailMessage);
}
TestException(ClientFacingErrorCode errorCode, Throwable cause) {
super(errorCode, cause);
}
TestException(ClientFacingErrorCode errorCode, String detailMessage, Throwable cause) {
super(errorCode, detailMessage, cause);
}
}
}
@@ -0,0 +1,28 @@
package com.project.auth.application.support.exception;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.EnumSource;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Tier 1 ErrorCode enum 인스턴스의 code()/message()/values() 호출 라인을 application 모듈
* 단위 측정에서 카운트되도록 한다. 통상적으로는 다른 모듈이 호출하지만 모듈 격리 측정에서는
* 그 호출이 application 모듈 .exec에 기록되지 않는다.
*/
class ErrorCodeEnumTest {
@ParameterizedTest
@EnumSource(CommonErrorCode.class)
void common_error_code_exposes_code_and_message(CommonErrorCode code) {
assertThat(code.code()).isNotBlank().startsWith("COMMON-");
assertThat(code.message()).isNotBlank();
}
@ParameterizedTest
@EnumSource(AuthErrorCode.class)
void auth_error_code_exposes_code_and_message(AuthErrorCode code) {
assertThat(code.code()).isNotBlank().startsWith("AUTH-");
assertThat(code.message()).isNotBlank();
}
}
@@ -0,0 +1,39 @@
package com.project.auth.application.support.logging;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.assertj.core.api.Assertions.assertThat;
/**
* jqwik 속성 테스트가 도달하기 어려운 LogSanitizer의 edge case를 직접 호출.
*
* 환경 의존(SHA-256 미지원) 분기는 단위 테스트로도 트리거 불가하므로 미커버 인정.
*/
class LogSanitizerEdgeCaseTest {
@Test
void normalize_single_arg_uses_default_max_length() {
// public single-arg normalize(value)는 normalize(value, DEFAULT_MAX_LENGTH)에 위임
assertThat(LogSanitizer.normalize("hello")).isEqualTo("hello");
}
@ParameterizedTest
@ValueSource(strings = {"@example.com", "user@", "@"})
void mask_email_returns_triple_star_for_malformed_emails(String malformed) {
// atIndex <= 0 또는 atIndex == length-1 → "***"
assertThat(LogSanitizer.actorId(malformed)).isEqualTo("***");
}
@ParameterizedTest
@ValueSource(strings = {
"1.2.3.999", // value > 255
"1.2.3.1234", // octet.length() > 3
"1.2.3.abc" // !Character.isDigit
})
void client_ip_falls_back_to_hash_when_octet_invalid(String input) {
String result = LogSanitizer.clientIp(input);
assertThat(result).startsWith("sha256:");
}
}
@@ -0,0 +1,180 @@
package com.project.auth.application.support.logging;
import net.jqwik.api.Arbitraries;
import net.jqwik.api.Arbitrary;
import net.jqwik.api.ForAll;
import net.jqwik.api.From;
import net.jqwik.api.Property;
import net.jqwik.api.Provide;
import net.jqwik.api.constraints.StringLength;
import static org.assertj.core.api.Assertions.assertThat;
/**
* LogSanitizer 속성 기반 테스트.
*
* 검증 대상 속성:
* - normalize: null/blank → "-", 결과 길이 한도 보장(단, "..." 접미사 허용),
* 제어/공백/'='/'|' 문자 제거.
* - clientIp: IPv4 형식이면 마지막 옥텟이 0으로 마스킹, 그 외는 sha256 prefix.
* - actorId: 이메일이면 마스킹, 그 외 anonymous/blank/null은 "anonymous", 나머지는 sha256 prefix.
* - requestPath: 일반 normalize와 동일 정책 적용 + 더 큰 길이 한도(200).
*/
class LogSanitizerPropertyTest {
private static final int DEFAULT_MAX = 160;
private static final int REQUEST_PATH_MAX = 200;
private static final int USER_AGENT_MAX = 120;
@Property
void normalize_null_or_blank_returns_dash(@ForAll @From("blankOrNull") String input) {
assertThat(LogSanitizer.normalize(input)).isEqualTo("-");
}
@Property
void normalize_strips_control_whitespace_equals_pipe_chars(@ForAll @StringLength(min = 1, max = 300) String raw) {
String result = LogSanitizer.normalize(raw);
if (result.equals("-")) {
return;
}
for (int i = 0; i < result.length(); i++) {
char ch = result.charAt(i);
// "..." 접미사 자체는 정규화되지 않는 평문이므로 통과
if (ch == '.') continue;
assertThat(Character.isISOControl(ch))
.as("ISO control char leaked at index %d", i)
.isFalse();
assertThat(ch == '=' || ch == '|')
.as("forbidden delimiter '=' or '|' leaked at index %d", i)
.isFalse();
assertThat(Character.isWhitespace(ch) && ch != '_')
.as("whitespace leaked at index %d", i)
.isFalse();
}
}
@Property
void normalize_output_length_is_bounded_by_max_plus_three(@ForAll @StringLength(min = 0, max = 1000) String raw) {
String result = LogSanitizer.normalize(raw);
if (result.equals("-")) {
return;
}
// sanitizer는 maxLength까지의 문자 + 입력이 maxLength 초과 시 "..." 접미사를 붙인다
assertThat(result.length()).isLessThanOrEqualTo(DEFAULT_MAX + 3);
}
@Property
void requestPath_output_length_is_bounded(@ForAll @StringLength(min = 0, max = 1000) String raw) {
String result = LogSanitizer.requestPath(raw);
if (result.equals("-")) {
return;
}
assertThat(result.length()).isLessThanOrEqualTo(REQUEST_PATH_MAX + 3);
}
@Property
void userAgent_output_length_is_bounded(@ForAll @StringLength(min = 0, max = 1000) String raw) {
String result = LogSanitizer.userAgent(raw);
if (result.equals("-")) {
return;
}
assertThat(result.length()).isLessThanOrEqualTo(USER_AGENT_MAX + 3);
}
@Property
void clientIp_with_valid_ipv4_masks_last_octet(@ForAll("ipv4") String ipv4) {
String result = LogSanitizer.clientIp(ipv4);
assertThat(result).endsWith(".0");
assertThat(result.split("\\.")).hasSize(4);
}
@Property
void clientIp_with_non_ipv4_falls_back_to_hash(@ForAll @StringLength(min = 1, max = 200) String value) {
// IPv4 형식이면 그대로 하위 검증을 통과하므로 이 속성에서는 제외
if (looksLikeIpv4(value)) return;
String result = LogSanitizer.clientIp(value);
if ("-".equals(result)) {
assertThat(value.trim()).isEmpty();
return;
}
assertThat(result).startsWith("sha256:");
// sha256 hex prefix는 16자
assertThat(result.length()).isEqualTo("sha256:".length() + 16);
}
@Property
void clientIp_for_blank_returns_dash(@ForAll @From("blankOrNull") String input) {
assertThat(LogSanitizer.clientIp(input)).isEqualTo("-");
}
@Property
void actorId_for_blank_anonymous_returns_anonymous(@ForAll @From("blankOrNullOrAnonymous") String input) {
assertThat(LogSanitizer.actorId(input)).isEqualTo("anonymous");
}
@Property
void actorId_for_email_masks_local_part(@ForAll("email") String email) {
String result = LogSanitizer.actorId(email);
assertThat(result).contains("@");
// local prefix는 최대 2글자 + ***
int atIndex = result.indexOf('@');
String localResult = result.substring(0, atIndex);
assertThat(localResult).endsWith("***");
// 마스킹 결과 길이는 prefix(1~2) + "***" = 4~5
assertThat(localResult.length()).isBetween(4, 5);
}
@Property
void actorId_for_non_email_non_blank_returns_hash_prefix(
@ForAll @StringLength(min = 1, max = 100) String value
) {
if (value.isBlank() || "anonymous".equals(value) || value.contains("@")) return;
String result = LogSanitizer.actorId(value);
assertThat(result).startsWith("sha256:");
assertThat(result.length()).isEqualTo("sha256:".length() + 16);
}
// ---------- Arbitraries ----------
@Provide
Arbitrary<String> blankOrNull() {
return Arbitraries.of("", " ", " ", "\t", "\n", null);
}
@Provide
Arbitrary<String> blankOrNullOrAnonymous() {
return Arbitraries.of("", " ", "anonymous", null);
}
@Provide
Arbitrary<String> ipv4() {
Arbitrary<Integer> octet = Arbitraries.integers().between(0, 255);
return octet.flatMap(a ->
octet.flatMap(b ->
octet.flatMap(c ->
octet.map(d -> a + "." + b + "." + c + "." + d))));
}
@Provide
Arbitrary<String> email() {
Arbitrary<String> local = Arbitraries.strings()
.alpha().ofMinLength(1).ofMaxLength(20);
Arbitrary<String> domain = Arbitraries.strings()
.alpha().ofMinLength(2).ofMaxLength(20);
return local.flatMap(l -> domain.map(d -> l + "@" + d + ".com"));
}
private static boolean looksLikeIpv4(String value) {
String[] parts = value.split("\\.", -1);
if (parts.length != 4) return false;
for (String p : parts) {
if (p.isEmpty() || p.length() > 3) return false;
for (int i = 0; i < p.length(); i++) {
if (!Character.isDigit(p.charAt(i))) return false;
}
int n = Integer.parseInt(p);
if (n > 255) return false;
}
return true;
}
}