Servlet Authentication 구조
JWT를 이용한 사용자 인증을 어떻게 코드로 구현하는지에 앞서, Spring Security에서 Servlet Authentication이 어떻게 돌아가는지 핵심적으로 알아보자.
먼저 Spring Security Authentication 구조를 이루고 있는 요소들은 다음과 같다.
- SecurityContextHolder - SecurityContextHolder는 인증된 사용자에 대한 세부 정보를 저장하는 곳이다.
- SecurityContext - SecurityContextHolder에서 얻을 수 있으며 사용자의 인증 상태와 세부 정보를 확인할 수 있다.
- Authentication - SecurityContext에 있는 Authentication은 인증된 유저에 대한 정보를 가지고 있다. 새로운 유저에 대한 인증을 위해서는 AuthenticationManager에 Authentication을 전달하여 인증을 수행한다. Authentication은 다음을 정보들을 포함한다.
- principal: user를 식별하는데 쓰인다. 보통 UserDetails에서 얻는 username/password로 인증한다.
- credentials: 보통 password를 의미한다. 또한 인증이 끝나면 보안을 위해 clear 된다.
- authorities: GratedAuthority 객체를 통해 유저에게 부여된 권한을 뜻한다.
- GrantedAuthority- Authentication 객체에 부여되는 권한 (역할, 범위 등)
- AuthenticationManager - Spring Security 필터가 인증을 수행하는 방식을 정의하는 API
- ProviderManager - AuthenticationManager의 구현체
- AuthenticationProvider - ProviderManager가 특정 방식의 인증을 수행하는데 사용된다.
- Request Credentials with AuthenticationEntryPoint - 클라이언트에서 자격 증명을 요청하는 데 사용된다.
- AbstractAuthenticationProcessingFilter - 인증에 사용되는 기본 필터
이런식으로 SecurityContextHolder 내부에 SecurityContext가 존재한다. SecurityContext는 유저의 인증 상태와 세부 정보를 나타내는 Authentication을 가지고 있으며 principal, credentials, authorities 정보를 가진다.
사용자의 인증은 다음과 같은 흐름으로 진행된다.
- 사용자가 어플리케이션에 접근하면 Spring Security의 SecurityFilterChain에 설정되어있는 인증 절차에 들어가게 된다.
- Authentication관련 Filter에서 사용자가 입력한 자격 증명, 즉 아이디/패스워드를 수집한다.
- 해당 정보를 가지고 Authentication 객체를 생성하여 AuthenticationManager에게 넘긴다.
- AuthenticationManager는 Authentication의 인증을 시도한다. 이 때 manager에 포함되어있는 AuthenticationProvider가 실제 인증 로직을 수행한다.
- 인증이 성공하면 Authentication 객체의 정보가 채워져서 반환된다. 해당 객체는 SecurityContextHolder를 통해 SecurityContext에 저장된다.
- 인증이 실패하면 exception이 반환되어 오류 처리에 대한 response를 반환한다.
큰 핵심 과정은 위와 같으며, 이제 어떻게 코드로 해당 과정들을 구현하는지 알아보자.
JWT 로그인 시스템 구현
위 흐름에서 실제 인증 로직은 AuthenticationProvider가 수행한다고 했다.
그럼 여기서 짧게 인증 로직이 어떻게 돌아가는지 알아보자.
- filter에서 request로 들어온 username/password를 가지고 UsernamePasswordAuthenticationToken를 생성해서 AuthenticationManager에게 인증을 요청한다. 실제로는 ProviderManager가 인증 로직을 수행한다.
- ProviderManager는 DaoAuthenticationProvider 타입인 AuthenticationProvider를 사용하여 인증한다.
- DaoAuthenticationProvider에서는 provider에 등록된 UserDetailsService에서 UserDetails란 객체를 가져온다.
- 가져온 UserDetails 객체에 대해 PasswordEncoder를 이용하여 입력된 password와 UserDetails의 password가 일치하는지 검증한다.
- 인증이 성공하면 UsernamePasswordAuthenticationToken을 반환하는데, 여기에 UserDetails와 Authorities 정보가 담겨져있다. 해당 토큰이 최종적으로 authentication filter에서 SecurityContextHolder에 등록되는 것이다.
이 인증 흐름을 보고 우리가 구현할 것은 다음과 같다.
1. UserDetails, UserDetailsService를 우리가 정의한 프로젝트의 entity를 사용하도록 custom class 구현하기.
2. 전체 인증 흐름을 제어해줄 authentication fitler 구현하기.
3. 우리는 로그인을 JWT 토큰을 이용해 구현할거니까 JWT 토큰 관련 utility 함수.
커스텀 UserDetails, UserDetailsService 구현하기
@Data
public class sbUserDetails implements UserDetails {
private final User user;
private final List<UserClanDto> userClanDtos;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getNickname();
}
}
UserDetails 인터페이스를 상속받아 custom UserDetails를 구현한다. 구현은 마치 Entity를 정의하는 것처럼 생각하면 이해가 쉽다. Entity의 attribute를 정의하듯이 JWT 토큰에 나타내고 싶은 정보들을 UserDetails 객체에 넣어둔다고 생각하면 된다. 여기서 getAuthorities()는 해당 user가 가질 수 있는 권한 정보를 반환한다. 여기선 간단하게 "ROLE_USER"라는 권한을 반환한다.
이는 spring security의 requestMatchers()에서 role 정보로 필터링하는데 사용된다.
.authorizeHttpRequests(
auth -> auth.requestMatchers("/api/users/register", "/api/users/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/clans/create").hasRole("USER")
.anyRequest().authenticated()
)
@Service
@RequiredArgsConstructor
public class sbUserDetailService implements UserDetailsService {
private final UserRepository userRepository;
private final UserClanRepository userClanRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
System.out.println("유저 디테일 서비스 호출");
User user = userRepository.findByLoginId(username).orElseThrow(() -> new UsernameNotFoundException("User not found"));
List<UserClanDto> userClanDtos = userClanRepository.findUserClanByUserId(user.getId());
return new sbUserDetails(user, userClanDtos);
}
}
UserDetailsService는 서비스 코드의 구현과 비슷하다. 입력받은 username를 가지는 user를 repository에서 가져온다.
만약 해당 유저가 존재하지 않으면 UsernameNotFoundException를 throw하면 된다.
반환은 User entity 그대로 하는 것이 아니라, 우리가 위에서 구현한 커스텀 UserDetails 객체를 반환하면 된다.
public class SecurityConfig {
private final sbUserDetailService userDetailsService;
private final PasswordEncoder passwordEncoder;
private final List<HandlerMapping> handlerMappings;
@Bean
public AuthenticationManager authenticationManager() {
DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider();
authenticationProvider.setUserDetailsService(userDetailsService);
authenticationProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(authenticationProvider);
}
이제 해당 UserDetailsService로 인증을 수행하기 위해 AuthenticationManager에 해당 서비스를 사용하는 AuthenticationProvider를 만들어 주입해준다. 위 코드 예제처럼 SercurityConfig에 Bean으로써 등록을 해주면 된다.
JwtAuthenticationFilter 구현하기
@Component
public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
this.authenticationManager = authenticationManager;
this.setAuthenticationSuccessHandler(new LoginAuthSuccessHandler());
this.setAuthenticationFailureHandler(new LoginAuthFailureHandler());
setFilterProcessesUrl("/api/users/login"); // 로그인 엔드포인트 설정
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
System.out.println("필터가 인증 시도!");
try {
LoginRqDto loginRqDto = new ObjectMapper().readValue(request.getInputStream(), LoginRqDto.class);
UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(loginRqDto.getId(), loginRqDto.getPassword());
return authenticationManager.authenticate(token);
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
이제 사용자 인증을 수행하는 authentication filter를 구현하자. 로그인 기능에 대한 filter는 Spring Security의 UsernamePasswordAuthenticationFilter 인터페이스를 확장하여 로그인 요청을 처리한다.
먼저 인증을 넘겨줄 대상인 AuthenticationManager를 bean으로 받아오도록 정의해주고,
생성자에서 인증 성공/실패 핸들러와 해당 필터를 동작시킬 로그인 엔드포인트를 설정해준다.
attemptAuthentication이라는 메소드를 오버라이딩 해야하는데, 이는 요청으로 들어온 request를 입력받는다.
해당 request에서 body로 들어온 로그인 요청 DTO를 읽어와 로그인 아이디/패스워드에 대한 정보를 추출한다.
추출된 아이디/패스워드로 UsernamePasswordAuthenticationToken을 생성하여 authenticationManager에 인증을 요청한다.
이렇게 설정하면 우리가 위에서 정의한 sbUserDetailsService 코드에서 해당 로그인 아이디에 대한 UserDetails를 생성하고, AuthenticaionProvider에서 token의 password와 UserDetails의 password를 비교하여 일치하면 인증 성공, 일치하지 않으면 인증 실패 상태가 된다.
@Component
@RequiredArgsConstructor
public class LoginAuthSuccessHandler implements AuthenticationSuccessHandler {
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
String token = JwtTokenProvider.generateToken(authentication);
response.setContentType("application/json");
response.addHeader("Authorization", "Bearer " + token);
response.setCharacterEncoding("UTF-8");
response.getWriter().write("{\"token\": \"" + token + "\"}");
}
}
@Component
public class LoginAuthFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
String errorMessage;
if(exception instanceof BadCredentialsException) {
errorMessage = "Check your username or password";
} else {
errorMessage = "Authentication failed";
}
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\": \"" + errorMessage + "\"}");
}
}
인증 성공, 실패에 대한 핸들러를 따로 구현하여 response 반환 값을 설정하였다.
사실 이렇게 핸들러를 따로 구현하지 않고, JwtAuthenticationFilter에서 unsuccessfulAuthentication(), successfulAuthentication() 함수를 오버라이딩해서 해당 내용을 그대로 작성해도 무방하다. 이 함수들에서 위 핸들러들을 call 하는 방식으로 작동되기 때문이다.
결론은 인증 성공시 Jwt Token을 생성하여 클라이언트에 반환하고, 인증 실패 시 error message를 반환하는 방식으로 구현하였다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
JwtAuthenticationFilter jwtAuthenticationFilter = new JwtAuthenticationFilter(authenticationManager());
JwtAuthorizationFilter jwtAuthorizationFilter = new JwtAuthorizationFilter();
http
.csrf(AbstractHttpConfigurer::disable) // CSRF 보호 비활성화 (API 환경에서 주로 사용)
.authorizeHttpRequests(
auth -> auth.requestMatchers("/api/users/register", "/api/users/login", "/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/api/clans/create").hasRole("USER")
.anyRequest().authenticated()
)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
filter 구현이 끝났으니 SecurityConfig의 SecurityFilterChain에서 JwtAuthenticationFilter를 체인에 등록해주면 된다.
JwtTokenProvider 구현하기
인증 성공 시 Jwt Token을 생성하기 위해 JwtTokenProvider 라는 utility 클래스를 구현한다.
@Component
@RequiredArgsConstructor
public class JwtTokenProvider {
private static final byte[] SECRET_KEY = System.getenv("JWT_SECRET_KEY").getBytes();
private static final long EXPIRATION_TIME = 86400000;
// JWT 토큰 생성
public static String generateToken(Authentication authentication) {
sbUserDetails userDetails = (sbUserDetails) authentication.getPrincipal();
System.out.println(userDetails);
Map<Long, String> clanRoles = new HashMap<>();
userDetails.getUserClanDtos().forEach(
userClanDto -> clanRoles.put(userClanDto.getClanId(), userClanDto.getRole().name())
);
return Jwts.builder()
.setSubject(authentication.getName())
.claim("userId", userDetails.getUser().getId())
.claim("roles", userDetails.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList())
.claim("clans", clanRoles)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(Keys.hmacShaKeyFor(SECRET_KEY))
.compact();
}
public static Authentication getAuthentication(String token) {
Claims claims = getClaims(token);
if (claims.get("roles") == null) {
throw new RuntimeException("No role in token");
}
List<SimpleGrantedAuthority> authorities = ((List<String>) claims.get("roles")).stream()
.map(SimpleGrantedAuthority::new) // ROLE_ 접두사 추가
.toList();
Long userId = claims.get("userId", Long.class);
return new UsernamePasswordAuthenticationToken(userId, null, authorities);
}
// JWT 토큰에서 사용자 이름 추출
public static String extractUsername(String token) {
return getClaims(token).getSubject();
}
public static boolean isTokenExpired(String token) {
return getClaims(token).getExpiration().before(new Date());
}
// JWT 토큰 검증
public static boolean validateToken(String token) {
try {
getClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
public static String getUsernameFromToken(String token) {
return getClaims(token).getSubject();
}
public static String resolveToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
public static Claims getClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(SECRET_KEY)
.build()
.parseClaimsJws(token)
.getBody();
}
}
많은 함수들이 존재하지만 일단 Authentication 과정이기 때문에 generateToken 함수에 주목하자.
인증 성공시 호출되는 handler의 함수를 보자.
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
여기서 Authentication 객체가 넘어오는 것을 확인할 수 있다.
해당 객체의 principal, 즉 유저의 식별을 위한 정보를 얻게되면 바로 우리가 구현한 커스텀 UserDetails 클래스 타입의 객체를 반환받게 되고. 이것이 바로 현재 로그인한 유저에 대한 인스턴스이다.
따라서 JWT 토큰도 해당 sbUserDetails 정보를 가지고 구성해주면 된다.
subject는 유저의 닉네임
claims에는 인증 유저에 대한 세부 정보. 여기서는 UserDetails GrantedAuthority 뿐만 아니라 우리가 필요한 것들을 모두 넣을 수 있다. 예를 들어 위 예시에는 가입한 클랜 멤버만 클랜 관련 API를 요청할 수 있도록 이후 Authorization을 구현할 것이라 유저의 클랜 가입 정보도 claims에 포함하였다.
이외 토큰의 발급 시간, 만료 시간 등을 설정해주고 signature를 추가하면 된다.
signature는 가장 맨 위 SECRET_KEY 객체를 key로 암호화 하게 된다.
해당 시크릿 키는 https://jwtsecret.com/generate와 같은 온라인 사이트에서 jwt secret key를 생성하고
보안을 위해 class 내부에 하드코딩하는것이 아니라 환경변수나 외부에서 주입할 수 있도록 해야한다.
로그인 요청
로그인 요청 시 아래와 같이 JWT 토큰 값이 잘 반환되는것을 확인할 수 있다.
다음 포스팅은 해당 JWT 토큰 값을 이용하여 API 권한을 제어하는 Authorization 과정을 다루도록 하겠다