1. JwtProvider
package com.example.springboot3template.common.security;
import com.example.springboot3template.auth.domain.entity.UserRoleEnum;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import java.security.Key;
import java.util.Date;
import javax.crypto.SecretKey;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
@Slf4j
@Component
public class JwtProvider {
// Header KEY 값
public static final String AUTHORIZATION_HEADER = "Authorization";
// Cookie
public static final String REFRESH_TOKEN_COOKIE = "RefreshToken";
// 사용자 권한 값의 KEY
public static final String AUTHORIZATION_KEY = "auth";
// Token 식별자
public static final String BEARER_PREFIX = "Bearer ";
// 토큰 만료시간
// private final long ACCESS_TOKEN_TIME = 5 * 60 * 1000L; // 5분
private final long ACCESS_TOKEN_TIME = 1 * 60 * 1000L; // 5분
private final long REFRESH_TOKEN_TIME = 14 * 24 * 60 * 60 * 1000L; // 2주
@Value("${jwt.secret.key}") // Base64 Encode 한 SecretKey
private String secretKey;
private Key key;
private final SignatureAlgorithm signatureAlgorithm = SignatureAlgorithm.HS256;
@PostConstruct
public void init() {
if (secretKey == null || secretKey.isEmpty()) {
throw new IllegalStateException("JwtProvider에 secretKey가 null입니다.");
}
// JwtProvider
byte[] bytes = Decoders.BASE64.decode(secretKey);
key = Keys.hmacShaKeyFor(bytes);
}
// 토큰 생성
public String createAcessToken(String username, UserRoleEnum role) {
Date date = new Date();
String auth = role.getAuthority();
log.info("AccessToken 생성: username={}, role={}", username, auth);
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username) // 사용자 식별자값(ID)
.claim(AUTHORIZATION_KEY, auth) // 사용자 권한
.setIssuedAt(date) // 발급일
.setExpiration(new Date(date.getTime() + ACCESS_TOKEN_TIME)) // 만료 시간
.signWith(key, signatureAlgorithm) // 암호화 알고리즘
.compact();
}
public String createRefreshToken(String username) {
Date now = new Date();
log.info("RefreshToken 생성: username={}", username);
return BEARER_PREFIX +
Jwts.builder()
.setSubject(username)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + REFRESH_TOKEN_TIME))
.signWith(key, signatureAlgorithm)
.compact();
}
// 어세스 토큰 헤더에 추가
public void addAccessTokenToHeader(String accessToken, HttpServletResponse res) {
res.setHeader(AUTHORIZATION_HEADER, accessToken);
log.info("AccessToken이 응답 헤더에 추가되었습니다.");
}
// JWT Cookie 에 저장
public void addRefreshTokenToCookie(String refreshToken, HttpServletResponse res) {
String token = substringToken(refreshToken);
Cookie cookie = new Cookie(REFRESH_TOKEN_COOKIE, token); // Name-Value
cookie.setPath("/");
cookie.setHttpOnly(true);
cookie.setSecure(true);
cookie.setMaxAge(14 * 24 * 60 * 60); // 2주
// Response 객체에 Cookie 추가
res.addCookie(cookie);
log.info("쿠키가 response에 추가되었습니다.");
}
// JWT 토큰 substring
public String substringToken(String tokenValue) {
if (StringUtils.hasText(tokenValue) && tokenValue.startsWith(BEARER_PREFIX)) {
return tokenValue.substring(7);
}
log.error("Not Found Token");
throw new NullPointerException("Not Found Token");
}
public SecretKey getSecretKey() {
return (SecretKey) this.key;
}
public long getExpiration(String token) {
Date expiration = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getExpiration();
return expiration.getTime() - System.currentTimeMillis();
}
public String getUsernameFromToken(String token) {
Claims claims = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
return claims.getSubject();
}
}
2. JwtFilter
package com.example.springboot3template.common.security;
import static com.example.springboot3template.common.security.JwtProvider.AUTHORIZATION_HEADER;
import static com.example.springboot3template.common.security.JwtProvider.REFRESH_TOKEN_COOKIE;
import com.example.springboot3template.auth.application.service.RefreshTokenService;
import com.example.springboot3template.common.globalException.CustomException;
import com.example.springboot3template.common.globalException.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.MalformedJwtException;
import io.jsonwebtoken.UnsupportedJwtException;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import io.jsonwebtoken.security.SignatureException;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import javax.crypto.SecretKey;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
@Component
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends OncePerRequestFilter {
private final UserDetailsService userDetailsService;
private final RefreshTokenService refreshTokenService;
@Value("${jwt.secret.key}")
private String secretKey;
@Override
protected void doFilterInternal(@NonNull HttpServletRequest req, @NonNull HttpServletResponse res, @NonNull FilterChain filterChain)
throws ServletException, IOException {
// 요청 url 확인
String url = req.getRequestURI();
// 로그인, 회원가입 등의 검증이 필요 없는 url일 경우 필터 제외
if (isAuthorizationPassRequest(url)) {
log.info("인증 제외 API 요청: {}", url);
filterChain.doFilter(req, res);
return;
}
// 헤더에서 어세스 토큰 확인
String accessToken = getAccessTokenFromHeader(req);
log.info("요청에서 추출된 토큰: {}", accessToken); // 개발용
// log.info("토큰 추출 완료");
SecretKey key = getSecretKey();
// 1. AccessToken 검증
if (StringUtils.hasText(accessToken) && validateToken(accessToken, key)) {
// if (isBlacklisted(accessToken)) {
// log.warn("AccessToken이 블랙리스트에 포함되어 있음");
// throw new CustomException(ErrorCode.INVALID_TOKEN);
// }
if (refreshTokenService.isBlackListed(accessToken)) {
log.warn("블랙리스트 토큰입니다.");
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
authenticateUser(accessToken, key);
log.info("AccessToken 인증 성공");
}
filterChain.doFilter(req, res);
}
// 토큰 검증
public boolean validateToken(String token, SecretKey key) {
if (token == null || token.isEmpty()) {
log.error("토큰 값이 null 또는 비어있습니다.");
return false;
}
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
log.info("토큰 검증 성공");
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
log.error("유효하지 않은 JWT 서명 또는 포맷 오류: {}", e.getMessage());
throw new CustomException(ErrorCode.INVALID_TOKEN);
} catch (ExpiredJwtException e) {
log.error("ExpiredJwtException - 만료된 토큰: {}", e.getMessage());
throw new CustomException(ErrorCode.UNAUTHORIZED);
} catch (UnsupportedJwtException e) {
log.error("UnsupportedJwtException - 지원하지 않는 토큰: {}", e.getMessage());
throw new CustomException(ErrorCode.INVALID_TOKEN);
} catch (IllegalArgumentException e) {
log.error("IllegalArgumentException - 잘못된 인자: {}", e.getMessage());
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
}
// 헤더에서 어세스 토큰 가져오기
public String getAccessTokenFromHeader(HttpServletRequest req) {
String bearerToken = req.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
log.warn("Authorization 헤더가 없거나 형식이 잘못되었습니다.");
return null;
}
public String getRefreshTokenFromCookie(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if (cookies == null || cookies.length == 0) {
log.warn("요청에 쿠키가 포함되어 있지 않습니다.");
throw new CustomException(ErrorCode.UNAUTHORIZED);
}
for (Cookie cookie : cookies) {
log.info("쿠키 이름: {}, 쿠키 값: {}", cookie.getName(), cookie.getValue()); // 개발용
// log.info("쿠키 이름: {}", cookie.getName());
if (REFRESH_TOKEN_COOKIE.equals(cookie.getName())) {
String cookieValue = cookie.getValue();
if (StringUtils.hasText(cookieValue)) {
log.info("디코딩된 토큰: {}", cookieValue); // 개발용
// log.info("토큰 디코딩 완료");
return cookieValue;
} else {
log.error("RefreshToken 쿠키는 존재하지만 값이 비어있거나 null입니다.");
throw new CustomException(ErrorCode.UNAUTHORIZED);
}
}
}
log.warn("RefreshToken 쿠키가 요청에 포함되지 않았습니다.");
throw new CustomException(ErrorCode.UNAUTHORIZED);
}
// 토큰에서 사용자 정보 가져오기
public Claims getUserInfoFromToken(String token, SecretKey key) {
return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody();
}
// 인증 통과 url
private boolean isAuthorizationPassRequest(String path) {
return path.startsWith("/api/v1/auth/") || path.startsWith("/api/v1/auth/sign-up");
}
// 디코딩 된 시크릿 키 전달
private SecretKey getSecretKey() {
if (secretKey == null || secretKey.isEmpty()) {
throw new IllegalStateException("JwtFilter에 secretKey가 null입니다.");
}
return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secretKey));
}
// 인증 객체 생성
private void authenticateUser(String token, SecretKey key) {
Claims claims = getUserInfoFromToken(token, key);
String username = claims.getSubject();
// 유저 정보 로드
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
// 인증 객체 생성 및 SecurityContextHolder에 저장
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
log.info("인증 완료: {}", authentication); // 개발용
// log.info("인증 완료");
}
}
3. RedisConfig
package com.example.springboot3template.common.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisPassword;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Value("${spring.data.redis.password}") // 비밀번호
private String redisPassword;
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisHost, redisPort);
if (!redisPassword.isEmpty()) {
config.setPassword(RedisPassword.of(redisPassword));
}
return new LettuceConnectionFactory(config);
}
@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
}
4. RedisTokenService
package com.example.springboot3template.auth.application.service;
import com.example.springboot3template.common.security.JwtProvider;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class RefreshTokenService {
private final RedisTemplate<String, String> redisTemplate;
private final JwtProvider jwtProvider;
// 리프레시 토큰 저장
public void saveRefreshToken(String username, String refreshToken, long expirationTimeMillis) {
String token = jwtProvider.substringToken(refreshToken);
redisTemplate.opsForValue().set("RT:" + username, token, expirationTimeMillis, TimeUnit.MILLISECONDS);
log.info("리프레시 토큰 저장 완료 - username={}, refreshToken={}", username, token); // 개발용
// log.info("리프레시 토큰 저장 완료 - username={}, refreshToken 발급완료", username); // 운영용
}
// 리프레시 토큰 조회
public String getRefreshToken(String username) {
return redisTemplate.opsForValue().get("RT:" + username);
}
// 리프레시 토큰 삭제
public void deleteRefreshToken(String username) {
redisTemplate.delete("RT:" + username);
log.info("리프레시 토큰 삭제 완료 - username={}", username);
}
// 로그아웃 시 블랙리스트 처리 (옵션)
public void addBlackList(String accessToken, long expirationTimeMillis) {
String key = "BL:" + accessToken;
redisTemplate.opsForValue().set(key, "logout", expirationTimeMillis, TimeUnit.MILLISECONDS);
log.info("액세스 토큰 블랙리스트 등록 완료 - key={}, expiresIn={}ms", key, expirationTimeMillis);
}
// 블랙리스트 여부 확인 (옵션)
public boolean isBlackListed(String accessToken) {
String result = redisTemplate.opsForValue().get(accessToken);
return "logout".equals(result);
}
}
5. AuthController
package com.example.springboot3template.auth.presentation.controller;
import com.example.springboot3template.auth.application.dto.req.LoginReq;
import com.example.springboot3template.auth.application.dto.req.SignUpReq;
import com.example.springboot3template.auth.application.dto.res.TokenRes;
import com.example.springboot3template.auth.application.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RequestMapping("/api/v1/auth")
@RestController
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
// 회원가입
@PostMapping("/sign-up")
public ResponseEntity<?> signUp(@Valid @RequestBody SignUpReq req) {
authService.signUp(req);
return ResponseEntity.ok().build();
}
// 로그인
@PostMapping("/login")
public ResponseEntity<TokenRes> login(@Valid @RequestBody LoginReq req, HttpServletResponse res) {
TokenRes tokenRes = authService.login(req, res);
return ResponseEntity.ok().body(tokenRes);
}
// 로그아웃
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest req) {
authService.logout(req);
return ResponseEntity.ok().build();
}
// 엑세스 토큰 재발급
@PostMapping("/reissue")
public ResponseEntity<TokenRes> reissueAccessToken(HttpServletRequest request, HttpServletResponse response) {
TokenRes tokenRes = authService.reissueAccessToken(request, response);
return ResponseEntity.ok().body(tokenRes);
}
}
6. TokenRes
package com.example.springboot3template.auth.application.dto.res;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class TokenRes {
private String accessToken;
}
7. AuthService
package com.example.springboot3template.auth.application.service;
import com.example.springboot3template.auth.application.dto.req.LoginReq;
import com.example.springboot3template.auth.application.dto.req.SignUpReq;
import com.example.springboot3template.auth.application.dto.res.TokenRes;
import com.example.springboot3template.auth.domain.entity.User;
import com.example.springboot3template.auth.domain.entity.UserRoleEnum;
import com.example.springboot3template.auth.infrastructure.repository.UserRepository;
import com.example.springboot3template.common.globalException.CustomException;
import com.example.springboot3template.common.globalException.ErrorCode;
import com.example.springboot3template.common.security.JwtFilter;
import com.example.springboot3template.common.security.JwtProvider;
import io.jsonwebtoken.Claims;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Optional;
import javax.crypto.SecretKey;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final JwtFilter jwtFilter;
private final JwtProvider jwtProvider;
private static final String ADMIN_TOKEN = "adminToken";
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final RefreshTokenService refreshTokenService;
private static final long REFRESH_TOKEN_TTL = 14 * 24 * 60 * 60 * 1000L;
// 회원가입
@Transactional
public void signUp(SignUpReq req) {
String username = req.getUsername();
String password = passwordEncoder.encode(req.getPassword());
UserRoleEnum role = req.getRole();
// 회원 중복 확인
Optional<User> checkUsername = userRepository.findByUsername(username);
if (checkUsername.isPresent()) {
throw new CustomException(ErrorCode.DUPLICATE_USERNAME);
}
if(role == null) {
role = UserRoleEnum.USER;
} else if(role == UserRoleEnum.ADMIN) {
if(req.getAdminToken() == null || !req.getAdminToken().equals(ADMIN_TOKEN)) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
}
// 사용자 등록
User user = User.builder()
.username(username)
.password(password)
.role(role)
.build();
userRepository.save(user);
}
// 로그인
@Transactional
public TokenRes login(LoginReq req, HttpServletResponse res) {
String username = req.getUsername();
String password = req.getPassword();
// 사용자 확인
User user = userRepository.findByUsername(username).orElseThrow(
() -> new CustomException(ErrorCode.LOGIN_FAIL_USERNAME)
);
// 비밀번호 확인
if (!passwordEncoder.matches(password, user.getPassword())) {
throw new CustomException(ErrorCode.LOGIN_FAIL_PASSWORD);
}
// 사용자 권한 가져오기
UserRoleEnum role = user.getRole();
// 엑세스 토큰 생성
String accessToken = jwtProvider.createAcessToken(username, role);
// 리프레시 토큰 생성 및 쿠키에 저장 후 Response 객체에 추가
String refreshToken = jwtProvider.createRefreshToken(user.getUsername());
jwtProvider.addRefreshTokenToCookie(refreshToken, res);
refreshTokenService.saveRefreshToken(username, refreshToken, REFRESH_TOKEN_TTL); // 2주
// Access Token은 응답 Body로 내려줌
return new TokenRes(accessToken);
}
// 로그아웃
@Transactional
public void logout(HttpServletRequest request) {
String accessToken = jwtFilter.getAccessTokenFromHeader(request);
String refreshToken = jwtFilter.getRefreshTokenFromCookie(request);
if (!StringUtils.hasText(accessToken)) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
// 1. AccessToken -> 블랙리스트로 등록
long expiration = jwtProvider.getExpiration(accessToken);
refreshTokenService.addBlackList(accessToken, expiration);
// 2. RefreshToken 삭제 (key = username 또는 식별자)
String username = jwtProvider.getUsernameFromToken(refreshToken);
refreshTokenService.deleteRefreshToken(username);
log.info("로그아웃 완료 - accessToken 블랙리스트 처리 & refreshToken 삭제");
}
@Transactional
public TokenRes reissueAccessToken(HttpServletRequest request, HttpServletResponse response) {
// 1. SecretKey 생성(디코딩 된 상태)
SecretKey key = jwtProvider.getSecretKey();
// 2. 리프레시 토큰 꺼내기 (쿠키에서)
String refreshToken = jwtFilter.getRefreshTokenFromCookie(request);
if (!StringUtils.hasText(refreshToken)) {
throw new CustomException(ErrorCode.UNAUTHORIZED);
}
// 3. 토큰 유효성 검증
boolean isValid = jwtFilter.validateToken(refreshToken, key);
if (!isValid) {
log.info("토큰이 유효하지 않습니다.");
throw new CustomException(ErrorCode.UNAUTHORIZED);
}
// 4. 토큰에서 사용자 정보 추출
Claims claims = jwtFilter.getUserInfoFromToken(refreshToken, key);
String username = claims.getSubject();
// 5. 사용자 조회 (DB에서)
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
UserRoleEnum role = user.getRole();
// Redis에 저장된 refreshToken과 비교
String savedRefreshToken = refreshTokenService.getRefreshToken(username);
if (!refreshToken.equals(savedRefreshToken)) {
log.warn("탈취된 토큰일 수 있습니다.");
throw new CustomException(ErrorCode.INVALID_TOKEN); // 탈취 가능성
}
// 6. 새로운 액세스 토큰 생성
String accessToken = jwtProvider.createAcessToken(username, role);
// 7. 응답 헤더에 새로운 액세스 토큰 추가 (선택)
jwtProvider.addAccessTokenToHeader(accessToken, response);
// 8. 기존의 리프레시 토큰 삭제
refreshTokenService.deleteRefreshToken(username);
// 9. 새로운 리프레시 토큰 발급
String newRefreshToken = jwtProvider.createRefreshToken(username);
// 10. 쿠키에 새로 발급된 리프레시 토큰 저장
jwtProvider.addRefreshTokenToCookie(newRefreshToken, response);
// 11. 레디스에 새로 발급된 리프레시 토큰 저장
refreshTokenService.saveRefreshToken(username, newRefreshToken, REFRESH_TOKEN_TTL);
log.info("AccessToken 재발급 완료 - username: {}", username);
return new TokenRes(accessToken);
}
}
'Spring Boot > Sprng Boot Sample Code' 카테고리의 다른 글
[Spring Boot 3.x] 6. @ControllerAdvice를 활용한 Global Exception Handling 전역 예외 처리 (0) | 2025.03.03 |
---|---|
[Spring Boot 3.x] 5. @Valid 어노테이션을 통한 DTO validation 설정 (0) | 2025.03.03 |
Spring Boot 에서 네이버 클라우드 Object Storage 에 파일 업로드 (1) | 2025.03.03 |
[SpringBoot 3.x] 4. @PreAuthorize 어노테이션을 이용한 API 인가 설정 (0) | 2025.03.03 |
[Spring Boot 3.x] 3. JWT 토큰 인증(filter) & API 테스트 (0) | 2025.03.01 |