package com.codestates.stackoverflowbe.global.auth.config;
import com.codestates.stackoverflowbe.domain.account.service.AccountService;
import com.codestates.stackoverflowbe.global.auth.filter.JwtVerificationFilter;
import com.codestates.stackoverflowbe.global.auth.handler.AccountAuthenticationSuccessHandler;
import com.codestates.stackoverflowbe.global.auth.handler.AccountAccessDeniedHandler;
import com.codestates.stackoverflowbe.global.auth.filter.JwtAuthenticationFilter;
import com.codestates.stackoverflowbe.global.auth.handler.AccountAuthenticationEntryPoint;
import com.codestates.stackoverflowbe.global.auth.handler.AccountAuthenticationFailureHandler;
import com.codestates.stackoverflowbe.global.auth.jwt.JwtTokenizer;
//import com.codestates.stackoverflowbe.global.auth.handler.OAuth2UserSuccessHandler;
import com.codestates.stackoverflowbe.global.auth.utils.CustomAuthorityUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
import java.util.Arrays;
@Configuration
public class SecurityConfiguration {
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
private final AccountService accountService;
private final CorsFilter corsFilter;
@Lazy // accountService의 순환참조 문제 해결
public SecurityConfiguration(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils, AccountService accountService,
CorsFilter corsFilter) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
this.accountService = accountService;
// this.corsFilter = corsFilter;
this.corsFilter = corsFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
httpSecurity
.headers().frameOptions().sameOrigin() // (해당 옵션 유효한 경우 h2사용가능) SOP 정책 유지, 다른 도메인에서 iframe 로드 방지
.and()
.csrf().disable()
// .cors(Customizer.withDefaults()) //CORS 처리하는 가장 쉬운 방법인 CorsFilter 사용, CorsConfigurationSource Bean을 제공
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 세션 정보 저장X
.and()
.formLogin().disable() // CSR 방식을 사용하기 때문에 formLogin 방식 사용하지 않음
.httpBasic().disable() // UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter 등 비활성화
.exceptionHandling() // 예외처리 기능
.authenticationEntryPoint(new AccountAuthenticationEntryPoint()) // 인증 실패시 처리 (UserAuthenticationEntryPoint 동작)
.accessDeniedHandler(new AccountAccessDeniedHandler()) //인가 거부시 UserAccessDeniedHandler가 처리되도록 설계
.and()
.apply(new CustomFilterConfigurer()) // 커스터마이징한 필터 추가
.and() // 허용되는 HttpMethod와 역할 설정
.authorizeHttpRequests( authorize -> authorize
.antMatchers(HttpMethod.GET, "/accounts").hasRole("ADMIN")
.antMatchers(HttpMethod.POST, "/accounts/**").permitAll()
.anyRequest().permitAll()
// .oauth2Login( oauth2 -> oauth2
// //OAuth2 인증이 성공했을 때 핸들러 처리
// .successHandler(new OAuth2UserSuccessHandler(jwtTokenizer, authorityUtils, accountService)) //OAuth 2.0 로그인이 성공했을 때의 동작을 정의하는 커스텀 핸들러
);
return httpSecurity.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
public class CustomFilterConfigurer extends AbstractHttpConfigurer<CustomFilterConfigurer, HttpSecurity> {
@Override
public void configure(HttpSecurity builder) throws Exception {
// authenticationManager : 사용자가 로그인 요청시 입력한 아이디와 패스워드를 해당 객체로 전달하여 인증 수행하며, 결과에 따라 로직 처리
AuthenticationManager authenticationManager = builder.getSharedObject(AuthenticationManager.class); // AuthenticationManager 객체얻기
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager, jwtTokenizer); // JwtAuthenticationFilter 객체 생성하며 DI하기
// AbstractAuthenticationProcessingFilter에서 상속받은 filterProcessurl을 설정 (설정하지 않으면 default 값인 /Login)
jwtAuthenticationFilter.setFilterProcessesUrl("/accounts/authenticate");
jwtAuthenticationFilter.setAuthenticationSuccessHandler(new AccountAuthenticationSuccessHandler());
jwtAuthenticationFilter.setAuthenticationFailureHandler(new AccountAuthenticationFailureHandler());
//⭐ 인가를 담당하는 JwtVerificationFilter이 정상적으로 동작되지 않는 것으로 보임.
JwtVerificationFilter jwtVerificationFilter = new JwtVerificationFilter(jwtTokenizer, authorityUtils);
// Spring Security FilterChain에 추가
builder
.addFilter(corsFilter)
.addFilter(jwtAuthenticationFilter)
// OAuth2LoginAuthenticationFilter : OAuth2.0 권한 부여 응답 처리 클래스 뒤에 jwtVerificationFilter 추가 (Oauth)
.addFilterAfter(jwtVerificationFilter, JwtAuthenticationFilter.class);
}
}
}
JwtAuthenticationFilter
package com.codestates.stackoverflowbe.global.auth.filter;
import com.codestates.stackoverflowbe.domain.account.entity.Account;
import com.codestates.stackoverflowbe.global.auth.jwt.JwtTokenizer;
import com.codestates.stackoverflowbe.global.auth.login.dto.LoginDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
@Slf4j
// 클라이언트 로그인 인증 요청을 처리하는 엔트리포인트 클래스
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JwtTokenizer jwtTokenizer;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager, JwtTokenizer jwtTokenizer) {
this.authenticationManager = authenticationManager;
this.jwtTokenizer = jwtTokenizer;
}
/*
* Spring Security의 인증처리에서 토큰 생성 부분을 가로챈다.
* 인증 위임을 해당 메서드가 오버라이딩해서 대신 객체를 전달한다.
* */
@SneakyThrows // checked 예외를 Runtime 예외로 변경하여 try~catch문을 사용하지 않아도 되게끔 한다.
@Override
//인증을 위임하기 위한 메서드
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) {
ObjectMapper objectMapper = new ObjectMapper(); //역직렬화 위한 ObjectMapper 인스턴스
LoginDto loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);
log.info("# attemptAuthentication : loginDto.getUsername()={}, login.getPassword()={}",loginDto.getUsername(),loginDto.getPassword());
// Username과 Password 정보를 포함한 미인증 토큰 발행
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
// AuthenticationManager에 인증 시도
return authenticationManager.authenticate(authenticationToken);
}
@Override
// 인증에 성공할 경우 호출 (AccessToken, RefreshToken 생성하여 응답헤더로 반환)
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
FilterChain chain, Authentication authResult) throws IOException, ServletException {
// 인증이 성공되어 Authentication의 principal 필드에 할당된 Account 객체 얻어오기
Account account = (Account) authResult.getPrincipal();
String accessToken = delegateAccessToken(account); // AccessToken 생성
String refreshToken = delegateRefreshToken(account); // RefreshToken 생성
response.setHeader("Authorization", "Bearer" + accessToken); //응답헤더(Authorization)에 AccessToken을 추가
response.setHeader("Refresh", refreshToken);
//AuthenticationSuccessHandler의 onAuthenticationSuccess() 메서드를 호출 -> AccountAuthenticationSuccessHandler의 onAuthenticationSuccess 호출
this.getSuccessHandler().onAuthenticationSuccess(request, response, authResult);
}
private String delegateAccessToken(Account account) {
Map<String, Object> claims = new HashMap<>();
claims.put("username", account.getEmail());
claims.put("roles", account.getRoles());
String subject = account.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getAccessTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String acceesToken = jwtTokenizer.generateAccessToken(claims, subject, expiration, base64EncodedSecretKey);
return acceesToken;
}
private String delegateRefreshToken(Account account) {
String subject = account.getEmail();
Date expiration = jwtTokenizer.getTokenExpiration(jwtTokenizer.getRefreshTokenExpirationMinutes());
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
String refreshToken = jwtTokenizer.generateRefreshToken(subject, expiration, base64EncodedSecretKey);
return refreshToken;
}
}
JwtVerificationFilter
(인가 필터)package com.codestates.stackoverflowbe.global.auth.filter;
import com.codestates.stackoverflowbe.global.auth.jwt.JwtTokenizer;
import com.codestates.stackoverflowbe.global.auth.utils.CustomAuthorityUtils;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.security.SignatureException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import java.util.Map;
public class JwtVerificationFilter extends OncePerRequestFilter { // request 당 한 번 실행
private final JwtTokenizer jwtTokenizer;
private final CustomAuthorityUtils authorityUtils;
public JwtVerificationFilter(JwtTokenizer jwtTokenizer, CustomAuthorityUtils authorityUtils) {
this.jwtTokenizer = jwtTokenizer;
this.authorityUtils = authorityUtils;
}
@Override // 다음 필터 사이에 동작할 로직으로 JWT 검증 및 인증컨텍스트 저장을 수행한다.
protected void doFilterInternal(HttpServletRequest request
, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
try {
Map<String, Object> claims = verifyJws(request); // JWT 검증
setAuthenticationToContext(claims);
//jwt 검증에 실패할 경우 발생하는 예외를 HttpServletRequest의 속성(Attribute)으로 추가
} catch (SignatureException se) {
request.setAttribute("exception", se);
} catch (ExpiredJwtException ee) {
request.setAttribute("exception", ee);
} catch (Exception e) {
request.setAttribute("exception", e);
}
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
String authorization = request.getHeader("Authorization");
// authorization이 null이거나 Bearer로 시작하지 않으면 이 필터를 실행하지 않는다.(shouldNotFilter)
return authorization == null || !authorization.startsWith("Bearer");
}
// JWT 검증
private Map<String, Object> verifyJws(HttpServletRequest request) {
//request의 header에서 JWT 얻기
String jws = request.getHeader("Authorization").replace("Bearer ", "");
//서버에 저장된 비밀키 호출
String base64EncodedSecretKey = jwtTokenizer.encodeBase64SecretKey(jwtTokenizer.getSecretKey());
//Claims (JWT의 Payload, 사용자 정보인 username, roles 얻기) < - 내부적으로 서명(Signature) 검증에 성공한 상태
Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();
return claims;
}
// SecurityContextHolder에 인증 정보 저장
private void setAuthenticationToContext(Map<String, Object> claims) {
String username = (String) claims.get("username");
//authorityUtils의 메서드로 claims에 담긴 roles를 기반으로한 List<GrantedAuthority> 만들기
List<GrantedAuthority> authorities = authorityUtils.createAuthorities((List)claims.get("roles"));
// 인증 토큰을 만들어 authentication으로 어퍼 캐스팅하여 SecurityContextHolder에 저장한다.
Authentication authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
JwtAuthenticationFilter
) 이후 인가 단계(JwtVerificationFilter
)에서 문제가 발생한다. 더 정확히 말하자면, 인증 필터 (JwtAuthenticationFilter
)는 액세스 토큰과 리프레쉬 토큰을 명백하게 반환한다.
인증에 성공하지 않으면 액세스 토큰과 리프레쉬 토큰 모두 발행되지 않고 예외가 발생하므로,
응답 헤더로 토큰을 받았다는 것은 로그인 정보돠 DB 정보가 일치해 UserDetails를 바탕으로 성공적으로 Authentication 객체가 생성되어 SpringContext에 저장되었음을 의미한다.
- 갑작스럽게 의문이 든다. SpringContext에 정말 저장된 것이 맞을까?
그런데 일반 유저 계정이나 관리자 계정 모두, 'ADMIN'이 요구되는 멤버 조회(http://{{host}}/accounts
)에게 값을 요청하게 되면 UnAuthorized 예외가 발생한다. (물론 인증으로 받은 토큰을 등록하고 난 이후의 상황)
그런데 401에러라고 하니, 시큐리티 예외처리에서 내가 SecurityFilterChain filterChain(HttpSecurity httpSecurity)
에 등록해 놓은 .authenticationEntryPoint(new AccountAuthenticationEntryPoint())
가 기억났다.
@Slf4j
public class AccountAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override // 인증 실패시 (AuthenticationException 발생) commence 메서드 동작
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
Exception exception = (Exception) request.getAttribute("exception");
ErrorResponder.sendErrorResponse(response, HttpStatus.UNAUTHORIZED); // 인증 실패를 나타내는 FORBIDDEN
logExceptionMessage(authException, exception);
// response.sendRedirect("/accounts/login"); // 다시 로그인 폼으로 리다이렉션
}
private void logExceptionMessage(AuthenticationException authException, Exception exception) {
String message = exception != null ? exception.getMessage() : authException.getMessage();
log.warn("Unauthorized error happend : {}", message); //⭐
}
}
AccountAuthenticationSuccessHandler
의 동작에 따른 로그가 출력되었는데... AccountAuthenticationFailureHandler
가 동작하지 않기 때문이다. (게다가 애초에, 로그인 인증부터 실패했으면 액세스 토큰이 발급될 일이 없잖은가!)ㅡ JwtVerificationFilter
의 Map<String, Object> claims = verifyJws(request)
에 디버깅을 찍어본다. malformedjwtexception
이 발생함을 확인할 수 있다.malformedjwtexception
은 전달되는 토큰의 값이 유효하지 않을 때 발생하는 예외이다. 실제로 삽입된 jwttokenizer가 아무런 정보도 담고 있지 않은 '맹짜'임을 확인할 수 있다.malformedjwtexception
은 토큰에 들어온 토큰 값의 형식이 올바르지 않을 때 발생하는 예외라고 한다. 토큰에 만료시간과 비밀키가 없는데 동작하는 것이 더 신기할 것이다.
- 그런데 애초에 인증 필터에서 Access토큰과 Refresh토큰이 만들어질 때는 비밀키를 가지고 JWT 토큰을 정상적으로 제작하다가,
갑자기 인가 필터에서 JWT토크나이저의 의존성 주입이 제대로 되지 않는 오류가 발생하는 것을 이해하기 어렵다.
그런데 말입니다. CustomFilterConfigurer
에서 구현된 JwtAuthenticationFilter
의 DI부분에 디버깅을 찍어보니 놀라운 일이 일어났다.
이미 필터를 만드는 순간에서부터 토큰이 제대로 DI되지 않은 것이다.
(참고) 정상적인 상황 :
이처럼, 환경변수로 세팅되어 있는 변수들 역시 디버깅할 때는 출력이 되어야 하는데... 뭘까?
역시, @Lazy 때문인가?
@Lazy
를 지워보자.
주석을 해제했더니 역시나 순환참조 예외가 발생한다.
***************************
APPLICATION FAILED TO START
***************************
Description:
The dependencies of some of the beans in the application context form a cycle:
accountController defined in file [C:\Users\LG\Desktop\Pre_Project\seb45_pre_031\be\stackoverflow-be\build\classes\java\main\com\codestates\stackoverflowbe\domain\account\controller\AccountController.class]
┌─────┐
| accountService defined in file [C:\Users\LG\Desktop\Pre_Project\seb45_pre_031\be\stackoverflow-be\build\classes\java\main\com\codestates\stackoverflowbe\domain\account\service\AccountService.class]
↑ ↓
| securityConfiguration defined in file [C:\Users\LG\Desktop\Pre_Project\seb45_pre_031\be\stackoverflow-be\build\classes\java\main\com\codestates\stackoverflowbe\global\auth\config\SecurityConfiguration.class]
└─────┘
httpSecurity..exceptionHandling() .authenticationEntryPoint(new AccountAuthenticationEntryPoint()) .accessDeniedHandler(new AccountAccessDeniedHandler())
로 구현된 new AccountAuthenticationEntryPoint()
가 작동했던 것을 기억한다.인증은 문제가 아닐 것으로 생각된다.
실제로 회원가입 후 로그인 인증 요청을 시도하면 인증 성공 메서드인 successfulAuthenticate()
에 브레이킹 포인트가 걸린다. 토큰과 Principal 모두 디버깅에서 확인할 수 있었다. (사진 누락)
역시나 인가 부분에서 malformedjwtexception
예외를 확인할 수 있다.
❗❗충격적이게도 Header를 세팅해줄 때 코드가 잘못되어 있었다.
- 인가 과정에서
verifyJws(HttpServletRequest request)
는 jwt토큰임을 알려주는 접두사인 "Bearer
"를 제거하여 실제 jws를 가져오고, 해당 jws를Map<String, Object> claims = jwtTokenizer.getClaims(jws, base64EncodedSecretKey).getBody();
를 통해 파싱하는 과정을 담고 있다.
- 자, 그러나 인증 과정에서 헤더에 전해준 Jws키를 확인해보자.
여기서는 "Bearer
"에 공백없이 액세스 토큰을 이어붙여서 헤더로 전달해주게 되었다.
따라서 액세스 토큰이 이미 SecurityContextHolder에 저장된 상태이기 때문에 인증 과정은 성공적으로 완수 된 것이다.
그러나 마지막에 Bearer를 전달해 줄때 공백 없이 액세스 토큰을 바로 이어붙여서 헤더로 전달했기 때문에 추후,
인가 필터에서 Bearer로 시작하는 jwt토큰은 캐치해서JwtVerificationFilter
를 동작시키지만 jws 객체를 얻어서 파싱하는 과정에서,
(String jws = request.getHeader("Authorization").replace("Bearer ", "");
)
Bearer+(공백)
인 접두사를 제거하지 못하고 그대로 다시 전달했기 때문에
MalFormedJwtException
(손상된 형태의 토큰 예외)가 발생하여 인가 과정을 실패하게 된 것이다.
try catch문으로 catch된 예외였기 때문에 Exception이 발견되지 않았다. Exception이 아닌 논리적 에러인 줄로만 알았었는데, 디버깅을 다 각도에서 시도하다 malformedjwtexception
이 디버깅 화면에 출력됨을 확인할 수 있었다.
콘솔에 출력되지 않았다고 무작정 논리적 에러라고 볼 수 없다는 사실을 깨달았다.
핸들러와 로그, 디버깅의 중요성을 깨달았다. 인증 성공, 실패 시 발생하는 로그가 아니었다면 디버깅과 추론에 한참 애를 먹고 있었을 것이다.
@Lazy
를 붙이는 것은 위험하다. 장기적으로는 클래스 자체를 제대로 설계하는 방법을 공부해야겠다.@Lazy
를 사용하는 것이 아닌 필터 이후 클래스 생성자 객체에서 사용을 해야할 것으로 보인다.@Lazy
를 시큐리티필터에 달아서 비밀키가 null이었을 때는 대체 왜 AccessToken을 생성하게 되었을까? 이유를 모르겠다.