매 요청마다 인가된 사용자가 요청하는 것인지 확인하기 위하여, 로그인 성공 시 JWT Access Token을 발급하고 사용자 요청 시 마다 토큰을 함께 전달받아 검증하는 로직을 추가하기로 했다. Spring Boot에서 JWT Access Token의 발급 및 검증은 Spring Security를 이용해 구현할 수 있다. 근데 생각보다 많이 복잡했다.
Spring Security
- Spring 기반 애플리케이션의 인증(Authentication)과 인가(권한 부여, Authorization)를 위한 보안 프레임워크
Spring Security - 특징
- 포괄적인 보안 기능 : 인증, 권한 부여, 세션 관리, CSRF 보호 등 다양한 보안 기능 제공
- 필터 체인 아키텍처 : 들어오는 HTTP 요청은, 여러가지 보안 필터를 통과하여 처리됨. 필요에 따라서 필터를 추가하거나 제거할 수 있음
- 다양한 인증 메커니즘 : 폼 로그인, Basic 인증, OAuth2, LDAP등 다양한 인증 방식 지원
JWT(JSON Web Token)
- 당사자 간에 정보를 JSON 객체로 안전하게 전송하기 위한 방식
- 전자서명이 되어 있어 신뢰할 수 있음
- HMAC, RSA/ECDSA를 사용한 공개키 방식으로 서명할 수 있음
- 헤더(Header), 페이로드(Paylaod), 서명(Signature) 구조로 나뉨
JWT - 특징
자체 포함적 : 필요한 모든 인증 정보가 토큰 자체에 포함됨
무상태(Stateless) : 서버는 세션 상태를 유지할 필요가 없음
이식성 : HTTP 헤더, URL 매개변수, 쿠키 등을 통해 쉽게 전송 가능
JWT Access Token
- JWT 형식으로 된 액세스 토큰
- 사용자가 웹 서비스나 API에 인증된 후 발급됨
- 사용자의 신원 및 권한 정보가 포함되어 있음
- 일반적으로 보안을 위해 짧은 만료 시간(15분 ~ 1시간)을 가짐
- 일반적으로 HTTP 요청 헤더의 Authorization 필드에 "Bearer" 접두사와 함께 전송함
- Access Token을 포함한 요청을 받은 서버는 토큰을 파싱하여 어떤 사용자인지 파악하고 적절한 처리 가능
1. Build.gradle Spring Security, JWT 의존성 추가
dependencies {
// 생략..
// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// 생략..
}
가장 먼저 build.gradle 파일에 Spring Security, JWT 의존성을 추가해 준다.
2. CustomUserDetails 클래스 작성
package com.yangsunkue.suncar.security;
import com.yangsunkue.suncar.common.enums.UserRole;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Collections;
/**
* JWT에 사용될 UserDetails를 커스텀한 클래스 입니다.
*/
@Getter
@Builder
@ToString
public class CustomUserDetails implements UserDetails {
private final String userId;
private final String username;
private final String password;
private final UserRole role;
@Builder.Default
private final boolean enabled = true;
@Builder.Default
private final boolean accountNonExpired = true;
@Builder.Default
private final boolean accountNonLocked = true;
@Builder.Default
private final boolean credentialsNonExpired = true;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// enum의 name()을 사용하여 문자열로 변환 후 권한 생성
return Collections.singletonList(
new SimpleGrantedAuthority("ROLE_" + role.name())
);
}
}
먼저 CustomUserDetails를 구현해야 한다. UserDetails 인터페이스를 커스텀한 구현체이다.
UserDetails
Spring Security의 핵심 인터페이스이며, 인증에 필요한 사용자 정보를 저장한다. UserDetails 객체를 통해 JWT Access Token, Authentication 객체가 만들어진다.
필드 설명
@Builder.Default가 붙은 필드들은, UserDetails 인터페이스 구현을 위해 반드시 존재해야 하는 필드들이다. 웬만하면 필드명을 바꾸지 않는 것이 좋다. UserDetails의 상태 확인 메서드들과 연결되기 때문이다.
나머지 아무것도 붙지 않은 필드들은 아래와 같다.
username : UserDetails 인터페이스의 getUsername() 메서드와 연결되므로, 필드명 변경 시 해당 메서드와 연결이 끊어질 수 있다.
password : UserDetails 인터페이스의 getPassword() 메서드와 연결된다. 이하동문이다.
userId : 완전히 커스텀된 필드이다. 자유롭게 추가하거나 제거해도 된다.
role : 자유롭게 변경 가능하지만, 필수 구현 메서드인 getAuthorities()에서 role이 사용되므로 해당 메서드를 함께 변경해야 한다.
getAuthorities() 추가 설명
- 사용자가 가진 권한(authorities)을 반환한다.
- Spring Security는 이 메서드를 통해 사용자의 권한을 확인하며, 이를 기반으로 접근 제어를 수행한다.
getauthorities() 사용처
- Controller에 @PreAuthorize("hasRole('ADMIN')") 같은 어노테이션 사용할 때
- SecurityConfig 에서 .antMatchers("/admin/**").hasRole("ADMIN") 같은 설정을 할 때
- SecurityContextHolder를 통해 현재 인증된 사용자의 권한을 확인할 때 ( SecurityContextHolder는 인증 객체 Authentication이 저장되는 곳이다 )
3.CustomUserDetailsService 작성
package com.yangsunkue.suncar.security;
import com.yangsunkue.suncar.common.constant.ErrorMessages;
import com.yangsunkue.suncar.entity.user.User;
import com.yangsunkue.suncar.exception.NotFoundException;
import com.yangsunkue.suncar.repository.user.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* JWT에 사용될 UserDetailService를 커스텀한 클래스 입니다.
*/
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
/**
* UserId를 받아 CustomUserDetails 객체를 반환합니다.
*/
@Override
@Transactional(readOnly = true)
public CustomUserDetails loadUserByUsername(String userId) {
User user = userRepository.findByUserId(userId)
.orElseThrow(() -> new NotFoundException(ErrorMessages.USER_NOT_FOUND));
return CustomUserDetails.builder()
.userId(user.getUserId())
.username(user.getUsername())
.password(user.getPasswordHash())
.role(user.getRole())
.enabled(!user.getIsDeleted())
.build();
}
}
CustomUserDetail을 리턴하는 메서드를 가진 서비스이다.
구현된 loadUserByUsername() 메서드는 Athentication 인증 객체를 생성할 때 필요한 CustomUserDetails 객체를 만들기 위해 주로 사용되며, 필수로 Override받아 구현해야 한다. 기존 인자는 userId가 아닌 username이지만, 난 userId필드를 고유 식별자로 사용할 것이기 때문에 수정했다.
Authentication에 대해서는 아래에서 설명하겠다.
4. JwtUtil 작성
package com.yangsunkue.suncar.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
/**
* JWT 생성, 검증, 파싱을 위한 클래스 입니다.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtUtil {
private final CustomUserDetailsService customUserDetailsService;
@Value("${jwt.secret}") // 시크릿 키 가져오기
private String secret;
@Value("${jwt.expiration}") // 토큰 만료 시간 가져오기
private Long expiration;
/**
* 서명 키를 생성합니다.
*
* 사용자 정의 시크릿 키를 해시에 사용할 수 있도록 바이트화해서 리턴 -> 서명 키
*/
private Key getSigningKey() {
byte[] keyBytes = secret.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
/**
* 액세스 토큰을 받아옵니다.
*/
public String generateToken(CustomUserDetails userDetails) {
/**
* 사용자 정의 클레임
* 필요 시 토큰에 데이터를 추가할 수 있다.
* ex) claims.put("email", userDetails.getEmail());
*/
Map<String, Object> claims = new HashMap<>();
claims.put("userId", userDetails.getUserId());
claims.put("userName", userDetails.getUsername());
claims.put("role", userDetails.getRole());
claims.put("authorities", userDetails.getAuthorities());
// userId를 고유 식별자 Subject로 사용
return createToken(claims, userDetails.getUserId());
}
/**
* 실제로 액세스 토큰을 생성하는 메서드 입니다.
*/
private String createToken(Map<String, Object> claims, String subject) {
return Jwts.builder()
.setClaims(claims) // 추가 정보들 ( 사용자 정의 클레임 )
.setSubject(subject) // 토큰의 주체 ( userId)
.setIssuedAt(new Date(System.currentTimeMillis())) // 발행 시간
.setExpiration(new Date(System.currentTimeMillis() + expiration)) // 만료 시간
.signWith(getSigningKey(), SignatureAlgorithm.HS256) // 서명
.compact(); // 최종 JWT 문자열 생성
}
/**
* 액세스 토큰의 유효성을 검증합니다.
*/
public Boolean isTokenValid(String token) {
try {
extractAllClaims(token); // 토큰 파싱 -> 여기서 예외 발생가능
return !isTokenExpired(token); // 토큰 만료여부 리턴
} catch (Exception e) {
log.error("JWT 토큰 검증 실패: {}", e.getMessage());
return false;
}
}
/**
* 액세스 토큰에서 사용자 아이디(Subject)를 추출합니다.
*/
public String extractUserId(String token) {
return extractClaim(token, Claims::getSubject);
}
/**
* 액세스 토큰에서 만료 날짜(Expiration)를 추출합니다.
*/
public Date extractExpiration(String token) {
return extractClaim(token, Claims::getExpiration);
}
/**
* 액세스 토큰에서 특정 클레임을 추출하는 메서드 입니다.
*/
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
/**
* 액세스 토큰을 파싱합니다.
* 서명 검증에 실패할 경우 예외가 발생합니다.
*/
private Claims extractAllClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey())
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* 토큰이 만료되었는지 확인합니다.
*/
private Boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
}
/**
* 토큰을 받아 Authentication 객체를 생성합니다.
*/
public Authentication getAuthentication(String token) {
String userId = extractUserId(token);
CustomUserDetails userDetails = customUserDetailsService.loadUserByUsername(userId);
return new UsernamePasswordAuthenticationToken(
userDetails,
null,
userDetails.getAuthorities()
);
}
}
JwtUtil 클래스는 JWT 토큰 생성, 키 생성, 토큰 파싱, 토큰 만료 확인 등 JWT에 관련된 유틸 메서드를 구현한다.
맨 아래 있는 getAuthentication() 메서드는 토큰을 받아 Authentication 객체를 생성하게 된다.
Authentication
- 인증된 사용자 정보가 담긴 객체이다. UserDetails을 기반으로 만들어지며, UserDetails 객체를 포함하고 있다.
- 인증된 사용자 정보를 전역적으로 저장하며, 각 요청마다 별개로 존재하기 때문에 서버는 여러 사용자의 인증 정보를 적절히 저장하고 사용할 수 있다.
- Authentication 객체는 SecurityContextHolder에 저장된다.
Authentication 객체에 담긴 인증된 사용자 정보는 이렇게 사용할 수 있다.
1. Authentication 객체 가져오기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
2. Authentication에 저장된 Principal을, CustomUserDetails로 캐스팅 ( getPrincipal() 메서드는 UserDetails를 리턴한다 )
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
3. CustomUserDetails의 getter 메서드를 이용해 사용자 정보 사용
String userId = userDetails.getUserId();
UserRole role = userDetails.getRole();
추가적으로 generateToken() 메서드를 보면 claim에 사용자의 추가 정보들을 넣고 있다.
사실 UserDetails 객체를 만들 땐 어차피 db에서 회원정보를 조회해 그 데이터를 넣어 만들기 때문에, 현재 내 코드 기준으로 백엔드 입장에서는 claim에 데이터를 넣어도 지금은 큰 의미가 없다.
하지만, 프론트엔드에서 토큰을 Base64 디코딩하면 토큰 클레임에 담긴 사용자 정보를 볼 수 있다. 이렇게 하면 프론트엔드가 사용자 정보를 얻기 위해 굳이 api를 한번 더 호출하지 않아도 된다. 이를 위해서 클레임에 정보를 담아 둔 것이다.
5. JwtRequestFilter 작성
package com.yangsunkue.suncar.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
/**
* JWT 인증 필터 클래스 입니다.
* 모든 요청에 대해 JWT 토큰을 확인하고 유효한 경우 인증 정보를 설정합니다.
*/
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtRequestFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
// 요청에서 토큰 추출
String jwt = resolveToken(request);
String requestURI = request.getRequestURI();
// 토큰이 유효한 경우
if (StringUtils.hasText(jwt) && jwtUtil.isTokenValid(jwt)) {
// 유저 정보로 인증 정보 설정
Authentication authentication = jwtUtil.getAuthentication(jwt);
SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("인증 정보 저장 완료: {}, URI: {}", authentication.getName(), requestURI);
}
else {
log.debug("유효한 JWT 토큰이 없습니다. URI: {}", requestURI);
}
filterChain.doFilter(request, response);
}
/**
* Authorization 헤더에서 Bearer 토큰을 추출합니다.
*/
private String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
}
JWT 토큰을 검증하는 클래스이다.
doFilterInternal() 메서드는 토큰을 검증하고 사용자 인증 정보를 SecurityContextHolder에 저장하는 메서드이고, 아래 resolveToken은 그 과정에서 활용되는 메서드이다.
아직 설정하진 않았지만, Spring Security 필터 체인 앞단에 이 JwtRequestFilter를 둘 것이다.
그렇게 하면 서버로 오는 모든 HTTP 요청은 이 JwtRequestFilter를 거치게 되며, 토큰이 유효하다면 SecurityContextHolder에 인증 정보(Authentication 객체)를 저장한다.
토큰이 유효하지 않다면 로그를 출력 후 인증 정보를 저장하지 않는다.
그 뿐이다. 여기선 토큰이 유효하지 않더라도 특별한 예외를 발생시키지 않으며, 그저 토큰이 유효하다면 인증 정보를 저장하고/유효하지 않다면 저장하지 않고 둘 중 하나의 일만 수행한다.
마지막으로 다음 필터 체인으로 요청을 넘긴다.
그렇다면 어디에서 예외 처리를 하는 것일까?
예외 처리/접근 거부 메커니즘
- 컨트롤러 등에서, @PreAuthorize 같은 어노테이션으로 권한을 검사할 때 발생한다.
- 다른 필터 체인에서 이뤄지는 권한 검사에 의해 예외 처리된다. ex) .antMatchers("/admin/**").hasRole("ADMIN")
- 개발자가 직접 SecurityContextHolder.getContext().getAuthentication() 메서드를 호출하여 권한 관련 로직을 작성할 때 예외 처리된다.
6. SecurityConfig 설정
package com.yangsunkue.suncar.config;
import com.yangsunkue.suncar.security.JwtRequestFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security 설정 클래스입니다.
*/
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtRequestFilter jwtRequestFilter;
/**
* Security Filter Chain 설정
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(requests -> requests
// 인증 없이 접근 가능한 경로 설정
.requestMatchers("/auth/**").permitAll()
// 그 외 모든 요청은 인증 필요
.anyRequest().authenticated()
)
// CSRF 보호 비활성화 ( RESTAPI에서는 일반적으로 비활성화 )
.csrf(csrf -> csrf.disable())
// 세션 관리 정책을 STATELESS로 설정 (JWT 사용)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
.addFilterBefore(jwtRequestFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* AuthenticationManager 빈 등록
*
* 사용자 인증 처리, 사용자명/비밀번호 로그인 요청 시 이를 검증한다.
*/
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager();
}
}
이번엔 SecurityConfig를 작성한다.
우리는 stateless 특성을 가지는 JWT를 구현 중이기 때문에, HTTP 세션 관리 정책을 stateless로 설정하는 것은 필수적이다. JWT는 상태 정보를 토큰 자체에 저장하기 때문이다.
HTTP 세션 관리 정책을 STATELESS로 지정하는 의미
- 상태 비저장(Stateless) 아키텍처 : 모든 요청은 독립적으로 처리되며, 서버는 클라이언트의 상태정보를 저장하지 않음
- 세션 고정 공격 방지 : 세션을 사용하지 않으므로 세션 고정 공격(Session Fixation)에 대한 위험 없음
- 요청별 인증 : 모든 요청마다 JWT 토큰을 검증해야 함
- 스프링 시큐리티 동작 변경 : SecurityContextPersistenceFilter가, 요청 간에 SecurityContext를 저장하지 않고 세션 관련 기능(세션 타임아웃, 동시 세션 제어)이 동작하지 않음
- 쿠키 비사용 : JSESSIONID 쿠키가 생성되지 않음 ( 사용자 세션 식별을 위한 쿠키 )
그리고 SecurityFilterChain에 방금 작성한 jwtRequestFilter를 추가한다.
jwtRequestFilter가 UsernamePasswordAuthenticationFilter.class보다 앞에 위치하게 하여, HTTP 요청이 jwtRequestFilter를 가장 먼저 통과하도록 한다. 물론 Spring Security 기본 필터들이 추가로 존재하지만 일단 지금은 생각하지 말자.
또한 AuthenticationManager를 빈으로 등록한다.
AuthenticationManager는 Spring Security 인증 프로세스의 핵심 컴포넌트이며, 자격 증명(id, pw 등) 유효성 검증, 인증 객체 생성 등의 역할을 수행하게 된다.
7. Global Exception Handler 예외 처리 추가
package com.yangsunkue.suncar.exception.handler;
import com.yangsunkue.suncar.exception.BaseException;
import com.yangsunkue.suncar.exception.InvalidPasswordException;
import com.yangsunkue.suncar.exception.NotFoundException;
import com.yangsunkue.suncar.exception.UnauthorizedException;
import com.yangsunkue.suncar.exception.dto.ErrorResponseDto;
import com.yangsunkue.suncar.service.log.ErrorLogService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 전역 예외 처리 핸들러 입니다.
*
* 특정 예외에 대하여 다음과 같은 동작을 수행합니다 :
* -> 로깅
* -> 상태코드 및 에러 응답 반환
*/
@RestControllerAdvice // @ControllerAdvice + @ResponseBody -> 응답을 자동으로 JSON 변환
@RequiredArgsConstructor
@Slf4j
public class GlobalExceptionHandler {
private final ErrorLogService errorLogService;
/**
* 모든 커스텀 예외를 처리합니다.
*/
@ExceptionHandler(BaseException.class)
public ResponseEntity<ErrorResponseDto> handleBaseException(
BaseException e,
HttpServletRequest request
) {
// 생략...
}
/**
* Spring Security 관련 예외들을 처리합니다.
*/
@ExceptionHandler({
InternalAuthenticationServiceException.class,
BadCredentialsException.class,
AccessDeniedException.class,
InsufficientAuthenticationException.class
})
public ResponseEntity<ErrorResponseDto> handleSecurityExceptions(
Exception e,
HttpServletRequest request
) {
BaseException baseException;
// InternalAuthenticationServiceException 처리
if (e instanceof InternalAuthenticationServiceException) {
Throwable cause = e.getCause();
if (cause instanceof NotFoundException) {
// 내부에 NotFoundException이 있으면 그것을 사용
baseException = (NotFoundException) cause;
} else {
// 다른 내부 예외인 경우
baseException = new UnauthorizedException("인증 처리 중 오류가 발생했습니다.");
}
}
// BadCredentialsException 처리 (잘못된 비밀번호)
else if (e instanceof BadCredentialsException) {
baseException = new InvalidPasswordException();
}
// AccessDeniedException 처리 (권한 부족)
else if (e instanceof AccessDeniedException) {
baseException = new com.yangsunkue.suncar.exception.AccessDeniedException("접근 권한이 없습니다.");
}
// 그 외 인증 관련 예외
else {
baseException = new UnauthorizedException("인증이 필요합니다.");
}
// 에러 로깅
errorLogService.logError(baseException, request);
// 에러 응답 생성
ErrorResponseDto response = ErrorResponseDto.of(
baseException.getMessage(),
baseException.getStatus(),
request.getRequestURI()
);
return ResponseEntity
.status(baseException.getStatus())
.body(response);
}
/**
* 500 InternalServerError - 예상치 못한 모든 예외를 처리합니다.
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponseDto> handleUnexpectedException(
Exception e,
HttpServletRequest request
) {
// 생략..
}
}
다음으로 Global Exception Handler에 스프링 시큐리티 관련 예외 처리를 추가한다.
물론 이건 안 해도 되긴 하지만 예외 처리까지 마무리해두고 싶었다.
로그인 서비스에서 사용되는 인증 메서드에서 발생할 수 있는 아래 대표적 예외 4가지에 대한 처리를 해 두었다.
InternalAuthenticationServiceException
BadCredentialsException
AccessDeniedException
InsufficientAuthenticationException
로그인 서비스에선 이런 메서드를 사용하는데,
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(dto.getUserId(), dto.getPassword())
);
여기서 사용하는 authenticate()가 위 4가지 예외를 대표적으로 뱉는다. 해당 예외들을 전역 핸들러로 캐치해서, 직접 만든 커스텀 예외로 변환하여 클라이언트에게 응답해 준다.
8. application.properties에 JWT Access Token 시크릿 키 + 만료 시간 저장
# JWT
jwt.secret=시크릿키여기에입력
jwt.expiration=86400000
JWT 시크릿 키와 만료 시간을 application.properties에 저장해둔다.
여기서 설정한 값이 JwtUtil 클래스의 secret, expiration 필드에서 각각 사용된다.
시크릿 키(secret)는 base64로 인코딩된 64바이트 길이의 키를 쓰는 것이 권장되며 안전하다.
시크릿 키는 Base64로 인코딩 하는 것이 권장되지만, 일반 텍스트 형태여도 작동하긴 한다.
또한 우리는 SHA256 알고리즘으로 토큰에 서명할 것이므로, 시크릿 키는 최소 32바이트 이상이어야 한다. 최대 크기에 제한은 없다.
만료 시간(expiration)은 밀리초 단위인데, 86400000은 24시간(1일)을 의미한다. 액세스 토큰의 만료 시간은 짧게(15분 ~ 1시간)설정하는 것이 좋지만 일단 길게 설정 하겠다.
그리고 사실 이렇게 application.properties에 직접 시크릿 키와 만료시간을 적어두는 건 좋지 않다. 환경변수로 관리해서 노출되지 않게 해야 하는데, 일단 이렇게 구현하고 나중에 변경하겠다.
9. 요청 처리 방법
// Authentication을 메서드 인자로 넣어 사용하기
@GetMapping("/profile")
public ResponseEntity<?> getUserProfile(Authentication authentication) {
// Spring Security가 자동으로 Authentication 객체를 주입해줍니다
CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal();
// 사용자 ID로 사용자 정보 가져오기
return ResponseEntity.ok(userService.getUserProfile(userDetails.getUserId()));
}
// SecurityContextHolder를 직접 사용하여 Authentication 객체 가져오기
@GetMapping("/settings")
public ResponseEntity<?> getUserSettings() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal();
return ResponseEntity.ok(userService.getUserSettings(userDetails.getUserId()));
}
// 특정 엔드포인트에 권한 제한 걸기
@PreAuthorize("hasRole('ADMIN')")
@GetMapping("/admin/users")
public ResponseEntity<?> getAllUsers() {
return ResponseEntity.ok(userService.getAllUsers());
}
사용자로부터 JWT 토큰을 받으면, 토큰 검증 후 이런 방식들로 처리할 수 있다.
authentication 객체에서 사용자 정보를 추출해 사용하거나, 특정 엔드포인트에 권한 제한을 거는 등 여러 가지 방식으로 사용이 가능하다.
마무리
구현 순서
1. build.gradle에 Spring Security, JWT 의존성 추가
2. UserDetails를 커스텀한 CustomUserDetails 작성 - 사용자 정보를 담은 객체, 토큰 발급할 때 사용됨
3. UserDetailsService를 커스텀한 CustomUserDetailsService 작성 - CustomUserDetails 객체를 만드는 메서드 작성
4. JwtUtil 작성 - JWT 토큰 생성, 키 생성, 토큰 파싱, 만료 확인 등 JWT 관련한 유틸 메서드 작성
5. JwtRequestFilter 작성 - 토큰 검증, 인증 객체 생성 및 저장 로직 작성
6. SecurityConfig 설정 - 세션 관리 정책을 Stateless로 설정, JwtRequestFilter를 필터 앞단에 설정, HTTP 요청이 JwtRequestFilter를 거치도록 함
7. Global Exception Handler - 인증과정에서 발생할 수 있는 예외 처리
8. application.properties에 시크릿 키 + 만료 시간 저장. 시크릿 키는 Base64 인코딩된 32바이트 이상 길이여야 함.
흐름 정리
A. 토큰 발급
1. 로그인 성공 시, 인증된 사용자 정보를 담은 CustomUserDetails 객체를 생성한다.
2. CustomUserDetails 객체로 JWT 토큰을 생성하고 리턴한다 -> 이 때 토큰 클레임에 추가적인 사용자 정보를 담을 수도 있다.
B. 사용자 요청 처리(토큰이 포함된 요청)
1. JwtRequestFilter에서 토큰을 검증하고, 유효하다면 토큰에서 subject(토큰소유자)나 클레임 등에서 사용자 식별값 (username, userId 등)을 추출한다.
2. 추출한 식별값으로 DB를 조회하여 최신화된 사용자 정보를 가져오고, 이 정보로 CustomUserDetails 객체를 생성한다.
3. CustomUserDetails 객체로 Authentication 객체를 생성한다. Authentication 객체는 CustomUserDetilas 객체를 포함하며, 인증과 관련된 메서드들을 사용할 수 있다.
4. Authentication 객체를 SecurityContextHolder에 저장한다.
5. 저장된 Authentication 객체는 어디서든 불러와 조회하거나, 권한을 검증하거나 하는 등 자유롭게 사용할 수 있다.
=============
'Development > 썬카(SunCar) - 개인 프로젝트' 카테고리의 다른 글
[썬카/백엔드] dotenv를 사용한 중요 데이터 환경변수화 (0) | 2025.03.22 |
---|---|
[썬카/정기 회고] 스프린트 1 종료 (1) | 2025.03.21 |
[썬카/백엔드] Soft delete 구현과 Base Entity 작성 및 적용 (0) | 2025.03.09 |
[썬카/백엔드] QueryDSL 도입 및 설정 (0) | 2025.03.08 |
[썬카/백엔드] 예외 전역 핸들러 및 커스텀 예외 구현 (0) | 2025.03.07 |