
JWT (Json Web Token) ์ RFC 7519 ์น ํ์ค์ผ๋ก ์ง์ ๋ย JSON ๊ฐ์ฒด๋ฅผ ์ฌ์ฉํด์ ํ ํฐ ์์ฒด์ ์ ๋ณด๋ฅผ ์ ์ฅํ๋ Web Token ์ ๋๋ค. ๋ค๋ฅธ ์ธ์ฆ ๋ฐฉ์๋ค์ ๋นํด ๊ฐ๋ณ๊ณ ๊ฐํธํด์ ์ ์ฉํ ์ธ์ฆ๋ฐฉ์ ์ ๋๋ค.
JWT๋ Header, Payload, Signature ๋ก ๊ตฌ์ฑ๋์ด ์์ต๋๋ค.
Header : Signature๋ฅผ ํด์ฑํ๊ธฐ์ํ ์๊ณ ๋ฆฌ์ฆ ์ ๋ณด.
Payload : ์๋ฒ์ ํด๋ผ์ด์ธํธ๊ฐ ์ฃผ๊ณ ๋ฐ๋ ์์คํ
์์ ์ค์ ์ฌ์ฉ๋ ๋ฐ์ดํฐ ์ ๋ณด.
Signature : ํ ํฐ์ ์ ํจ์ฑ ๊ฒ์ฆ์ ์ํ ๋ฌธ์์ด. ํค๋์ ํ์ด๋ก๋๋ฅผ ๋น๋ฐํค๋ก ์๋ช
ํ ๊ฐ. ์ด ์๋ช
์ ํตํด ๋ฐ์ดํฐ์ ๋ฌด๊ฒฐ์ฑ์ ๋ณด์ฅํ๊ณ , ํด๋ผ์ด์ธํธ๊ฐ ํ ํฐ์ ๋ณ์กฐํ ์ ์๊ฒ ํฉ๋๋ค.

์์ฒด ํฌํจ(Self-contained)
JWT๋ ์ฌ์ฉ์ ์ ๋ณด์ ๊ฐ์ ํด๋ ์์ ์์ฒด์ ์ผ๋ก ํฌํจํ๋ฏ๋ก ๋ณ๋์ ๋ฐ์ดํฐ๋ฒ ์ด์ค ์กฐํ ์์ด ์ ๋ณด ํ์ธ์ด ๊ฐ๋ฅํฉ๋๋ค.
ํ์ฅ์ฑ
JWT๋ ๋ค์ํ ํด๋ ์์ ๋ด์ ์ ์์ด ์ ์ฐํ๊ฒ ์ฌ์ฉํ ์ ์์ต๋๋ค. ์์คํ
์ํ ํ์ฅ์ด ์ฉ์ดํฉ๋๋ค.
ํ ํฐ ๊ธฐ๋ฐ ์ธ์ฆ
์๋ฒ์ ์ํ ์ ๋ณด๋ฅผ ์ ์ฅํ ํ์ ์์ด, ํด๋ผ์ด์ธํธ๊ฐ ํ ํฐ์ ๋ณด์ ํด ์ํ๋ฅผ ์ ์งํฉ๋๋ค.(Stateless)
์ธ์ฝ๋ฉ
Base64 URL Safe Encoding์ ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์ URL, Cookie, Header ๋ชจ๋ ์ฌ์ฉ ๊ฐ๋ฅํฉ๋๋ค.
ํ ํฐ ํฌ๊ธฐ
ํ์ด๋ก๋์ ํฌํจ๋ ์ ๋ณด๊ฐ ๋ง์์ง๋ฉด ํ ํฐ ํฌ๊ธฐ๊ฐ ์ปค์ง๋๋ค.
๋ฌดํจํ ์ด๋ ค์
JWT๋ ๋ฐ๊ธ ํ ๋ง๋ฃ ์ ๊น์ง๋ ๋ฌดํจํ๊ฐ ์ด๋ ค์, ์ฆ์ ๋ก๊ทธ์์ ์ฒ๋ฆฌ๋ ํ ํฐ ํด์ง ๋ฑ์ ์์
์ด ์ด๋ ต์ต๋๋ค.
ย JWT์ ๊ฐ์ฅ ํฐ ์ฅ์ ์ ์ธ์ ๊ณผ์ ์ฐจ์ด์์ ์ ์ ์๋๋ฐ, ์ธ์ ๊ณผ ๋ค๋ฅด๊ฒ ์ ๊ทผ์ ๋ํ ์ํ์ ๋ณด๋ฅผ ์๋ฒ์์ ๊ด๋ฆฌํ์ง ์๊ธฐ ๋๋ฌธ์ ์๋ฒ์ ๋ถํ๋ฅผ ์๋์ ์ผ๋ก ์ค์ผ ์ ์์ต๋๋ค. ์ด๋ฅผ Stateless ํ๋ค๊ณ ํํ ํฉ๋๋ค. ย ํ์ง๋ง ์ด๋ฌํ ์ํ์ ๋ณด๋ฅผ ์๋ฒ์์ ๊ด๋ฆฌํ์ง ์๊ธฐ ๋๋ฌธ์ Access Token์ด ํ์ทจ ๋์์ ๋ Token์ด ๋ง๋ฃ๋๊ธฐ ์ ๊น์ง๋ ํ์ทจ๋ Token์ ๋ํด์ ์ด๋ ํ ์กฐ์น๋ฅผ ์ทจํ ์๊ฐ ์์ต๋๋ค. ๊ทธ๋์ Access Token์ ๋ง๋ฃ ์๊ฐ์ ์งง๊ฒ ์ค์ ์ ํ๊ฒ ๋ฉ๋๋ค.
Access Token์ ๋ง๋ฃ ์๊ฐ์ ์งง๊ฒ ์ค์ ํ๊ฒ ๋๋ฉด ์ฌ์ฉ์๋ ๊ทธ๋งํผ ์์ฃผ ๋ก๊ทธ์ธ์ด๋ผ๋ ๊ณผ์ ์ ๊ฑฐ์ณ์ผ ํฉ๋๋ค. ์์ฃผ ์ฌ์ฉํ๋ ์ฌ์ดํธ์ธ๋ฐ ๊ณ์ ๋ก๊ทธ์์ ์ฒ๋ฆฌ๊ฐ ๋๋ค๋ฉด ์ฌ์ฉ์๋ ์์ฒญ ๋ถํธํด ํ๊ฒ ์ฃ . ๊ทธ๋์ ๋ง๋ฃ ์๊ฐ์ด ๊ธด Refresh Token์ ์ฌ์ฉํ๊ฒ ๋ฉ๋๋ค. Access Token์ด ๋ง๋ฃ ๋์์ ๋ ๋ก๊ทธ์ธ ์ ๋ฐ์ Refresh Token์ผ๋ก Token์ ๊ฐฑ์ ์ ์์ฒญํ๊ณ , ๊ฐฑ์ ๋ ํ ํฐ์ผ๋ก ์ด์ ์ ์์ฒญํ API๋ฅผ ๋ค์ ์์ฒญํ๋ ๊ฒ์ด์ฃ .
์ผ๋ฐ์ ์ผ๋ก Access Token์ 1๋ถ ~ 30๋ถ ์ ๋, Refresh Token์ 1์ฃผ or ๊ทธ ์ด์ ์ ๋์ ๋ง๋ฃ์๊ฐ์ ์ค์ ํฉ๋๋ค. ๋ณด์ ์ ์ฑ ์ ๋ฐ๋ผ ๋ค๋ฅด๊ฒ ์ค์ ํฉ๋๋ค.
๋ง๋ฃ ์๊ฐ์ด ๊ธด Refresh Token ์ด ํ์ทจ๋๋ฉด ๋ง๋ฃ ์ ๊น์ง๋ ๋ณด์์์ ๋ฌธ์ ๊ฐ ๋ฐ์ ํ ๊ฒ ์
๋๋ค. ์์์ ๋งํ๊ฒ๊ณผ ๊ฐ์ด Refresh Token์ ๊ฒฝ์ฐ์๋ Stateless ํ๊ธฐ ๋๋ฌธ์ Access Token๊ณผ ๋ง์ฐฎ๊ฐ์ง๋ก ํ์ทจ ๋์์ ๋ ์๋ฒ์์๋ ์ด๋ ํ ์กฐ์น๋ฅผ ์ทจํ ์ ์์ต๋๋ค.
๋ก๊ทธ์ธ ์ ๋ฐํ ๋ Refresh Token ์ ์ ์ฅ๋งค์ฒด(Database)์ ์ ์ฅํ์ฌ ํ์ทจ ํ์ธ ์ ์ ์ฅ๋ Refresh Token์ ๋ณ๊ฒฝํ๋ฏ๋ก์จ ๊ฐฑ์ ์ ๋ง์ ์ ์๊ฒ ์ง๋ง, ์ด๋ ํ์ทจ๋ฅผ ํ์ธํ์ ๋์ ํํ์ฌ ๊ฐ๋ฅํ ๋์ฑ
์
๋๋ค.
์ฌ๊ธฐ์ RTR (Refresh Token Rotation) ์ ์ ์ฉํ ์๋ ์์ต๋๋ค. Refresh Token์ ํ์ฉํ์ฌ ๊ฐฑ์ ์ ํ ๋ฒ๋ง ํ ์ ์๊ฒ ์ค์ ํ๋ ๋ฐฉ์์ธ๋ฐ์. Refresh Token์ผ๋ก Access Token์ ๊ฐฑ์ ํ ๋ Refresh Token๋ ๊ฐฑ์ ํ์ฌ ์ ์ฅ๋งค์ฒด(Database)์ ์ ์ฅ, ์ด์ Refresh Token์ผ๋ก ๊ฐฑ์ ์์ฒญ ์ ํ์ทจ๋ ๊ฒ์ผ๋ก ํ๋จํ์ฌ ์ ์ฅ ๋งค์ฒด์ Refresh Token ์ด๊ธฐํํ๋ ๋ฐฉ๋ฒ์
๋๋ค.
ํต์ ์ค์ Token์ด ํ์ทจ๋๊ฑฐ๋ ์กฐ์๋์ง ์๋๋ก HTTPS ์ฌ์ฉ.
Token์ ๋ง๋ฃ๊ธฐ๊ฐ์ ์งง๊ฒ ์ค์ .
CSRF(Cross-Site Request Forgery) ๋ฐฉ์ง, security ์ ์ฉ.
SpringBoot 3.3.1, gradle Procject ์์ ์ฝ๋์
๋๋ค.
Spring Security ์ JWT ๋ฅผ ์ ์ฉํ๊ธฐ ์ํ ์์กด์ฑ ์ฃผ์
์ ์ถ๊ฐํฉ๋๋ค.


build.gradle
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
//์๋ต..
//JWT
implementation group: 'io.jsonwebtoken', name: 'jjwt-api', version: '0.12.6'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-impl', version: '0.12.6'
runtimeOnly group: 'io.jsonwebtoken', name: 'jjwt-jackson', version: '0.12.6'
application.yml
jwt:
auth-key: ${jwt-key}
cookie-token-name: "GN_AUT"
๋ณด์์ ์ํด jwt-key ๋ ํ๊ฒฝ๋ณ์์ ๋ฑ๋ก์ ํฉ๋๋ค.
IntelliJ ์์ ํ๊ฒฝ๋ณ์ ๋ฑ๋กํ๋ ๋ฐฉ๋ฒ์ shift ๋๋ฒ ๋๋ฌ edit configurations ์ ๊ฒ์ํด ๋์จ ํ์
์ฐฝ์์ Environment variables ์ ๊ฐ์ ๋ฑ๋กํ๋ฉด ๋ฉ๋๋ค. Environment variables ๊ฐ ์๋ค๋ฉด Modify opetions๋ฅผ ๋๋ฌ ์ถ๊ฐํด ์ฃผ์ด์ผ ํฉ๋๋ค.
jwt-key ๋ ์ฌ์ฉํ ์๊ณ ๋ฆฌ์ฆ์ ๋ฐ๋ผ ๊ธธ์ด๋ฅผ ๋ง์ถ์ด Base64๋ก ์ธ์ฝ๋ฉ๋ ๊ฐ์ ์ค์ ํ์๋ฉด ๋ฉ๋๋ค.
(Base64 ์ธ์ฝ๋ฉ์ ๊ฒ์ํ๋ฉด ๋์ค๋ ์จ๋ผ์ธ ์ธ/๋์ฝ๋๋ฅผ ํ์ฉํ์ธ์.)

Token์ ์์ฑ, Cookie ์ Token ์ถ๊ฐ ๋ฐ ์ถ์ถ, Claim ๋ฐ์ดํฐ ์ถ์ถ, Token ์ ํจ์ฑ ๊ฒ์ฆ, XSS ๊ณต๊ฒฉ ๋ฐฉ์ง ๊ธฐ๋ฅ์ ์ํ TokenProvider ๋ฅผ ๊ตฌํํฉ๋๋ค.
config.jwt.TokenProvider
@Component
@Slf4j
public class TokenProvider implements InitializingBean {
@Value("${server.servlet.context-path}")
public String contextPath;
@Value("${jwt.auth-key}")
private String jwtAuthKey;
@Value("${jwt.cookie-token-name}")
private String tokenNameInCookie;
private SecretKey signingKey;
public final String authClaimName = "AUTH";
@Override
public void afterPropertiesSet() {
this.signingKey = Keys.hmacShaKeyFor(Base64.getDecoder().decode(this.jwtAuthKey));
}
public TokenResponse createToken(Authentication authentication, Member member) {
String authorities = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.joining(","));
Date now = new Date();
long dateTime = (now).getTime();
//1์๊ฐ
long tokenValidityInMilliseconds = 1000 * 3600;
Date accessValidity = new Date(dateTime + tokenValidityInMilliseconds);
//24์๊ฐ
long refreshTokenValidityInMilliseconds = 24 * (1000 * 3600);
Date refreshValidity = new Date(dateTime + refreshTokenValidityInMilliseconds);
return TokenResponse.builder()
.accessToken(
Jwts.builder()
.subject(authentication.getName())
.claim(this.authClaimName, authorities)
.issuedAt(now)
.expiration(accessValidity)
.signWith(this.signingKey)
.compact())
.refreshToken(
Jwts.builder()
.subject(authentication.getName())
.claim(this.authClaimName, authorities)
.issuedAt(now)
.expiration(refreshValidity)
.signWith(this.signingKey)
.compact())
.build();
}
public String getTokenFromCookie(HttpServletRequest httpServletRequest) {
Cookie[] cookies = httpServletRequest.getCookies();
String requestURI = httpServletRequest.getRequestURI().replace(this.contextPath, "");
if (cookies == null) {
log.error("No cookies found in request. Request URI: {}", requestURI);
return null;
}
return Arrays.stream(cookies)
.filter(cookie -> this.tokenNameInCookie.equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.map(this::doXssFilter)
.orElseGet(() -> {
log.error("Access token in cookie does not exist. Request URI: {}", requestURI);
return null;
});
}
public void setTokenToCookie(HttpServletResponse httpServletResponse, String accessToken) {
log.info("Add Token in Cookie.");
Cookie cookie = new Cookie(this.tokenNameInCookie, accessToken);
cookie.setHttpOnly(true);
cookie.setPath("/");
httpServletResponse.addCookie(cookie);
}
public String getUserId(String token) {
return Jwts.parser().verifyWith(this.signingKey)
.build().parseSignedClaims(token).getPayload().getSubject();
}
public String getClaim(String token, String claimName) {
return (String) Jwts.parser().verifyWith(this.signingKey)
.build().parseSignedClaims(token).getPayload().get(claimName);
}
public boolean validateToken(String token) throws ExpiredJwtException {
try {
Jwts.parser().verifyWith(this.signingKey).build().parseSignedClaims(token);
return true;
} catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
log.error("Invalid jwt signature.", e);
} catch (ExpiredJwtException e) {
log.error("This token is expired.", e);
throw e;
} catch (UnsupportedJwtException e) {
log.error("This jwt token is not supported.", e);
} catch (IllegalArgumentException e) {
log.error("Invalid jwt token.", e);
} catch (DecodingException e) {
log.error("JWT token decoding failed", e);
}
return false;
}
/**
* XSS(Cross-Site Scripting) ๊ณต๊ฒฉ์ ๋ฐฉ์งํ๊ธฐ ์ํด ํน์๋ฌธ์๋ฅผ HTML ๋ก ๋ณํ
*/
private String doXssFilter(String origin) {
if (StringUtils.isEmpty(origin)) {
return null;
}
return origin.replace("'", "'")
.replace("\"", """)
.replace("(", "(")
.replace(")", ")")
.replace("/", "/")
.replace("<", "<")
.replace(">", ">")
.replace("&", "&");
}
InitializingBean ์ ๊ตฌํํ์ฌ Bean ๋ฑ๋ก ํ jwtAuthKey๋ฅผ ์ด์ฉํด SecretKey๋ฅผ ์์ฑํฉ๋๋ค. Access Token์ 1์๊ฐ, Refresh Token์ ๋ง๋ฃ์๊ฐ์ 24์๊ฐ์ผ๋ก ์ค์ ํฉ๋๋ค.
GenericFilterBean์ ์์๋ฐ์ ์ฝ๋๋ฅผ ์์ฑํฉ๋๋ค. ์ด๋ Spring Security ๊ฐ ๋ด๋นํ๋ ์ธ์ฆ๊ณผ์ ์ ์ ์ฒ๋ฆฌํด์ผํฉ๋๋ค. ๋ง๋ฃ๋ Token ์ ๋ํด์๋ ๋ฑ๋ก๋ ์ฌ์ฉ์์ธ์ง, ๊ถํ์ด ์๋์ง ์ฒดํฌํ ํ์๊ฐ ์๊ธฐ ๋๋ฌธ์
๋๋ค.
๋ก๊ทธ์ธ, Swagger ๊ด๋ จ ์ฃผ์๋ฅผ ์ ์ธํ ๋ชจ๋ ์์ฒญ์ ๋ํด์ Token ๊ฒ์ฆ์ ์งํํฉ๋๋ค. Cookie ์์ Access Token์ ์ถ์ถํด ์ ํจ์ฑ ๊ฒ์ฆ์ ํ๊ณ ์ ํจํ๋ค๋ฉด Spring Security์ ์ฌ์ฉ์ ์ธ์ฆ์ ๋ณด๋ฅผ ์ ์ฅํฉ๋๋ค. Token์ด ์ ํจ์ฌ์ง ์์ ๊ฒฝ์ฐ ํด๋ผ์ด์ธํธ์๊ฒ ๋ฐ๋ก ErrorResponse๋ฅผ ์ ๋ฌํฉ๋๋ค.
config.jwt.JwtFilter
@RequiredArgsConstructor
@Slf4j
public class JwtFilter extends GenericFilterBean {
private final TokenProvider tokenProvider;
private final List<String> ignoreUris = List.of(SecurityConfig.ignoreUris);
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws ServletException, IOException {
HttpServletRequest httpServletRequest = (HttpServletRequest) request;
HttpServletResponse httpServletResponse = (HttpServletResponse) response;
String requestURI = httpServletRequest.getRequestURI().replace(this.tokenProvider.contextPath, "");
if (!this.ignoreUris.contains(requestURI)
&& !requestURI.startsWith("/swagger-") && !requestURI.startsWith("/api-docs")) {
log.info("Request URI : " + requestURI);
//1. SessionsCookie ์์ Access Token ์ถ์ถ
String accessToken = tokenProvider.getTokenFromCookie(httpServletRequest);
TokenValidDto tokenValidDto = new TokenValidDto(false, null, accessToken);
//2. Token ์ ํจ์ฑ ๊ฒ์ฆ
if (StringUtils.isNotEmpty(accessToken)) {
try {
checkTokenValidity(tokenValidDto);
} catch (ExpiredJwtException e) {
// Token ๋ง๋ฃ ์ ๋ฐ๋ก Token ๋ง๋ฃ ๋ฉ์์ง ์ ์ก
ResponseUtils.sendResponse(httpServletResponse, ErrorCode.EXPIRED_TOKEN);
return;
}
}
//3. Spring Security Authentication token ์ถ๊ฐ
if (tokenValidDto.isValid()) {
log.info(tokenValidDto.getUserId() + "'s token is valid.");
Collection<SimpleGrantedAuthority> authorities =
Arrays.stream(this.tokenProvider.getClaim(tokenValidDto.getAccessToken()
, this.tokenProvider.authClaimName).split(","))
.map(SimpleGrantedAuthority::new).toList();
Authentication authentication = new UsernamePasswordAuthenticationToken(
new User(this.tokenProvider.getUserId(
tokenValidDto.getAccessToken()), "", authorities),
tokenValidDto.getAccessToken(),
authorities
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
chain.doFilter(request, response);
}
private void checkTokenValidity(TokenValidDto tokenValidDto) {
if (StringUtils.isNotEmpty(tokenValidDto.getAccessToken())
&& tokenProvider.validateToken(tokenValidDto.getAccessToken())) {
String userId = tokenProvider.getUserId(tokenValidDto.getAccessToken());
tokenValidDto.setValid(true);
tokenValidDto.setUserId(userId);
}
}
}
common.response.util.ResponseUtils
@Slf4j
public class ResponseUtils {
public static void sendResponse(ServletResponse response, ResponseCode errorCode) {
MessageConfig messageConfig = ApplicationContextHolder.getContext()
.getBean(MessageConfig.class);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
ObjectMapper objectMapper = new ObjectMapper();
try (PrintWriter writer = response.getWriter()) {
writer.write(objectMapper.writeValueAsString(ErrorResponse.builder()
.status(errorCode.code())
.message(messageConfig.getMessage(errorCode))
.build()));
} catch (IOException e) {
log.error("Fail to send response.");
}
}
}
config.jwt.dto.TokenValidDto
@Getter
@Setter
@AllArgsConstructor
public class TokenValidDto {
private boolean valid;
private String userId;
private String accessToken;
}
common.response.ErrorResponse
@Builder
public record ErrorResponse(
String status,
String message,
@JsonInclude(JsonInclude.Include.NON_NULL)
String detailMessage
) {
}
JWT ์ Spring Security ๋ฅผ ํจ๊ป ์ฌ์ฉํ๋ฉด ๋ณด์์ฑ๊ณผ ํ์ฅ์ฑ ์ธก๋ฉด์์ ์ฅ์ ์ด ๋ง์ต๋๋ค. ์ ๊ทผ ๊ฒฝ๋ก์ ๋ํ 401 UNAUTHORIZED, 403 FORBIDDEN ์ค์ ์ ์ฝ๊ฒ ํ ์ ์๊ณ ํํฐ๋ฅผ ์ถ๊ฐํ์ฌ ์๋ธ๋ฆฟ ์ด์ ์ Token์ ์ ํจ์ฑ์ ๊ฒ์ฆํ ์๋ ์์ต๋๋ค.
Spring Security filter ์ด์ ์ JwtFilter๊ฐ ๋์ํ๋๋ก ์ถ๊ฐํ๊ณ , 401, 403 ์ฒ๋ฆฌ์ ๋ํ class ๋ฅผ ์์ฑํ์ฌ exceptionHandling ์ค์ ์ ํ๊ฒ ์ต๋๋ค.
401 UNAUTHORIZED ์ฒ๋ฆฌ๋ฅผ ์ํด AuthenticationEntryPoint์ ๊ตฌํ์ฒด JwtAuthenticationEntryPoint ๋ฅผ ์์ฑํฉ๋๋ค.
config.jwt.JwtAuthenticationEntryPoint
@Component
@Slf4j
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) {
log.error("401 UNAUTHORIZED");
ResponseUtils.sendResponse(response, ErrorCode.NOT_AUTHENTICATION);
}
}
403 FORBIDDEN ์ฒ๋ฆฌ๋ฅผ ์ํด AccessDeniedHandler์ ๊ตฌํ์ฒด JwtAccessDeniedHandler ๋ฅผ ์์ฑํฉ๋๋ค.
@Component
@Slf4j
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) {
log.error("403 FORBIDDEN");
ResponseUtils.sendResponse(response, ErrorCode.FORBIDDEN);
}
}
PasswordEncoder ์ค์ ์ ํ์ํ ์ ๋ณด๋ฅผ ์ค์ ํ์ผ์ ์ถ๊ฐํฉ๋๋ค. Bcrypt ์ํธํ ๋ฐฉ์์ ์ฌ์ฉํ๋ฉด ๊ฐ๋จํ์ง๋ง, ์์ง SHA-256 + salt key ์ ๋ํ ์์๋ ๋ง๊ธฐ ๋๋ฌธ์ ์ฌ๊ธฐ์๋ SHA-256 + salt key ๋ฅผ ์ฌ์ฉํ๊ฒ ์ต๋๋ค.
application.yml
pdkdf2:
key: geonny.log
salt-length: 16
iterations: 256
algorithm: PBKDF2WithHmacSHA256
config.SecurityConfig
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
public static final String[] ignoreUris = {"/v1/login"};
private final TokenProvider tokenProvider;
private final MessageConfig messageConfig;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
private final String[] swaggerUris = {"swagger-ui.html", "/swagger-ui/**", "/api-docs/**"};
@Value("${pdkdf2.key}")
private String pdkdf2Key;
@Value("${pdkdf2.salt-length}")
private Integer saltLength;
@Value("${pdkdf2.iterations}")
private Integer iterations;
@Value("${pdkdf2.algorithm}")
private String algorithm;
@Bean
public PasswordEncoder passwordEncoder() {
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("bcrypt", new BCryptPasswordEncoder());
encoders.put("SHA-256", new Pbkdf2PasswordEncoder(
this.pdkdf2Key, this.saltLength, this.iterations,
Pbkdf2PasswordEncoder.SecretKeyFactoryAlgorithm.valueOf(this.algorithm)));
return new DelegatingPasswordEncoder("SHA-256", encoders);
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(AbstractHttpConfigurer::disable)
.headers(c -> c.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)
.disable())
.authorizeHttpRequests(auth -> {
auth
.requestMatchers(ignoreUris).permitAll()
.requestMatchers(this.swaggerUris).permitAll()
.anyRequest().authenticated();
})
.exceptionHandling(c ->
c.authenticationEntryPoint(this.jwtAuthenticationEntryPoint)
.accessDeniedHandler(this.jwtAccessDeniedHandler))
.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(new JwtFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
domain.login.LoginController
@RestController
@RequiredArgsConstructor
@Tag(name = "๋ก๊ทธ์ธ", description = "๋ก๊ทธ์ธ ์์ฒญ")
@RequestMapping("v1")
public class LoginController {
private final LoginService loginService;
private final MessageConfig messageConfig;
@Operation(summary = "๋ก๊ทธ์ธ ์์ฒญ", description = """
""", operationId = "API-000-01")
@PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ItemResponse<TokenResponse>> getMemberById(
@RequestBody @Valid LoginRequest parameter,
HttpServletResponse httpServletResponse) {
return ResponseEntity.ok()
.body(ItemResponse.<TokenResponse>builder()
.status(messageConfig.getCode(NormalCode.SEARCH_SUCCESS))
.message(messageConfig.getMessage(NormalCode.SEARCH_SUCCESS))
.item(loginService.login(httpServletResponse, parameter))
.build());
}
}
domain.login.LoginService
@Service
@RequiredArgsConstructor
public class LoginService {
private final MemberQueryMethodRepository memberQueryMethodRepository;
private final AuthenticationManagerBuilder authenticationManagerBuilder;
private final TokenProvider tokenProvider;
private final PasswordEncoder passwordEncoder;
@Transactional
public TokenResponse login(
HttpServletResponse httpServletResponse, LoginRequest parameter) {
Member member = this.memberQueryMethodRepository.findById(parameter.memberId())
.orElseThrow(() -> new ServiceException(ErrorCode.NOT_AUTHENTICATION));
if (!this.passwordEncoder.matches(parameter.password(), member.getPassword())) {
throw new ServiceException(ErrorCode.NOT_AUTHENTICATION);
}
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(parameter.memberId(),
parameter.password());
try {
Authentication authentication =
this.authenticationManagerBuilder.getObject()
.authenticate(authenticationToken);
SecurityContextHolder.getContext()
.setAuthentication(authentication);
TokenResponse tokenResponse = this.tokenProvider
.createToken(authentication, member);
this.tokenProvider.setTokenToCookie(httpServletResponse, t
tokenResponse.accessToken());
return tokenResponse;
} catch (BadCredentialsException e) {
throw new ServiceException(ErrorCode.SERVICE_ERROR);
}
}
}
domain.login.CustomUserDetailService
CustomUserDetailService ๋ LoginService ์์ AuthenticationManagerBuilder ์ ์ํด ํธ์ถ๋๋ฉฐ ์ธ์ฆ์ ๋ณด๋ฅผ ํฌํจํ Security UserDetail ์ ๋ณด๋ฅผ ๋ฆฌํดํฉ๋๋ค.
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailService implements UserDetailsService {
private final MemberQueryMethodRepository memberQueryMethodRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberQueryMethodRepository.findById(username)
.map(user -> createUser(username, user))
.orElseThrow(() -> new UsernameNotFoundException(username + " -> not found."));
}
private User createUser(String username, Member member) {
if (member.getAuthority() == null) {
throw new ServiceException(ErrorCode.FORBIDDEN);
}
List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
grantedAuthorities.add(new SimpleGrantedAuthority(member.getAuthority()
.getAuthorityCode()));
log.info(username + "'s Authority : {}", grantedAuthorities);
return new User(
member.getMemberId(),
member.getPassword(),
grantedAuthorities
);
}
}
common.code.ErrorCode ์ฝ๋ ์ถ๊ฐ
EXISTS_DATA("ERR_DT_02"),
SQL_ERROR("ERR_SQ_01"),
NOT_AUTHENTICATION("ERR_AT_01"),
FORBIDDEN("ERR_AT_02"),
EXPIRED_TOKEN("ERR_AT_03");
resources/messages/message.properties ๋ฉ์์ง ์ถ๊ฐ
EXISTS_DATA=์ด๋ฏธ ์กด์ฌ๋ ๋ฐ์ดํฐ ์
๋๋ค.
SQL_ERROR=๋ฐ์ดํฐ๋ฅผ ์ฒ๋ฆฌํ๋๋ฐ ์คํจํ์์ต๋๋ค.
NOT_AUTHENTICATION=์๊ฒฉ์ฆ๋ช
์ ์คํจํ์์ต๋๋ค.
FORBIDDEN=์ ๊ทผ์ด ๊ฑฐ๋ถ๋์์ต๋๋ค.
EXPIRED_TOKEN=ํ ๊ทผ์ ๋ณด๊ฐ ๋ง๋ฃ๋์์ต๋๋ค.
์ด์ Swagger ๋ฅผ ํตํด ๋ก๊ทธ์ธ์ ํด๋ณด๊ฒ ์ต๋๋ค. http://localhost:13713/my-api/swagger-ui/index.html ์ ์ ์ํ์ฌ ๋ก๊ทธ์ธ Operation ์ Try it out ํฉ๋๋ค.



๋ก๊ทธ์ธ ์ฑ๊ณต ์ Cookie ํ์ธ (๊ฐ๋ฐ์๋ชจ๋ > Application > Cookies > domain์ฃผ์)


console
This jwt token is not supported.
401 UNAUTHORIZED
Controller method ์ @PreAuthorize("hasRole('ADMIN')") ๊ณผ ๊ฐ์ด ์ถ๊ฐํ๋ฉด ํ์ธํ์ค ์ ์์ต๋๋ค.

console
403 FORBIDDEN
TokenProvider ์ tokenValidityInMilliseconds ๋ฅผ ์งง๊ฒ ์ค์ ํ๋ฉด ํ์ธํ์ค ์ ์์ต๋๋ค.

console
This token is expired.
JWT expired 1159987 milliseconds ago at 2024-09-09T06:01:31.000Z.
Current time: 2024-09-09T06:20:50.987Z. Allowed clock skew: 0 milliseconds.
Example Code ์์๋ Front๋ก ๋ถํฐ password ๋ฅผ ๋ฐ์ ๋ ์ํธํ์ ๋ํด์๋ ์๋ต๋์ด ์์ต๋๋ค. RSA ๋ฅผ ํ์ฉํ์ฌ Front ๋ก RSA Public key ๋ฅผ ์ ๋ฌํ๊ณ , Front๋ password ๋ฅผ RSA Public key๋ก ์ํธํ ํ์ฌ ์ ์ก, API ์๋ฒ๋ Private Key๋ก ๋ณตํธํ ํ์ฌ ์ฒ๋ฆฌํ๋ ๋ก์ง์ ์ถ๊ฐํ์ฌ ๋ณด์์ ๋์ผ ์ ์์ต๋๋ค.
Refresh Token์ ํ์ฉํ์ฌ Access Token ์ด ๋ง๋ฃ๋์์ ๋ Front๋ก ๋ง๋ฃ์ฝ๋๋ฅผ ์ ์กํ๊ณ , Front ๊ฐ ๋ง๋ฃ ์ฝ๋๋ฅผ ๋ฐ์ผ๋ฉด Refresh Token ์ ์๋ฒ๋ก ๋ค์ ์ ์กํ์ฌ Token ์ ๊ฐฑ์ ํ๋ ๋ก์ง์ ์ถ๊ฐํ๋ฉด ์ฌ์ฉ์ ํธ์์ฑ๊ณผ ๋ณด์์ ๋์ผ ์ ์์ต๋๋ค.
ย ์ธ์ ๋ฐฉ์๋ณด๋ค Token ๋ฐฉ์์ ์ ํธํ๋ ์ด์ ๋ ํ์ฅ์ฑ๊ณผ ๊ฒฝ์ ์ฑ ์ ๋๋ค. ์๋น์ค๊ฐ ํ์ฅ๋์ด๋ ํ ํฐ์ ์ ํจ์ฑ๋ง ๊ฒ์ฆํ๋ฉด ๋๊ณ , ์๋ฒ์ ๋ถํ๊ฐ ์๋์ ์ผ๋ก ์ ๊ธฐ ๋๋ฌธ์ ํ๋์จ์ด์ ๋๋ ๋น์ฉ๋ ์ค์ผ ์ ์์ ๊ฒ ์ ๋๋ค. ์ต๋ํ ๋ณด์์ ์ธ ์กฐ์น๋ค์ ์ทจํจ์ผ๋ก์จ ์์ ํ๊ฒ JWT ๋ฅผ ์ฌ์ฉํ ์ ์๋๋ก ํ๋ ๊ฒ์ด ์ค์ํฉ๋๋ค.
GenericFilterBean์ ํ์ค Filter Interface ์ ๋ฌ๋ฆฌ Spring Bean ์ผ๋ก ๊ด๋ฆฌ๋๋ฏ๋ก, ํํฐ์ ์๋ช ์ฃผ๊ธฐ๊ฐ Spring Context์ ์ํด ๊ด๋ฆฌ๋ฉ๋๋ค. ์ด๋ก ์ธํด Spring Container์ ์์๊ณผ ์ข ๋ฃ์ ๋ฐ๋ผ ํํฐ๋ ์๋์ผ๋ก ์ด๊ธฐํ ๋ฐ ์๋ฉธ๋๋ฉฐ, ์ถ๊ฐ์ ์ธ ์ค์ ์์ด Spring bean ๊ด๋ฆฌ ๊ธฐ๋ฅ์ ํ์ฉํ ์ ์์ต๋๋ค. (Spring Bean DI ๊ฐ๋ฅ)
์๋ sendResponse ํจ์์์ ApplicationContext์ ์ธ์คํด์ค๋ฅผ ์์ฑํ์ง ์๊ณ ApplicationContextHolder๋ฅผ ๋ณ๋๋ก ๊ตฌํํ์ฌ Context๋ฅผ ๋ฐ์์ค๋ ์ด์ ๊ฐ ๋ฌด์์ธ๊ฐ์?
public static void sendResponse(ServletResponse response, ResponseCode errorCode) {
MessageConfig messageConfig = ApplicationContextHolder.getContext()
.getBean(MessageConfig.class);
// omission...
}
doXssFilter์ origin ๋๋ฒ์งธ replace("\"", """) ์์ ๋ณํํ๋ ค๋ ๋ฌธ์๋ \ ์ธ๊ฐ์, " ์ธ๊ฐ์ ๐คโ