인증된 사용자 정보 추출 커스텀 어노테이션 생성

1. 커스텀 어노테이션이 필요한 이유

 

1.1 사용자 식별 정보 추출 커스텀 어노테이션을 사용하는 이유

 

JWT 토큰 인증 후 토큰에 있는 정보를 가지고 member의 정보(id, email 등)을 추출하는 로직이 필요한데, 컨트롤러에서 항상 파라미터로 인증 정보를 전달받아서 식별 정보를 추출하는 코드를 계속 작성하는 것은 중복된 코드를 계속 작성하는 불필요한 작업이기 때문에 깔끔하게 어노테이션 하나로 선언적으로 표현하기 위해서 커스텀 어노테이션 사용

 

2. @AuthMember 어노테이션 선언

 

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.PARAMETER)
@Parameter(hidden = true)
public @interface AuthMember {
}
  • Target은 PARAMETER 옵션을 적용해서 전달인자로 넘긴다라는 것
  • Retention을 통해 정보가 유지되는 시점은 RUNTIME 옵션을 적용해서 컴파일 이후 런타임 시기에도 JVM에 의해 참조가 가능하도록 함
  • Parameter은 Swagger와 관련된 어노테이션으로 API 문서에 표시하고 싶지 않은 Parameter라는 의미

 

3. @AuthMembrArgumentResolver 작성

 

@AuthMember 어노테이션을 Controller에서 parameter로 넘길 때 어떤 작업을 수행할지를 지정해 주는 역할

 

@Component
@RequiredArgsConstructor
public class AuthMemberArgumentResolver implements HandlerMethodArgumentResolver {

    private final MemberService memberService;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(AuthMember.class) &&
                parameter.getParameterType().equals(Member.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

        Object principal = null;

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

        if (authentication != null) {
            if (authentication.getName().equals("anonymousUser")) {
                throw new GlobalException(GlobalErrorCode._BAD_REQUEST);
            }
            else {
                principal = authentication.getPrincipal();
            }
        }
        
        if (principal == null || principal.getClass() == String.class) {
            throw new GlobalException(GlobalErrorCode.MEMBER_NOT_FOUND);
        }

        UsernamePasswordAuthenticationToken authenticationToken =
                (UsernamePasswordAuthenticationToken) authentication;

        return memberService.getMember(Long.valueOf(authenticationToken.getName()));
    }
}

 

3.1 supportsParameter 메소드

@Override
public boolean supportsParameter(MethodParameter parameter) {
    return parameter.hasParameterAnnotation(AuthMember.class) &&
            parameter.getParameterType().equals(Member.class);
}

 

첫번째로 resolver가 수행이 되면 메소드 파라미터를 검사해 @AuthMember가 붙은 경우, 또는 해당 어노테이션이 붙은 파라미터의 타입이 Member인 경우를 검사

 

supportsParameter 메소드를 통과(true를 반환하게 되는 경우)하게 되면, resolveArgument 메소드로 처리 차례가 넘어가게 됨

 

3.2 resolveArgument 메소드

 

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,
                                  NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {

     Object principal = null;

     Authentication authentication = SecurityContextHolder.getContext().getAuthentication();

     if (authentication != null) {
        if (authentication.getName().equals("anonymousUser")) {
             throw new GlobalException(GlobalErrorCode._BAD_REQUEST);
        }
        else {
           principal = authentication.getPrincipal();
        }
     }

     if (principal == null || principal.getClass() == String.class) {
        throw new GlobalException(GlobalErrorCode.MEMBER_NOT_FOUND);
     }

     return memberService.getMember(Long.valueOf(usernamePasswordAuthenticationToken.getName()));
}

SecurityContextHolder는 현재 사용자의 인증 정보를 저장하고 관리하는 Spring Security의 핵심 구성 요소 중 하나입니다. 이 컨텍스트는 현재 실행 중인 쓰레드와 연결되어 있으며, 각 쓰레드마다 고유한 인증 정보를 가지고 있습니다. 이는 쓰레드 로컬(thread-local) 저장소를 사용해 구현됩니다. 이 특성 때문에 다른 사용자의 인증 정보가 섞여서 가져와질 위험은 없습니다.

ThreadLocal (쓰레드 로컬):

  • SecurityContextHolder는 기본적으로 ThreadLocal을 사용하여 각 쓰레드마다 별도의 SecurityContext를 관리합니다. 이 말은, 동일한 쓰레드 내에서는 동일한 SecurityContext가 사용되지만, 다른 쓰레드에서는 서로 다른 SecurityContext가 유지된다는 뜻입니다.
  • 웹 애플리케이션에서는 일반적으로 각 요청(request)이 별도의 쓰레드에서 처리되기 때문에, 하나의 요청이 다른 요청과 인증 정보를 공유하지 않습니다.

Principal

  • principal은 보통 사용자의 주요 식별자를 의미하며, 인증된 사용자의 정보가 포함된 객체입니다. 이 정보는 다양한 형태를 가질 수 있습니다.
    • 기본적인 경우: 사용자 이름(username), 이메일 주소 등.
    • 사용자 객체: 커스텀 인증 프로세스에서는 사용자의 모든 정보를 포함한 객체가 principal로 설정될 수 있습니다. 예를 들어, UserDetails 인터페이스를 구현한 사용자 객체일 수 있습니다

 

여기서 중요한 포인트는 비인증 회원들은 익명 사용자로 서버에 접근한다는 것!!

 

만약에 authentication을 null을 통해 예외를 발생시킨다면, null이 아니기 때문에 통과해버리는 결과가 나옴

 

그렇기 때문에 null이 아니라 anonymousUser인지 체크 필요!!

 

익명사용자는 문자열 anonymousUser 이 저장되어 있는 principal과 ROLE_ANONYMOUS 권한 정보를 가지고 있는 객체입니다. 그리고 해당 객체(토큰)를 생성하고, 처리하는 필터가 AnonymousAuthenticationFilter

 

익명 사용자 인증 동작 과정

  • 요청이 왔을 때, 인증 객체가 있는지 확인
  • 인증 객체가 있다면, 인증받은 사용자를 SecurityContext에 넣고, 다음 필터를 진행
  • 인증 객체가 없다면, AnonymousAutenticationToken의 인증 객체를 생성하고 SecurityContextHolder에 저장

 

 

[스프링 시큐리티] 비인증 사용자를 위한 '익명 사용자' 알아보기

안녕하세요😎 백엔드 개발자 제임스입니다 :) 오늘 포스팅할 내용은 스프링 시큐리티와 관련된 내용입니다. 제가 프로젝트를 진행하면서 인증과 관련된 기능을 구현하던 중이었습니다. 해당

kang-james.tistory.com

 

SecurityContext에 저장된 인증 정보에서 getName 메소드를 통해 사용자 ID를 추출하고, ID에 해당하는 Member객체를 조회해옴

 

Principal 인터페이스와 getName() 메서드

Authentication 인터페이스는 Principal 인터페이스를 확장하고 있습니다. 이 Principal 인터페이스는 Java 표준 인터페이스로, 사용자의 이름을 반환하는 getName() 메서드를 정의하고 있습니다. 따라서, Authentication객체는 Principal에서 상속된 getName() 메서드를 통해 사용자의 이름을 얻을 수 있습니다.

 

4. Resolver 등록하기

RequiredArgsConstructor
@Configuration
public class WebConfig implements WebMvcConfigurer {

    private final GetTokenArgumentResolver getTokenArgumentResolver;
    private final AuthMemberArgumentResolver authMemberArgumentResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(getTokenArgumentResolver);
        resolvers.add(authMemberArgumentResolver);
    }
}

 

설정한 Resolver를 사용하기 위해서는 Resovler에 대한 등록이 필요

 

5. 컨트롤러에서 사용하기

@GetMapping("/test")
public ApiResponse<String> testAuth(@AuthMember Member member) {
    return ApiResponse.onSuccess("인증 성공", "인증 성공 + " + member.getId());
}

인증 정보를 가지고 있는 member의 ID가 추출되어 반환이 됨