@AuthenticationPrincipal 적용

ᄋᄌᄒ·2023년 12월 19일
0

Spring_Security

목록 보기
1/5
post-thumbnail

글 쓰기 전에

단순히 스터디용으로 만들고 있던 프로젝트에 새로이 역할군별로 멤버분들도 들어오시고 기획부터 다시 잡고 있는데, 아마 서버 배포까지하고 잠깐동안 운영까지 하지 않을까 싶다. 그렇게 되면 시큐리티(개인정보)는 누가 담당을 했던간에 백엔드들은 철저하게 알고 있어야한다고 생각했는데, 아쉽게도 jwt token 담당을 맡지 못해서 지식이 많이 부족한 상태였다. 다행히 위에 어노테이션 적용과 더불어 OAuth2까지 맡을 예정이기 때문에 공부하고 적응할 시간이 충분하다. 글은 실제로 프로젝트 추가내용 순으로 @AuthenticationPrincipal -> OAuth2 apply 로 진행하며 겸겸 jwt Review를 해볼까 한다.

@AuthenticationPrincipal

📌Before

클라이언트의 요청으로 jwt token을 발급받고, 자신이 가지고 있는 accessToken의 authentication를 통해 member entity의 username을 호출하는 형식이었다. 그래서 service나 controller에서 "로그인한 사용자"가 사용하는 로직이 필요로 할 때 아래 SecurityUtil.getCurrentUsername()을 통해 리턴받은 값들로 다시 재활용하여 사용했다.

public class SecurityUtil {
    public static String getCurrentUsername() {
        final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || authentication.getName() == null) {
            return "anonymousUser";
//            throw new RuntimeException("No authentication information.");
        }
        return authentication.getName();
    }
}

📌What it is?

annotation AuthenticationPrincipal은 Authentication.getPrincipal()을 사용하기 위한 어노테이션이다.

AuthenticationPrincipalArgumentResolver

@AutneticationPrincipal

  1. SecurityContextHolder로부터 Authentication 객체를 가져오고, 해당 객체에서 Object principal 객체를 가져옵니다.
  2. @AuthenticationPrincipal 어노테이션이 적용된 파라미터를 확인합니다.
  3. 해당 부분은 AuthenticationPrincipal 어노테이션의 속성 값에 따른 처리 부분으로 default false인 상태에서는 적용되지 않습니다.
  4. principal 인스턴스를 반환합니다.

결과만 말하면 어노테이션을 사용하면 authentication의 주체 객체 정보를 받아올 수 있다는 것. 그럼 그 객체는?

UserDetails 란?

Spring Security에서 사용자의 정보를 담는 인터페이스이다.
기본적으로 Spring Security의 principal 객체는 Object 형태로 UserDetails를 형변환 해야 한다.

[Member.class(Entity)]

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@SuperBuilder
@EqualsAndHashCode(of = "id", callSuper = false)
public class Member extends BaseTimeEntity implements UserDetails {
    @Id @GeneratedValue
    @Column(name = "member_id", unique = true, nullable = false)
    private Long id;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(unique = true, nullable = false)
    private String username;
    
    @Enumerated(EnumType.STRING)
    private Role role;
    
    //...이하 생략(implement UerDetails method도 마찬가지)
}

[CustomUserDetailsService.class(service)]

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final MemberRepository memberRepository;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return memberRepository.findByUsername(username)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다."));
    }

    // 해당하는 User 의 데이터가 존재한다면 UserDetails 객체로 만들어서 return
    private UserDetails createUserDetails(Member member) {
        return User.builder()
                .username(member.getUsername())
                .password(member.getPassword())
                .roles(member.getRole().getKey())
                .build();
    }
}

[login flow 중]

// 실제 검증 (사용자 비밀번호 체크)이 이루어지는 부분
// authenticate 매서드가 실행될 때 CustomUserDetailsService 에서 만든 loadUserByUsername 메서드가 실행
Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken);

customUserDetailsService.loadUserByUsername()가 위 코드(로그인)에서 실행되어서, 반환되는 Authentication의 principal은 loadUserByUsername()에서 반환한 CustomUserDetails 객체가 된다.

우리는 우선 로그인을 통해 jwt token을 발급받을 것이고,이후에 토큰을 사용하는 요청으로 유효성을 확인하고 정보(UserDetails)를 받아올 것이다. 이를 UsernamePasswordAuthenticationToken 객체의 principal로 주입하여 반환한다.반환된 authentication은 SecurityContextHolder에 저장되고 이후에는 인증 객체가 필요할 때 앞서 SecurityContextHolder에서 찾아 반환하게 되는 것이다.

그렇기에 @AuthenticationPrincipal어노테이션은SecurityContextHolder에서 principal을 가져오게 되는 것이다.

[JwtAuthenticationFilter.class(doFilter method 중)

if (token != null && tokenService.validateToken(token)) {
            // 토큰이 유효할 경우 토큰에서 Authentication 객체를 가지고 와서 SecurityContext에 저장
            Authentication authentication = tokenService.getAuthentication(token);
            SecurityContextHolder.getContext().setAuthentication(authentication);
            request.setAttribute("username", authentication.getName());
            log.info("set Authentication to security context for '{}', uri: '{}'", authentication.getName(), ((HttpServletRequest) request).getRequestURI());
        }        

📌After

@AuthenticationPrincipal어노테이션을 그대로 쓰면 개발자의 실수로 인증이 필요없는 메서드에서 인증요구를 통해 반환되는 null(NullPointerException)처리를 하기가 어려워진다. 따라서 이런 부분을 해결 해주는 custom annotation과 null이 발생했을 때 처리해주는 custom ArgumentResolver 클래스를 별도로 작성하면 비효율적인 방식을 피할 수 있다.

[AuthUser.annotation]

@Target({ElementType.PARAMETER, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface AuthUser {

    boolean errorOnInvalidType() default true;
}

[CustomAuthenticationPrincipalArgumentResolver.class]

public final class CustomAuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return findMethodAnnotation(AuthUser.class, parameter) != null;
    }

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

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null) {
            // Custom Exception 을 통한 예외 처리
            throw new SecurityHandler(ErrorStatus._UNAUTHORIZED_LOGIN_DATA_RETRIEVAL_ERROR);
        }
        Object principal = authentication.getPrincipal();
        AuthUser annotation = findMethodAnnotation(AuthUser.class, parameter);
        if (principal == "anonymousUser") {
            // Custom Exception 을 통한 예외 처리
            throw new SecurityHandler(ErrorStatus._UNAUTHORIZED_LOGIN_DATA_RETRIEVAL_ERROR);
        }
        findMethodAnnotation(AuthUser.class, parameter);
        if (principal != null && !ClassUtils.isAssignable(parameter.getParameterType(), principal.getClass())) {
            if (annotation.errorOnInvalidType()) {
                throw new ClassCastException(principal + " is not assignable to " + parameter.getParameterType());
            }
        }

        return principal;
    }

    private <T extends Annotation> T findMethodAnnotation(Class<T> annotationClass, MethodParameter parameter) {
        T annotation = parameter.getParameterAnnotation(annotationClass);
        if (annotation != null) {
            return annotation;
        }
        Annotation[] annotationsToSearch = parameter.getParameterAnnotations();
        for (Annotation toSearch : annotationsToSearch) {
            annotation = AnnotationUtils.findAnnotation(toSearch.annotationType(), annotationClass);
            if (annotation != null) {
                return annotation;
            }
        }
        return null;
    }
}

글을 마치며

생각보다 내가 직접 쓴 글이 없는 것 같다;; 참고한 자료들 중에 매우매우매우 설명을 자세하게 해준 글이 하나 있어서 도움을 많이 받았는데 직접 리뷰글을 작성하려니 이 글보다 더 잘쓰기가 어려운 것..같다.(감사합니다) 애초에 jwt 관련 지식이 부족한 상태다보니 더욱 그러했던 것 같다.

reference

https://wildeveloperetrain.tistory.com/324#google_vignette
(위에 링크 매우 친절하고 좋습니다)
https://velog.io/@sonaky47/Spring-Security-Jwt-%ED%86%A0%ED%81%B0%EC%A0%95%EB%B3%B4%EB%A1%9C-%ED%95%84%ED%84%B0%EB%A7%81-%EB%90%9C-%EC%9C%A0%EC%A0%80%EC%A0%95%EB%B3%B4%EB%A5%BC-%EC%BB%A8%ED%8A%B8%EB%A1%A4%EB%9F%AC%EB%8B%A8%EC%97%90%EC%84%9C-AuthenticationPricipal-%EC%96%B4%EB%85%B8%ED%85%8C%EC%9D%B4%EC%85%98%EC%9D%84-%ED%86%B5%ED%95%B4-%EA%B0%80%EC%A0%B8%EC%98%A4%EB%8A%94%EB%B2%95

0개의 댓글