[Spring Security] 스프링 시큐리티와 JWT로 인증 구현하기
✅Spring Security 란?
- 스프링 시큐리티는 인증 (Authentication) ,인가(Authorize) 부여 및 보호 기능을 제공하는 프레임워크다.
- 인증'과 '인가'에 대한 부분을 Filter 흐름에 따라 처리한다.
우선 Security 를 본격적으로 알아보기 전에 인증과 인가에 대해 정의해보자.
인증: 해당 사용자가 본인이 맞는지를 확인하는 절차.
인가: 인증된 사용자가 요청된 자원에 접근가능한가를 결정하는 절차
금융 예시로 비유하자면:
- 인증: 은행 창구에서 신분증을 제시해 본인이 맞는지 확인.
- 인가: 확인된 고객이 계좌 이체(특정 작업)를 할 수 있는 권한이 있는지 판단.
또한 Filter 흐름으로 처리를 한다했는데 Security는 Request가 Controller 로 가기전에 중간에서 실행된다.
✅Spring Security 동작 흐름
처음 Spring Security 를 접하면 이 그림만 보고는 이해하기가 힘들 것이다. 그래서 하나의 예시를 들며 이해하면
- 사용자 요청: 고객(사용자)이 은행 앱에 ID와 비밀번호를 입력해 로그인 요청을 보낸다. (HTTP 요청)
- 토큰 생성: 은행 직원(AuthenticationFilter)이 고객의 신분 정보를 확인하고, 이를 기반으로 임시 신분증 (UsernamePasswordAuthenticationToken)을 만든다.
- 토큰 전달: 이 임시 신분증은 은행 관리자(AuthenticationManager, 실제로는 ProviderManager)에게 넘어간다.
- 인증 시도: 관리자는 등록된 인증 담당자들(AuthenticationProvider)에게 이 신분증을 검토하라고 지시한다.
- 고객 정보 조회: 인증 담당자는 은행 데이터베이스(UserDetailsService)에 고객 정보를 요청한다. (예: 계좌 정보 확인)
- 정보 확인: 데이터베이스에서 고객의 정보를 찾아 고객 프로필(UserDetails)을 생성한다.
- 정보 비교: 인증 담당자는 임시 신분증(토큰)과 고객 프로필(UserDetails)을 비교해 일치 여부를 확인한다.
- 결과 반환: 인증이 성공하면 고객의 권한 정보가 담긴 공식 신분증(Authentication 객체)을 발급하거나, 실패 시 오류 (AuthenticationException)를 반환한다.
- 인증 완료: 발급된 공식 신분증은 최초 직원(AuthenticationFilter)에게 돌아간다.
- 정보 저장: 이 신분증은 은행 금고(SecurityContext)에 안전하게 보관되고, 고객 세션(SecurityContextHolder)에 기록된다.
💡 개인적으로 다른 분들이 작성한 Security 관련 블로그를 참고해도 될거 같아 모듈에 대한 자세한 건 해당 링크를 참조 하면 좋을거 같다.
🔒 Spring Security 구조, 흐름 그리고 역할 알아보기 🌱
스프링 시큐리티는 인증 (Authentication) ,권한(Authorize) 부여 및 보호 기능을 제공하는 프레임워크다.Java / Java EE 프레임워크개발을 하면서 보안 분야는 시간이 많이 소요되는 활동들 중 하나다. Sprin
velog.io
✅Spring Security 적용 및 사용방법
최근에 진행한 프로젝트에 Security 를 적용한 과정을 적어볼려한다.
#1 build.gradle 의존성 추가
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'
#2 SecurityConfig 생성
- FilterChain 의 역할을 하는 메소드를 직접 구현하여 Bean 으로 등록
- JWT 필터와 인증/인가 규칙을 정의
@Configuration
@EnableWebSecurity
@EnableMethodSecurity(prePostEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService userDetailsService;
@Bean
public JwtFilter jwtFilter() {
return new JwtFilter(jwtUtil, userDetailsService);
}
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // CSRF 비활성화 (REST API용)
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 세션 사용 안 함
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/**").permitAll() // 인증 없이 접근 가능
.requestMatchers("/admin/**").hasRole("ADMIN") // ADMIN 권한 필요
.anyRequest().authenticated()) // 나머지 요청은 인증 필요
.addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class); // JWT 필터 추가
return http.build();
}
}
#3 JwtUtil 생성
@Component
public class JwtUtil {
private static final String BEARER_PREFIX = "Bearer ";
private static final long TOKEN_TIME = 60 * 60 * 1000L;
@Value("${jwt.secret.key}")
private String secretKey;
private Key key;
// JWT 서명 알고리즘. HS256은 대칭키 방식
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@PostConstruct // 빈 초기화 후 실행. 비밀키를 디코딩해 키 객체 생성
public void init() {
// Base64로 인코딩된 비밀키를 디코딩
byte[] bytes = Base64.getDecoder().decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// 사용자 정보를 받아 JWT 토큰 생성
public String createToken(Long userId, String email, UserRole userRole, String nickName) {
Date date = new Date(); // 현재 시간
return BEARER_PREFIX +
Jwts.builder()
.setSubject(String.valueOf(userId)) // 토큰의 주제(subject)로 사용자 ID 설정
.claim("email", email) // 클레임에 이메일 추가
.claim("userRole", userRole) // 클레임에 사용자 역할 추가
.claim("nickName", nickName) // 클레임에 닉네임 추가
.setExpiration(new Date(date.getTime() + TOKEN_TIME))
.setIssuedAt(date) // 토큰 발급 시간 설정
.signWith(key, signatureAlgorithm) // 비밀키로 서명
.compact(); // 최종 토큰 문자열 생성
}
// 클라이언트가 보낸 토큰에서 "Bearer " 접두사를 제거
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
throw new ServerException("Not Found Token");
}
// JWT 토큰에서 클레임(사용자 정보) 추출
public Claims extractClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key) // 서명 검증용 비밀키 설정
.build()
.parseClaimsJws(token) // 토큰 파싱
.getBody(); // 클레임 반환
}
}
jwt 를 올바르게 생성했다면 아래와 같은 값이 postman 화면에 출력 될 것이다.
#4 JwtFilter 구현
@RequiredArgsConstructor
public class JwtFilter implements Filter {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService userDetailsService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String url = httpRequest.getRequestURI();
// /auth로 시작하는 URL은 인증 없이 통과
if (url.startsWith("/auth")) {
chain.doFilter(request, response);
return;
}
String bearerJwt = httpRequest.getHeader("Authorization");
if (bearerJwt == null) {
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "JWT 토큰이 필요합니다.");
return;
}
// "Bearer " 접두사를 제거해 순수 토큰 추출
String jwt = jwtUtil.substringToken(bearerJwt);
try {
Claims claims = jwtUtil.extractClaims(jwt);
if (claims == null) {
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "잘못된 JWT 토큰입니다.");
return;
}
// 클레임에서 이메일 추출
String email = claims.get("email", String.class);
// 이메일로 사용자 정보 로드
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
// 인증 객체 생성. 사용자 정보와 권한 포함
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
} catch (SecurityException | MalformedJwtException e) {
log.error("Invalid JWT signature", e);
httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않는 JWT 서명입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token", e);
httpResponse.sendError(HttpServletResponse.SC_UNAUTHORIZED, "만료된 JWT 토큰입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token", e);
httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST, "지원되지 않는 JWT 토큰입니다.");
} catch (Exception e) {
log.error("Internal server error", e);
httpResponse.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
}
Security Config 에 해당 코드를 살펴보자
.addFilterBefore(jwtFilter(), UsernamePasswordAuthenticationFilter.class);
UsernamePasswordAuthenticationFilter.class 를 실행하기전 jwtFilter() 를 실행하라는 코드이다.
위에서 jwtFilter()를 거치면 UsernamePasswordAuthenticationFilter.class 를 거치게 되는데 이게 뭘까?
♐ UsernamePasswordAuthenticationFilter 란?
- ID와 Password를 사용하는 실제 Form 기반 유저 인증을 처리하는 역할이다.
인증 객체를 만들어서 Authentication 객체를 만들어 아이디 패스워드를 저장하고, AuthenticationManager 에게 인증처리를 맡긴다.
AuthenticationManager가 실질적인 인증을 검증 단계를 총괄하는 클래스인 AuthenticationProvider에게 인증 처리를 위임한다. 그럼 AuthenticationProvider가 UserDetailsService와 같은 서비스를 사용해서 인증을 검증한다.
인증을 성공한 경우, 인증에 성공한 결과를 담은 인증객체(Authentication)를 생성한 다음 SecurityContext에 저장한다.
자 그럼 여기서 Authentication가 어떤 객체를 의미하는 건지 궁금해진다.
AuthenticaltionProvider 가 UserDetailService 를 이용해 UserDetail 를 불러온다.
#UserDetails 메소드
해당 메소드는 Security 에서 기본으로 제공하는 것이고 이것을 implement 를 받아 Custom객체를 만들수있다.
JWTfilter 에서 Security 를 이용한 코드는 해당 코드이다.
// 이메일로 사용자 정보 로드
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
// 인증 객체 생성. 사용자 정보와 권한 포함
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
첫줄을 보면 UserDetails 타입으로 userDetailsService 에서 email 을 통해 객체를 불러온다. 코드를 살펴보자.
#5 CustomUserPrincipal.class 생성
@Getter
@RequiredArgsConstructor
public class CustomUserPrincipal implements UserDetails {
private final User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_" + user.getUserRole()));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return UserDetails.super.isAccountNonExpired();
}
@Override
public boolean isAccountNonLocked() {
return UserDetails.super.isAccountNonLocked();
}
@Override
public boolean isCredentialsNonExpired() {
return UserDetails.super.isCredentialsNonExpired();
}
@Override
public boolean isEnabled() {
return UserDetails.super.isEnabled();
}
}
UserDetail 을 직접쓰기보다 도메인 모델과 인증 정보를 분리하는 게 유지보수에 더 좋기 때문에 implement 해서 UserDetail의 메소드들을 override 를 하여 User엔티티를 저장한다.
#6 userDetailsService 생성
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return userRepository.findByEmail(username)
.map(CustomUserPrincipal::new)
.orElseThrow(()->new UsernameNotFoundException("유저를 찾을수 없습니다."));
}
}
userRepository에서 user를 찾고 위에서 지정한 CustomUserPrincipal 형식으로 전환하여 반환한다.
# 최종코드 (이해한데로 과정 정리)
- 들어오는 요청을 Filter 에서 가로채 토큰 Authorization 키 값을 추출한다.
- 토큰 유효성을 검사하고 해당 데이터를 AuthenticaltionProvider 가 UserDetailService 를 이용해 UserDetail 를 불러온다.
- UserPasswordAuthenticationToken 에 객체를 저장하고 SecurityContextHolder.getContext().setAuthentication(authentication)을 통해 보안 컨텍스트에 등록한다.
- SecurityContextHolder에 인증 정보가 등록되어 있으므로, 이후의 인증 및 인가 처리에서 사용자 정보를 확인할 수 있다.
이게 정석적이고 효율적으로 인증 절차 코드를 작성한건지는 미지수이다..
잘못된 내용이나 , 올바른 방향이 있는지 더 살펴봐야겠다. 댓글도 좋아요!!!