5.7 KiB
5.7 KiB
Authentication Object Access 예시
좋은 예시
예시 1. 프로젝트 전용 @CurrentUser를 정의한다
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal
public @interface CurrentUser {
}
좋은 이유:
- controller가 Spring Security 애노테이션에 직접 결합되지 않는다
- 현재 사용자 접근 규칙이 한 파일에 모인다
- Spring 공식 문서도 같은 메타 애노테이션 방식을 예시로 보여 준다.
예시 2. controller는 전용 현재 사용자 타입만 받는다
public record AuthenticatedUser(
String userId,
Set<String> authorities
) {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/sessions")
public class SessionCommandController {
private final CreateSessionUseCase createSessionUseCase;
@PostMapping
public ApiResult<CreateSessionResponse> create(
@CurrentUser AuthenticatedUser currentUser,
@Valid @RequestBody CreateSessionRequest request
) {
CreateSessionResult result = createSessionUseCase.create(
new CreateSessionCommand(
currentUser.userId(),
request.email(),
request.password()
)
);
return ApiResult.success(new CreateSessionResponse(
result.sessionId(),
result.accessToken()
));
}
}
좋은 이유:
- controller가 현재 사용자 접근을 명시적으로 드러낸다
- application에는 필요한 값만 전달한다
- SecurityContextHolder 직접 접근이 없다
예시 3. ID만 필요하면 claim/field만 바로 주입한다
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@AuthenticationPrincipal(expression = "userId")
public @interface CurrentUserId {
}
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/password")
public class PasswordController {
private final ChangePasswordUseCase changePasswordUseCase;
@PostMapping("/change")
public ApiResult<Void> changePassword(
@CurrentUserId String userId,
@Valid @RequestBody ChangePasswordRequest request
) {
changePasswordUseCase.change(
new ChangePasswordCommand(
userId,
request.currentPassword(),
request.newPassword()
)
);
return ApiResult.success(null);
}
}
좋은 이유:
- 필요한 최소 actor 정보만 유스케이스로 간다
- current user 타입 전체를 넘기지 않아도 된다
- 공식 문서의 expression 기반 메타 애노테이션 패턴과 맞는다.
예시 4. Principal은 단순 확인 endpoint에 제한적으로 쓴다
@RestController
@RequestMapping("/api/v1/me")
public class MeController {
@GetMapping
public ApiResult<Map<String, String>> me(Principal principal) {
return ApiResult.success(Map.of("name", principal.getName()));
}
}
좋은 이유:
- 단순 identity 확인 수준에는 충분하다
- 복잡한 Security 타입을 노출하지 않는다
- Spring MVC가 공식 지원하는 기본 method argument다.
나쁜 예시
예시 1. controller가 SecurityContextHolder를 직접 읽는다
@RestController
@RequestMapping("/api/v1/me")
public class BadMeController {
@GetMapping
public ApiResult<String> me() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
return ApiResult.success(principal.getUserId());
}
}
나쁜 이유:
- controller가 보안 저장소 접근과 캐스팅 책임까지 가진다
- 시그니처에서 현재 사용자 의존이 드러나지 않는다
- Spring 공식 문서도 이 패턴보다 @AuthenticationPrincipal 쪽을 권장 예시로 보여 준다.
예시 2. application이 Spring Security 타입을 직접 받는다
@Service
public class BadChangePasswordService {
public void change(Authentication authentication, String currentPassword, String newPassword) {
String userId = ((CustomUserPrincipal) authentication.getPrincipal()).getUserId();
// ...
}
}
나쁜 이유:
- application이 Spring Security에 결합된다
- 유스케이스 입력이 보안 프레임워크 타입에 종속된다
- 테스트와 재사용성이 나빠진다
예시 3. controller가 role check로 인가를 직접 처리한다
@RestController
@RequestMapping("/api/v1/admin")
public class BadAdminController {
@PostMapping("/users/{userId}/lock")
public ApiResult<Void> lock(
@CurrentUser AuthenticatedUser currentUser,
@PathVariable String userId
) {
if (!currentUser.authorities().contains("ROLE_ADMIN")) {
throw new AccessDeniedException("forbidden");
}
// ...
return ApiResult.success(null);
}
}
나쁜 이유:
- 인가 규칙이 controller imperative code로 새어 나갔다
- security rule/method security와 역할이 충돌한다
- defense in depth 구조가 흐려진다.
예시 4. JWT claim을 여러 계층에서 직접 파싱한다
@Service
public class BadUserService {
public void doSomething(JwtAuthenticationToken authentication) {
String userId = authentication.getToken().getClaimAsString("sub");
// ...
}
}
나쁜 이유:
- application이 특정 인증 메커니즘(JWT claim 구조)에 결합된다
- principal 해석 책임이 security adapter에 모이지 않는다
- 토큰 구조 변경이 여러 계층으로 번진다