1. JWT
JWT 개념 | Notion
1. JWT
chanmin-study-log.notion.site
1.1 application.yml, build.gradle 설정하기
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'
1.2 JwtTokenProvider - 사용자 정보로 JWT 토큰 생성하기
@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는 기본적으로 세 부분으로 구성
- 헤더(Header), 페이로드(Payload), 그리고 서명(Signature). 이 중 페이로드(Payload) 부분에 클레임(Claims)이 포함됨
💡 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());
}
1.3 JwtAuthenticationFilter 를 등록하여 자체 인증 로직 구현하기
@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을 인자로 받아 필터링 작업을 수행
- 사용자 정보 로드
- jwtTokenProvider.getUserId(token)을 통해 토큰에서 사용자 ID를 추출
- userDetailsService.loadUserByUserName(memberId)을 사용해 해당 사용자 ID에 대한 UserDetails 객체를 로드
- 사용자 인증 설정 UserDetails 객체가 유효하다면, UsernamePasswordAuthenticationToken 객체를 생성하고, Spring Security의 SecurityContextHolder에 설정하여 현재 요청을 인증된 것으로 표시
💡 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가 실행되어 추가적인 인증 절차를 처리
1.4 예외 처리 필터를 JwtAuthenticationFilter 이전에 등록하여 예외 처리하기
💡 특정 필터에서 예외가 발생하면, 앞서 거쳐간 필터에서 예외를 처리한다고 함!!
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)
1.5 JwtHandler를 등록하여 JwtFilter를 통과하지 못한 경우에 대하여 처리하기
필터를 통과하지 못한 경우는 두 가지가 존재하는데 인증에 통과하지 못한 경우와 인가(권한)에 통과하지 못한 두 가지 경우가 존재
- 인증에 실패한 경우
@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 메소드를 구현
2. 인가에 실패한 경우
@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);
});
2. UserDetails
2.1 UserDetails란?
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;
}
}
2.2 UserDetailsService 란?
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);
}
}
3. Spring Security 설정하기
Spring Security 개념 | Notion
1. Spring Security 란?
chanmin-study-log.notion.site
3.1 SecurityConfig 설정하기
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();
}
1. CSRF 설정 코드 작성
.csrf(AbstractHttpConfigurer::disable)
- CSRF(Cross-Site Request Forgery)는 웹 애플리케이션의 취약점 중 하나로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위를 하도록 만드는 공격
- 이 설정은 CSRF 보호 기능을 비활성화. 이렇게 설정하면, CSRF 토큰 없이도 요청을 처리할 수 있게 됨
2. httpBasic 설정 코드 작성
- httpBasic(): Http basic Auth 기반으로 로그인 인증창이 생김. 기본 인증 로그인을 이용하지 않을 경우 disable
3. cors 설정
CORS 개념 | Notion
1. CORS
chanmin-study-log.notion.site
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;
}
4. X-Frame-Option 설정
x-frame-option 헤더 설정(iframe) | Notion
1. iframe
chanmin-study-log.notion.site
5. authorizeHttpRequests 설정코드 작성
- HTTP 요청에 대한 인증 및 권한을 정의
- permitAll()로 설정한 경로로 들어오는 요청은 모든 사용자에게 허용하도록 설정하고, 그 외의 모든 요청은 인증된 사용자만 접근할 수 있도록 설정
6. exceptionHandling 설정 코드 작성
- 인증 및 인가가 되지 않았을 경우에 대한 처리 핸들러 등록
7. addFIlterBefore 설정코드 작성
- UsernamePasswordAuthenticationFilter 전에 jwtAuthenticationFilter가 작동하도록 필터 등록
8. sessionManagement 설정코드 작성
- 세션 관리 전략을 정의
- SessionCreationPolicy.STATELESS는 스프링 시큐리티가 세션을 생성하거나 사용하지 않도록 설정. 이는 주로 JWT와 같은 토큰 기반 인증에서 사용됨
9. formLogin 설정코드 작성
- form 기반으로 진행하는 로그인에 관한 설정을 정의. disable로 처리하여 로그인 페이지를 설정하지 않음
'Dev > SpringBoot' 카테고리의 다른 글
카카오 로그인 구현하기(4) - 커스텀 어노테이션을 통해 사용자 정보, Token 정보 가져오기 (0) | 2025.02.10 |
---|---|
카카오 로그인 구현하기(3) - Redis + 액세스 토큰 재발급하기 (0) | 2025.02.10 |
카카오 로그인 구현하기(1) - 카카오 로그인 (0) | 2025.02.10 |
SpotlessApply 및 Pre-Commit (0) | 2025.02.10 |
Redis를 이용하여 Write Back 구현하기 (0) | 2025.02.10 |