1. TokenProvider
@Log4j2
@Component // 개발자가 직접 작성한 Class를 Bean으로 등록
public class TokenProvider {
private static final String AUTHORITIES_KEY = "auth";
private static final String BEARER_TYPE = "Bearer";
private static final long ACCESS_TOKEN_EXPIRE_TIME = 1000 * 60 * 30; // 30분
private static final long REFRESH_TOKEN_EXPIRE_TIME = 100 * 60 * 60 * 24 * 7; // 7일
private final Key key;
public TokenProvider(@Value("${jwt.secret}") String secretKey) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
// 유저 정보를 가지고 AccessToken, RefreshToken을 생성함
public TokenDto generateTokenDto(Authentication authentication){
// 권한 가져오기
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
long now = (new Date()).getTime();
// Access Token 생성
Date accessTokenExpiresIn = new Date(now + ACCESS_TOKEN_EXPIRE_TIME);
String accessToken = Jwts.builder()
.setSubject(authentication.getName()) // payload "sub": "name"
.claim(AUTHORITIES_KEY, authorities) // payload "auth": "ROLE_USER"
.setExpiration(accessTokenExpiresIn) // payload "exp": 151621022 (ex)
.signWith(key, SignatureAlgorithm.HS512) // header "alg": "HS512"
.compact();
// Refresh Token 생성
String refreshToken = Jwts.builder()
.setExpiration(new Date(now + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key, SignatureAlgorithm.HS512)
.compact();
return TokenDto.builder()
.grantType(BEARER_TYPE)
.accessToken(accessToken)
.accessTokenExpiresIn(accessTokenExpiresIn.getTime())
.refreshToken(refreshToken)
.build();
}
// JWT 토큰을 복호화하여 토큰에 들어 있는 정보를 꺼냄
public Authentication getAuthentication(String accessToken){
// 토큰 복호화
Claims claims = parseClaims(accessToken);
if (claims.get(AUTHORITIES_KEY) == null) {
throw new RuntimeException("권한 정보가 없는 토큰입니다.");
}
// 클레임에서 권한 정보 가져오기
Collection < ? extends GrantedAuthority > authorities =
Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// UserDetails 객체를 만들어서 Authentication 리턴
UserDetails principal = new User(claims.getSubject(), "", authorities);
return new UsernamePasswordAuthenticationToken(principal, "", authorities);
}
// 토큰 정보를 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.info("잘못된 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.info("만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.info("지원되지 않는 JWT 토큰입니다.");
} catch (IllegalArgumentException e) {
log.info("JWT 토큰이 잘못되었습니다.");
}
return false;
}
// Token 복호화 및 예외 발생(토큰 만료, 시그니처 오류) 시 Claims 객체가 만들어지지 않음
private Claims parseClaims(String accessToken){
try{
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody();
} catch (ExpiredJwtException e){
return e.getClaims();
}
}
}
JWT 토큰에 관련된 암호화, 복호화, 검증 로직은 다 이곳에서 이루어진다.
- generateTokenDto
유저 정보를 바탕으로 Access Token과 Refresh Token을 생성한다.
Access Token에는 유저와 권한 정보를 담고 Refresh Token에는 아무 것도 담지 않음.
- getAuthentication
JWT 토큰을 복호화하여 토큰에 들어 있는 정보를 꺼낸다.
권한이 있는 지 체크 한 후, 클레임의 권한 정보를 List에 저장하고 UserDetails 객체를 만들어서 클레임의 회원 식별 id, 권한 정보를 저장한다. UsernamePasswordAuthenticationTokene 객체로 return하는데 Spring Security를 사용하기 위한 절차로 받아들이면 된다.
- validateToken
토큰 정보를 검증한다. Jwts 클래스로 비밀키를 전달하고 토큰으로 클레임을 만들 수 있다면 true 반환. 아니라면 상황에 맞는 Exception 발생시킴.
2. JwtFilter
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter { // Spring Request 앞단에 붙일 Custom Filter
public static final String AUTHORIZATION_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final TokenProvider tokenProvider;
// 실제 필터링 로직은 doFilterInternal에 들어감
// JWT 토큰의 인증 정보를 현재 쓰레드의 SecurityContext 에 저장하는 역할 수행
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 1. Request Header 에서 토큰을 꺼냄
String jwt = resolveToken(request);
// 2. validateToken 으로 토큰 유효성 검사
// 정상 토큰이면 해당 토큰으로 Authentication 을 가져와서 SecurityContext 에 저장
if (StringUtils.hasText(jwt) && tokenProvider.validateToken(jwt)){
Authentication authentication = tokenProvider.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
// Request Header 에서 토큰 정보를 꺼내오기
private String resolveToken(HttpServletRequest request){
String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)){
return bearerToken.split(" ")[1].trim();
}
return null;
}
}
- doFilterInternal
실제 필터링 로직을 수행하는 곳.
Request Header 에서 토큰을 꺼내고 유효성을 검사한 후 SecurtiyContext에 저장한다.
<대신 직접 DB 를 조회한 것이 아니라 Access Token 에 있는 Member ID 를 꺼낸 거라서, 탈퇴로 인해 Member ID 가 DB 에 없는 경우 등 예외 상황은 Service 단에서 고려해야 한다.>
- resolveToken
getHeader() 메서드로 헤더에 있는 토큰 정보를 꺼내온다. StringUtils.hasText()로 토큰 null 체크를 하고 Bearer Token이면 "Token" 문자열을 반환한다.
3. JwtAuthenticationEntryPoint
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
// 유효한 자격증명을 제공하지 않고 접근하려 할 때 401
response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
}
}
유저 정보 없이 접근하면 SC_UNAUTHORIZED (401) 에러를 반환
4. JwtAccessDeniedHandler
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
// 필요한 권한이 없이 접근하려 할 때 403
response.sendError(HttpServletResponse.SC_FORBIDDEN);
}
}
유저 정보는 있으나 자원에 접근할 수 있는 권한이 없는 경우 SC_FORBIDDEN (403) 에러를 반환
5. Spring Security
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final TokenProvider tokenProvider;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{
// CSRF 설정 Disable
http
.csrf((csrfConfig) ->
csrfConfig.disable()
)
.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedOrigins(Collections.singletonList("http://localhost:80"));
config.setAllowedMethods(Collections.singletonList("*"));
config.setAllowedHeaders(Collections.singletonList("*"));
config.setMaxAge(3600L); //1시간
return config;
}
}))
.headers(
headersConfigurer ->
headersConfigurer
.frameOptions(
HeadersConfigurer.FrameOptionsConfig::sameOrigin
)
)
// 시큐리티는 기본적으로 세션을 사용
// 여기서는 세션을 사용하지 않기 때문에 세션 설정을 Stateless로 설정
.authorizeHttpRequests(authorizeRequest ->
authorizeRequest
.requestMatchers(
AntPathRequestMatcher.antMatcher("/auth/**")
).permitAll()
.anyRequest().authenticated() // 나머지 API는 전부 인증 필요
)
// exception handling 할 때 만든 클래스를 추가
.exceptionHandling((exceptionConfig) ->
exceptionConfig.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
/*.sessionManagement(m -> m.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)*/
// JwtFilter 를 addFilterBefore 로 등록했던 Jwt SecuriityConfig 클래스를 적용
.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
springboot 와 security의 버전이 각각 3.x 와 6.x 로 변경되어 일부 코드들은 그에 맞게 수정했다.
특히 람다식을 사용하여 구현해야 했는데, 아직 구조를 명확하게 알지 못해 검색을 통해 나온 정보들로 꾸려봤다...
1. crsf 토큰 비활성화
.csrf((csrfConfig) ->
csrfConfig.disable()
)
2. Cors 활성화
.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true);
config.setAllowedOrigins(Collections.singletonList("http://localhost:80"));
config.setAllowedMethods(Collections.singletonList("*"));
config.setAllowedHeaders(Collections.singletonList("*"));
config.setMaxAge(3600L); //1시간
return config;
}
}))
백엔드와 프론트엔드의 도메인 주소가 다른 상태로 작업할 경우 연동 시 에러가 발생한다고 한다. 현재 시큐리티를 사용 중이기 때문에 Cors를 활성화하는 코드를 추가해 줬다.
3. h2-console을 위한 설정 추가
.headers(
headersConfigurer ->
headersConfigurer
.frameOptions(
HeadersConfigurer.FrameOptionsConfig::sameOrigin
)
)
4. 세션 stateless로 설정
.sessionManagement(m -> m.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
5. 로그인, 회원가입 permitAll 설정
.authorizeHttpRequests(authorizeRequest ->
authorizeRequest
.requestMatchers(
AntPathRequestMatcher.antMatcher("/auth/**")
).permitAll()
.anyRequest().authenticated() // 나머지 API는 전부 인증 필요
)
6. 직접 만든 클래스로 exception handling
// exception handling 할 때 만든 클래스를 추가
.exceptionHandling((exceptionConfig) ->
exceptionConfig.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
7. JwtSecurityConfig 클래스 적용
// JwtFilter 를 addFilterBefore 로 등록했던 Jwt SecuriityConfig 클래스를 적용
.addFilterBefore(new JwtFilter(tokenProvider), UsernamePasswordAuthenticationFilter.class);
참고 : https://bcp0109.tistory.com/301
'springboot 개인프로젝트 기록' 카테고리의 다른 글
07. Spring Security + JWT를 이용한 로그인, 회원가입 구현 (4) (1) | 2024.03.31 |
---|---|
05. Spring Security + JWT를 이용한 로그인, 회원가입 구현 (2) (1) | 2024.03.29 |
04. Spring Security + JWT를 이용한 로그인 구현 (1) (0) | 2024.03.28 |
03. 부트스트랩 템플릿 적용 (0) | 2024.03.26 |
02. 1차 ERD 구성 및 구현할 기능들 (0) | 2024.03.22 |