로그인을 구현하기 전에 스프링 시큐리티 설정을 잡아줄 것이다
JWT를 사용하기 때문에 csrf, formLogin, httpBasic 방식을 모두 비활성화
JWT 방식은 세션 설정을 STATELESS로 해줘야 한다
로그인과 회원가입은 권한 없이 접근 가능해야 하므로 permitAll 해준다
password를 암호화하기 위해 BCryptPasswordEncoder를 Bean으로 등록
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final UserRepository userRepository;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
// AuthenticationManager를 Bean으로 등록해야 로그인 처리가 가능함
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// csrf, form 로그인 방식, http basic 방식 disable
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
// 세션 설정 STATELESS
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/signup", "/css/**", "/js/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated())
return http.build();
}
}
AuthenticationManager는 스프링 시큐리티의 UserDetails, UserDetailsService를 거쳐 UserRepository에 접근하게 된다
스프링 시큐리티의 UserDetails, UserDetailsService 인터페이스를 커스텀하여 입맛에 맞게 설정한다
@RequiredArgsConstructor
public class CustomUserDetails implements UserDetails {
private final User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> collection = new ArrayList<>();
collection.add(new GrantedAuthority() {
@Override
public String getAuthority() {
return user.getRoleName();
}
});
return collection;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
}
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("회원이 존재하지 않습니다."));
return new CustomUserDetails(user);
}
}
JWT 방식으로 일반 로그인을 진행하면 사용자에게 access/refresh 토큰이 발급된다
refresh 토큰은 DB에 저장되어 관리된다
JWT 토큰을 관리하는 JwtUtil을 만들자
@Slf4j
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.access-token-expiration-time}")
private long ACCESS_TOKEN_EXPIRE_TIME;
@Value("${jwt.refresh-token-expiration-time}")
private long REFRESH_TOKEN_EXPIRE_TIME;
// 토큰 암호화 키
private SecretKey key;
// application.yml에서 jwt.secret 값을 가져와서 비밀 키로 세팅
@PostConstruct
public void init() {
this.key = Keys.hmacShaKeyFor(secretKey.getBytes());
}
// Access 토큰 생성 메소드
// username, role, category 담겨있음
public String generateAccessToken(String category, String username, String role) {
return Jwts.builder()
.subject(username)
.claim("role", role)
.claim("category", category)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + ACCESS_TOKEN_EXPIRE_TIME))
.signWith(key)
.compact();
}
// Refresh 토큰 생성 메소드
// category만 담겨있음
public String generateRefreshToken(String category) {
return Jwts.builder()
.claim("category", category)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + REFRESH_TOKEN_EXPIRE_TIME))
.signWith(key)
.compact();
}
// JWT로부터 subject를 꺼내서 username 확인
public String getUsernameFromToken(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().getSubject();
}
// JWT로부터 role claim 추출
public String getRoleFromToken(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
// JWT로부터 category 추출 (access, refresh 구분)
public String getCategory(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token).getPayload().get("category", String.class);
}
// 토큰이 만료되었으면 true, 아니면 false
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(key).build().parseSignedClaims(token)
.getPayload().getExpiration().before(new Date());
}
// 만료된 토큰에서 username 추출
public String getUsernameFromExpirationToken(String token) {
try {
return Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
} catch (ExpiredJwtException e) {
// 만료된 토큰이어도 일단 내부 정보 반환(재발급 시 사용자 정보가 필요할 수 있음)
return e.getClaims().getSubject();
}
}
}
다음은 API 요청이 올 때마다 가장 먼저 실행되어 토큰을 검사하는 JwtFilter를 만들자
클라이언트는 권한이 필요한 경우 Access 토큰을 요청 헤더에 첨부한다
Access 토큰이 만료된 경우 특정한 응답을 해야한다
// 모든 API 요청이 올 때마다 가장 먼저 실행되어 토큰 검사
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final UserRepository userRepository;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
log.info("[JwtAuthFilter] doFilterInternal");
// 요청 헤더에서 Authorization 키의 값(토큰) 추출
String authorization = request.getHeader("Authorization");
// 토큰이 없거나, Bearer 타입이 아니면 필터 통과 (인증 실패 처리됨)
if (authorization == null || !authorization.startsWith("Bearer ")) {
log.warn("JWT 토큰 없음");
filterChain.doFilter(request, response);
return;
}
// "Bearer " 접두사를 제거하고 순수 토큰 값만 추출
String accessToken = authorization.split(" ")[1];
// 토큰 유효성 검증 (만료 여부, 위조 여부 등 확인)
try {
jwtUtil.isExpired(accessToken);
} catch (ExpiredJwtException e) {
// response body
response.getWriter().print("access token expired");
// response status code (401 에러 띄움)
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// 토큰이 access인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(accessToken);
if (!accessToken.equals("access")) {
response.getWriter().print("invalid access token");
// response status code (401 에러 띄움)
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// 토큰에서 사용자 이름(username) 추출
String username = jwtUtil.getUsernameFromToken(accessToken);
// 추출한 username으로 DB에서 실제 사용자 정보 조회
// (토큰에는 비밀번호 같은 민감한 정보가 없으므로 DB 조회가 필요할 수 있음)
User foundUser = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("회원이 존재하지 않습니다."));
// 인증 객체(Authentication) 생성을 위한 임시 User 객체 생성
// 비밀번호는 이미 토큰 검증을 통과했으므로 임의의 값으로 설정
User user = User.builder()
.username(username)
.password("temppassword")
.name(foundUser.getName())
.role(foundUser.getRole())
.provider("NONE")
.providerId(null)
.build();
// UserDetails 객체 생성 (Spring Security가 사용하는 사용자 정보 객체)
CustomUserDetails customUserDetails = new CustomUserDetails(user);
// 스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
// 세션(Security Context)에 인증 정보 등록
// 이 요청이 끝날 때까지만 인증된 상태로 유지됨 (Stateless)
SecurityContextHolder.getContext().setAuthentication(authToken);
// 다음 필터로 진행
filterChain.doFilter(request, response);
}
}
Filter에서 임시 User를 생성할 때 비밀번호를 "temppassword" 같은 임시 문자열로 하는 것이 문제가 되지 않을까? 라는 의문이 생겼다
결론부터 말하면 아무 문제 되지 않는다!
이유 1: 이미 인증이 완료된 상태이다
JwtAuthenticationFilter가 실행되는 시점은 이미 사용자가 로그인을 성공하고, JWT 토큰을 가지고 서버에 요청을 보낸 상태이다. 비밀번호 검사 단계는 전에 끝난 것이다.
여기서 만드는 User는 비밀번호를 검증하기 위한 것이 아니라 Spring Security에게 "이 사람은 이미 인증된 사용자입니다"라고 알려주기 위한 정보 저장용일 뿐이다
이유 2: 비밀번호가 사용되지 않는다
여기서 생성된 Authtication 객체(authToken)는 SecurityContextHolder에 저장된다. Spring Security는 이후의 요청 처리 과정에서 이 객체를 꺼내 쓰는데, 이때 비밀번호 필드는 확인하지 않는다. username(식별자)과 role(권한)만 확인한다
그래서 "temppassword" 같이 두면 오히려 이것이 진짜 비밀번호가 아니다 라는 것을 명시적으로 보여주는 효과가 있다
AuthService에 로그인 로직을 구현할 것이다
DB에 저장되어 있는 회원 정보를 기반으로 검증할 로직을 작성한다
access 토큰에는 username과 role 모두 들어가 있지만
refresh 토큰은 단순히 재발급 용도이기 때문에 정보를 넣지 않았다
access 토큰은 주로 로컬 스토리지에 저장하고, refresh 토큰은 주로 쿠키에 저장한다고 한다
Access 토큰
짧은 생명 주기로 탈취에서 사용까지 기간이 매우 짧고, 에디터 및 업로더에서 XSS를 방어하는 로직을 작성하여 최대한 보호할 수 있지만 CSRF 공격의 경우 클릭 한 번으로 단시간에 요청이 진행된다
권한이 필요한 모든 경로에 사용되기 때문에 CSRF 공격의 위험보다는 XSS 공격을 받는 게 더 나은 선택이다
Refresh 토큰
쿠키는 XSS 공격을 받을 수 있지만 httpOnly를 설정하면 완벽히 방어할 수 있다
CSRF는 Access 토큰이 접근하는 CRUD에 취약하지만 토큰 재발급 경로에서는 크게 피해를 입힐 만한 로직이 없다
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final AuthenticationManager authenticationManager;
// 회원가입
@Transactional
public void signup(SignupDto signupDto) {
// DB에 입력한 username이 존재하는지 확인
if (userRepository.existsByUsername(signupDto.getUsername())) {
throw new RuntimeException("이미 가입되어 있는 회원입니다.");
}
// 없으면 DB에 회원 저장
User user = signupDto.toEntity(passwordEncoder.encode(signupDto.getPassword()));
userRepository.save(user);
}
public void login(LoginDto loginDto, HttpServletResponse response) {
// 입력 데이터에서 username, password 꺼냄
String username = loginDto.getUsername();
String password = loginDto.getPassword();
// 로그인을 위한 Spring Security 인증 토큰 생성
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(username, password, null);
// AuthenticationManager를 통해 사용자 인증 시도
// 인증 성공 시, 사용자 정보(Principal)와 권한(Authorities)을 포함한 Authentication 객체 반환
Authentication authentication = authenticationManager.authenticate(authToken);
// 인증된 사용자 이름 추출
String authenticatedUsername = authentication.getName();
// 인증된 사용자의 권한(Role) 추출
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
// JWT 액세스 토큰 및 리프레시 토큰 생성
String access = jwtUtil.generateAccessToken("access", authenticatedUsername, role);
String refresh = jwtUtil.generateRefreshToken("refresh");
// DB에서 사용자 정보 조회 (리프레시 토큰 저장을 위함)
User user = userRepository.findByUsername(authenticatedUsername)
.orElseThrow(() -> new UsernameNotFoundException("해당하는 회원을 찾을 수 없습니다."));
// 새로 발급된 리프레시 토큰을 DB에 저장 (기존 토큰이 있다면 업데이트)
RefreshToken refreshToken = RefreshToken.builder()
.user(user)
.tokenValue(refresh)
.build();
refreshTokenRepository.save(refreshToken);
// 클라이언트 응답 헤더에 액세스 토큰 추가 (Bearer 타입)
response.setHeader("Authorization", "Bearer " + access);
// 클라이언트 응답 쿠키에 HttpOnly 리프레시 토큰 추가
response.addCookie(createCookie("refresh", refresh));
// HTTP 응답 상태를 OK(200)로 설정
response.setStatus(HttpStatus.OK.value());
}
// 쿠키 생성 메소드
public Cookie createCookie(String key, String value) {
Cookie cookie = new Cookie(key, value);
cookie.setMaxAge(24 * 60 * 60); // 쿠키 유효 기간 설정
//cookie.setSecure(true); // https 통신을 진행할 경우 설정
//cookie.setPath("/"); // 쿠키가 적용될 범위 설정
cookie.setHttpOnly(true); // 클라이언트 단에서 자바스크립트로 접근하지 못하도록 설정
return cookie;
}
}
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationConfiguration authenticationConfiguration;
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
// AuthenticationManager를 Bean으로 등록해야 로그인 처리가 가능함
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// csrf, form 로그인 방식, http basic 방식 disable
.csrf(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.httpBasic(AbstractHttpConfigurer::disable)
// 세션 설정 STATELESS
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/signup", "/css/**", "/js/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated())
/* JwtAuthenticationFilter 추가 */
.addFilterAfter(new JwtAuthenticationFilter(jwtUtil, userRepository), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
로그인 요청에 사용되는 DTO를 만들자
/**
* 로그인 요청에 사용되는 DTO
*/
@Data
public class LoginDto {
private String username;
private String password;
}
LoginDto를 authService에 전달하여 로그인 로직을 수행한다
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/signup")
public ResponseEntity<String> signup(@RequestBody SignupDto signupDto) {
authService.signup(signupDto);
return ResponseEntity.ok("회원가입 성공");
}
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody LoginDto loginDto, HttpServletResponse response) {
authService.login(loginDto, response);
return ResponseEntity.ok("로그인 성공");
}
}
Postman /api/auth/login
회원가입 한 아이디와 비밀번호로 로그인 진행

로그인 했을 때 헤더와 쿠키에 토큰이 잘 담긴 것을 확인할 수 있다

DB에도 잘 저장된다

다음은 토큰이 만료되었을 때 재발급하는 로직을 만들 것이다