현재 진행중인 커뮤니티 백엔드 프로젝트에 Spring security와 JWT token을 통해 인증 인가를 구현해보려고 한다.
사용자가 이메일과 비밀번호를 입력하여 로그인 요청 -> LogInFilter에서 처리하도록
LogInFilter는 입력된 이메일과 비밀번호를 UsernamePasswordAuthenticationToken 객체로 생성하고, AuthenticationManager를 사용하여 인증시도
인증 성공 시 successfulAuthentication 메서드 호출 -> JWTUtil을 사용하여 두 가지 토큰을 생성 (액세스 토큰, 리프레시 토큰)
사용자가 API 요청을 보낼 때, JWTFilter가 요청을 가로챔
요청 헤더에서 액세스 토큰을 추출합니다.
JWTUtil을 사용하여 액세스 토큰의 유효성 검증 -> 만료된 경우에는 SC_UNAUTHORIZED 상태 코드와 함께 "access token expired" 메시지 반환
토큰의 카테고리가 "access"인지 확인 -> 맞지 않으면 "invalid access token" 메시지 반환
유효한 액세스 토큰 -> 토큰에서 사용자 정보를 추출
추출한 정보로 스프링 시큐리티의 인증 객체 생성 -> 세션 등록 -> 다음 필터로 전달
모든 검증이 완료되면 요청을 다음 필터로 전달하여 실제 요청 처리
사용자가 액세스 토큰 만료로 새로운 토큰 요청 -> 이때 쿠키에 저장된 리프레시 토큰 검증 -> 유효성 검증
유효성 검사 통과 시 -> 새로운 액세스 토큰, 리프레시 토큰 생성 -> 데이터베이스에 리프레시 토큰 저장
사용자가 로그아웃 요청 -> 리프레시 토큰 검증 및 삭제
Spring Security를 사용하여 웹 에플리케이션 보안 설정
AuthenticationConfiguration에서 AuthenticationManager 가져오기HttpSecurity 객체를 통해 보안 설정을 정의@Configuration
@Log4j2
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final RefreshTokenRepository refreshTokenRepository;
private final AuthenticationConfiguration authenticationConfiguration;
private final JWTUtil jwtUtil;
private final EmailService emailService;
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http, AuthenticationConfiguration authenticationConfiguration) throws Exception {
return http
.httpBasic(AbstractHttpConfigurer::disable)
.csrf(AbstractHttpConfigurer::disable)
.cors(withDefaults())
.authorizeHttpRequests(authorize -> authorize
//주소 바꾸기
.requestMatchers("/api/users/logIn").permitAll()
.requestMatchers("/api/users/logOut").permitAll()
.requestMatchers("/api/reissue").permitAll()
.requestMatchers("/api/posts/info").permitAll()
.requestMatchers("/api/users/signup").permitAll()
.requestMatchers("/error").permitAll()
.requestMatchers("/api/users/email/**").permitAll()
.requestMatchers("/api/users/signup/nickname/**").permitAll()
.requestMatchers("/images/profile/**").permitAll()
.requestMatchers("/images/post/**").permitAll()
.requestMatchers("/api/posts/**").hasRole("USER")
.requestMatchers("/api/users/user/**").hasRole("USER")
.anyRequest().authenticated()
)
.addFilterBefore(new JWTFilter(jwtUtil, refreshTokenRepository), LogInFilter.class)
.addFilterAt(new LogInFilter("/api/users/logIn", authenticationManager(authenticationConfiguration), jwtUtil, refreshTokenRepository, emailService), UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(new CustomLogoutFilter(jwtUtil, refreshTokenRepository, "/api/users/logOut"), LogoutFilter.class)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.build();
}
@Bean
public BCryptPasswordEncoder bCryptPasswordEncoder() {
return new BCryptPasswordEncoder();
}
}
요청이 들어올 때마다 호출, JWT를 검증하고 사용자 인증을 처리
@Log4j2
public class JWTFilter extends OncePerRequestFilter {
private final JWTUtil jwtUtil;
public JWTFilter(JWTUtil jwtUtil, RefreshTokenRepository refreshTokenRepository) {
this.jwtUtil = jwtUtil;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//request에서 Authorization 헤더를 찾음
// 헤더에서 access키에 담긴 토큰을 꺼냄
String accessToken = request.getHeader("access");
// 토큰이 없다면 다음 필터로 넘김
if (accessToken == null) {
filterChain.doFilter(request, response);
return;
}
try {
jwtUtil.isExpired(accessToken);
} catch (ExpiredJwtException e) {
//response body
PrintWriter writer = response.getWriter();
writer.print("access token expired");
//response status code
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
// 토큰이 access인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(accessToken);
if (!category.equals("access")) {
//response body
PrintWriter writer = response.getWriter();
writer.print("invalid access token");
//response status code
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
//토큰에서 username과 role 획득
String username = jwtUtil.getEmail(accessToken);
String role = jwtUtil.getRole(accessToken);
//userEntity를 생성하여 값 set
User user = User.builder()
.email(username)
.role(role)
.build();
//UserDetails에 회원 정보 객체 담기
CustomUserDto customUserDetails = new CustomUserDto(user);
//스프링 시큐리티 인증 토큰 생성
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
//세션에 사용자 등록
SecurityContextHolder.getContext().setAuthentication(authToken);
filterChain.doFilter(request, response);
}
}JWT(JSON Web Token)를 생성 및 검증
@Component
public class JWTUtil {
private SecretKey secretKey;
public JWTUtil(@Value("${spring.jwt.secret}") String secret) {
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), Jwts.SIG.HS256.key().build().getAlgorithm());
}
public String getEmail(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("email", String.class);
}
public String getRole(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("role", String.class);
}
public Boolean isExpired(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().getExpiration().before(new Date());
}
public String getCategory(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload().get("category", String.class);
}
public Boolean validateToken(String token) {
try {
Jwts.parser().verifyWith(secretKey).build().parse(token);
return true;
} catch (ExpiredJwtException | SignatureException e) {
return false;
}
}
//토큰 생성
public String createJwt(String category,String email, String role, Long expiredMs) {
return Jwts.builder()
.claim("category", category)
.claim("email", email)
.claim("role", role)
.issuedAt(new Date(System.currentTimeMillis()))
.expiration(new Date(System.currentTimeMillis() + expiredMs))
.signWith(secretKey)
.compact();
}
}Spring Security의 UsernamePasswordAuthenticationFilter를 확장하여 사용자 로그인 요청 처리
사용자가 로그인을 시도하면 이 필터가 실행 → 사용자 인증 처리하고 JWT를 생성 → 응답 헤더.
@Log4j2
public class LogInFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager;
private final JWTUtil jwtUtil;
private final ObjectMapper objectMapper = new ObjectMapper();
private final RefreshTokenRepository refreshTokenRepository;
private final EmailService emailService;
public LogInFilter(String defaultFilterUrl, AuthenticationManager authenticationManager, JWTUtil jwtUtil, RefreshTokenRepository refreshTokenRepository, EmailService emailService) {
setFilterProcessesUrl(defaultFilterUrl);
this.authenticationManager = authenticationManager;
this.jwtUtil = jwtUtil;
this.refreshTokenRepository = refreshTokenRepository;
this.emailService = emailService;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res) throws AuthenticationException {
log.info("hi");
String email = null;
String password = null;
try (BufferedReader reader = req.getReader()) {
Map<String, String> requestBody = objectMapper.readValue(reader, new TypeReference<Map<String, String>>() {});
email = requestBody.get("email");
password = requestBody.get("password");
} catch (IOException e) {
throw new RuntimeException(e);
}
log.info("email: " + email);
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(email, password, null);
return authenticationManager.authenticate(authToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest req, HttpServletResponse res, FilterChain chain, Authentication authentication) {
//유저 정보
String email = authentication.getName();
// emailService.sendEmailNotice(email);
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
Iterator<? extends GrantedAuthority> iterator = authorities.iterator();
GrantedAuthority auth = iterator.next();
String role = auth.getAuthority();
//토큰 생성
String access = jwtUtil.createJwt("access", email, role, 6000L);
String refresh = jwtUtil.createJwt("refresh", email, role, 86400000L);
//Refresh 토큰 저장
addRefreshEntity(refresh, 86400000L);
res.setHeader("access", access);
res.setHeader("Set-Cookie","refresh=" + refresh +";Path=/; Domain=localhost; HttpOnly; Max-Age=604800; SameSite=None; Secure;");
res.setStatus(HttpStatus.OK.value());
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest req, HttpServletResponse res, AuthenticationException failed) {
log.info("Authentication failed");
res.setStatus(401);
}
private void addRefreshEntity(String refresh, Long expiredMs) {
Date date = new Date(System.currentTimeMillis() + expiredMs);
RefreshToken refreshToken = RefreshToken.builder()
.token(refresh)
.expirationDate(date.toString())
.build();
refreshTokenRepository.save(refreshToken);
}
}사용자 로그아웃을 처리하는 커스텀 필터
JWT 기반 인증 시스템에서 사용자의 Refresh 토큰을 검증하고 제거 → 로그아웃 수행
public class CustomLogoutFilter extends GenericFilterBean {
private final JWTUtil jwtUtil;
private final RefreshTokenRepository refreshTokenRepository;
private final String defaultFilterUrl;
public CustomLogoutFilter(JWTUtil jwtUtil, RefreshTokenRepository refreshTokenRepository, String defaultFilterUrl) {
this.jwtUtil = jwtUtil;
this.refreshTokenRepository = refreshTokenRepository;
this.defaultFilterUrl = defaultFilterUrl;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
doFilter((HttpServletRequest) request, (HttpServletResponse) response, chain);
}
private void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
//path and method verify
if (!defaultFilterUrl.equals(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
String requestMethod = request.getMethod();
if (!requestMethod.equals("POST")) {
filterChain.doFilter(request, response);
return;
}
//get refresh token
String refresh = null;
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals("refresh")) {
refresh = cookie.getValue();
}
}
//refresh null check
if (refresh == null) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
//expired check
try {
jwtUtil.isExpired(refresh);
} catch (ExpiredJwtException e) {
//response status code
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
//response status code
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
//DB에 저장되어 있는지 확인
Boolean isExist = refreshTokenRepository.existsByToken(refresh);
if (!isExist) {
//response status code
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}
//로그아웃 진행
//Refresh 토큰 DB에서 제거
refreshTokenRepository.deleteByToken(refresh);
Cookie cookie = new Cookie("refresh", null);
cookie.setMaxAge(0);
cookie.setPath("/");
response.addCookie(cookie);
response.setStatus(HttpServletResponse.SC_OK);
}
}리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급 기능
@RestController
@RequiredArgsConstructor
public class ReissueController {
private final ReissueService reissueService;
@PostMapping("/api/reissue")
public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
return reissueService.reissue(request, response);
}
}리프레시 토큰을 사용하여 새로운 액세스 토큰과 리프레시 토큰을 발급
@Service
@RequiredArgsConstructor
public class ReissueService {
private final JWTUtil jwtUtil;
private final RefreshTokenRepository refreshTokenRepository;
public ResponseEntity<?> reissue(HttpServletRequest request, HttpServletResponse response) {
// get refresh token
String refresh = getRefreshTokenFromCookies(request);
if (refresh == null) {
return new ResponseEntity<>("refresh token null", HttpStatus.BAD_REQUEST);
}
// expired check
try {
jwtUtil.isExpired(refresh);
} catch (ExpiredJwtException e) {
return new ResponseEntity<>("refresh token expired", HttpStatus.BAD_REQUEST);
}
// 토큰이 refresh인지 확인 (발급시 페이로드에 명시)
String category = jwtUtil.getCategory(refresh);
if (!category.equals("refresh")) {
return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
}
// DB에 저장되어 있는지 확인
Boolean isExist = refreshTokenRepository.existsByToken(refresh);
if (!isExist) {
return new ResponseEntity<>("invalid refresh token", HttpStatus.BAD_REQUEST);
}
String email = jwtUtil.getEmail(refresh);
String role = jwtUtil.getRole(refresh);
// make new JWT
String newAccess = jwtUtil.createJwt("access", email, role, 600000L);
String newRefresh = jwtUtil.createJwt("refresh", email, role, 86400000L);
// Refresh 토큰 저장 DB에 기존의 Refresh 토큰 삭제 후 새 Refresh 토큰 저장
refreshTokenRepository.deleteByToken(refresh);
addRefreshEntity(newRefresh, 86400000L);
// response
response.setHeader("access", newAccess);
response.setHeader("Set-Cookie", "refresh=" + newRefresh + ";Path=/; Domain=localhost; HttpOnly; Max-Age=604800; SameSite=None; Secure;");
return new ResponseEntity<>(HttpStatus.OK);
}
private void addRefreshEntity(String refresh, Long expiredMs) {
Date date = new Date(System.currentTimeMillis() + expiredMs);
RefreshToken refreshToken = RefreshToken.builder()
.token(refresh)
.expirationDate(date.toString())
.build();
refreshTokenRepository.save(refreshToken);
}
private String getRefreshTokenFromCookies(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals("refresh")) {
return cookie.getValue();
}
}
}
return null;
}
}RefreshToken 엔티티와 관련된 데이터베이스 작업을 수행하는 메서드를 제공
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
// token을 사용하여 RefreshToken을 찾는 메서드
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<RefreshToken> findByToken(String token);
Boolean existsByToken(String token);
@Transactional
void deleteByToken(String token);
}