컨트롤러의 엔드포인트에 해당 어노테이션을 이용하여 인증 정보를 받아오도록 만들었는데,
JwtAuthentication 이라는 별도의 인증 객체와
JwtAuthenticationToken, JwtAuthenticationProvider 를 직접 구현하여 사용중입니다.
@RequiredArgsConstructor
public class JwtAuthenticationProvider implements AuthenticationProvider {
private final JwtService jwt;
private final MemberService memberService;
@Override
public boolean supports(Class<?> authentication) {
return JwtAuthenticationToken.class.isAssignableFrom(authentication);
}
@Override public Authentication authenticate(Authentication authentication) throws AuthenticationException {
JwtAuthenticationToken authenticationToken = (JwtAuthenticationToken) authentication;
return processing(authenticationToken.toAuthentionRequest());
}
private Authentication processing(AuthenticationRequest req){
try{
Member member = this.memberService.login(req.getEmail(), req.getPassword());
if(!member.isActivated()){
throw new NotAllowedMemberException("승인되지 않은 사용자입니다. 관리자에게 문의하세요.");
}
JwtAuthenticationToken authenticated =
new JwtAuthenticationToken(member.toJwtAuthentication(), null, member.getAuthorities());
String refreshToken = jwt.createRefreshToken(member.getEmail());
String accessToken = jwt.createAccessToken(member.toClaims());
authenticated.setDetails(new AuthenticationResult(refreshToken, accessToken, member));
return authenticated;
}catch(NotFoundException e){
throw new UsernameNotFoundException(e.getMessage());
}catch(IllegalArgumentException e){
throw new BadRequestException(e.getMessage());
}catch(DataAccessException e){
throw new AuthenticationServiceException(e.getMessage(), e);
}
}
}
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private final Object principal;
private String credentials;
public JwtAuthenticationToken(String principal, String credentials){
super(null);
super.setAuthenticated(false);
this.principal = principal;
this.credentials = credentials;
}
public JwtAuthenticationToken(Object principal, String credentials, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
super.setAuthenticated(true);
this.principal = principal;
this.credentials = credentials;
}
public AuthenticationRequest toAuthentionRequest(){
return new AuthenticationRequest(String.valueOf(principal), credentials);
}
@Override
public void setAuthenticated(boolean authenticated) {
if(authenticated){
throw new IllegalArgumentException("Cannot set this token to trusted.");
}
super.setAuthenticated(authenticated);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
this.credentials = null;
}
@Override
public String getCredentials() {
return this.credentials;
}
@Override
public Object getPrincipal() {
return principal;
}
}
@AllArgsConstructor
@ToString
public class JwtAuthentication {
public final Long userId;
public final String email;
public final Collection<? extends GrantedAuthority> authorities;
}
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
/**
* Member Repository
*/
private final MemberRepository memberRepository;
/**
* JwtService
*/
private final JwtService jwtService;
private final ObjectMapper objectMapper;
/**
* @param request HTTP Request
* @param response HTTP Response
* @param filterChain 체이닝을 위한 객체
* @throws ServletException
* @throws IOException
*/
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
/**
* (1) Request Header 에서 토큰을 추출한다. 만약 토큰값이 null이라면 다음 필터를 진행시킨다.
* (2) Token 이 존재한다면 decode 만 진행하여 subject를 확인한다.
* (3) subject 가 access 이라면 DecodedJWT 를 verify 해본다.
* - 성공 시 : Authentication 객체를 세팅하여 SecurityContext에 적용한다.
* - 실패 시 : 401 UnauthorizedException
* (4) subject가 refresh 라면 DecodedJWT 를 verify 해본다.
* - 성공 시 : Access token을 신규 생성하여 response 의 Authorization header에 넣어 반환., 417 Error를 발생한다.
* - 실패 시 : 401 UnauthorizedException
*/
SecurityContextHolder.clearContext();
String token = jwtService.extractTokenFromHeader(request)
.orElse(null);
if(token == null) {
filterChain.doFilter(request, response);
return;
}
try {
DecodedJWT decoded = jwtService.verify(token);
String type = decoded.getSubject();
if(type.equals(jwtService.getJwtConfigure().getRefreshTokenSubject())){
String refreshedToken = reIssueAccessToken(decoded);
response.setStatus(HttpServletResponse.SC_EXPECTATION_FAILED);
response.setHeader("Authorization", refreshedToken);
return;
}
accessTokenAuthentication(request, decoded);
}catch(JWTVerificationException e){
setupErrorResponse(response, HttpStatus.UNAUTHORIZED, e.getMessage());
return ;
}
filterChain.doFilter(request,response);
}
private void accessTokenAuthentication(HttpServletRequest request, DecodedJWT jwt){
Member member = Member.builder()
.id(jwt.getClaim("user_id").asLong())
.email(jwt.getClaim("email").asString())
.role(Role.of(jwt.getClaim("role").asString()))
.build();
saveAuthentication(request, member);
}
/**
* Access token을 재발급한다.
* @param jwt
*/
private String reIssueAccessToken(DecodedJWT jwt){
String email = jwt.getClaim("email").asString();
return this.memberRepository.findByEmail(email)
.map(user -> user.toClaims())
.map(claims -> jwtService.createAccessToken(claims))
.orElse(null);
}
/**
* authentication 정보를 저장한다.
* @param request HttpSevletRequest
* @param member Member
*/
private void saveAuthentication(HttpServletRequest request, Member member){
// log.info("save authentication!.");
JwtAuthenticationToken authenticated = new JwtAuthenticationToken(
new JwtAuthentication(member.getId(), member.getEmail(), member.getAuthorities()),
null,
member.getAuthorities()
);
authenticated.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticated);
}
private HttpServletResponse setupErrorResponse(HttpServletResponse response, HttpStatus status, String message) throws IOException, ServletException{
response.setHeader("content-type", "application/json");
ApiResult<?> E401 = ApiResult.ERROR(message, status);
log.info("E401 : {}", objectMapper.writeValueAsString(E401));
response.setStatus(status.value());
response.getWriter().write(objectMapper.writeValueAsString(E401));
response.getWriter().flush();
response.getWriter().close();
return response;
}
}
@RequiredArgsConstructor
public class EntryPointUnauthorizedHandler implements AuthenticationEntryPoint {
static ApiResult<?> E401 = ApiResult.ERROR("Authentication error (cause: Unauthorized)", HttpStatus.UNAUTHORIZED);
private final ObjectMapper objectMapper;
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setHeader("content-type", "application/json");
response.getWriter().write(objectMapper.writeValueAsString(E401));
response.getWriter().flush();
response.getWriter().close();
}
}
@RequiredArgsConstructor
@EnableWebSecurity
public class SecurityConfigure {
private final JwtConfigure jwtConfigure;
private final MemberService memberService;
private final MemberRepository memberRepository;
private final EntryPointUnauthorizedHandler entryPointUnauthorizedHandler;
private final ObjectMapper objectMapper;
private static final String[] AUTHENTICATION_WHITE_LIST = {
"/**",
"/api/v1/auth/**",
"/api/v1/members/sign-up"
};
@Bean
public JwtService jwtService(){
return new JwtService(jwtConfigure);
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer(){
return (web) -> web
.ignoring()
.requestMatchers("/swagger-resources",
"/webjars/**",
"/static/**",
"/templates/**",
"/css/**",
"/h2-console/**",
"/h2/**");
}
@Bean
public JwtAuthenticationProvider jwtAuthenticationProvider(){
return new JwtAuthenticationProvider(jwtService(), memberService);
}
public JwtAuthenticationFilter jwtAuthenticationFilter(){
return new JwtAuthenticationFilter(memberRepository, jwtService(), objectMapper );//, List.of(AUTHENTICATION_WHITE_LIST));
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
http.csrf().disable()
.cors().disable()
.formLogin().disable()
.httpBasic().disable()
.headers().disable()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling()
.authenticationEntryPoint(entryPointUnauthorizedHandler)
.and()
.authorizeHttpRequests()
.requestMatchers(toH2Console()).permitAll()
.requestMatchers(AUTHENTICATION_WHITE_LIST).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/v1/members/**")).authenticated()
.anyRequest().permitAll();
http.addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
@RestController
@RequestMapping("api/v1/members")
@Slf4j
public class MemberController {
private final MemberService memberService;
@PostMapping("/sign-up")
public ApiResult<MemberDto> signUp(@RequestBody MemberJoinDto joinDto){
log.debug("joinDto : {}", joinDto.toString());
return OK(memberService.createUser(joinDto.getEmail(), joinDto.getPassword()));
}
@GetMapping("/{userId}")
public ApiResult<MemberDto> getMemberInfo(@AuthenticationPrincipal JwtAuthentication authentication,
@PathVariable Long userId){
log.info("/api/v1/members/userId called.");
log.info(authentication.toString());
return OK(this.memberService.getUser(userId));
}
}
문제는 위 Controller 에서 /api/v1/members/{userId}
부분인데요, AccessDecision 을 구성하기 전에 인증 정보가 제대로 전달되는지 확인하기 위해
테스트를 돌려보았는데,
request 의 Authorization Header 를 Bearer 아무값
이런식으로 넣어서 보낼 시 유효한 토큰의 경우 JwtAuthentication 객체의 정보가 반환되는데,
Authorization Header를 없애고 보낼 시에도 해당 컨트롤러에 도달하는것이 확인되었습니다.
당연히 NullPointerException 이 발생하고요.
SecurityFilterChain 을 구성했기 때문에 인증에 실패하면 컨트롤러에 도달하기도 전에 EntryPointUnauthorizedHandler 에서 커트를 해야할 것 같은데
커트가 되지 않는것 같습니다.
FilterChain 자체는 ServletDispatcher 에 진입하기도 전에 실행 되는 친구일거고
SecurityFilterChain은 Proxy를 이용한 방식이라서 실행이 되는것이고
뭐가되었던 정상적인 인증객체를 생성하지 못했기 때문에
SecurityConfigure 를 구성할 때
해당 엔드포인트를 requestMatcher("/api/v1/members/**").authenticated()
로 설정했다면 인증이 이루어지지 않아 컨트롤러에 도달하지 않아야한다고 생각을 했는데
예상치 못한 NullPointerException 이 발생하여 헤메고 있습니다.
제가 무엇을 잘못 이해하고 있는 것인가요?
코드 github로 가져오는게 더 좋을 것 같은데 짤려서 안보임
못보겠다.. 깃헙 링크좀
문제 찾았습니다. 화이트리스트를 잘못 작성해서 발생한 문제얐네요 감사합니더