- select
- ORM
- 프로그래머스
- springboot
- PYTHON
- DI
- 스프링
- 데이터베이스
- Docker
- join
- jpa
- string
- SSL
- 문자열
- spring
- AWS
- static
- java
- spring security 6
- 자바
- nginx
- sql
- 1차원 배열
- Django
- hibernate
- @transactional
- spring boot
- spring mvc
- mysql
- 스프링부트
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | ||
6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
개발하는 자몽
[Spring Security] 토큰 기반 로그인/로그아웃 구현하기 본문
Notice Spring Boot 3.2.5, Spring Security 6.2.5 기반으로 작성한 글입니다.
Notice 24.07.06 로그인 구현 부분
Spring Security Authentication Architecture

Spring Security를 기반으로 보안 관련 기능을 구현하기 위해서는 위의 그림을 이해해야 한다. 아래는 로그인을 예시로 한 Spring Security 인증 과정이다.
1. 사용자가 로그인 정보와 함께 인증을 요청한다.
2.
...
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
private final AuthenticationManager authenticationManager;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String token = getJwtFromRequest(request);
if (StringUtils.hasText(token)) {
/*
JWT 유효성 검사 로직
*/
String username = tokenProvider.extractUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 인증용 토큰 객체 생성
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userDetails,
userDetails.getPassword(),
userDetails.getAuthorities()
);
...
}
...
}
3.
Note AuthenticationManager는 Spring Security 필터가 어떻게 인증을 수행해야 하는지 정의한다.
// AuthenticationManager의 구현체인 ProviderManager를 빈으로 등록
// ProviderManager에게 커스텀 AuthenticationProvider를 전달
@Bean
public AuthenticationManager authenticationManager(UserDetailsService userDetailsService) {
JwtAuthenticationProvider provider = new JwtAuthenticationProvider(userDetailsService, passwordEncoder());
return new ProviderManager(provider);
}
4.

각
public ProviderManager(AuthenticationProvider... providers) {
...
public List<AuthenticationProvider> getProviders() {
return this.providers;
}
...
}
5, 실제 DB를 통해 사용자의 인증 정보를 가져오는
...
public class JwtAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
UsernamePasswordAuthenticationToken authToken = (UsernamePasswordAuthenticationToken) authentication;
String username = authToken.getName();
if (username == null) {
throw new UsernameNotFoundException("Invalid username or password");
}
// UserDetailsService에게 사용자 정보 전달
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
...
}
6. 받은 사용자 정보를 이용하여 DB에서 해당 사용자 정보를 찾는다. 찾은 사용자 정보를 바탕으로
...
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//DB에서 사용자 정보 조회
UserEntity user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
//UserDetails 객체 생성
return new User(
user.getUsername(),
user.getPassword(),
mapAuthorities(user.getRole())
);
}
//사용자가 가진 권한 반환
private Collection<? extends GrantedAuthority> mapAuthorities(Role role) {
return role.getAuthorities();
}
}
7.
...
public class JwtAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...
// UserDetailsService에게 사용자 정보 전달
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
String password = userDetails.getPassword();
// 사용자 정보 비교
// verifyCredentials: 비밀번호를 검증하는 커스텀 메서드 - 보통 PasswordEncoder#matches 사용
verifyCredentials(password, (String) authToken.getCredentials());
...
}
8. 인증이 완료되면 권한을 포함하여 사용자 정보를 담은
- 생성할
Authentication 객체에는 보안을 위해 비밀번호(credentials)를 담지 않는다. - 위
Authentication 객체는UsernamePasswordAuthenticationToken 으로 구현할 수 있다.
...
public class JwtAuthenticationProvider implements AuthenticationProvider {
private final UserDetailsService userDetailsService;
private final PasswordEncoder passwordEncoder;
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
...
// UserDetailsService에게 사용자 정보 전달
...
// 사용자 정보 비교
...
// 인증 토큰 발급 및 반환
return new UsernamePasswordAuthenticationToken(
username,
null,
userDetails.getAuthorities()
);
}
9. 돌아가서
...
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
private final AuthenticationManager authenticationManager;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String token = getJwtFromRequest(request);
if (StringUtils.hasText(token)) {
...
// 인증용 토큰 객체 생성: authenticationToken
...
// 인증 시도 -> 인증 완료: authentication 객체 받음
Authentication authentication = authenticationManager.authenticate(authenticationToken);
...
}
...
}
10.
Note 사용자 정보를 저장하는 것은 Spring Security가 전통적인 세션-쿠키 기반의 인증 방식을 사용함을 의미한다.
...
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
private final AuthenticationManager authenticationManager;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String token = getJwtFromRequest(request);
if (StringUtils.hasText(token)) {
...
// 인증용 토큰 객체 생성: authenticationToken
...
// 인증 시도 -> 인증 완료: authentication 객체 받음
Authentication authentication = authenticationManager.authenticate(authenticationToken);
// 인증 객체 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
...
}
...
}
⇒ 위 모든 과정을 거치고 나서
...
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
private final AuthenticationManager authenticationManager;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
String token = getJwtFromRequest(request);
if (StringUtils.hasText(token)) {
// 4 ~ 10 과정
}
//요청 넘김 (애플리케이션의 나머지 동작 수행)
filterChain.doFilter(request, response);
}
모든 필터를 통과하고 애플리케이션에서 할 일을 마친 후 바로 클라이언트로 응답이 가는 것이 아닌, 다시 필터를 통해서 돌아가는 것을 기억합시다.
토큰 기반 로그인(인증) 구현
Spring Security는 기본적으로
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin)) // H2 웹콘솔 iframe 정상 작동을 위해 같은 Origin에 대해 허용
.sessionManagement(session -> session.sessionCreationPolicy(STATELESS)) //here
...
;
return http.build();
}
}
현재 토큰 기반의 인증을 구현하므로 사용자의 로그인 요청이 성공하면 Access Token과 Refresh Token을 발급하도록 구현한다. 로그인 요청 시에는 토큰 인증이 아닌 로그인 요청 정보(Response Body)만 검증하도록 한다.
따라서 로그인 요청인 경우에는 Header에 토큰이 없으므로 바로 다음 필터로 넘어가게 된다.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
...
@Override
protected void doFilterInternal(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
// JWT 검사 및 인증 객체 설정
String token = getJwtFromRequest(request);
if (StringUtils.hasText(token)) {
//Header에 JWT가 있는 요청들은 이 부분 실행
...
}
//Header에 JWT가 없는 요청들은 바로 doFilter() 실행. Ex) 회원가입 등...
filterChain.doFilter(request, response);
}
}
토큰 인증 등의 과정을 진행하지 않고 바로
Note 참고로 spring security configuration에서 해당 URI를
이제 로그인 요청을 처리하는 컨트롤러 코드를 구현한다.
...
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService authService;
@PostMapping("/sign-in")
public ResponseEntity<AuthenticationRespDto> authenticate(@RequestBody AuthenticationReqDto dto) {
log.info("[authenticate] login request: {}", dto);
authService.isValidCredentials(dto); //here
...
}
}
로그인 정보와 함께
@Service
@Transactional
@RequiredArgsConstructor
public class AuthenticationService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
...
public AuthenticationRespDto authenticate(AuthenticationReqDto dto) {
//jwt, refresh token 발급
UserDetails userDetails = userDetailsService.loadUserByUsername(dto.username());
String jwt = tokenProvider.generateToken(userDetails);
String refreshToken = tokenProvider.generateRefreshToken(userDetails);
//이전 토큰 무효화 및 새 토큰 저장
UserEntity user = userRepository.findByUsername(dto.username())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
revokeUserAllTokens(user);
saveUserToken(user, jwt);
return new AuthenticationRespDto(jwt, refreshToken);
}
public void isValidCredentials(AuthenticationReqDto dto) {
UserEntity user = userRepository.findByUsername(dto.username())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (user == null || !passwordEncoder.matches(dto.password(), user.getPassword())) {
throw new BadCredentialsException("Invalid username or password");
}
}
...
}
username 이 DB에 있는지 확인한다. 없으면UsernameNotFoundException 을 던지고, 있으면 해당 정보를 가진 객체를 꺼낸다.- DB에서 꺼낸 객체의
password 와 사용자가 입력한password 를 비교한다. 일치하지 않으면BadCredentialsException 을 던진다.
검증이 성공적으로 완료되면 다시 컨트롤러로 돌아간다.
...
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService authService;
@PostMapping("/sign-in")
public ResponseEntity<AuthenticationRespDto> authenticate(@RequestBody AuthenticationReqDto dto) {
log.info("[authenticate] login request: {}", dto);
authService.isValidCredentials(dto);
AuthenticationRespDto response = authService.authenticate(dto); //here
log.info("[authenticate] login response: {}", response);
...
}
}
@Service
@Transactional
@RequiredArgsConstructor
public class AuthenticationService {
private final UserDetailsService userDetailsService;
private final TokenProvider tokenProvider;
private final UserRepository userRepository;
private final TokenRepository tokenRepository;
...
public AuthenticationRespDto authenticate(AuthenticationReqDto dto) {
//jwt, refresh token 발급
UserDetails userDetails = userDetailsService.loadUserByUsername(dto.username());
String jwt = tokenProvider.generateToken(userDetails);
String refreshToken = tokenProvider.generateRefreshToken(userDetails);
//이전 토큰 무효화 및 새 토큰 저장
UserEntity user = userRepository.findByUsername(dto.username())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
revokeUserAllTokens(user);
saveUserToken(user, jwt);
return new AuthenticationRespDto(jwt, refreshToken);
}
...
}
UserDetailsService#loadUserByUsername 를 이용하여 사용자 정보를 담는 객체인UserDetails 를 생성한다.- 해당 객체를
TokenProvider#generateToken 으로 넘겨 사용자 정보를 바탕으로 Access Token을 생성한다. - 그다음에 Refresh Token을 생성하는
TokenProvider#generateRefreshToken 을 호출한다. - 토큰 생성 과정에서 예외 발생 없이 잘 넘어가면 사용자가 이전에 발급받은 모든 토큰을 무효화(invalide) 처리한다.
private void revokeUserAllTokens(UserEntity user) { List<Token> userTokens = tokenRepository.findAllValidTokenByUser(user.getId()); if (userTokens.isEmpty()) return; // 사용자 모든 토큰 만료시키기 userTokens.forEach(token -> { token.configureRevoked(true); token.configureExpired(true); }); tokenRepository.saveAll(userTokens); }
- 이제 새롭게 발급한 토큰을 저장한다.
private void saveUserToken(UserEntity user, String jwt) { Token token = Token.builder() .user(user) .token(jwt) .expired(false) .revoked(false) .build(); tokenRepository.save(token); }
- 모든 과정이 성공적으로 완료되면 생성된 Access Token, Refresh Token을 DTO 객체에 담아 반환한다.
다시 컨트롤러로 돌아온다.
...
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthenticationController {
private final AuthenticationService authService;
@PostMapping("/sign-in")
public ResponseEntity<AuthenticationRespDto> authenticate(@RequestBody AuthenticationReqDto dto) {
log.info("[authenticate] login request: {}", dto);
authService.isValidCredentials(dto);
AuthenticationRespDto response = authService.authenticate(dto); //here
log.info("[authenticate] login response: {}", response);
return ResponseEntity.ok(response); //here
}
}
반환받은 DTO를
토큰 기반 로그아웃 구현
Spring Security에 JWT 기반 로그아웃을 위한 내장된 구현은 별도로 없다. JWT의 stateless 특성으로 인하여 서버는 사용자의 세션을 저장하거나 추적하지 않기 때문이다.
따라서 클라이언트가 토큰 관리와 로그아웃 과정 초기화를 담당하게 된다. 프론트엔트의 경우 사용자는 토큰을 로컬 스토리지에서 제거하거나 토큰이 만료되도록 만료 날짜를 변경 또는 설정하면 된다. 또는 백엔드에서 이러한 토큰에 관한 보안이나 무효화하는 메커니즘을 구현할 수도 있다.
여기에서는 로그아웃을 구현할 때 Spring Security에게 로그아웃을 위한 엔드포인트를 제공하고, Spring은 개발자에게 로그아웃 핸들러를 제공한다.
우선 Security Configuration에서 로그아웃 설정을 한다.
...
@Configuration
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {
...
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
...
.logout(logout -> logout.logoutUrl("/api/auth/sign-out")
.addLogoutHandler(logoutHandler)
.logoutSuccessHandler((request, response, authentication) -> SecurityContextHolder.clearContext())
);
return http.build();
}
}
- 로그아웃 요청을 받을 URL을 지정한다.
- 로그아웃을 처리할
LogoutHandler 를 등록한다. 이 핸들러는LogoutHandler 인터페이스를 구현한 구현체이다.... @Service @RequiredArgsConstructor public class LogoutHandlerImpl implements LogoutHandler { private final TokenRepository tokenRepository; @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { String token = getJwtFromRequest(request); Token storedToken = tokenRepository.findByToken(token) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 토큰(JWT)입니다.")); //토큰 무효화하기 storedToken.configureExpired(true); storedToken.configureRevoked(true); tokenRepository.save(storedToken); } private String getJwtFromRequest(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { return bearerToken.substring(7); } return null; } }
- 요청에서 토큰을 꺼낸다.
- 해당 토큰이 DB에 있는지 확인한다.
- 토큰이 없는 경우
404 NOT FOUND 에러가 발생한다.
- 토큰이 없는 경우
- DB에 토큰이 있으면 토큰을 무효화 설정한다.
- 설정 후에 해당 변경을 저장한다.
- 로그아웃이 성공하면 호출되는
logoutSuccessHandler 를 설정한다.
여기의 로그아웃 구현은 로그아웃 핸들러 외에 특별히 설정할 것이 없다. 관련하여 자세한 내용은 아래 링크를 참고!
Handling Logouts :: Spring Security
If you are using Java configuration, you can add clean up actions of your own by calling the addLogoutHandler method in the logout DSL, like so: Custom Logout Handler CookieClearingLogoutHandler cookies = new CookieClearingLogoutHandler("our-custom-cookie"
docs.spring.io
번외. JWT Authentication
기간 내에 구현하려고 하다 보니 공식 문서를 꼼꼼히는 뒤늦게 보게 됐는데, 이런 부분이 있더라... 나중에 참고하기 위해 남겨두기..
OAuth 2.0 Resource Server JWT :: Spring Security
Most Resource Server support is collected into spring-security-oauth2-resource-server. However, the support for decoding and verifying JWTs is in spring-security-oauth2-jose, meaning that both are necessary in order to have a working resource server that s
docs.spring.io

🔖참고
'Java & Kotlin > Spring' 카테고리의 다른 글
[Spring Security] 예외 처리하기 (0) | 2024.06.21 |
---|---|
[Spring Security] 역할/권한 구현하기 (0) | 2024.06.14 |
Spring Security 6 - Architecture (1) | 2024.06.07 |
[TIL / Spring] 설정 파일과 프로필 (0) | 2024.04.26 |
[Spring Data JPA] TransactionRequiredException (0) | 2023.08.19 |