2025. 3. 12. 14:40ㆍSpringboot/security
전체코드 : https://github.com/gks930620/spring_securty_all
https://brilliantdevelop.tistory.com/225에 이어 refresh token을 추가해
토큰재발급과 로그아웃기능을 구현해보자.
설정파일
application.yml
spring:
datasource:
url: jdbc:h2:mem:security
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create
properties:
hibernate:
show_sql: true
format_sql: true
default_batch_fetch_size: 100
open-in-view: false
devtools:
livereload:
enabled: true
freemarker:
cache: false
restart:
enabled: true
thymeleaf:
cache: false
jwt:
secret : ${JWT_SECRET_KEY}
#키는 길이만 충분하고 노출되지만 않으면 됨. gpt한테 만들어하던가 내가 막 타자 아무렇게 해도됨
expiration_access: 60000 #1분 테스트용
expiration_refresh : 300000 #테스트용 5분
logging:
level:
org.hibernate.SQL: debug
org.hibernate.type: trace
org.springframework.security : DEBUG
SecurityConfig
logout과 토큰 재발급관련 사항만 추가되었다.
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtUtil jwtUtil;
private final CustomUserDetailsService customUserDetailsService; //내가 빈으로 등록한것들
private final AuthenticationConfiguration authenticationConfiguration; //authenticationManger를 갖고있는 빈.
private final RefreshRepository refreshRepository;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http //내부H2DB 확인용. 진짜 1도 안중요함.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/h2-console/**").permitAll() // H2 콘솔 접근 허용
)
.csrf(csrf -> csrf.ignoringRequestMatchers("/h2-console/**")) // H2 콘솔 CSRF 비활성화
.headers(
headers -> headers.frameOptions(frame -> frame.disable())); // H2 콘솔을 iframe에서 허용
http //기본 session방식관련 다 X
.csrf(csrf -> csrf.disable())
.sessionManagement(
session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.formLogin(form -> form.disable())
.logout(logout -> logout.disable()) //기본 로그아웃 사용X
.httpBasic(basic -> basic.disable());
http //경로와 인증/인가 설정.
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/login", "/api/join", "/api/refresh/reissue").permitAll() //login필터는 기본적으로 /login 일 때 동작
.requestMatchers("/api/my/info","/api/logout").authenticated() //security 기본 로그아웃 url인 /logout은 사용X
);
http //필터
.userDetailsService(customUserDetailsService)
.addFilterAt(
new JwtLoginFilter(authenticationConfiguration.getAuthenticationManager(), jwtUtil,
refreshRepository),
UsernamePasswordAuthenticationFilter.class) //기존 세션방식의 로그인 검증필터 대체.
.addFilterBefore(
new JwtAccessTokenCheckAndSaveUserInfoFilter(jwtUtil, customUserDetailsService),
UsernamePasswordAuthenticationFilter.class);
http
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
String errorCause =
request.getAttribute("ERROR_CAUSE") != null ? (String) request.getAttribute(
"ERROR_CAUSE") : null;
//인증없이(access token없이) 인증필요한 곳에 로그인했을 떄.
if (errorCause == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\": \"인증이 필요합니다.\"}");
return;
}
// JwtAccessTokenCheckAndSaveUserInfoFilter 토큰체크하는부분
if (errorCause.equals("토큰만료")) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401 응답
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().write("{\"error\": \"Access Token expired\"}");
return;
}
if (errorCause.equals("로그인실패")) { //jwtLoginFilter 로그인시도부분.
response.setStatus(
HttpServletResponse.SC_UNAUTHORIZED); //로그인실패도 401로 하는게 보통
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"error\": \"아이디 비번 틀림.\"}");
return;
}
})
);
return http.build();
}
}
JwtUtil
jwtUtil에서 refresh 토큰생성메소드와 token type( access vs refresh)를 구별하는 메소드가 추가.
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secretKey;
@Value("${jwt.expiration_access}")
private long expirationAccess;
@Value("${jwt.expiration_refresh}")
private long expirationRefresh;
private SecretKey getSigningKey() {
return Keys.hmacShaKeyFor(secretKey.getBytes()); //HMAC 알고리즘일 때는 SecretKey로 return하기.
}
// Access Token 생성
public String createAccessToken(String username) {
return Jwts.builder()
.subject(username) // ✅ setSubject() -> subject()
.claim("token_type", "access") //타입구분을 위해 추가.
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationAccess))
.signWith(getSigningKey()) // ✅ SignatureAlgorithm.HS256 대신 Jwts.SIG.HS256 사용
.compact();
}
public String createRefreshToken(String username) {
return Jwts.builder()
.subject(username)
.issuedAt(new Date())
.claim("token_type" ,"refresh")
.expiration(new Date(System.currentTimeMillis() + expirationRefresh))
.signWith(getSigningKey()) // ✅ SignatureAlgorithm.HS256 대신 Jwts.SIG.HS256 사용
.compact();
}
//토큰에서 username 추출. 뭐 subject에 uuid를 넣기도하지만.. 여기서는 subject에 username세팅했었음.
public String extractUsername(String token) {
return Jwts.parser()
.verifyWith(getSigningKey()) // 0.12.3버전에서는 verifyWith에 Key말고 SecretKey가 와야한다.
.build()
.parseSignedClaims(token)
.getPayload()
.getSubject();
}
//토큰에서 인증여부 확인. 코드상 문제가없다면 보통 만료됐을 때 false
public boolean validateToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(getSigningKey()) // ✅ 서명 검증
.build()
.parseSignedClaims(token) // ✅ JWT 파싱
.getPayload(); // ✅ claims(토큰 정보) 추출
// 토큰 만료 확인
Date expiration = claims.getExpiration();
return expiration.after(new Date()); // 현재 시간보다 만료 시간이 뒤에 있어야 유효
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
// 토큰에서 token_type 클레임 추출
public String getTokenType(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
return claims.get("token_type", String.class); // token_type 값 반환
} catch (ExpiredJwtException e) { //만료되었어도 token type은 return
Claims claims = e.getClaims();
return claims != null ? claims.get("token_type", String.class) : null;
}
}
}
//jjwt 버전에 따라 구현방식이 다르다. 현재는 0.12.3 버전.
Access token과 Refresh token으로 로그인 기능 구현하기
로그인시도

기존 access token 생성 후 보내는 기능에서 refresh token이 추가되었는데
refresh token은 DB에 저장해야 된다.
RefreshEntity
@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class RefreshEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
private UserEntity userEntity;
@Column(unique = true)
private String token;
}
RefreshRepository
public interface RefreshRepository extends JpaRepository<RefreshEntity,Long> {
public void deleteByToken(String token);
public RefreshEntity findByToken(String token);
}
JwtLoginFilter
로그인 성공했을 때 refresh 관련내용이 추가되었다.
@RequiredArgsConstructor
public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter {
private final AuthenticationManager authenticationManager; //new 로 생성하면 부모의 authenticationManager필드는 null이기 때문에 생성자로 주입.
private final JwtUtil jwtUtil;
private final RefreshRepository refreshRepository;
// 로그인 시도
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
try {
Map<String, String> credentials = new ObjectMapper().readValue(request.getInputStream(), HashMap.class);
String username = credentials.get("username");
String password = credentials.get("password");
UsernamePasswordAuthenticationToken authRequest = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
this.setDetails(request, authRequest);
return authenticationManager.authenticate(authRequest); //여기서 AuthenticationException 발생.
} catch (IOException e) {
e.printStackTrace();
throw new RuntimeException("Failed to parse authentication request", e); //readValue하는과정에서 발생.
}
}
// 로그인 성공 → JWT 토큰 발급
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException {
CustomUserAccount customUserAccount = (CustomUserAccount) authResult.getPrincipal();
String accessToken = jwtUtil.createAccessToken(customUserAccount.getUsername());
String refreshToken=jwtUtil.createRefreshToken(customUserAccount.getUsername());
// 새 Refresh Token 저장 (기존 Token 삭제 X)
RefreshEntity refreshEntity = new RefreshEntity();
refreshEntity.setUserEntity(customUserAccount.getUserEntity()); // 이전 필터에서 사용자정보저장할 떄 userEntity는 entity상태로 저장됨.
refreshEntity.setToken(refreshToken);
refreshRepository.save(refreshEntity);
// 토큰을 응답에 포함
response.setContentType("application/json");
response.getWriter().write(new ObjectMapper().writeValueAsString(
Map.of("access_token", accessToken, "refresh_token", refreshToken)
));
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request,
HttpServletResponse response, AuthenticationException failed)
throws IOException, ServletException {
request.setAttribute("ERROR_CAUSE" , "로그인실패"); //실패 후 config의 entryPoint로
super.unsuccessfulAuthentication(request,response,failed);
}
}
로그인 후 access token을 가지고 /api/my/info 요청

/api/my/info 요청에 access token으로 로그인하는 부분으로 인해 바뀌는 코드는 없다.
refresh token 재발급

클라이언트가 access token이 만료됐다는 사실을 받으면
refresh token으로 토큰재발급을 신청한다. (클라이언트가 신청한다. 서버는 각각의 요청만 잘 처리하면 됨)
/api/refresh/reiisue


이 요청은 permitAll() 이기 때문에 필터에서 통과돼야 한다.
/api/refresh/reiisue에서는 토큰이 refresh 토큰일 거고 이 토큰에 대한 검증은 컨트롤러에서 한다.
그래서 필터에서는 refresh 토큰이면 그냥 통과시키면 된다.
JwtAccessTokenCheckAndSaveUserInfoFilter
@RequiredArgsConstructor
public class JwtAccessTokenCheckAndSaveUserInfoFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil; //단순히 jwt기능제공
private final UserDetailsService userDetailsService; //내가만들고 빈 등록한 CustomUserDetailsService
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String token = getTokenFromRequest(request);
//refresh든 access든 authroization header로 보낸다고 가정함.
// 보내는 방식이 다르면 거기에 맞춰 따로 token얻는 방법을 작성해야함.
if (token == null) {
chain.doFilter(request, response);
return;
}
//token은 access 아니면 refresh 2개뿐
String tokenType = jwtUtil.getTokenType(token);
if (tokenType.equals("refresh")) {
chain.doFilter(request, response); //refresh토큰이 있다 => /api/refresh/reissue는 인증 필요없는 곳 무사통과
return;
}
//access token에 대해서....
if (!jwtUtil.validateToken(token)) { //토큰이 문제 있다면.. jwtUtil에 문제가 없다면 만료되었을 때만.
request.setAttribute("ERROR_CAUSE", "토큰만료");
chain.doFilter(request, response); // access_token이 만료된거라면 인증필요한 url => security가 authenticationException
return;
}
//만료 안 되었다면 SecurityContext에 인증정보 담아 로그인한걸로 판단!!
String username = jwtUtil.extractUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(
username); //내가 만든 CustomUserAccount
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(userDetails, null,
userDetails.getAuthorities());
authenticationToken.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authenticationToken); //이걸 해야 비로소 securityConfig가 로그인한 걸로 간주
chain.doFilter(request, response); //인증된 상태로 통과!
}
private String getTokenFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { //띄어쓰기 주의
return bearerToken.substring(7);
}
return null;
}
}
필터를 통과해 Controller에 오면 Controller에서 토큰재발급을 하면 된다.
RefreshController
@RestController
@RequestMapping("/api/refresh")
@RequiredArgsConstructor
public class RefreshController {
private final JwtUtil jwtUtil;
private final RefreshService refreshService;
@RequestMapping("/reissue")
public ResponseEntity<?> refreshAccessToken(@RequestHeader("Authorization") String refreshToken) {
//refresh토큰은 Authroization 헤더에..
String token = refreshToken.replace("Bearer ", "");
//폐기된 토큰(로그아웃)인지도 검증 = DB에 있냐 없냐 ! ( 클라이언트가 폐기된 토큰을 사용할 수도 있음. 탈취됐을 수도 있고..)
RefreshEntity find = refreshService.getRefresh(token);
if(find==null){
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(
Map.of("error","Refresh Token discarded"));
}
//기존 refresh 토큰 삭제
refreshService.deleteRefresh(token);
// Refresh Token 검증
if (!jwtUtil.validateToken(token)) { //만료되었을 때
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(
Map.of("error","Refresh Token expired"));
}
//만료 안되었을 때 , 폐기된 적도 없을 때..
String username = jwtUtil.extractUsername(token);
String newAccessToken = jwtUtil.createAccessToken(username);
String newRefreshToken=jwtUtil.createRefreshToken(username);
refreshService.saveRefresh(newRefreshToken); //refresh토큰은 저장
return ResponseEntity.ok(Map.of("access_token", newAccessToken , "refresh_token",newRefreshToken) );
}
}
RefreshService
@Service
@RequiredArgsConstructor
public class RefreshService {
private final RefreshRepository refreshRepository;
private final UserRepository userRepository;
private final JwtUtil jwtUtil;
@Transactional(readOnly = false)
public RefreshEntity getRefresh(String token){
return refreshRepository.findByToken(token);
}
@Transactional(readOnly = false)
public void saveRefresh(String token){
RefreshEntity refreshEntity = new RefreshEntity();
String username = jwtUtil.extractUsername(token);
refreshEntity.setUserEntity(userRepository.findByUsername(username));
refreshEntity.setToken(token);
refreshRepository.save(refreshEntity);
}
@Transactional(readOnly = false)
public void deleteRefresh(String token){
refreshRepository.deleteByToken(token);
}
}
로그아웃
config에서 로그아웃 /api/logout을 .authenticated()로 했다.
그래서 controller에 가기 위해서는 access token을 가지고 요청해야 필터를 통과한다.

그래서 postman 에서 보낼 때 Authrization 헤더에 Bearer방식으로 access token을 보내고
별도의 header(또는 body) 등으로 refresh토큰을 보내야 한다.
LogoutController
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class LogoutController {
private final JwtUtil jwtUtil;
private final RefreshService refreshService;
@RequestMapping("/logout")
public ResponseEntity<?> logout(@RequestHeader("RefreshToken") String refreshToken) {
//header 이름은 RefreshToken으로 정했다.
refreshService.deleteRefresh(refreshToken);
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
}
}
※참고로 refresh토큰을 삭제했기 때문에 refresh토큰으로 재발급요청에서 재발급은 안된다.
하지만 access token이 만료되기전까지 기존 access token으로 로그인은 가능하다.
여기서는 클라이언트가 기존 access token 사용안한다고 믿는 거고,
보안을 강화하려면 access token도 못 쓰게하는 별도의 기능이 서버에 필요하다.
실행결과
로그인전 /my/info

회원가입전 로그인시도

회원가입

회원가입 후 로그인시도

access token으로 /api/my/info 요청

access token시 /api/my/info

refresh토큰 재발급 /api/refresh/reissue

로그아웃시도 - Authrization Barerer에는 access token , Header에는 refresh토큰 세팅 후 요청
이미지에는 access token세팅한 부분이 안보임.

로그아웃 후 재발급 신청 => discared

refresh토큰 만료 => expired
