JWT 공부를 시작했을 때 JWT를 왜 사용하는지를 한동안 고민에 잠겼고 결론에 대해서 정리하고 싶어서 작성하게 되었다.
JWT의 STATELESS 상태에 대한 집착
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService customUserDetailsService; // 기본 세션 로그인
private final CustomOauth2UserService customOAuth2UserService; // OAuth2 등록
// AuthenticationManager가 인자로 받을 AuthenticationConfiguraion 객체 생성자 주입
private final AuthenticationConfiguration authenticationConfiguration;
private final JWTUtil jwtUtil; //JWTUtil 주입
// AuthenticationManager Bean 등록
// AuthenticationConfiguration 인자를 또 받기 때문에 주입
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.requestMatchers("/login", "/", "/join").permitAll()
.requestMatchers("/admin").hasAuthority("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form.disable())
.httpBasic(auth -> auth.disable())
.sessionManagement(session -> session // jwt 방식에서는 session --> STATELESS
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.userDetailsService(customUserDetailsService)
.csrf(csrf -> csrf.disable())
// --> 우리는 formLogin 을 disable 해놨기 때문에 UsernamePasswordAuthenticationFilter 을 직접 커스텀 해야한다.
.addFilterBefore(new JWTFilter(jwtUtil), LoginFilter.class)
.addFilterAt(new LoginFilter(authenticationManager(authenticationConfiguration), jwtUtil), UsernamePasswordAuthenticationFilter.class)
.cors(httpSecurityCorsConfigurer -> httpSecurityCorsConfigurer.configurationSource(configurationSource())
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource configurationSource(){
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("*");
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.setAllowedMethods(List.of("GET","POST","DELETE"));
source.registerCorsConfiguration("/**",config);
return source;
}
}
위의 시큐리티 JWT config를 구성한 코드를 보면
SessionCreationPolicy.STATELESS 상태가 등장하는 것을 볼 수 있다.
그래서 나는 이 STATELESS 상태에 초점이 맞추어 생각을 했다. --> JWT 구현을 위해 STATELESS 상태가 필요하지만 STATELESS에 초점을 잡고 생각을 하니 문제가 많아졌다. --> 아래를 읽다보면 왜 문제인지 알 수 잇을 것이다.
문제 상황
일단 기본적으로 JWT를 사용하면 토큰 탈취의 문제로 기존 Access 토큰과 함께 Refresh 토큰의 도입을 이야기한다.
따라서 Refresh, Access라는 2가지의 토큰을 발급해주는데, Refresh 토큰 요청는 Access 토큰 만료보다 주기 자체가 길기 때문에 탈취 당할 확률은 낮다고는 하지만 어찌되었든 탈취 당할 수 있다.
그래서 이 탈취 당할 확률을 방지하기 위해 서버측에서 어떠한 방법을 구현해야 하는데 이 부분에 대해서 많은 고민을 했다.
레디스, 데이터베이스의 도입
만약, 토큰이 탈취 되었을때 서버의 제어권과 로그아웃 문제 등 을 생각해보자.
일단 토큰이 탈취되면 토큰의 만료 기간 까지 서버측은 고통을 받을 것이다.
따라서 아예 서버를 꺼버리거나 서버 비밀키를 변경하는 상황까지 가는 불상사가 발생할 것이다.
혹은 프론트 서버측 로그아웃을 구현할 수도 있는데 이때 이미 해커가 토큰을 복제 했다면 해커는 그 토큰을 가지고 계속 서버에 접속할 수 있기 때문에 여전히 문제가 있다.
그래서 서버측에서 이러한 문제에 대해서 구현해줘야 하는데 이를 위해 서버측 Redis와 같은 저장소에 발급한 Refresh 토큰을 저장해서 로그아웃을 하거나 토큰이 탈취 당했을 때 블랙리스트를 도입하여 블랙리스트에 저장한다는 구현들이 많았다.
그래서 로그아웃 상태거나 탈취된 토큰은 Redis 서버에서 제거하여 앞으로 Access 토큰 재발급이 불가능하도록 설정하는 것이었다.
하지만 모순...
위처럼 서버에서 레디스같은 저장소를 도입하는 것을 이야기 했지만 나는 그것은 모순이라고 생각했다.
Refresh들을 저장하기 위한 Redis를 도입해버리면 사실상 세션 클러스터링을 작업하고 세션 방식을 사용하는 것이 좋지 않을까? 라는 생각을 하게 된 것이다.
왜냐면 나는 jwt config 를 작성하면서 앞 단에서 세션 STATELESS 작업을 했는데, 뒷단인 다른 곳에서 상태 저장이 생겨버리는 것이 아닌가 라는 생각이 든 것이다.
그래서 탈취를 막으면서도 Redis를 도입하지 않을 방법에 대해서 고민을 했지만.....
시원한 해답은 얻지를 못했다. 아래처럼 계속 둘레에 빠져 한 곳만 바로보고 있던 것이다. STATELESS → 그런데 Redis → 그럼 차라리 세션 → 왜 JWT를 사용했지?
결론 : JWT를 왜 사용하는가?
우리는 우리가 할려는 일에 대해서 목표가 무엇인지 판단해야 한다. 우리가 JWT의 목적을 확인하지 않고 구현에만 열중한다면 무엇을 하는지도 모르는 것에 불과하다. 따라서 JWT의 STATELESS한 상태에만 목적을 두는 것이 아닌 JWT가 왜 필요한지를 생각했고 해답을 찾았다.
JWT를 사용한 이유
모바일 앱
JWT가 사용된 주 이유는 결국 모바일 앱의 등장이다.
모바일 앱의 특성상 주로 JWT 방식으로 인증/인가를 진행한다.
그렇기 때문에 결국 세션 STATLESS는 앱의 등장으로 인해 부수적인 효과인 것이다.
장시간 로그인과 세션
장기간 동안 로그인 상태를 유지하려고 세션 설정을 하면 서버 측 부하가 많이 가기 때문에 JWT 방식을 이용하는 것도 한 방법이다.
위의 이런 이유때문에 jwt가 만들어졌고 JWT의 목적이 STATELESS가 아니기 때문에 나중에 로그아웃에 대해서 레디스나 디비에 리프레시 토큰을 저장하는 로직을 추가하는 것이 올바르다고 판단을 내렸다.
원래의 나라면 Service는 Class로 만들었을 테지만, 팀원 중 한명이 유지보수에 좋다고 해서 분리해보겠다.
UserServiceImpl.java
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
@Override
@Transactional
public void signUp(UserDto userDto) {
if (!userDto.getPassword().equals(userDto.getPasswordCheck())) {
throw new RuntimeException("비밀번호가 다릅니다.");
}
if (userRepository.existsByEmail(userDto.getEmail())) {
throw new RuntimeException("이메일이 존재합니다.");
}
if (userRepository.existsByUsernick(userDto.getUsernick())) {
throw new RuntimeException("닉네임이 존재합니다.");
}
Role role = roleRepository.findByName(userDto.getUsername().equals("admin") ? "ADMIN" : "USER");
User user = new User();
user.setRoles(Collections.singleton(role));
user.setEmail(userDto.getEmail());
user.setUsernick(userDto.getUsernick());
user.setUsername(userDto.getUsername());
user.setPassword(passwordEncoder.encode(userDto.getPassword()));
userRepository.save(user);
}
@Override
public Optional<User> findByEmail(String email) {
return userRepository.findByEmail(email);
}
@Override
public void deleteUser(String email) {
Optional<User> emailOptional = userRepository.findByEmail(email);
if (emailOptional.isPresent()) {
userRepository.delete(emailOptional.get());
} else {
throw new RuntimeException("삭제할 사용자가 존재하지 않습니다.");
}
}
}
signUp 메서드를 보면 username이 admin이면 Role을 ADMIN으로 주고 그게 아니면 USER로 주는 것으로 진행했다.
5. 컨트롤러
Usercontroller.java
@Controller
@RequiredArgsConstructor
@RequestMapping("/api/users/")
@Slf4j
public class UserController {
private final UserServiceImpl userServiceimpl;
private final CustomUserDetailsService customUserDetailsService;
@GetMapping("/signup")
public String signup(Model model) {
model.addAttribute("user", new UserDto());
return "/user/singupform";
}
@PostMapping("/signup")
public String signup(@ModelAttribute("user") UserDto userDto,
RedirectAttributes redirectAttributes) {
try {
userServiceimpl.signUp(userDto);
redirectAttributes.addFlashAttribute("success", " 성공적으로 회원가입 됐습니다.");
return "redirect:/users/login";
} catch (Exception e) {
log.error("회원가입 오류 : {} " , e.getMessage());
redirectAttributes.addFlashAttribute("error", "회원가입에 실패했습니다." + e.getMessage());
e.printStackTrace();
}
return "redirect:/api/users/login";
}
@GetMapping("/login")
public String showLoginForm() {
return "/user/loginform";
}
// @PostMapping("/login")
// public void login(@ModelAttribute User user, RedirectAttributes redirectAttributes) {
// try {
// UserDetails userDetails = customUserDetailsService.loadUserByUsername(user.getUsername());
// Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, user.getPassword(), userDetails.getAuthorities());
// SecurityContextHolder.getContext().setAuthentication(authentication);
// } catch (Exception e) {
// redirectAttributes.addFlashAttribute("error", "아이디 또는 비밀번호가 올바르지 않습니다.");
// }
// }
// 일반 로그인 회원의 정보 가져오기
@GetMapping("/home")
public String index(Model model, Authentication authentication) {
String username = authentication.getName();
Optional<User> userOptional = userServiceimpl.findByEmail(username);
if (userOptional.isPresent()) {
model.addAttribute("user", userOptional.get());
return "user/home"; // user/home.html 템플릿으로 이동
}
return "redirect:/api/users/login";
}
}
userDto로부터 회원가입에 필요한 정보를 받아서 회원가입을 한다.
여기서 애를 먹었는데, @Postmapping("/login")에서 로그인이 들어오면 그 정보를 가지고 있었는데 저부분 때문에 자꾸 로그인이 안됐다.
@Postmapping("/login") 메소드에서 CustomUserDetailsService를 통해 사용자를 로드하고 Authentication을 설정하려고 하지만, 이 방식은 Spring Security의 기본 인증 방식과 충돌할 수 있다.
Spring Security는 기본적으로 formLogin을 사용하여 로그인 처리를 자동으로 수행하므로, 수동으로 인증을 설정할 필요는 없다.
로그인 처리를 Spring Security의 기본 메커니즘에 맡기기 위해, UserController의 login 메소드를 제거하고, 로그인 페이지에서 제공한 폼 데이터를 Spring Security의 formLogin을 통해 처리하게 하는 것이 좋다.
6. UserDetails
CustomUserDetails.java
public class CustomUserDetails implements UserDetails {
private final String email;
private final String password;
private final Collection<? extends GrantedAuthority> authorities;
public CustomUserDetails(String email, String password, Set<Role> roles) {
this.email = email;
this.password = password;
this.authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority(role.getName()))
.collect(Collectors.toList());
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return password;
}
@Override
public String getUsername() {
return email;
}
@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의 UserDetails 인터페이스를 상속하여 정의한다.
이 클래스가 하는 역할은 Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다.
일단 시큐리티를 사용하면 우리가 직접 로그인 처리를 안해도 된다.
POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 됨!
토큰 방식이 아닌 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.(Key값 : Security ContextHolder)
Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 지금 작성한 CustomUserDetails에서 UserDetails를 설정해준다고 보면 된다.
7. UserDetailsService
CustomUserDetailsService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
log.info("로그인이 들어왓나?");
log.info("조회할 이메일: {}", username);
Optional<User> userOptional = userRepository.findByEmail(username);
if (userOptional.isEmpty()) {
log.info("사용자가 없다");
throw new UsernameNotFoundException("사용자가 존재하지 않습니다: " + username);
}
User foundUser = userOptional.get();
Set<Role> roles = foundUser.getRoles();
return new CustomUserDetails(
foundUser.getEmail(),
foundUser.getPassword(),
roles
);
}
}
사용자가 로그인할 때, CustomUserDetailsService의 loadUserByUsername 메서드가 호출되어 사용자 정보를 데이터베이스에서 조회한다.
만약, 사용자가 존재하지 않는 경우 UsernameNotFoundException 예외를 발생시키고 사용자가 존재하는 경우, UserDetails 객체를 생성하고 반환한다.
Spring Security는 UserDetails 객체를 사용하여 사용자의 비밀번호와 권한 정보를 확인한다.
CustomUserDetails 객체는 사용자의 비밀번호와 권한 정보를 Spring Security에 제공한다.
Spring Security는 이 정보를 바탕으로 사용자가 인증되었는지 확인하고, 권한에 따라 접근을 제어한다.
즉, loadUserByUsername 메서드는 로그인을 진행할 때 주어진 사용자 이름(email, 로그인 진행 시 아이디)을 기반으로 사용자의 세부 정보를 검색하고 반환하는 역할을 한다.
jwt 방식을 사용한다면 이 방식은 필요없다.
7. SecurityConfig
SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final CustomUserDetailsService customUserDetailsService;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.requestMatchers("/api/users/signup", "/api/users/login", "/api/users/home").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/api/users/login") // 로그인 페이지의 경로
.loginProcessingUrl("/login") // 로그인 폼이 제출되는 URL
// .usernameParameter("email") // 변경된 필드명
// .passwordParameter("password")
.defaultSuccessUrl("/api/users/home")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/")
)
.sessionManagement(sessionManagement -> sessionManagement
.maximumSessions(1)
.maxSessionsPreventsLogin(true)
)
.userDetailsService(customUserDetailsService)
.csrf(csrf -> csrf.disable());
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}