Spring Security는 Java 애플리케이션에서 인증/권한 부여 서비스를 제공하는 프레임워크이다.
이를 활용해서 소셜 로그인(카카오, 애플)을 구현해보았다.
이 글에서는 우선 Spring Security로 JWT를 구현하는 방법에 대한 글을 써보려고 한다.
일단 Spring Security는 그리 쉽지 않다.. 구현도 쉽지 않고, 찾아보니 이 쪽 영역을 제대로 공부하기 위해서 사람들이 시간을 잡고 공부하는 것 같았다.
그래서 공부방향을 코드에 대한 대략적인 JWT 작동 방식을 이해하고, 내가 나중에 확실히 또 써먹을 수 있도록 공부해보는 것으로 잡았다.
아래를 봐도 아주 복잡한 filter의 향연을 볼 수 있다 ..
인증 과정?
Spring Security에서 JWT 인증 과정은 다음과 같다.
doFilterInternal
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
// 1. Request Header 에서 토큰을 꺼냄
String jwt = resolveToken(request);
log.debug(String.valueOf(request));
// 2. validateToken 으로 토큰 유효성 검사
// 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
if (StringUtils.hasText(jwt) && tokenService.verifyToken(jwt)) {
System.out.println("Normal jwt");
Authentication authentication = tokenService.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
1. 클라이언트에서 요청이 오면, 해당 요청의 header에서 "Authorization" 헤더를 가져온다.
2. "Authorization" 헤더에서 JWT 토큰을 가져온다.
3. JWT 토큰 유효성을 검증한다. (verify)
4. 유효하다면? -> Authentication 객체 생성
(Authentication 객체란 Spring Security에서 한 유저의 인증 정보를 가지고 있는 객체이다. 이 정도로 이해하자..)
5. 해당 Authentication객체를 SecurityContextHolder에 담는다. (이때, 권한 관리를 위해 세션을 저장한다.)(SecurityContextHolder.getContext().setAuthentication(authentication) 메서드)
6. 마지막으로, doFilter 메서드로 체인을 따라 다음에 존재하는 필터로 이동한다.
(doFilter는 해당 필터에서 처리하고 다시 다음 필터로 넘겨주는 메소드이다.)
아래는 전체 코드입니다.
세팅
@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter{
private final TokenService tokenService;
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/v2/api-docs/**", "/configuration/**", "/swagger-resources/**",
"/swagger-ui/**", "/oauth/**","/webjars/**","/h2-console/**", "/members/**","/script/**","/oauth/**");
}
@Override
protected void configure(HttpSecurity http) throws Exception {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(tokenService);
http.httpBasic().disable()
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers(HttpMethod.OPTIONS).permitAll()
.antMatchers(
"/api/v1/auth/**",
"/oauth/**",
"/script/**",
"/forum/**",
"/interview/**",
"/**",
"/members/**",
"/api/v1/version",
"/auth/**",
"/token/**").permitAll()
.anyRequest().authenticated()
.and()
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
}
주로 antMatchers 에 포함되는 url은 아래와 같이 사용된다. 해당 권한이 있어야지만 해당 api에 접근이 가능하다.
내가 구현하는 곳은 딱히 Role을 정해두지 않았고, 로그인이 안되면 다른 곳에 접근이 애초에 불가능하여 일단 전부 열어뒀다.
.antMatchers("/api/v1/user/**")
.hasAnyRole("USER", "MANAGER", "ADMIN")
.antMatchers("/api/v1/manager/**")
.hasAnyRole("MANAGER", "ADMIN")
JwtAuthenticationFilter 코드
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenService tokenService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
// 1. Request Header 에서 토큰을 꺼냄
String jwt = resolveToken(request);
log.debug(String.valueOf(request));
// 2. validateToken 으로 토큰 유효성 검사
// 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
if (StringUtils.hasText(jwt) && tokenService.verifyToken(jwt)) {
System.out.println("Normal jwt");
Authentication authentication = tokenService.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(7);
}
log.debug("토큰이 존재하지 않습니다.");
return null;
}
}
TokenService 코드
@Service
@RequiredArgsConstructor
public class TokenService {
private String secretKey = "~~~~~~~";
private static final String AUTHORITIES_KEY = "role";
private final UserDetailsService userDetailsService;
@PostConstruct
protected void init() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes());
}
public Token generateToken(String uid, String role) {
long tokenPeriod = 1000L * 60L * 90L;
long refreshPeriod = 1000L * 60L * 60L * 24L * 30L * 3L;
Claims claims = Jwts.claims().setSubject(uid);
claims.put("role", role);
Date now = new Date();
return new Token(
Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenPeriod))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact(),
Jwts.builder()
.setExpiration(new Date(now.getTime() + refreshPeriod))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact());
}
public String generateAccessToken(String uid, String role) {
long tokenPeriod = 1000L * 60L * 10L;
Claims claims = Jwts.claims().setSubject(uid);
claims.put("role", role);
Date now = new Date();
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + tokenPeriod))
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();
}
public long verifyRefreshToken(String token){
try{
Jws<Claims> claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token);
return claims.getBody()
.getExpiration().getTime();
} catch (Exception e) {
return 0;
}
}
public boolean verifyToken(String token) {
try {
Jws<Claims> claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token);
return claims.getBody()
.getExpiration()
.after(new Date());
} catch (Exception e) {
return false;
}
}
public Authentication getAuthentication(String accessToken) {
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
final String socialId = claims.getSubject();
final CurrentUserDetails currentUserDetails = (CurrentUserDetails) userDetailsService.loadUserByUsername(socialId);
return new UsernamePasswordAuthenticationToken(currentUserDetails, "", currentUserDetails.getAuthorities());
}
private Claims parseClaims(String accessToken) {
try {
return Jwts.parserBuilder().setSigningKey(secretKey).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e) {
return e.getClaims();
}
}
}
참조
https://iseunghan.tistory.com/category/%F0%9F%92%90%20Spring/Spring%20Security
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-jwt
'BackEnd > JAVA\SPRING' 카테고리의 다른 글
[OPEN SOURCE] FOSSLIGHT 오픈소스 기여 (0) | 2023.08.21 |
---|---|
[SPRING+JAVA] Whisper API와 ChatGPT API 연동 (2) | 2023.06.29 |
[SPRING] Swagger, 프론트와의 소통을 편하게 하는 자동 툴 (2) | 2023.02.11 |
[SPRING] Spring으로 JWT 구현하기1 - jwt-java 라이브러리 (2) | 2023.02.09 |
[SPRING] SPRING 기초, SPRINGBOOT 프로젝트 폴더 구조 (0) | 2023.01.15 |