https://github.com/voidmelody/SpringSecurity-Jwt
생각보다 워낙 다양한 방법들이 있어서, 내가 원하는 구현에 있어서 어려움이 많았다.
많은 다른 이들의 포스팅과 레퍼런스 등을 참고해서 그래도 가장 만족할만한 코드를 짠 것 같습니다.
세션 기반이 아닌, Jwt 토큰을 활용한 header에 넘겨주는 방식이므로 참고 바랍니다.
기본 흐름은 다음과 같다.
- Http Request가 서버로 온다.(해당 요청은 로그인이라 가정하자.)
- AuthenticationFilter가 요청을 Filter한다. (이 포스팅에서는 JwtAuthenticationFilter라는 UsernamepasswordAuthenticationFilter를 상속한 커스텀필터)
- AuthenticationFilter에서는 온 요청에서 아이디와 비밀번호를 추출해서 usernamePasswordAuthenticationToken을 생성한다. 이후 Filter는 AuthenticationManager에게 해당 토큰을 넘겨준다.
- AuthenticationManager는 authenticate()를 호출한다.
- authenticate()는 userDetailsService에 loadUserByUsername함수를 이용해 해당 아이디(username)을 토대로 DB에서 사용자 정보를 가져온다. 그리고 해당 정보를 UserDetails객체로 반환한다.
- 정상적으로 반환이 완료되었다면, 정상적으로 Authentication 결과가 반환된다.
- 정상적으로 Authentication이 되었다면, UsernamePasswordAuthenticationFilter의 successfulAuthentication 함수를 오버라이딩해서 jwt 토큰을 발급해서 response에 셋팅해서 반환해준다.
현재 스프링 시큐리티는 필터를 기반으로 실행되고 있다.
필터는 dispatcher servlet으로 요청이 오기 전에 동작한다.
많은 필터들이 있고 이를 마치 한 겹씩 거쳐지나간다 생각하면 된다.
그럼 Jwt의 인증절차는 어떻게 진행될까?
- 사용자가 로그인을 한다.
- 서버에서는 계정 정보를 읽어 사용자를 확인 후, 사용자의 고유 ID 값을 부여한 후 기타 정보와 함께 Payload 에 집어넣는다.
- JWT 토큰의 유효기간을 설정한다.
- 암호화할 Secret key 를 이용해 Access Token 을 발급한다.
- 사용자는 Access Token 을 받아 저장 후, 인증이 필요한 요청마다 토큰을 헤더에 실어 보낸다.
- 서버에서는 해당 토큰의 Verify Signature 를 Secret key 로 복호화한 후, 조작 여부, 유효기간을 확인한다.
- 검증이 완료되었을 경우, Payload 를 디코딩 하여 사용자의 ID 에 맞는 데이터를 가져온다.
- SecurityConfig
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
public static final long accessToken_expired = 1000L * 60 * 60; // 1시간
public static final long refreshToken_expired = 2000L * 60 * 60; // 2시간
private final AuthenticationConfiguration authenticationConfiguration;
private final JwtTokenProvider jwtTokenProvider;
private final RefreshTokenRepository refreshTokenRepository;
@Bean
public AuthenticationManager authenticationManager() throws Exception
{
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception{
httpSecurity
// 문자열을 Base64로 인코딩 전달
.httpBasic().disable()
// 쿠키 기반이 아닌 JWT 기반이므로 사용 X
.csrf().disable()
.cors()
.and()
//Spring Security 세션 정책 : 세션을 생성 및 사용하지 않음
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 조건 별로 요청 허용 / 제한 설정
.authorizeHttpRequests()
// 회원 가입과 로그인은 모두 승인
.requestMatchers("/", "/register/**", "/login/**", "/refresh/**").permitAll()
// /admin 시작 요청은 ADMIN 권한이 있는 유저에게만 허용
.requestMatchers("/admin/**").hasRole("ADMIN")
// /user 시작 요청은 USER 권한이 있는 유저에게만 허용
.requestMatchers("/user/**").hasRole("USER")
.anyRequest().denyAll()
.and()
// login 주소가 호출되면 인증 및 토큰 발행 필터 추가
.addFilter(new JwtAuthenticationFilter(jwtTokenProvider, authenticationManager(), refreshTokenRepository))
// JWT 토큰검사
.addFilterBefore(new JwtAuthorizationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class)
// 에러 헨들링
.exceptionHandling()
.accessDeniedHandler(new AccessDeniedHandler() {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 권한 문제 발생 시 해당 부분 호출
response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
response.getWriter().write("권한이 없는 사용자입니다.");
}
})
.authenticationEntryPoint(new AuthenticationEntryPoint() {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 인증 문제 발생 시 해당 부분 호출
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
response.setCharacterEncoding("utf-8");
response.setContentType("text/html; charset=UTF-8");
response.getWriter().write("인증되지 않은 사용자입니다.");
}
});
return httpSecurity.build();
}
@Bean
public PasswordEncoder passwordEncoder(){
// createDelegatingPasswordEncoder()를 통해 비밀번호 앞에 Encoding 방식이 붙은 채로 저장된다.
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
filter를 두 개 추가해줬습니다.
여기서 주의깊게 봐야할건, AuthorizationFilter가 AuthenticationFilter보다 앞서서 존재한다는 것입니다.
* JwtAuthenticationFilter = 1차적으로 로그인을 처리하는 Filter. 로그인 값인 username, password를 가지고 인증 과정을 거치고 나서 토큰을 생성해 반
* JwtAuthorizationFilter = JWT의 인가를 처리하는 역할. AuthorizationFilter가 AuthenticationFilter보다 앞에 있는 이유는 SecurityContext에 Authentication을 넣어주기 때문입니다. 자세한건 AuthorizationFilter 코드보며 다시 설명드리겠습니다.
- JwtAuthenticationFilter
@Slf4j
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final JwtTokenProvider jwtTokenProvider;
private final AuthenticationManager authenticationManager;
private final RefreshTokenRepository refreshTokenRepository;
private final Long accessToken_expired = SecurityConfig.accessToken_expired;
private final Long refreshToken_expired = SecurityConfig.refreshToken_expired;
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider, AuthenticationManager authenticationManager, RefreshTokenRepository refreshTokenRepository){
this.jwtTokenProvider = jwtTokenProvider;
this.authenticationManager = authenticationManager;
this.refreshTokenRepository = refreshTokenRepository;
setFilterProcessesUrl("/login");
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try{
ObjectMapper objectMapper = new ObjectMapper();
MemberLogin member = objectMapper.readValue(request.getInputStream(), MemberLogin.class);
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(member.getUsername(), member.getPassword());
return authenticationManager.authenticate(usernamePasswordAuthenticationToken);
}catch(Exception e){
throw new RuntimeException(e);
}
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
Member member = (Member) authResult.getPrincipal();
SignResponseDto signResponseDto = SignResponseDto.builder()
.id(member.getMemberId())
.username(member.getUsername())
.name(member.getName())
.contact(member.getContact())
.email(member.getEmail())
.role(member.getRole().toString())
.token(TokenDto.builder()
.access_token(jwtTokenProvider.createToken(member.getUsername(), member.getRole().toString(), accessToken_expired))
.refresh_token(jwtTokenProvider.createToken(member.getUsername(), member.getRole().toString(), refreshToken_expired))
.build())
.build();
log.info("jwt AccessToken = {}", signResponseDto.getToken().getAccess_token());
String jsonStr = new ObjectMapper()
.writerWithDefaultPrettyPrinter()
.writeValueAsString(signResponseDto);
response.getWriter().write(jsonStr);
// 저장
RefreshToken refreshToken = refreshTokenRepository.save(
RefreshToken.builder()
.id(member.getMemberId())
.token(signResponseDto.getToken().getRefresh_token())
.expiration(refreshToken_expired)
.member(member)
.build()
);
}
}
처음 헷갈렸던 부분은, 컨트롤러에서 login 관련 매핑을 진행하고 해당 컨트롤러에서 아이디와 비밀번호 체크를 직접 로직을 작성했었습니다.
하지만 이는 UsernamePasswordAuthenticationFilter가 있기에 무의미합니다.
UsernamePasswordAuthenticationFilter를 사용할 때 주의할 점은, setFilterProcessUrl을 설정하면 된다는 것입니다.
대신에 생성자 주입으로 해주었습니다.
/login 으로 요청이 오면 해당 filter가 가져가는 것입니다.
아이디와 비밀번호가 로그인 요청으로 들어온다면, 해당 정보를 토대로 UsernamePasswordAuthenticationToken을 생성합니다.
그 후 AuthenticationManager에게 authenticate()함수를 호출하며 해당 토큰을 넘겨줍니다.
그러면 AuthenticationManager는 AuthenticationProvider들을 조회해 인증을 요구합니다.
- AuthenticationProvider
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final CustomUserDetailService userDetailService;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
// AuthenticationFilter에서 생성된 토큰으로부터 아이디와 비밀번호 조회
String username = token.getName();
String password = (String)token.getCredentials();
//UserDetailsService를 통해 DB에서 아이디로 사용자 조회
Member member = (Member)userDetailService.loadUserByUsername(username);
if(!passwordEncoder.matches(password, member.getPassword())){
throw new BadCredentialsException(member.getUsername() + "Invalid Password!!");
}
return new UsernamePasswordAuthenticationToken(member, password, member.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
AuthenticationProvider는 먼저 요청으로 왔던 아이디와 비밀번호를 빼옵니다.
그리고 아이디를 토대로 UserDetailsService의 loadUserByUsername을 통해 DB에서 유저 정보(UserDetails)를 가져옵니다.
요청으로 왔던 비밀번호 vs DB에서 가져온 유저의 비밀번호를 비교함으로써 검증합니다.
일치하다면 인증이 성공된 UsernamePasswordAuthenticationToken을 생성해서 AuthenticationManager에게 반환합니다.
- UserDetailsService
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
}
}
AuthenticationFilter는 정상적으로 Authentication이 되었다면 이후 successfulAuthentication함수를 사용할 수 있습니다.
해당 함수는 UsernamepasswordAuthenticationFilter에 들어가져있습니다.
다시 해당 부분만 가져와보겠습니다.
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
Member member = (Member) authResult.getPrincipal();
SignResponseDto signResponseDto = SignResponseDto.builder()
.id(member.getMemberId())
.username(member.getUsername())
.name(member.getName())
.contact(member.getContact())
.email(member.getEmail())
.role(member.getRole().toString())
.token(TokenDto.builder()
.access_token(jwtTokenProvider.createToken(member.getUsername(), member.getRole().toString(), accessToken_expired))
.refresh_token(jwtTokenProvider.createToken(member.getUsername(), member.getRole().toString(), refreshToken_expired))
.build())
.build();
log.info("jwt AccessToken = {}", signResponseDto.getToken().getAccess_token());
String jsonStr = new ObjectMapper()
.writerWithDefaultPrettyPrinter()
.writeValueAsString(signResponseDto);
response.getWriter().write(jsonStr);
// 저장
RefreshToken refreshToken = refreshTokenRepository.save(
RefreshToken.builder()
.id(member.getMemberId())
.token(signResponseDto.getToken().getRefresh_token())
.expiration(refreshToken_expired)
.member(member)
.build()
);
}
이제 인증이 되었으니 토큰을 생성해서 응답에 같이 보내주고 있습니다.
이렇게 Authentication 부분은 마쳤습니다.
이제 Authorization 부분을 봐봅시다.
- AuthorizationFilter
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends GenericFilterBean {
private final JwtTokenProvider jwtTokenProvider;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
if (token != null && jwtTokenProvider.validateToken(token)) {
Authentication authentication = jwtTokenProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
chain.doFilter(request, response);
}
}
우선 jwtTokenProvider가 어떤 역할을 해야하는지 봐야겟죠?
@Slf4j
@RequiredArgsConstructor
@Component
public class JwtTokenProvider {
// 경로 변경
@Value("${key.salt}")
private String salt;
private Key secretKey;
private final CustomUserDetailService userDetailsService;
@PostConstruct
protected void init(){
secretKey = Keys.hmacShaKeyFor(salt.getBytes(StandardCharsets.UTF_8));
}
// 토큰 생성
public String createToken(String username, String role, long expiration){
Claims claims = Jwts.claims().setSubject(username);
claims.put("role", role);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + expiration))
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}
// 권한 정보
// Spring Security 인증 과정에서 권한 확인을 위한 기능
public Authentication getAuthentication(String token){
UserDetails userDetails = userDetailsService.loadUserByUsername(this.getUserName(token));
return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities());
}
//토큰에 담겨 있는 유저 id 획득
// 만료된 토큰에 대해서는 ExpiredJwtException이 발생하므로 처리.
public String getUserName(String token){
try{
Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().getSubject();
}catch (ExpiredJwtException e) {
log.error("만료되었습니다.");
return e.getClaims().getSubject();
}
return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token).getBody().getSubject();
}
//Authorization Header를 통해 인증
public String resolveToken(HttpServletRequest request){
String authorization = request.getHeader("Authorization");
if(authorization == null || !authorization.substring(0, "BEARER ".length()).equalsIgnoreCase("BEARER ")){
return null;
}else{
String token = authorization.split(" ")[1].trim();
return token;
}
}
// 토큰 검증
public boolean validateToken(String token){
try{
Jws<Claims> claims = Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(token);
// 만료되었을 시 false
return !claims.getBody().getExpiration().before(new Date());
}catch(Exception e){
return false;
}
}
}
이해가 금방 가시겠지만,
먼저 요청으로 온 부분에서 헤더를 파싱해서 원하는 accessToken 부분을 가져다가
validateToken을 통해 서버에서 가지고 있던 secretKey로 풀어서 유효기간이 만료되었는지 판단합니다.
이후 getAuthentication 함수를 통해 아까 userDetailsService의 loadUserByUsername을 활용해 DB에서 UserDetails를 가져옵니다.
어차피 토큰 안에 아이디 등의 정보가 들어가져있으므로 아이디가 일치하지 않다면
토큰에서 아이디를 빼서 DB에서 조회해도 정보가 나오지 않으므로 자연스레 검증도 가능하네요.
이렇게 토큰에 대한 Authorization도 마쳤습니다.
인턴하면서 작업하게 되었는데, 한 일주일을 고생했네요 ....ㅜㅡㅜ
코드들은 깃허브에 업로드 했으니 참고바랍니다.
'스프링 정리' 카테고리의 다른 글
테스트코드에서의 @Transactional (0) | 2023.10.30 |
---|---|
Spring Batch - @PersistJobDataAfterExecution (0) | 2023.04.04 |
Spring Batch & Quartz 활용해보기 (0) | 2023.03.24 |
Spring Batch 간단하게 활용해보기(스프링 배치 5.0) (2) | 2023.03.24 |
Spring Security - authentication /authorization (0) | 2023.03.05 |