1. 필터의 순서 정하기
1.1. Spring Security 동작 과정
요청이 들어오면 그림과 같이 AuthenticationFilter 를 거쳐 UsernamePasswordAuthenticationToken 을 만들고 이를 이용하여 그 다음 과정을 쭉쭉 거치게 됩니다. 결론적으로는 SecurityContextHolder 에 인증이 완료된 유저네임과 패스워드가 저장되게 되죠. 그렇다면 JWT 필터는 어떤가요?
1.2. JWT 필터
JWT 필터도 별반 다르지 않습니다. 요청이 들어오면 요청 헤더에 들어있는 토큰을 꺼내어 토큰 검증을 거칩니다. 유효한 토큰이라면 아래와 같이 시큐리티에 건내줄 수 있습니다.
1
2
3
4
| UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user, accessToken, new ArrayList<>());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
|
1.3. 결론은요?
요청이 들어오면 JWT 필터를 거치고 시큐리티 필터를 거치도록 하였습니다. 토큰을 먼저 꺼내보고 유효하지 않은 경우 아예 접근 자체를 못하게 하기 위해서였습니다.
2. JWT
AccessToken 은 Authorization 헤더에, RefreshToken 은 쿠키와 레디스에 넣는 방법을 사용했습니다.
2.1. JWTAuthenticationFilter.java
이곳에서는 JWT 필터를 거치는 api 와 거치지 않는 api 를 설정할 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
| package com.planner.travel.global.jwt;
import com.planner.travel.global.jwt.token.TokenAuthenticator;
import com.planner.travel.global.jwt.token.TokenExtractor;
import com.planner.travel.global.jwt.token.TokenValidator;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@RequiredArgsConstructor
public class JWTAuthenticationFilter extends OncePerRequestFilter {
private final TokenExtractor tokenExtractor;
private final TokenValidator tokenValidator;
private final TokenAuthenticator tokenAuthenticator;
@Override
protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response,
@NotNull FilterChain filterChain) throws ServletException, IOException {
String requestURI = request.getRequestURI();
if (requestURI.equals("/api/v1/auth/signup") ||
requestURI.equals("/api/v1/auth/login") ||
requestURI.startsWith("/api/v1/auth/token") ||
requestURI.startsWith("/api/v1/docs")
) {
filterChain.doFilter(request, response);
return;
}
String accessTokenFromHeader = tokenExtractor.getAccessTokenFromHeader(request);
String accessToken = null;
if (accessTokenFromHeader != null) {
accessToken = accessTokenFromHeader;
}
tokenValidator.validateAccessToken(accessToken);
tokenAuthenticator.getAuthenticationUsingToken(accessToken);
filterChain.doFilter(request, response);
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| package com.planner.travel.global.jwt.token;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class TokenExtractor {
public String getAccessTokenFromHeader(HttpServletRequest request) {
String accessTokenFromHeader = request.getHeader("Authorization")
.substring(7);
log.info("===========================================================================");
log.info("AccessToken from header: " + accessTokenFromHeader);
log.info("===========================================================================");
if (!accessTokenFromHeader.isEmpty()) {
return accessTokenFromHeader;
}
return null;
}
}
|
2.1.2.TokenValidator.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
| package com.planner.travel.global.jwt.token;
import com.planner.travel.domain.user.repository.UserRepository;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.security.Key;
@Component
@RequiredArgsConstructor
@Slf4j
public class TokenValidator {
private final UserRepository userRepository;
@Autowired
private Key key;
public void validateAccessToken(String token) throws ExpiredJwtException {
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
}
Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token);
}
}
|
2.1.3. TokenAuthenticator.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| package com.planner.travel.global.jwt.token;
import com.planner.travel.domain.user.entity.User;
import com.planner.travel.domain.user.repository.UserRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Optional;
@Component
@Slf4j
@RequiredArgsConstructor
public class TokenAuthenticator {
private final SubjectExtractor subjectExtractor;
private final UserRepository userRepository;
public void getAuthenticationUsingToken(String accessToken) {
accessToken = accessToken.substring(7);
Long userId = subjectExtractor.getUesrIdFromToken(accessToken);
Optional<User> user = userRepository.findById(userId);
if (user.isEmpty()) {
throw new EntityNotFoundException();
}
UsernamePasswordAuthenticationToken authenticationToken
= new UsernamePasswordAuthenticationToken(user, accessToken, new ArrayList<>());
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
}
}
|
2.1.4. JWTRefreshService.java
- AccessToken 이 만료 될 경우, 쿠키에서 refreshToken 을 꺼내어 유저를 검증합니다.
- 검증된 유저의 경우 AccessToken 과 refreshToken 이 갱신됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| package com.planner.travel.global.jwt;
import com.planner.travel.global.jwt.token.SubjectExtractor;
import com.planner.travel.global.jwt.token.TokenGenerator;
import com.planner.travel.global.jwt.token.TokenType;
import com.planner.travel.global.util.CookieUtil;
import com.planner.travel.global.util.RedisUtil;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Service
@Slf4j
@RequiredArgsConstructor
public class JWTRefreshService {
private final CookieUtil cookieUtil;
private final RedisUtil redisUtil;
private final SubjectExtractor subjectExtractor;
private final TokenGenerator tokenGenerator;
public void refreshAccessToken(HttpServletRequest request, HttpServletResponse response) {
Cookie cookieFromRequest = cookieUtil.getCookie(request, "refreshToken");
String refreshToken = cookieFromRequest.getValue();
String userId = subjectExtractor.getUesrIdFromToken(refreshToken).toString();
log.info("======================================================");
log.info("refreshToken: " + refreshToken);
log.info("userId: " + userId);
log.info("======================================================");
String redisValue = redisUtil.getData(userId);
if (refreshToken.equals(redisValue)) {
String newAccessToken = tokenGenerator.generateToken(TokenType.ACCESS, userId);
String newRefreshToken = tokenGenerator.generateToken(TokenType.REFRESH, userId);
response.setHeader("Authorization", newAccessToken);
cookieUtil.setCookie("refreshToken", newRefreshToken, response);
redisUtil.setData(userId, newRefreshToken);
}
}
}
|
2.1.5. KeyConfiguration.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| package com.planner.travel.global.jwt.token;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.util.Base64;
@Configuration
public class KeyConfiguration {
@Value("${spring.secret.key}")
private String SECRET_KEY;
@Bean
public Key jwtSigningKey() {
byte[] keyBytes = Base64.getDecoder().decode(SECRET_KEY.getBytes(StandardCharsets.UTF_8));
return Keys.hmacShaKeyFor(keyBytes);
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
| package com.planner.travel.global.jwt.token;
import io.jsonwebtoken.Jwts;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.security.Key;
@Component
@Slf4j
public class SubjectExtractor {
@Autowired
private Key key;
public Long getUserIdFromToken(String token) {
if (token.startsWith("Bearer ")) {
token = token.substring(7);
}
String userIdFromToken = Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody()
.getSubject();
Long userId = Long.parseLong(userIdFromToken);
log.info("===========================================================================");
log.info("Extracted UserID from Token: " + userId);
log.info("===========================================================================");
return userId;
}
}
|
2.1.7. TokenGenerator.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| package com.planner.travel.global.jwt.token;
import com.planner.travel.global.util.RedisUtil;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.security.Key;
import java.util.Date;
@Component
@RequiredArgsConstructor
@Slf4j
public class TokenGenerator {
private final RedisUtil redisUtil;
static final long ACCESS_TOKEN_VALID_TIME = 15 * 60 * 1000L; // 15 분간 유효.
static final long REFRESH_TOKEN_VALID_TIME = 30 * 60 * 1000L; // 30 분간 유효.
@Autowired
private Key key;
public String generateToken (TokenType tokenType, String userId) {
Claims claims = Jwts.claims().setSubject(userId);
Date now = new Date();
long extraTime = 0L;
if (tokenType.equals(TokenType.ACCESS)) {
extraTime = ACCESS_TOKEN_VALID_TIME;
} else if (tokenType.equals(TokenType.REFRESH)) {
extraTime = REFRESH_TOKEN_VALID_TIME;
}
String token = Jwts.builder()
.setClaims(claims)
.setIssuedAt(now)
.setExpiration(new Date(now.getTime() + extraTime))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
return token;
}
}
|
2.1.8. CookieUtil.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
| package com.planner.travel.global.util;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.ResponseCookie;
import org.springframework.stereotype.Component;
@Component
public class CookieUtil {
public Cookie getCookie(HttpServletRequest request, String cookieName) {
Cookie[] cookies = request.getCookies();
for (Cookie cookie : cookies) {
if (cookie.getName().equals(cookieName)) return cookie;
}
return null;
}
public void setCookie(String key, String value, HttpServletResponse response) {
ResponseCookie responseCookie = ResponseCookie.from(key, value)
.path("/")
.secure(true)
.sameSite("None")
.httpOnly(true)
.maxAge(Integer.MAX_VALUE)
.build();
response.addHeader("Set-Cookie", responseCookie.toString());
}
public void deleteCookie(String key, HttpServletResponse response) {
ResponseCookie responseCookie = ResponseCookie.from(key, "")
.path("/")
.secure(true)
.sameSite("None")
.httpOnly(true)
.maxAge(0)
.build();
response.addHeader("Set-Cookie", responseCookie.toString());
}
}
|
2.1.9. RedisUtil.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
| package com.planner.travel.global.util;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;
import java.time.Duration;
@Component
@RequiredArgsConstructor
public class RedisUtil {
private final StringRedisTemplate stringRedisTemplate;
private ValueOperations<String, String> valueOperations;
public String getData(String key) {
valueOperations = stringRedisTemplate.opsForValue();
return valueOperations.get(key);
}
public void setData(String key, String value) {
valueOperations = stringRedisTemplate.opsForValue();
valueOperations.set(key, value);
}
public void setDataWithExpire(String key, String value, Duration duration) {
valueOperations = stringRedisTemplate.opsForValue();
valueOperations.set(key, value, duration);
}
public void deleteData(String key) {
stringRedisTemplate.delete(key);
}
}
|
2.2. JWTController.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| package com.planner.travel.global.jwt;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping(value = "/api/v1/auth/token")
@RequiredArgsConstructor
public class JWTController {
private final JWTRefreshService jwtRefreshService;
@PostMapping(value = "/refresh")
public void refreshAccessToken(HttpServletRequest request, HttpServletResponse response) {
jwtRefreshService.refreshAccessToken(request, response);
}
}
|
3. Security
시큐리티에서 제공하는 form 로그인을 사용하지 않고, 로그인 리퀘스트로 받은 값을 그대로 사용하려면 시큐리티를 커스텀 해야 합니다. 그래서 시큐리티의 동작과정에서 보이는 클래스들을 아래와 같이 커스텀 하였습니다.
3.1. CustomAuthenticationFilter.java
- 로그인 리퀘스트를 통해 아이디와 비밀번호를 받습니다.
- 이를 이용하여 UsernamePasswordAuthenticationToken 을 만듭니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
| package com.planner.travel.global.security;
import com.fasterxml.jackson.core.exc.StreamReadException;
import com.fasterxml.jackson.databind.DatabindException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.planner.travel.domain.user.dto.request.LoginRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import java.io.IOException;
@Slf4j
public class CustomAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
public CustomAuthenticationFilter(final AuthenticationManager authenticationManager) {
super.setAuthenticationManager(authenticationManager);
}
@Override
public Authentication attemptAuthentication(final HttpServletRequest request, final HttpServletResponse response) {
final UsernamePasswordAuthenticationToken authenticationToken;
try {
final LoginRequest loginRequest = new ObjectMapper().readValue(request.getInputStream(), LoginRequest.class);
log.info("===========================================================================");
log.info("Login user's email : " + loginRequest.email());
log.info("===========================================================================");
authenticationToken = new UsernamePasswordAuthenticationToken(loginRequest.email(), loginRequest.password());
} catch (IOException e) {
throw new RuntimeException(e);
}
setDetails(request, authenticationToken);
return this.getAuthenticationManager().authenticate(authenticationToken);
}
}
|
3.2. CustomAuthenticationProvider.java
- UsernamePasswordAuthenticationToken 으로부터 아이디와 비밀번호를 조회합니다.
- CustomUserDetailsService 를 사용하여 아이디로 사용자를 조회하고, 존재한다면 이를 이용하여 새로운 UsernamePasswordAuthenticationToken 을 생성합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
| package com.planner.travel.global.security;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
@RequiredArgsConstructor
public class CustomAuthenticationProvider implements AuthenticationProvider {
private final CustomUserDetailsService customUserDetailsService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;
@Override
public Authentication authenticate(Authentication authentication) {
final UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication;
final String email = token.getName();
final String password = (String) token.getCredentials();
final CustomUserDetails userDetails;
userDetails = (CustomUserDetails) customUserDetailsService.loadUserByUsername(email);
if (!bCryptPasswordEncoder.matches(password, userDetails.getPassword())) {
throw new BadCredentialsException("AUTH_02");
}
return new UsernamePasswordAuthenticationToken(userDetails, password, userDetails.getAuthorities());
}
@Override
public boolean supports(Class<?> authentication) {
return authentication.equals(UsernamePasswordAuthenticationToken.class);
}
}
|
3.2.1. 왜 UsernamePasswordAuthenticationToken 에 userDetails 을 넣나요?
인텔리제이에서 UsernamePasswordAuthenticationToken 을 한번 찾아가보면, 아래와같이 나옵니다.
파라미터를 보면 principal 은 Object 입니다. 그래서 유저의 아이디나 이메일같이 유저의 특정값이 아닌, 유저를 넣어주었습니다. 이 principal 은 아래와 같이 인증이 완료된 유저의 인증 정보를 꺼내올 때 사용할 수 있습니다.
1
2
3
4
5
6
| public Long getLoginUserIndex() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Object principal = authentication.getPrincipal();
Long userId = ((User) principal).getId();
return userId;
}
|
유저의 어떤 정보가 필요할 지 몰라서 User 자체를 넣어준 것이죠 😇
3.3. CustomUserDetailsService.java
- 토큰을 통해 찾아온 이메일을 이용하여 특정 유저가 존재하는지 확인합니다.
- 만약 유저가 존재한다면, 이를 CustomUserDetails 에 넣어 반환 합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| package com.planner.travel.global.security;
import com.planner.travel.domain.user.entity.User;
import com.planner.travel.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Service;
import java.util.Optional;
@Service
@RequiredArgsConstructor
@Slf4j
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) {
Optional<User> user = userRepository.findByEmail(email);
return new CustomUserDetails(user.orElseThrow());
}
}
|
3.4. CustomUserDetails.java
사용자를 저장합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
| package com.planner.travel.global.security;
import com.planner.travel.domain.user.entity.User;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import java.util.Collection;
import java.util.Collections;
public record CustomUserDetails(User user) implements UserDetails {
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Collections.singleton(new SimpleGrantedAuthority("ROLE_USER"));
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getEmail();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
|
4. 총 정리
- 로그인 이후 요청이 들어올 때, JWTAuthenticationFilter 를 먼저 거치게 됩니다.
- 리퀘스트 URI 가 토큰 검증을 거쳐야 하는 URI 인 경우 accessToken 을 검증합니다.
- accessToken 의 검증이
- 제대로 완료된 경우, 유저에 대한 정보를 security 에 넘겨줍니다.
- 실패한 경우 유저 인증에 실패 했으므로, 403 을 반환합니다.
- security 는 받은 정보를 사용하여 인가 과정을 거칩니다.
- 인가 과정에 실패한 경우 403 을 반환합니다.