이번에는 jwt토큰과 관련된 로직을 해결해야한다.
일반적으로 jwt를 이용한 로그인 구현은 다음과 같은 과정을 통해 진행된다.
- 유저가 로그인에 성공하면 accessToken과 refreshToken을 발급한다.
- 발급한 accessToken과 refreshToken을 클라이언트에게 넘겨준다. 이 때, refreshToken은 db에 저장한다.
- 유저는 인증이 필요한 요청시 마다 accessToken을 넘겨준다.
- 서버에서는 filter를 통해 해당 accessToken을 검증한다.
- 검증에 실패할 경우 refreshToken을 클라이언트에게 다시 요청하고, 검증이 성공할 경우 클라이언트의 요청을 수행한다.
- 클라이언트가 refreshToken을 넘겨주면 해당 refreshToken을 검증한다.
- refreshToken도 검증이 실패하면, 로그아웃을 시키고 다시 로그인을 유도한다.
이 부분은 크게 다르지 않으므로, 해당 순서와 동일하게 진행하면 된다.
우선 jwt토큰과 관련된 부분을 처리할 클래스를 생성한다. 이 클래스에는 토큰 발급, 검증, 추출, 업데이트 등 토큰과 관련된 기능들이 모여있다.
package com.project.bookforeast.common.security.service;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.project.bookforeast.common.security.error.TokenErrorResult;
import com.project.bookforeast.common.security.error.TokenException;
import com.project.bookforeast.user.dto.UserDTO;
import com.project.bookforeast.user.entity.User;
import com.project.bookforeast.user.repository.UserRepository;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import jakarta.servlet.http.HttpServletRequest;
@Service
public class JwtUtil {
private final UserRepository userRepository;
@Autowired
public JwtUtil(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Value("${jwt.secret-key}")
private String SECRETKEY;
private Long ACCESS_TOKEN_EXPIRATION_PERIOD = 600000L;
private Long REFRESH_TOKEN_EXPIRATION_PERIOD = 3600000L;
public String generateAccessToken(UserDTO userDTO) {
return createToken(ACCESS_TOKEN_EXPIRATION_PERIOD, userDTO);
}
public String generateRefreshToken(UserDTO userDTO) {
return createToken(REFRESH_TOKEN_EXPIRATION_PERIOD, userDTO);
}
private String createToken(Long expirationPeriod, UserDTO userDTO) {
Map<String, Object> claims = new HashMap<>();
claims.put("sub", userDTO.getSocialId());
claims.put("iss", userDTO.getSocialProvider());
return Jwts.builder()
.addClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expirationPeriod))
.signWith(SignatureAlgorithm.HS256, SECRETKEY)
.compact();
}
public <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = Jwts.parser().setSigningKey(SECRETKEY).parseClaimsJws(token).getBody();
return claimsResolver.apply(claims);
}
public boolean validateAccessToken(String accessToken) {
if(accessToken == null || accessToken.length() <= 0) {
throw new TokenException(TokenErrorResult.ACCESS_TOKEN_NEED);
}
boolean isTokenExpired = checkTokenExpired(accessToken);
if(isTokenExpired == true) {
throw new TokenException(TokenErrorResult.TOKEN_EXPIRED);
} else {
return isTokenExpired;
}
}
public Boolean validateRefreshToken(String refreshToken) {
User user = userRepository.findByRefreshToken(refreshToken);
if(user == null) {
new TokenException(TokenErrorResult.TOKEN_EXPIRED);
}
String refreshTokenInDB = user.getRefreshToken();
if(!refreshToken.equals(refreshTokenInDB) || checkTokenExpired(refreshTokenInDB)) {
new TokenException(TokenErrorResult.TOKEN_EXPIRED);
}
return true;
}
public boolean checkTokenExpired(String token) {
Date expirationDate = extractClaim(token, Claims::getExpiration);
boolean isTokenExpired = expirationDate.after(new Date());
return isTokenExpired;
}
public Map<String, String> initToken(UserDTO savedOrFindUser) {
Map<String, String> tokenMap = new HashMap<>();
String accessToken = generateAccessToken(savedOrFindUser);
String refreshToken = generateRefreshToken(savedOrFindUser);
tokenMap.put("accessToken", accessToken);
tokenMap.put("refreshToken", refreshToken);
updRefreshTokenInDB(refreshToken, savedOrFindUser);
return tokenMap;
}
public Map<String, String> refreshingAccessToken(UserDTO userDTO, String refreshToken) {
Map<String, String> tokenMap = new HashMap<>();
String accessToken = generateAccessToken(userDTO);
tokenMap.put("accessToken", accessToken);
tokenMap.put("refreshToken", refreshToken);
return tokenMap;
}
private void updRefreshTokenInDB(String refreshToken, UserDTO savedOrFindUser) {
savedOrFindUser.setRefreshToken(refreshToken);
userRepository.save(savedOrFindUser.toEntity());
}
public String extractTokenFromHeader(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if(StringUtils.hasText(header) && header.startsWith("Bearer ")) {
return header.substring(7);
} else {
throw new TokenException(TokenErrorResult.ACCESS_TOKEN_NEED);
}
}
}
물론 처음부터 이 메소드들을 다 작성한 것은 아니지만, 편의를 위해 한 번에 코드로 적어두었다.
1편을 통해 SecurityContext에 인증정보를 등록하는 것을 성공했으면, controller에서 initToken을 통해서 토큰을 생성해 준다.
@PostMapping("/social-login")
public ResponseEntity<Map<String, String>> socialLogin(@RequestBody @Valid SocialLoginDTO socialLoginDTO)
{
UserDTO savedOrFindUser = userService.socialLogin(socialLoginDTO);
securityService.saveUserInSecurityContext(socialLoginDTO);
Map<String, String> tokenMap = jwtUtil.initToken(savedOrFindUser);
return ResponseEntity.ok(tokenMap);
}
토큰 생성시에는 refreshToken정보를 db에서 update해준다.
그 후 토큰 정보를 클라이언트에게 전달한다. 그러면 유저에게 다음과 같은 형태로 토큰 정보가 전달된다.
이번 글에서는 토큰 생성까지만 해보도록 하고, 발급된 토큰을 이용한 검증은 다음 글에서 작성하겠다.