Spring Security + JWT (RefreshToken, AccessToken)를 사용한 로그인, 로그아웃 구현 - 4편
개요
* 1~3편 정리
1편에서 기본적인 세팅은 끝이 났다.
2편에서는 SecurityConfig에 대해서 보안 설정을 했다.
3편에서는 RefreshToken과 BlackListToken에 대해서 엔티티와 레포지토리, 서비스를 작성했다.
4편에서는 본격적으로 JWT 코드를 짜볼 것이다.
코드도 많이 길고 복잡하다..
<- 대략적인 파일 구조
<- 파일 구조에 맞게 세팅한다.
1. 사용자 인증 및 권한 관리를 위해 설정할 것
1.1 CustomUserDetails.java
public class CustomUserDetails implements UserDetails {
private final String userName; // 이름
private final String password; // 비밀번호
private final List<GrantedAuthority> authorities; // 권한 목록
// 생성자
public CustomUserDetails(String userName, String password, List<String> roles){
this.userName = userName;
this.password = password;
this.authorities = roles.stream() // 권한 관련 작업을 하기 위한 role return
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
}
`
` // 사용자가 가진 권한 반환
// SimpleGrantedAuthority 객체를 사용하여 역할을 권한으로 변환
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
// 사용자의 비밀번호를 반환
@Override
public String getPassword() {
return password;
}
// 사용자의 이름을 반환
@Override
public String getUsername() {
return userName;
}
// 사용자의 계정 만료 상태
@Override
public boolean isAccountNonExpired() {
return true;
}
// 잠금 상태
@Override
public boolean isAccountNonLocked() {
return true;
}
// 자격 증명 만료 상태
@Override
public boolean isCredentialsNonExpired() {
return true;
}
// 활성화 상태
@Override
public boolean isEnabled() {
return true;
}
}
- spring Security의 UserDetails 인터페이스를 상속하여 정의한다.
- 이 클래스가 하는 역할은 Spring Security는 이 클래스를 사용하여 사용자 인증 및 권한 부여를 처리한다.
- SimpleGrantedAuthority 는 역할 이름을 그대로 사용한다는 의미로 데이터베이스에 ROLE_ADMIN, ROLE_USER 이렇게 되어 있다면 이걸 그대로 사용하겠다는 의미이다.
- List<String> 형태의 역할을 생성자로 받고, 이를 GrantedAuthority로 변환하며 신원을 증명하는 데 사용하는 정보 --> 비밀번호나 토큰
- 일단 시큐리티를 사용하면 우리가 직접 로그인 처리를 안해도 된다.
- POST /login 에 대한 요청을 security가 가로채서 로그인 진행해주기 때문에 우리가 직접 @PostMapping("/login") 을 만들지 않아도 됨!
- 토큰 방식이 아닌 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.
(Key값 : Security ContextHolder) - Security ContextHolder 의 내부는 Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 지금 작성한 CustomUserDetails가 UserDetails를 설정해준다고 보면 된다.
1.2 CustomUserDetailsService.java
- 일단 이 클래스는 Spring Security의 UserDetailsService 인터페이스를 상속받아 구현한다.
- 나는 UserDetailsService 인터페이스를 구현한 CustomUserDetailsService 라는 클래스를 구현했다.
- 이전까지는 UserService에서 findUserByLoginId 이런 식으로 User을 불러왔었다.
- 하지만, 위에서 말했듯 기존 세션 방식에서의 Security는 UserDetails 가 필요하기 때문에 따로 설정이 필요하다.
=> 위에서 만든 CustomUserDetails 를 return하거나 org.springframework.security.core.userdetails.User를 불러와 리턴하는 방법이 있다. - 둘의 방식과 쓰임은 똑같다.
Q. CustomUserDetailsService.java 는 어떻게 사용될까?
- 사용자가 로그인할 때, CustomUserDetailsService의 loadUserByUsername 메서드가 호출되어 사용자 정보를 데이터베이스에서 조회한다.
- 만약, 사용자가 존재하지 않는 경우 UsernameNotFoundException 예외를 발생시키고
- 사용자가 존재하는 경우, UserDetails 객체를 생성하고 반환한다.
- Spring Security는 UserDetails 객체를 사용하여 사용자의 비밀번호와 권한 정보를 확인한다..
- UserDetails 객체는 사용자의 비밀번호와 권한 정보를 Spring Security에 제공한다.
- Spring Security는 이 정보를 바탕으로 사용자가 인증되었는지 확인하고, 권한에 따라 접근을 제어한다.
- UserDetailsService 를 작성하는 방법은 다양하게 작성이 가능하고 나는 그 중 아래 2가지로 구현을 해봤다.
1.2.1. UserBuilder를 사용하여 반환
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<User> user = userRepository.findByUserName(username); // 데이터베이스에서 사용자 이름을 기준으로 사용자를 조회
if (!user.isPresent()) {
throw new UsernameNotFoundException("해당 사용자가 존재하지 않습니다. : " + username);
}
// UserBuilder를 사용하여 UserDetails 객체를 생성 --> 사용자 정보를 쉽게 생성하고 구성
// withUsername(username)를 호출하여 사용자 이름을 설정
UserBuilder userBuilder = org.springframework.security.core.userdetails.User.withUsername(username);
userBuilder.password(user.get().getPassword()); // 암호화된 비밀번호를 설정
userBuilder.roles(user.get().getRole().stream()
.map(role -> role.getRoleName().name())
.toArray(String[]::new));
return userBuilder.build();
}
}
- 첫 번째 방법은 UserBuilder를 사용하여 UserDetails 객체를 생성하는 방법이다.
- Spring Security에서 제공하는 UserBuilder는 UserDetails 객체를 유연하게 생성하고 설정할 수 있도록 도와준다.
- UserBuilder는 org.springframework.security.core.userdetails.User 클래스 내부에 정적 메서드로 정의되어 있다.
- org.springframework.security.core.userdetails.User 클래스의 withUsername 정적 메서드를 사용하여
UserBuilder 인스턴스를 생성한다. - 이때 사용자 이름, 암호화된 비밀번호, 역할을 설정하여 UserBuilder를 반환한다.
- 이를 통해 UserDetails 객체를 커스텀 하지 않고도 쉽게 생성할 수 있다. -> 즉, 위에서 만든 CustomUserDetails 클래스는 사용 하지 않고도 시큐리티가 제공하는 기능을 사용하여 사용자 정보를 데이터베이스에서 꺼낼 수 있다.
- 이 방식은 간결하고 가독성이 높아, UserDetails 객체를 쉽게 생성할 수 있다.
1.2.2 CustomUserDetails 클래스에 직접 값 반환
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
Optional<User> userOptional = userRepository.findByUserName(username); // 데이터베이스에서 사용자 이름을 기준으로 사용자를 조회
if (!userOptional.isPresent()) {
throw new UsernameNotFoundException("해당 사용자가 존재하지 않습니다. : " + username);
}
User foundUser = userOptional.get();
Set<Role> roles = foundUser.getRoles();
List<String> roleNames = roles.stream()
.map(role -> role.getRoleName().name()) // "ROLE_" 접두사를 제거하지 않음
.collect(Collectors.toList());
return new CustomUserDetails(
foundUser.getUsername(),
foundUser.getPassword(),
roleNames
);
}
}
- 두 번째 방법이다.
- 위에서 만든 CustomUserDetails에 사용자 이름, 암호화된 비밀번호, 역할을 담아 반환해주는 것이다.
- 어찌됐던 두 개의 사용법은 다 똑같고 반환 부분도 거의 비슷하다.
- 하지만 첫 번째 방법과 달리 위에서 만든 CustomUserDetails 클래스를 사용하면 사용자 정보를 추가하거나 커스텀할 수 있다.
- 특정 요구 사항에 맞게 클래스와 메서드를 자유롭게 정의할 수 있다.
Q. 위의 코드들을 보면 org.springframework.security.core.userdetails.User 클래스를 호출하거나, CustomUserDetails 클래스를 불러오던데 무슨 차이인지?
A.
- org.springframework.security.core.userdetails.User 클래스는 UserDetails 인터페이스를 시큐리티 자체적으로 구현하고 있다.
- 즉, org.springframework.security.core.userdetails.User 클래스는 UserDetails 인터페이스를 구현한 기본 제공 구현체로, 사용자 이름, 비밀번호, 권한 정보를 포함하고 있다.
- CustomUserDetails 클래스를 안불러와도 사용이 가능하다는 뜻이다.
- 위에서도 설명했듯이 기존 세션방식으로 시큐리티 로그인에 성공하면 Security Session을 생성해 준다.
--> (Key값 : Security ContextHolder) - 이때 Security Session(Authentication(UserDetails)) 이런 식의 구조로 되어있는데 CustomUserDetails 클래스에서 UserDetails를 설정해준다고 보면 되고 org.springframework.security.core.userdetails.User 클래스는 자체적으로 UserDetails 값을 가지고 있다고 보면 된다.
Q. 어떤 방법이 적절한가? --> 실무에서는?
- 단순한 요구 사항: 간단한 사용자 인증을 처리해야 하고, 특별한 커스터마이징이 필요 없다면 UserBuilder를 사용
- 확장성 필요: 사용자 정보에 추가적인 필드를 포함하거나, 커스터마이징된 사용자 정보를 사용해야 한다면 CustomUserDetails를 사용 --> 선호!
- 실무에서는 UserDetails를 상속하여 새로운 커스텀 UserDetails 를 만드는 것을 선호한다.
2. JWT 설정
이제 본격적으로 JWT 리프레시 토큰, 엑세스 토큰 발급을 위한 코드를 작성하겠다.
2.1 JwtAuthenticationToken.java
public class JwtAuthenticationToken extends AbstractAuthenticationToken {
private String token; // JWT 토큰
private Object principal; // 사용자 정보
private Object credentials; // 자격 증명 정보
// 인증이 된 사용자 정보와 권한을 설정
public JwtAuthenticationToken(Collection<? extends GrantedAuthority> authorities , Object principal, Object credentials) {
super(authorities); // 권한 설정
this.principal = principal; // 사용자를 식별하는 고유한 엔티티 --> 사용자의 사용자 이름, ID
this.credentials = credentials; // 사용자가 자신의 신원을 증명하는 데 사용하는 정보 -> 비밀번호, 토큰
this.setAuthenticated(true); // 사용자가 가질 수 있는 권한이나 역할 --> ROLE_USER, ROLE_ADMIN
}
// 인증되지 않은 상태로 JWT 토큰을 설정
public JwtAuthenticationToken(String token){
super(null); // 권한 없음
this.token = token;
this.setAuthenticated(false); // 인증되지 않은 상태 설정
}
// 자격 증명 정보를 반환
@Override
public Object getCredentials() {
return this.credentials;
}
// 사용자 정보를 반환
@Override
public Object getPrincipal() {
return this.principal;
}
}
- JWT 기반으로 한 인증을 나타내는 토큰을 구현하는 클래스이다.
- AbstractAuthenticationToken을 상속받아 인증 정보를 반환하는 메서드를 제공한다.
- 인증되지 않은 상태와 인증된 상태를 구분하여 생성자 제공한다.
- Principal
사용자를 식별하는 고유한 엔티티 --> 사용자의 사용자 이름 또는 ID - Credentials
사용자가 자신의 신원을 증명하는 데 사용하는 정보 --> 비밀번호나 토큰 - Authorities
사용자가 시스템에서 가질 수 있는 권한이나 역할 --> ROLE_USER나 ROLE_ADMIN
- 사용자가 시스템에 로그인을 시도하면, Credentials를 통해 자신의 신원을 증명하고, 시스템은 Principal을 통해 사용자를 식별하며, 이후 Authorities를 통해 사용자가 어떤 작업을 수행할 수 있는지를 결정한다.
2.2 JwtTokenizer.java
@Component
@Slf4j
@Getter
public class JwtTokenizer {
private final byte[] accessSecret; // Access Token 서명에 사용할 비밀키
private final byte[] refreshSecret; // Refresh Token 서명에 사용할 비밀키
public static Long ACCESS_TOKEN_EXPIRE_COUNT = 30 * 60 * 1000L; // Access Token의 만료 시간 (30분)
public static Long REFRESH_TOKEN_EXPIRE_COUNT = 7 * 24 * 60 * 60 * 1000L; // Refresh Token의 만료 시간 (7일)
public JwtTokenizer(@Value("${jwt.secretKey}") String accessSecret,
@Value("${jwt.refreshKey}") String refreshSecret){
this.accessSecret = accessSecret.getBytes(StandardCharsets.UTF_8);
this.refreshSecret = refreshSecret.getBytes(StandardCharsets.UTF_8);
}
// JWT 토큰을 생성하는 메서드
private String createToken(Long id, String email, String username, List<RoleName> roles, long expireCount, byte[] secret) {
Claims claims = Jwts.claims().setSubject(email); // JWT 클레임 설정
claims.put("userId", id); // 사용자 ID 클레임 추가
claims.put("username", username); // 사용자 이름 클레임 추가
claims.put("roles", roles); // 사용자 권한 클레임 추가
Date now = new Date();
Date expiration = new Date(now.getTime() + expireCount); // 만료 시간 계산
return Jwts.builder() // JWT 빌더
.setClaims(claims) // 클레임 설정
.setIssuedAt(now) // 발급 시간 설정
.setExpiration(expiration) // 만료 시간 설정
.signWith(SignatureAlgorithm.HS256, getSigningKey(secret)) // 서명 설정
.compact(); // JWT 생성
}
// Access Token을 생성하는 메서드
public String createAccessToken(Long id, String email, String username, List<RoleName> roles) {
return createToken(id, email, username, roles, ACCESS_TOKEN_EXPIRE_COUNT, accessSecret);
}
// Refresh Token을 생성하는 메서드
public String createRefreshToken(Long id, String email, String username, List<RoleName> roles) {
return createToken(id, email, username, roles, REFRESH_TOKEN_EXPIRE_COUNT, refreshSecret);
}
// secretKey - byte형식
public static Key getSigningKey(byte[] secretKey) {
return Keys.hmacShaKeyFor(secretKey); // 서명에 사용할 키 생성
}
// Jwt 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내는 메서드
// 토큰에서 유저 아이디 얻기
public Long getUserIdFromToken(String token) {
Claims claims = parseToken(token, accessSecret); // 토큰 파싱
return Long.valueOf((Integer) claims.get("userId")); // 사용자 ID 반환
}
// 토큰 복호화
public Claims parseToken(String token, byte[] secretKey) {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey(secretKey)) // 서명 키 설정
.build()
.parseClaimsJws(token) // 토큰 파싱
.getBody(); // 클레임 반환
}
// accessToken 토큰 복호화
public Claims parseAccessToken(String accessToken) {
return parseToken(accessToken, accessSecret);
}
// refreshToken 토큰 복호화
public Claims parseRefreshToken(String refreshToken) {
return parseToken(refreshToken, refreshSecret);
}
public boolean isTokenExpired(String token, byte[] secretKey) {
try {
Jwts.parserBuilder()
.setSigningKey(getSigningKey(secretKey)) // 서명 키 설정
.build()
.parseClaimsJws(token);
return false; // 토큰이 유효할 때 false를 반환해야 함
} catch (ExpiredJwtException e) {
log.error("Token expired", e);
} catch (Exception e) {
log.error("Token invalid", e);
}
return true; // 토큰이 만료되었거나 유효하지 않을 때 true를 반환
}
public boolean isAccessTokenExpired(String accessToken) {
return isTokenExpired(accessToken, accessSecret);
}
public boolean isRefreshTokenExpired(String refreshToken) {
return isTokenExpired(refreshToken, refreshSecret);
}
// 주어진 Refresh Token을 사용하여 새로운 Access Token 생성
public String refreshAccessToken(String refreshToken) {
Claims claims = parseRefreshToken(refreshToken); // Refresh Token 파싱
Long userId = claims.get("userId", Long.class); // 사용자 ID 추출
String email = claims.getSubject(); // 이메일 추출
String username = claims.get("username", String.class); // 사용자 이름 추출
List<RoleName> roles = (List<RoleName>) claims.get("roles"); // 사용자 권한 추출
return createAccessToken(userId, email, username, roles); // 새로운 Access Token 생성
}
}
- JWT 토큰을 생성하고 파싱하는 역할을 하는 클래스이다.
- 이 클래스에 JWT를 활용하는데에 필요한 메서드들을 한 데에 모아놓았다.
- Access Token 및 Refresh Token의 생성 메서드를 제공하며 토큰 만료 여부를 확인하는 메서드도 제공한다.
- Refresh Token을 사용하여 Access Token이 만료된 경우 새로운 Access Token을 생성하는 메서드를 제공한다.
- (Access Token : 30분 // Refresh Token : 7일)로 토큰의 만료시간을 정했다.
주요 메서드
- createToken(Long id, String email, String username, List<RoleName> roles, long expireCount, byte[] secret)
- JWT 토큰을 생성한다.
- Access Token과 Refresh Token 를 생성하는 기본 메서드이다.
- createAccessToken(Long id, String email, String username, List<RoleName> roles)
- 액세스 토큰을 생성한다.
- createToken 메서드를 호출하여 Access Token의 만료 시간을 ACCESS_TOKEN_EXPIRE_COUNT로 설정하고, 비밀 키를 accessSecret으로 설정하여 Access Token을 생성한다.
- createRefreshToken(Long id, String email, String username, List<RoleName> roles)
- 주어진 사용자 정보를 바탕으로 새로운 Refresh Token을 생성한다.
- createToken 메서드를 호출하여 Refresh Token을 생성한다.
- Refresh Token은 일반적으로 더 긴 만료 기간을 가지기 때문에 사용자가 다시 로그인하지 않고도 새로운 Access Token을 발급받을 수 있도록 한다.
- parseToken(String token, byte[] secretKey)
- 토큰을 파싱하여 클레임 정보를 얻는다.
- 추출된 클레임 정보를 바탕으로 사용자의 ID, 이메일, 역할 등의 정보를 확인할 수 있다.
- 파싱 과정에서 토큰의 서명도 검증하여 토큰이 변조되지 않았음을 확인다.
- isTokenExpired(String token, byte[] secretKey)
- 토큰이 만료되었는지 확인한다.
- 토큰을 파싱하여 유효성을 검증하고, 만료 여부를 확인한다.
- 만약 토큰이 유효하면 false를 반환하고, 만료되었거나 유효하지 않으면 true를 반환한다.
- 만료된 토큰이나 서명이 유효하지 않은 토큰에 대해서는 예외를 처리다.
- refreshAccessToken(String refreshToken)
- 주어진 Refresh Token을 사용하여 새로운 Access Token을 생성한다.
- Refresh Token을 파싱하여 클레임 정보를 추출하고, 해당 정보를 바탕으로 새로운 Access Token을 생성한다.
- 이를 통해 사용자는 Refresh Token을 이용하여 새로운 Access Token을 발급받아 계속해서 인증된 상태를 유지할 수 있다.
2.3 JwtAuthenticationFilter.java
@RequiredArgsConstructor
@Slf4j
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenizer jwtTokenizer; // JWT 토큰 생성 및 검증을 위한 JwtTokenizer
private final JwtBlacklistService jwtBlacklistService;
private final RefreshTokenService refreshTokenService;
private static final List<String> PERMIT_ALL_PATHS = List.of(
"/", "/css/.*", "/api/login", "/api/.*",
"/userregform", "/css/.*", "/files/.*", "/loginform"
// "/oauth2/**", "/login/oauth2/code/github","/registerSocialUser","/saveSocialUser"
// 추가적으로 permitAll 경로들을 여기에 추가
);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String requestPath = request.getRequestURI(); // 요청 경로 추출
// 인증이 필요 없는 경로인지 확인
if (isPermitAllPath(requestPath)) {
filterChain.doFilter(request, response); // 다음 필터로 넘어감
return;
}
// 쿠키에서 Access Token을 얻어냄
String token = getToken(request);
if (!StringUtils.hasText(token)) {
// Access Token이 없는 경우 처리
handleMissingToken(request, response);
} else {
// Access Token이 있는 경우 처리
handleTokenValidation(request, response, token);
}
filterChain.doFilter(request, response); // 다음 필터로 넘어감
log.info("토큰 제대로?? : {}", token);
}
// 요청 경로가 인증 없이 접근 가능한지 확인하는 메서드
private boolean isPermitAllPath(String requestPath) {
return PERMIT_ALL_PATHS.stream().anyMatch(requestPath::matches);
}
// Access Token이 없는 경우 처리하는 메서드
private void handleMissingToken(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 쿠키에서 Refresh Token을 얻어옴
String refreshToken = getRefreshToken(request);
if (StringUtils.hasText(refreshToken)) {
try {
// Refresh Token이 DB에 존재하는지 확인
if (refreshTokenService.isRefreshTokenValid(refreshToken)) {
// Refresh Token이 유효한지 확인
if (!jwtTokenizer.isRefreshTokenExpired(refreshToken)) {
// Refresh Token이 유효한 경우 새로운 Access Token 발급
String newAccessToken = jwtTokenizer.refreshAccessToken(refreshToken);
// 새로운 Access Token을 쿠키에 설정
setAccessTokenCookie(response, newAccessToken);
// 새로운 Access Token으로 인증 정보 설정
getAuthentication(newAccessToken);
} else {
// Refresh Token이 만료된 경우
handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Refresh token expired");
}
} else {
// Refresh Token이 DB에 없는 경우
handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Refresh token not found in database");
}
} catch (ExpiredJwtException e) {
// Refresh Token이 만료된 경우
handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired refresh token", e);
}
} else {
// Refresh Token도 없는 경우
handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found in request");
}
}
// Access Token이 있는 경우 처리하는 메서드
private void handleTokenValidation(HttpServletRequest request, HttpServletResponse response, String token) throws ServletException, IOException {
try {
// 토큰이 블랙리스트에 있는지 확인
if (jwtBlacklistService.isTokenBlacklisted(token)) {
// 토큰이 블랙리스트에 있으면 인증 실패 처리
handleException(request, JwtExceptionCode.BLACKLISTED_TOKEN, "Token is blacklisted: " + token);
} else {
// 블랙리스트에 없으면 인증 정보 설정 시도
getAuthentication(token);
}
} catch (ExpiredJwtException e) {
// Access Token이 만료된 경우 Refresh Token 확인
handleExpiredAccessToken(request, response, token, e);
} catch (UnsupportedJwtException e) {
// 지원하지 않는 토큰인 경우
handleException(request, JwtExceptionCode.UNSUPPORTED_TOKEN, "Unsupported token: " + token, e);
} catch (MalformedJwtException e) {
// 잘못된 형식의 토큰인 경우
handleException(request, JwtExceptionCode.INVALID_TOKEN, "Invalid token: " + token, e);
} catch (IllegalArgumentException e) {
// 토큰이 없는 경우
handleException(request, JwtExceptionCode.NOT_FOUND_TOKEN, "Token not found: " + token, e);
} catch (Exception e) {
// 그 외 다른 예외 발생 시
handleException(request, JwtExceptionCode.UNKNOWN_ERROR, "JWT filter internal error: " + token, e);
}
}
// Access Token이 만료된 경우 처리하는 메서드
private void handleExpiredAccessToken(HttpServletRequest request, HttpServletResponse response, String token, ExpiredJwtException e) throws ServletException, IOException {
log.warn("Access token expired: {}", token);
String refreshToken = getRefreshToken(request);
if (StringUtils.hasText(refreshToken) && !jwtTokenizer.isRefreshTokenExpired(refreshToken)) {
// Refresh Token이 유효한 경우 새로운 Access Token 발급
String newAccessToken = jwtTokenizer.refreshAccessToken(refreshToken);
// 새로운 Access Token을 쿠키에 설정
setAccessTokenCookie(response, newAccessToken);
// 새로운 Access Token으로 인증 정보 설정
getAuthentication(newAccessToken);
} else {
// Refresh Token이 없거나 만료된 경우 처리 필요
handleException(request, JwtExceptionCode.EXPIRED_TOKEN, "Expired Token : " + token, e);
}
}
// 쿠키에서 Access Token을 추출하는 메서드
private String getToken(HttpServletRequest request) {
// 추가
String authorization = request.getHeader("Authorization");
if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) {
return authorization.substring(7);
}
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("accessToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
// 쿠키에서 Refresh Token을 추출하는 메서드
private String getRefreshToken(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("refreshToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
// 새로운 Access Token을 쿠키에 설정하는 메서드
private void setAccessTokenCookie(HttpServletResponse response, String newAccessToken) {
Cookie accessTokenCookie = new Cookie("accessToken", newAccessToken);
accessTokenCookie.setHttpOnly(true); // XSS 보호를 위해 HttpOnly 설정
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
response.addCookie(accessTokenCookie);
}
// Access Token을 사용하여 인증 정보 설정하는 메서드
private void getAuthentication(String token) {
Claims claims = jwtTokenizer.parseAccessToken(token);
String email = claims.getSubject();
Long userId = claims.get("userId", Long.class);
String username = claims.get("username", String.class);
List<GrantedAuthority> authorities = getGrantedAuthorities(claims);
CustomUserDetails userDetails = new CustomUserDetails(username, "", authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));
Authentication authentication = new JwtAuthenticationToken(authorities, userDetails, null);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
// JWT Claims에서 권한 정보를 추출하는 메서드
private List<GrantedAuthority> getGrantedAuthorities(Claims claims) {
List<String> roles = (List<String>) claims.get("roles");
List<GrantedAuthority> authorities = new ArrayList<>();
for (String role : roles) {
authorities.add(() -> role);
}
return authorities;
}
// 예외 처리 메서드
private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage) {
handleException(request, exceptionCode, logMessage, null);
}
private void handleException(HttpServletRequest request, JwtExceptionCode exceptionCode, String logMessage, Exception e) {
request.setAttribute("exception", exceptionCode.getCode());
log.error(logMessage, e);
throw new BadCredentialsException(logMessage, e);
}
}
- 토큰 발급 및 검증에 있어서 아주 중요한 핵심이다.
- 클라이언트 요청 시 JWT 인증을 하기 위해 설치하는 커스텀 필터로, UsernamePasswordAuthenticationFilter 이전에 실행 될 것이다.
- 클라이언트로부터 들어오는 요청에서 JWT 토큰을 처리하고, 유효한 토큰인 경우 해당 토큰의 인증 정보(Authentication)를 SecurityContext에 저장하여 인증된 요청을 처리할 수 있도록 한다.
- OncePerRequestFilter를 상속받아 요청 당 한 번 실행되는 필터 클래스이다.
- 요청 경로에 따라 인증이 필요 없는 경로를 처리하거나, Access Token을 검증하고 Access Token이 유효하지 않거나 존재하지 않는 경우, Refresh Token을 이용하여 새로운 Access Token을 발급한다.
- 만약 Access Token이 유효하면, 사용자 정보를 추출하여 인증 정보 설정한다.
- 이 필터는 결과적으로 JWT를 통해 username + password 인증을 수행하는 핵심이다.
주요 메서드
- doFilterInternal()
- 필터링 로직을 구현하는 주요 클래스이며 요청이 인증이 필요한지 확인하고, 필요시 토큰을 검증하여 인증 정보를 설정한다.
- 요청 경로가 인증 없이 접근 가능한지 확인하고, Access Token을 추출하여 유효성을 검증한다.
- 필요한 경우 새로운 Access Token을 발급하고, 검증된 토큰을 통해 인증 정보를 설정다.
- isPermitAllPath()
- 인증이 필요 없는 경로인지 확인한다.
- 요청 경로가 인증 없이 접근 가능한 경로 목록에 포함되어 있는지 확인하여, 포함되어 있으면 true, 아니면 false를 반환
- handleMissingToken()
- Access Token이 없는 경우 Refresh Token을 사용하여 새로운 Access Token을 발급한다.
- Refresh Token이 유효하지 않으면 적절한 예외를 처리한다.
- handleTokenValidation()
- Access Token이 있는 경우 처리
- 토큰이 블랙리스트에 있는지 확인하고, 블랙리스트에 없으면 토큰을 검증하여 인증 정보를 설정한다.
- 만료된 토큰이나 지원되지 않는 토큰, 잘못된 형식의 토큰에 대해서는 적절한 예외를 처리한다.
- handleExpiredAccessToken()
- Access Token이 만료된 경우 처리
- 만료된 Access Token을 확인하고, Refresh Token이 유효한 경우 새로운 Access Token을 발급
- Refresh Token이 없거나 만료된 경우 적절한 예외를 처리한다.
- getToken()
- 쿠키에서 Access Token을 추출
- 요청 헤더에서 Authorization 헤더를 확인하고, 쿠키에서 Access Token을 추출하여 반환
- getRefreshToken()
- 쿠키에서 Refresh Token 추출
- setAccessTokenCookie()
- 새로운 Access Token을 쿠키에 설정하여 클라이언트에게 반환
- getAuthentication()
- Access Token을 사용하여 인증 정보를 설정한다.
- 토큰을 파싱하여 사용자 정보와 권한 정보를 추출하고, 이를 통해 Spring Security의 인증 정보를 설정다.
- getGrantedAuthorities()
- JWT 클레임에서 권한 정보를 추출한다.
- JWT 클레임에서 사용자 역할 정보를 추출하여 Spring Security에서 사용할 수 있는 권한 목록으로 변환다.
- handleException()
- 예외 처리 메서드
동작 과정
1. JwtAuthenticationFilter는 요청을 처리하며, JwtTokenizer를 사용하여 토큰을 검증하고 필요한 경우 새로운 Access Token을 생성한다.
2. JwtAuthenticationFilter는 토큰이 유효한 경우, JwtAuthenticationToken을 사용하여 Spring Security의 인증 컨텍스트에 사용자 정보를 설정한다.
- JwtTokenizer는 토큰 생성, 파싱, 만료 여부 확인, 새로운 Access Token 생성 등 JWT 관련 기능을 제공한다.
- JwtAuthenticationToken은 인증된 사용자 정보를 저장하고, 인증 컨텍스트에 설정하는 역할을 수행
2.4 CustomAuthenticationEntryPoint.java
@Component
@Slf4j
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
//시큐리티가 인증되지 않은 사용자가 (인증해야만 사용할 수 있는) 리소스에 접근 할때 동작하게 하는 인터페이스
//사용자가 인증되지 않았을때.. 어떻게 처리할지를 구현함.
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
String exception = (String)request.getAttribute("exception");
//어떤요청인지를 구분..
//RESTful로 요청한건지.. 그냥 페이지 요청한건지 구분해서 다르게 동작하도록 구현.
if(isRestRequest(request)){
handleRestResponse(request, response, exception);
} else {
handleRestResponse(request, response, exception);
}
}
//페이지로 요청이 들어왔을 때 인증되지 않은 사용자라면 무조건 /loginform으로 리디렉션 시키겠다.
private void handlePageResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {
log.error("Page Request - Commence Get Exception : {}", exception);
if (exception != null) {
// 추가적인 페이지 요청에 대한 예외 처리 로직을 여기에 추가할 수 있습니다.
}
response.sendRedirect("/loginform");
}
private boolean isRestRequest(HttpServletRequest request) {
String requestedWithHeader = request.getHeader("X-Requested-With");
return "XMLHttpRequest".equals(requestedWithHeader) || request.getRequestURI().startsWith("/api/");
}
// RESTful 요청에 대한 인증 실패 응답을 처리한다.
private void handleRestResponse(HttpServletRequest request, HttpServletResponse response, String exception) throws IOException {
log.error("Rest Request - Commence Get Exception : {}", exception);
if (exception != null) {
if (exception.equals(JwtExceptionCode.INVALID_TOKEN.getCode())) {
log.error("entry point >> invalid token");
setResponse(response, JwtExceptionCode.INVALID_TOKEN);
} else if (exception.equals(JwtExceptionCode.EXPIRED_TOKEN.getCode())) {
log.error("entry point >> expired token");
setResponse(response, JwtExceptionCode.EXPIRED_TOKEN);
} else if (exception.equals(JwtExceptionCode.UNSUPPORTED_TOKEN.getCode())) {
log.error("entry point >> unsupported token");
setResponse(response, JwtExceptionCode.UNSUPPORTED_TOKEN);
} else if (exception.equals(JwtExceptionCode.NOT_FOUND_TOKEN.getCode())) {
log.error("entry point >> not found token");
setResponse(response, JwtExceptionCode.NOT_FOUND_TOKEN);
} else {
setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
}
} else {
setResponse(response, JwtExceptionCode.UNKNOWN_ERROR);
}
}
private void setResponse(HttpServletResponse response, JwtExceptionCode exceptionCode) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
HashMap<String, Object> errorInfo = new HashMap<>();
errorInfo.put("message", exceptionCode.getMessage());
errorInfo.put("code", exceptionCode.getCode());
Gson gson = new Gson();
String responseJson = gson.toJson(errorInfo);
response.getWriter().print(responseJson);
}
}
- 인증되지 않은 사용자가 보호된 리소스에 접근하려 할 때, Spring Security에서 자동으로 호출되는 클래스이다.
- AuthenticationEntryPoint 인터페이스를 상속하고 주로 인증되지 않은 사용자에게 적절한 응답을 반환하는 역할을 한다.
주요 메서드
- commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException)
- 인증되지 않은 사용자가 보호된 리소스에 접근할 때 호출된다.
- 요청이 RESTful API 요청인지, 일반 페이지 요청인지 구분하여 각각 다르게 처리한다.
- handlePageResponse(HttpServletRequest request, HttpServletResponse response, String exception)
- 페이지 요청에 대한 인증 실패 응답을 처리한다.
- 인증되지 않은 사용자를 로그인 폼으로 리디렉션한다.
- isRestRequest(HttpServletRequest request)
- 요청이 RESTful API 요청인지 확인한다.
- X-Requested-With 헤더가 XMLHttpRequest인 경우나 요청 URI가 /api/로 시작하는 경우 RESTful 요청으로 간주한다.
- handleRestResponse(HttpServletRequest request, HttpServletResponse response, String exception)
- RESTful 요청에 대한 인증 실패 응답을 처리한다.
- 예외 코드에 따라 적절한 응답을 설정한다.
- setResponse(HttpServletResponse response, JwtExceptionCode exceptionCode)
- HTTP 응답을 설정한다.
- JSON 형식으로 예외 정보를 포함하여 응답을 작성한다.
2.5 JwtExceptionCode.java
public enum JwtExceptionCode {
UNKNOWN_ERROR("UNKNOWN_ERROR", "UNKNOWN_ERROR"),
NOT_FOUND_TOKEN("NOT_FOUND_TOKEN", "Headers에 토큰 형식의 값 찾을 수 없음"),
INVALID_TOKEN("INVALID_TOKEN", "유효하지 않은 토큰"),
EXPIRED_TOKEN("EXPIRED_TOKEN", "기간이 만료된 토큰"),
UNSUPPORTED_TOKEN("UNSUPPORTED_TOKEN", "지원하지 않는 토큰"),
BLACKLISTED_TOKEN("BLACKLISTED_TOKEN", "로그아웃하여 블릭리스트에 올라간 토큰");
@Getter
private String code;
@Getter
private String message;
JwtExceptionCode(String code, String message) {
this.code = code;
this.message = message;
}
}
- JWT 관련 예외 코드를 정의한다.
2.6 UserRestController.java
@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class UserRestController {
private final UserService userService;
private final PasswordEncoder passwordEncoder;
private final JwtTokenizer jwtTokenizer;
private final RefreshTokenService refreshTokenService;
private final JwtBlacklistService jwtBlackListService;
@PostMapping("/login")
public ResponseEntity login(@RequestBody @Valid UserLoginDto userLoginDto,
BindingResult bindingResult, HttpServletResponse response) {
System.out.println("로그인 성공 시 유저이름과 비밀번호 출력 테스트");
System.out.println(userLoginDto.getUsername());
System.out.println(userLoginDto.getPassword());
// username,password가 null일때 --> 정해진 형식에 맞지 않을때
if(bindingResult.hasErrors()){
return new ResponseEntity(HttpStatus.BAD_REQUEST);
}
// username과 password 값을 잘 받아왔다면
// 우리 서비스에 가입한 사용자 인지 확인
Optional<User> user = userService.findByUserName(userLoginDto.getUsername());
// 요청정보에서 얻어온 비밀번호와 우리 서비스가 갖고있는 비밀번호가 일치하는지 확인
if(!passwordEncoder.matches(userLoginDto.getPassword(), user.get().getPassword())) {
// 비밀번호가 일치하지 않을때
return new ResponseEntity("비밀번호가 올바르지 않습니다.",HttpStatus.UNAUTHORIZED);
}
// username과 password가 맞다면
// 롤객체를 꺼내서 롤의 이름만 리스트로 얻어온다.
List<RoleName> roles = user.get().getRole().stream().map(Role::getRoleName).collect(Collectors.toList());
// 로그아웃 할 떄 refreshToken이 삭제가 되지 않았을 경우를 대비해 로그인 할 떄 기존의 refreshToken을 제거해준다.(이중장치 느낌)
refreshTokenService.deleteRefreshToken(user.get().getUserId());
// 사용자가 유효하면 JwtTokenizer를 사용해 accessToken과 refreshToken을 생성
String accessToken = jwtTokenizer.createAccessToken(
user.get().getUserId(), user.get().getEmail(), user.get().getUserName(), roles);
String refreshToken = jwtTokenizer.createRefreshToken(
user.get().getUserId(), user.get().getEmail(), user.get().getUserName(), roles);
// 리프레시토큰을 디비에 저장
RefreshToken refreshTokenEntity = new RefreshToken();
refreshTokenEntity.setValue(refreshToken);
refreshTokenEntity.setUserId(user.get().getUserId());
refreshTokenService.addRefreshToken(refreshTokenEntity);
// 응답으로 보낼 값들을 준비
UserLoginResponseDto loginResponseDto = UserLoginResponseDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.userId(user.get().getUserId())
.username(user.get().getUserName())
.build();
Cookie accessTokenCookie = new Cookie("accessToken",accessToken);
accessTokenCookie.setHttpOnly(true); //보안 (쿠키값을 자바스크립트같은곳에서는 접근할수 없어요.)
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT/1000)); //30분 쿠키의 유지시간 단위는 초 , JWT의 시간단위는 밀리세컨드
Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken);
refreshTokenCookie.setHttpOnly(true);
refreshTokenCookie.setPath("/");
refreshTokenCookie.setMaxAge(Math.toIntExact(JwtTokenizer.REFRESH_TOKEN_EXPIRE_COUNT/1000)); //7일
response.addCookie(accessTokenCookie);
response.addCookie(refreshTokenCookie);
return new ResponseEntity(loginResponseDto, HttpStatus.OK);
}
// 인증된 사용자만 접근할 수 있는 테스트 엔드포인트
@GetMapping("/authtest")
public ResponseEntity<String> authTest(){
return ResponseEntity.ok("authTest");
}
// 리프레시 토큰을 사용해 새로운 액세스 토큰을 발급
@PostMapping("/refreshToken")
public ResponseEntity refreshToken(HttpServletRequest request, HttpServletResponse response){
//1. 쿠키로부터 리프레시토큰을 얻어온다. --> 요청의 쿠키에서 refreshToken을 추출
String refreshToken = null;
Cookie[] cookies = request.getCookies();
if(cookies != null){
for(Cookie cookie : cookies){
if("refreshToken".equals(cookie.getName())){
refreshToken = cookie.getValue();
break;
}
}
}
//2-1. 없다 --> 오류로 응담
if(refreshToken == null){
return new ResponseEntity(HttpStatus.BAD_REQUEST);
}
//2-2. 있을때
//3. 토큰으로부터 정보 얻어오기 --> JwtTokenizer를 사용해 리프레시 토큰을 파싱하고, 사용자 정보를 조회
Claims claims = jwtTokenizer.parseRefreshToken(refreshToken);
Long userId = Long.valueOf ((Integer)claims.get("userId"));
User user = userService.getUser(userId).orElseThrow(() -> new IllegalArgumentException("사용자를 찾지 못했습니다."));
//4. accessToken 생성
List roles = (List)claims.get("roles");
String accessToken = jwtTokenizer.createAccessToken(userId, user.getEmail(), user.getUserName(), roles);
//5. 쿠키 생성 response로 보내고
Cookie accessTokenCookie = new Cookie("accessToken", accessToken);
accessTokenCookie.setHttpOnly(true);
accessTokenCookie.setPath("/");
accessTokenCookie.setMaxAge(Math.toIntExact( JwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
response.addCookie(accessTokenCookie);
// 6. 적절한 응답결과(ResponseEntity)를 생성해서 응답
UserLoginResponseDto responseDto = UserLoginResponseDto.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.userId(user.getUserId())
.username(user.getUserName())
.build();
return new ResponseEntity(responseDto, HttpStatus.OK);
}
// 로그아웃 처리
@GetMapping("/logout")
public void logout(@CookieValue(name = "accessToken", required = false) String accessToken, @CookieValue(name = "refreshToken", required = false) String refreshToken, HttpServletResponse response) {
System.out.println("로그아웃 들어왔나");
if (accessToken == null) {
// accessToken이 존재하지 않으면 로그인되지 않은 상태로 간주하고 처리할 수 있습니다.
try {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("Access token not found in cookies.");
} catch (IOException e) {
e.printStackTrace();
}
return;
}
// JWT 토큰 추출
String jwt = accessToken;
System.out.println("jwt: " + jwt);
// 토큰의 만료 시간 추출
Date expirationTime = Jwts.parser()
.setSigningKey(jwtTokenizer.getAccessSecret())
.parseClaimsJws(jwt)
.getBody()
.getExpiration();
System.out.println("만료시간: " + expirationTime);
// 블랙리스트에 토큰 저장
JwtBlacklist blacklist = new JwtBlacklist(jwt, expirationTime);
jwtBlackListService.save(blacklist);
// SecurityContext를 클리어하여 현재 세션을 무효화
SecurityContextHolder.clearContext();
// accessToken 쿠키 삭제
Cookie accessCookie = new Cookie("accessToken", null);
accessCookie.setPath("/");
accessCookie.setMaxAge(0);
response.addCookie(accessCookie);
// 데이터베이스에서 리프레시 토큰을 삭제
Cookie refresCcookie = new Cookie("refreshToken", null);
refresCcookie.setPath("/");
refresCcookie.setMaxAge(0);
response.addCookie(refresCcookie);
// 로그아웃 전 db에 저장되어있는 refreshToken을 삭제한다.
refreshTokenService.deleteRefreshToken(refreshToken);
// /login 페이지로 리디렉션
try {
response.sendRedirect("/");
} catch (IOException e) {
e.printStackTrace();
}
}
}
- 사용자 인증 관련 REST API를 제공한다.
- 로그인 요청 시 이 앤드포인트에서 토큰을 쿠키에 저장한다.
주요 메서드
- login()
- 사용자 로그인 처리 로직
- refreshToken()
- 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급
- logout()
- 사용자 로그아웃 처리 로직
2.7 login.form
<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>로그인</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous">
<link rel="stylesheet" href="/css/login.css">
</head>
<body style="background-color: #f0f2f5;">
<section class="vh-100">
<div class="container py-5 h-100">
<div class="row d-flex justify-content-center align-items-center h-100">
<div class="col col-xl-10">
<div class="card" style="border-radius: 25px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);">
<div class="col-md-6 col-lg-7 d-flex align-items-center">
<div class="card-body p-4 p-lg-5 text-black">
<form id="loginForm" action="/api/login" method="post">
<div class="d-flex align-items-center mb-3 pb-1">
<span class="h1 fw-bold mb-0">로그인</span>
</div>
<div class="form-outline mb-4">
<input type="text" id="username" name="username" class="form-control form-control-lg" required="required" placeholder="아이디를 입력하세요"/>
</div>
<div class="form-outline mb-4">
<input type="password" id="password" name="password" class="form-control form-control-lg" required="required" placeholder="비밀번호를 입력하세요"/>
</div>
<div class="pt-1 mb-4">
<button type="button" class="btn btn-dark btn-lg btn-block" onclick="loginUser()">로그인</button>
</div>
<a th:href="@{/oauth2/authorization/github}" class="btn btn-primary">Login with GitHub</a>
<p class="pb-lg-2" style="color: #393f81;">아직 회원이 아니신가요? <a th:href="@{/userregform}" style="color: #393f81;">회원가입</a></p>
<p class="mb-5 pb-lg-2" style="color: #393f81;">로그인 없이 이용하고 싶으신가요? <a th:href="@{/}" style="color: #393f81;">이동하기</a></p>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<script>
function loginUser() {
var username = document.getElementById('username').value;
var password = document.getElementById('password').value;
var data = {
username: username,
password: password
};
fetch('/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(response => {
if (!response.ok) {
throw new Error('로그인 실패');
}
return response.json();
})
.then(data => {
// 로그인 성공 처리
console.log('로그인 성공:', data);
// 성공 시 다음 페이지로 이동 혹은 필요한 작업 수행
window.location.href = '/trending'; // 예시: 블로그 페이지로 이동
})
.catch(error => {
console.error('로그인 에러:', error);
// 실패 시 에러 처리 혹은 사용자에게 알림
alert('로그인 실패. 다시 시도해주세요.');
});
}
</script>
</body>
</html>
- jwt는 RestAPI 환경에서 사용하기 좋은 토큰이다.
- 그래서 fetch 를 사용하여 서버측에서 토큰을 검증하면 클라이언트 쿠키에 등록하는 방식으로 코드를 구성했다.
- 참고로 위의 저 js 코드에는 엑세스 토큰이 만료되면 리프레시 토큰으로 재발급하는 과정의 코드는 없다.
- 오직 로그인시에 쿠키를 발급해주는 과정만 담겨있다.
3. 로그인/ 로그아웃 과정 정리
로그인 과정
- 클라이언트가 /api/login 엔드포인트에 사용자 로그인 정보를 담아 POST 요청을 보낸다.
- 이때, UserRestController의 login 메서드 호출되며 요청에서 UserLoginDto 객체를 받아 사용자의 로그인 정보를 확인한다.
- UserService를 사용하여 데이터베이스에서 사용자 정보를 조회하고, PasswordEncoder를 사용하여 비밀번호를 검증한다.
- 데이터베이스에 등록된 사용자라면, JwtTokenizer를 사용해 accessToken과 refreshToken을 생성한다.
- 생성된 accessToken과 refreshToken을 쿠키에 설정한다.
-> accessToken은 일반적으로 짧은 만료 시간을 가지며, refreshToken은 더 긴 만료 시간을 가진다. - 클라이언트에게 accessToken과 refreshToken을 포함한 응답을 보낸다.
- 즉, 로그인에 성공되면 클라이언트의 브라우저에 accessToken과 refreshToken이 쿠키로 저장된다.
- 쿠키는 만료 시간이 설정되어 있기 때문에 만료 시간이 지나면 쿠키는 자동으로 삭제된다.
- accessToken이 만료되면 클라이언트는 서버에 요청할 때 refreshToken을 사용하여 새로운 accessToken을 발급한다.
- 이때 클라이언트는 accessToken이 만료되었음을 감지하고, 쿠키에 저장된 refreshToken을 사용하여 /api/refreshToken 엔드포인트에 요청을 보낸다.
- 서버는 요청에서 refreshToken을 추출하고 이 토큰은 보통 쿠키에서 추출되며, HttpServletRequest 객체의 getCookies() 메서드를 사용해 접근할 수 있다.
- 서버는 JwtTokenizer를 사용하여 refreshToken의 유효성을 확인하고, 해당 토큰의 클레임에서 사용자 정보를 추출한다. 이 과정에서 refreshToken의 만료 여부도 확인한다.
- refreshToken이 유효하면, 서버는 새로운 accessToken을 생성한다. 이때 refreshToken은 보통 갱신되지 않으며, 여전히 유효한 상태로 유지된다.
- 서버는 새로 생성된 accessToken을 쿠키에 설정하여 클라이언트에 응답으로 보낸다.
- 새로운 accessToken을 사용하여 인증된 요청을 계속할 수 있다. 클라이언트는 응답에서 새로운 accessToken을 쿠키에서 받아서 저장하고, 이후의 요청에서 이 새로운 accessToken을 사용한다.
- 이 과정에서 리프레시 토큰의 만료 시간이 지나지 않았다면 리프레시 토큰은 그대로 유효하므로 새로 발급된 accessToken으로 계속해서 접근할 수 있다.
- 만약, refreshToken이 만료되면 클라이언트는 더 이상 새로운 accessToken을 발급받을 수 없다.
-> 사용자는 다시 로그인을 해야 한다. - refreshToken이 만료되지 않았으면, 사용자는 계속해서 새로운 accessToken을 발급받아 사용할 수 있다.
-> 이로 인해 사용자는 계속해서 인증된 상태를 유지할 수 있다.
로그아웃 과정
- 클라이언트가 /api/logout 엔드포인트에 요청을 보낸다.
- UserRestController의 logout 메서드 호출하며 요청에서 accessToken과 refreshToken을 쿠키에서 추출한다.
- accessToken의 만료 시간을 확인하고 블랙리스트에 저장하여 이후 검증에서 차단한다.
- 쿠키에서 accessToken과 refreshToken을 삭제한다.
- 데이터베이스에서 해당 리프레시 토큰을 삭제한다.
- 로그아웃 후 사용자는 자동으로 메인 홈 페이지로 리디렉션된다.