jwt:
secret: ${JWT_SECRET_KEY}
access-token-validity: ${JWT_ACCESS_TOKEN_TIME}
refresh-token-validity: ${JWT_REFRESH_TOKEN_TIME}
secret key와, 액세스 토큰 만료시간, 리프레시 토큰 만료 시간을 yml에 지정
secret key 값은 중요한 정보이니만큼 github로 관리하지 않고, 별도로 다른 파일에 설정해주는 것이 좋음
# jwt build.gradle
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
# Spring Security build.gradle
testImplementation 'org.springframework.security:spring-security-test'
implementation 'org.springframework.boot:spring-boot-starter-security'
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-validity}")
private long accessTokenValidityMilliSeconds;
@Value("${jwt.refresh-token-validity}")
private long refreshTokenValidityMilliSeconds;
private Key key;
@PostConstruct
public void initKey() {
secretKey = Base64.getEncoder().encodeToString(secretKey.getBytes(StandardCharsets.UTF_8));
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
}
}
JWT 생성에 사용될 Key는 initKey() 메소드로 secretKey를 decode하여 Key에 주입
# 토큰 생성 메서드
public String generateAccessToken(Long memberId) {
return generateToken(memberId, accessTokenValidityMilliSeconds);
}
public String generateRefreshToken(Long memberId) {
return generateToken(memberId, refreshTokenValidityMilliSeconds);
}
public String generateToken(Long memberId, long validity) {
long now = (new Date()).getTime();
Date validityDate = new Date(now + validity);
Claims claims = Jwts.claims();
claims.put("id", memberId);
return Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date())
.setExpiration(validityDate)
.signWith(key)
.compact();
}
JWT (JSON Web Token)에서 Claims는 토큰에 포함된 정보(클레임)들을 나타냄
JWT는 기본적으로 세 부분으로 구성
💡
Claims는 JWT 내에 포함된 정보의 집합으로, 보통 사용자나 시스템 간에 정보를 안전하게 전달하기 위해 사용됩니다. 클레임은 토큰에 포함될 수 있는 정보를 key-value 형태로 표현한 것. -chatGPT-
# 토큰 유효성 검사 메소드
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (UnsupportedJwtException
| MalformedJwtException
| IllegalArgumentException
| SignatureException e) {
throw new GlobalException(GlobalErrorCode.AUTH_INVALID_TOKEN);
} catch (ExpiredJwtException e) {
throw new GlobalException(GlobalErrorCode.AUTH_EXPIRED_TOKEN);
}
}
# 토큰 에러 코드
AUTH_EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH401", "만료된 토큰입니다."),
AUTH_INVALID_TOKEN(HttpStatus.NOT_FOUND, "AUTH402", "유효하지 않은 코드입니다..");
# 요청으로부터 헤더를 추출하여 인증 토큰을 추출하는 메소드
public String resolveBearerToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
# token을 통해 memberId 추출 메소드
public Long getmemberId(String token) {
return Long.parseLong(Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody().get("id").toString());
}
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final MemberDetailsService memberDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtTokenProvider.resolveBearerToken(request);
if (token != null) {
if (jwtTokenProvider.validateToken(token)) {
Long memberId = jwtTokenProvider.getmemberId(token);
UserDetails userDetails =
memberDetailsService.loadUserByUsername(memberId.toString());
if (userDetails != null) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(
userDetails, "", userDetails.getAuthorities());
SecurityContextHolder.getContext()
.setAuthentication(usernamePasswordAuthenticationToken);
} else {
throw new GlobalException(GlobalErrorCode.MEMBER_NOT_FOUND);
}
} else {
throw new GlobalException(GlobalErrorCode.AUTH_INVALID_TOKEN);
}
}
filterChain.doFilter(request, response); // 다음 필터로 넘어가기
}
}
Spring Security에서 JWT 기반 인증을 처리하기 위해 작성된 커스텀 필터
JwtAuthenticationFilter 클래스는 OncePerRequestFilter를 상속받아 매 요청마다 한 번씩 실행
요청에 포함된 JWT 토큰을 검증하고, 검증에 성공하면 해당 사용자를 인증된 사용자로 설정
doFilterInternal 메서드 HttpServletRequest, HttpServletResponse, FilterChain을 인자로 받아 필터링 작업을 수행💡 UsernamePasswordAuthenticationToken
UsernamePasswordAuthenticationToken은 Spring Security에서 사용자의 인증 정보를 나타내는 클래스입니다. 이 클래스는Authentication인터페이스를 구현하며, 주로 사용자가 제출한 인증 정보(예: 사용자 이름과 비밀번호)를 나타내거나, 인증이 완료된 후 인증된 사용자를 나타내기 위해 사용
💡 SecurityContextHolder
SecurityContextHolder는 Spring Security에서 현재 애플리케이션의 보안 컨텍스트(Security Context)를 관리하는 중요한 클래스로, 주로 인증 정보와 관련된 데이터를 저장하고 접근하는 데 사용됩니다.
💡 Security Context
보안 컨텍스트는 애플리케이션에서 현재 사용자의 인증 및 권한 부여 상태를 저장하는 객체입니다. 이 객체는 일반적으로SecurityContext인터페이스를 구현한SecurityContextImpl클래스의 인스턴스로 표현됩니다.
추후 SecurityConfig 생성 후 위 필터 등록 필요
http.addFilterBefore(JwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
JwtAuthenticationFilter를 UsernamePasswordAuthenticationFilter 앞에 추가jwtFilter가 실행되어 JWT 토큰을 검사하고 인증 정보를 설정UsernamePasswordAuthenticationFilter가 실행되어 추가적인 인증 절차를 처리💡 특정 필터에서 예외가 발생하면, 앞서 거쳐간 필터에서 예외를 처리한다고 함!!
JwtAuthenticationFilter에서 발생한 예외를 처리하기 위해 필터를 하나 더 등록
@Component
public class ExceptionHandleFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (GlobalException e) {
response.setContentType("application/json; charset=UTF-8");
response.setStatus(HttpStatus.UNAUTHORIZED.value());
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(response.getOutputStream(), (new ErrorResponse(e.getGlobalErrorCode())));
}
}
}
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(exceptionHandleFilter, JwtAuthenticationFilter.class)
필터를 통과하지 못한 경우는 두 가지가 존재하는데 인증에 통과하지 못한 경우와 인가(권한)에 통과하지 못한 두 가지 경우가 존재
@Slf4j
@Component
public class JwtAuthenticationFailEntryPoint implements AuthenticationEntryPoint {
private final HandlerExceptionResolver resolver;
public JwtAuthenticationFailEntryPoint(
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException {
resolver.resolveException(
request, response, null, new GlobalException(GlobalErrorCode._UNAUTHORIZED));
}
}
인증에 대한 실패 처리 handler는 AuthenticationEntryPoint 인터페이스의 commence 메소드를 구현
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
private final HandlerExceptionResolver resolver;
public JwtAccessDeniedHandler(
@Qualifier("handlerExceptionResolver") HandlerExceptionResolver resolver) {
this.resolver = resolver;
}
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException accessDeniedException)
throws IOException {
resolver.resolveException(
request, response, null, new GlobalException(GlobalErrorCode._FORBIDDEN));
}
}
인가에 대한 실패 처리 handler는 AccessDeniedHandler 인터페이스의 handler 메소드를 구현
@ControllerAdvice는 Handler(Controller)단에서 발생하는 Exception을 처리하는 것이 때문에 컨트롤러 밖으로 던저진 예외 처리가 불가능 → HandlerExceptionResolver 사용
1. 예외 발생 컨텍스트
- 스프링 애플리케이션에서 예외가 발생하는 시점과 위치에 따라 예외 처리의 흐름이 달라집니다.
- 일반적으로, 컨트롤러나 서비스 계층에서 예외가 발생하면, 스프링은 이 예외를
@ControllerAdvice로 전달하여 처리합니다.- 그러나
AccessDeniedHandler와 같은 시큐리티 관련 핸들러에서 발생하는 예외는 이미 시큐리티 필터 체인 내에서 발생한 것입니다. 이 예외는 컨트롤러에 도달하지 않기 때문에, 스프링 MVC의 예외 처리 흐름과는 별개로 처리됩니다.2.
HandlerExceptionResolver의 역할
HandlerExceptionResolver는 예외를 처리하기 위해, 예외가 발생한 곳에서 해당 예외를 적절한 방식으로 해석하여 스프링 MVC가 관리하는 예외 처리 메커니즘으로 넘깁니다.- 예외가
HandlerExceptionResolver를 통해 전달되지 않으면, 해당 예외는 시큐리티 컨텍스트 내에서만 처리되고 스프링 MVC의 예외 처리 범위 밖에 있게 됩니다. 이 경우,@ControllerAdvice에 정의된 예외 처리 메서드들이 이 예외를 감지하거나 처리할 수 없게 됩니다.출처 : -chatGPT-
추후 SecurityConfig 생성 후 위 핸들러 등록 필요
.exceptionHandling(exceptionHandling -> {
exceptionHandling.authenticationEntryPoint(jwtAuthenticationFailEntryPoint);
exceptionHandling.accessDeniedHandler(jwtAccessDeniedHandler);
});
Spring Security에서 사용자의 정보를 담는 인터페이스
Spring Security에서 사용자의 정보를 불러오기 위해서 구현해야 하는 인터페이스로 기본 오버라이드 메서드들은 아래와 같음
| 메소드 | 리턴 타입 | 설명 | 기본값 |
|---|---|---|---|
| getAuthorities() | Collection<? extends GrantedAuthority> | 계정의 권한 목록을 리턴 | |
| getPassword() | String | 계정의 비밀번호를 리턴 | |
| getUsername() | String | 계정의 고유한 값을 리턴 ( ex : DB PK값, 중복이 없는 이메일 값 ) | |
| isAccountNonExpired() | boolean | 계정의 만료 여부 리턴 | true ( 만료 안됨 ) |
| isAccountNonLocked() | boolean | 계정의 잠김 여부 리턴 | true ( 잠기지 않음 ) |
| isCredentialsNonExpired() | boolean | 비밀번호 만료 여부 리턴 | true ( 만료 안됨 ) |
| isEnabled() | boolean | 계정의 활성화 여부 리턴 | true ( 활성화 됨 ) |
대부분의 경우 Spring Security의 기본 UserDetails로는 실무에서 필요한 정보를 모두 담을 수 없기에 CustomUserDetails를 구현하여 사용
@RequiredArgsConstructor
public class MemberDetails implements UserDetails {
private final Member member;
@Override
public String getPassword() {
return null;
}
@Override
public String getUsername() {
return member.getId().toString();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
List<String> roles = new ArrayList<>();
roles.add("ROLE_MEMBER");
return roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList());
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
Spring Security에서 유저의 정보를 가져오는 인터페이스
Spring Security에서 유저의 정보를 불러오기 위해서 구현해야하는 인터페이스로 기본 오버라이드 메서드는 아래와 같음
| 메소드 | 리턴 타입 | 설명 |
|---|---|---|
| loadUserByUsername | UserDetails | 유저의 정보를 불러와서 UserDetails로 리턴 |
@Service
@RequiredArgsConstructor
public class MemberDetailsService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String memberId) throws UsernameNotFoundException {
Member member =
memberRepository
.findById(Long.parseLong(memberId))
.orElseThrow(
() -> new GlobalException(GlobalErrorCode.MEMBER_NOT_FOUND));
return new MemberDetails(member);
}
}
SecurityConfig : Spring Security 환경 설정을 구성하기 위한 클래스
Spring Security 5.7 이후부터 @Bean으로 SecurityFilterChain을 구현해서 시큐리티를 적용시키는 방법을 권장하기 때문에 필터 체인 구성을 extends로 하는 이전방식을 사용하지 않고 빈 등록 방식으로 코드를 작성
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations());
}
정적 자원에 대해 보안을 적용하지 않도록 설정
정적 자원은 보통 HTML, CSS, JavaScript, 이미지 파일 등을 의미하며, 이들에 대해 보안을 적용하지 않음으로써 성능을 향상시키도록 함
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
log.debug("WebSecurityConfig Start !!! ");
return http.csrf(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin))
.authorizeHttpRequests(
authorize ->
authorize
.requestMatchers(
"/api/auth/kakao/login",
"/api/auth/kakao/refresh",
"/swagger-ui/**",
"/swagger-resources/**",
"/v3/api-docs/**")
.permitAll()
.anyRequest()
.authenticated())
.exceptionHandling(
configurer ->
configurer
.accessDeniedHandler(jwtAccessDeniedHandler)
.authenticationEntryPoint(jwtAuthenticationFailEntryPoint))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(exceptionHandleFilter, JwtAuthenticationFilter.class)
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(AbstractHttpConfigurer::disable)
.build();
}
CSRF 설정 코드 작성
.csrf(AbstractHttpConfigurer::disable)
CSRF(Cross-Site Request Forgery)는 웹 애플리케이션의 취약점 중 하나로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 하도록 만드는 공격
이 설정은 CSRF 보호 기능을 비활성화. 이렇게 설정하면, CSRF 토큰 없이도 요청을 처리할 수 있게 됨
httpBasic 설정 코드 작성
httpBasic(): Http basic Auth 기반으로 로그인 인증창이 생김. 기본 인증 로그인을 이용하지 않을 경우 disable
cors 설정
CORS(Cross-Origin Resource Sharing)는 다른 도메인의 리소스에 웹 페이지가 접근할 수 있도록 브라우저에게 권한을 부여하는 메커니즘
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
특정 CORS 구성 소스(corsConfigurationSource())를 사용하여 CORS 설정을 적용할 필요가 있음
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE"));
configuration.setAllowedHeaders(Arrays.asList("X-Requested-With", "Content-Type", "Authorization", "X-XSRF-token"));
configuration.setAllowCredentials(false);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
X-Frame-Option 설정
authorizeHttpRequests 설정코드 작성
HTTP 요청에 대한 인증 및 권한을 정의
permitAll()로 설정한 경로로 들어오는 요청은 모든 사용자에게 허용하도록 설정하고, 그 외의 모든 요청은 인증된 사용자만 접근할 수 있도록 설정
exceptionHandling 설정 코드 작성
인증 및 인가가 되지 않았을 경우에 대한 처리 핸들러 등록
addFIlterBefore 설정코드 작성
UsernamePasswordAuthenticationFilter 전에 jwtAuthenticationFilter가 작동하도록 필터 등록
sessionManagement 설정코드 작성
세션 관리 전략을 정의
SessionCreationPolicy.STATELESS는 스프링 시큐리티가 세션을 생성하거나 사용하지 않도록 설정. 이는 주로 JWT와 같은 토큰 기반 인증에서 사용됨
formLogin 설정코드 작성
form 기반으로 진행하는 로그인에 관한 설정을 정의. disable로 처리하여 로그인 페이지를 설정하지 않음