이전에 작성하던 포스트를 기반으로 작성했습니다.
Access token과 Refresh token은 모두 JWT 를 통해서 발행할 것이다.
이름 그대로 JSON을 이용한 Web Token 입니다. 주로 서비스에 대한 인증이나 CSRF 토큰등에 사용될 수 있겠지요. 이런 JWT는 위와 같은 구조를 가집니다.
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.1</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.1</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.1</version>
<scope>runtime</scope>
</dependency>
java에서 jwt 를 사용하기 위해 pom.xml에 jwt와 관련된 dependency를 추가한다.
-JwtUtil.java
@Component
public class JwtUtil {
public final static long TOKEN_VALIDATION_SECOND = 1000L * 10;
public final static long REFRESH_TOKEN_VALIDATION_SECOND = 1000L * 60 * 24 * 2;
final static public String ACCESS_TOKEN_NAME = "accessToken";
final static public String REFRESH_TOKEN_NAME = "refreshToken";
@Value("${spring.jwt.secret}")
private String SECRET_KEY;
private Key getSigningKey(String secretKey) {
byte[] keyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
return Keys.hmacShaKeyFor(keyBytes);
}
public Claims extractAllClaims(String token) throws ExpiredJwtException {
return Jwts.parserBuilder()
.setSigningKey(getSigningKey(SECRET_KEY))
.build()
.parseClaimsJws(token)
.getBody();
}
public String getUsername(String token) {
return extractAllClaims(token).get("username", String.class);
}
public Boolean isTokenExpired(String token) {
final Date expiration = extractAllClaims(token).getExpiration();
return expiration.before(new Date());
}
public String generateToken(Member member) {
return doGenerateToken(member.getUsername(), TOKEN_VALIDATION_SECOND);
}
public String generateRefreshToken(Member member) {
return doGenerateToken(member.getUsername(), REFRESH_TOKEN_VALIDATION_SECOND);
}
public String doGenerateToken(String username, long expireTime) {
Claims claims = Jwts.claims();
claims.put("username", username);
String jwt = Jwts.builder()
.setClaims(claims)
.setIssuedAt(new Date(System.currentTimeMillis()))
.setExpiration(new Date(System.currentTimeMillis() + expireTime))
.signWith(getSigningKey(SECRET_KEY), SignatureAlgorithm.HS256)
.compact();
return jwt;
}
public Boolean validateToken(String token, UserDetails userDetails) {
final String username = getUsername(token);
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
}
}
메소드를 간략히 설명하자면
doGenerateToken() : 토큰을 생성, 페이로드에 담길 값은 username
extractAllclaims() : 토큰이 유효한 토큰인지 검사한 후, 토큰에 담긴 Payload 값을 가져온다.
getUsername() : 추출한 Payload로부터 userName을 가져온다.
isTokenExpired() : 토큰이 만료됐는지 안됐는지 확인.
geneate~~Token() : Access/Refresh Token을 형성
package com.donggeun.springSecurity.service;
import org.springframework.stereotype.Service;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
@Service
public class CookieUtil {
public Cookie createCookie(String cookieName, String value){
Cookie token = new Cookie(cookieName,value);
token.setHttpOnly(true);
token.setMaxAge((int)JwtUtil.TOKEN_VALIDATION_SECOND);
token.setPath("/");
return token;
}
public Cookie getCookie(HttpServletRequest req, String cookieName){
final Cookie[] cookies = req.getCookies();
if(cookies==null) return null;
for(Cookie cookie : cookies){
if(cookie.getName().equals(cookieName))
return cookie;
}
return null;
}
}
token은 cookie 형태로 저장될 것.
나는 Access Token과 Refresh Token을 HttpOnly로 설정을 해두고 사용한다.
(정답은 없다한다.)
그리고 Controller 부분
@PostMapping("/login")
public Response login(@RequestBody RequestLoginUser user,
HttpServletRequest req,
HttpServletResponse res) {
try {
final Member member = authService.loginUser(user.getUsername(), user.getPassword());
final String token = jwtUtil.generateToken(member);
final String refreshJwt = jwtUtil.generateRefreshToken(member);
Cookie accessToken = cookieUtil.createCookie(JwtUtil.ACCESS_TOKEN_NAME, token);
Cookie refreshToken = cookieUtil.createCookie(JwtUtil.REFRESH_TOKEN_NAME, refreshJwt);
redisUtil.setDataExpire(refreshJwt, member.getUsername(), JwtUtil.REFRESH_TOKEN_VALIDATION_SECOND);
res.addCookie(accessToken);
res.addCookie(refreshToken);
return new Response("success", "로그인에 성공했습니다.", token);
} catch (Exception e) {
return new Response("error", "로그인에 실패했습니다.", e.getMessage());
}
}
간략히 설명하자면, 사용자에게 로그인을 성공했다는 것과 함께,
user의 id,pw가 맞으면 토큰과 refresh token을 쿠키값으로 주겠다는 것.
그럼 이제 이 토큰 값을 가지고 있는 유저의 경우에는 서버에서 제공하는 서비스를 이용할 수 있을 것이다.
Spring Security는 세션 방식으로 사용자의 인증/허가를 주로 이루고 있다.
따라서 우리는 기존 방식을 Custom 하여 Token 방식으로 구성해야 할 것이다.
또한 스프링 Security는 사용자의 요청과 응답사이에 여러가지 기능을 수행하는 필터(Filter)를 두어 인증/허가 기능을 수행하고 있다.
간단하게 설명하자면,
우리가 사용할 부분은 UsernamePasswordAuthenticationFilter 앞에 Custom Filter를 두어 세션이 존재하지 않아도 올바른 Jwt 값이 존재하면, SecurityContextHolder에 UserDetail 정보를 넣어 로그인 된 사용자로 인식 하도록 할 것이다.
구현은 다음과 같이 하였다.
@Slf4j
@Component
public class JwtRequestFilter extends OncePerRequestFilter {
@Autowired
private MyUserDetailsService userDetailsService;
@Autowired
private JwtUtil jwtUtil;
@Autowired
private CookieUtil cookieUtil;
@Autowired
private RedisUtil redisUtil;
@Override
protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException {
final Cookie jwtToken = cookieUtil.getCookie(httpServletRequest,JwtUtil.ACCESS_TOKEN_NAME);
String username = null;
String jwt = null;
String refreshJwt = null;
String refreshUname = null;
try{
if(jwtToken != null){
jwt = jwtToken.getValue();
username = jwtUtil.getUsername(jwt);
}
if(username!=null){
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
if(jwtUtil.validateToken(jwt,userDetails)){
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities());
usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
}
}
}catch (ExpiredJwtException e){
Cookie refreshToken = cookieUtil.getCookie(httpServletRequest,JwtUtil.REFRESH_TOKEN_NAME);
if(refreshToken!=null){
refreshJwt = refreshToken.getValue();
}
}catch(Exception e){
}
try{
if(refreshJwt != null){
refreshUname = redisUtil.getData(refreshJwt);
if(refreshUname.equals(jwtUtil.getUsername(refreshJwt))){
UserDetails userDetails = userDetailsService.loadUserByUsername(refreshUname);
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken(userDetails,null,userDetails.getAuthorities());
usernamePasswordAuthenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(httpServletRequest));
SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken);
Member member = new Member();
member.setUsername(refreshUname);
String newToken =jwtUtil.generateToken(member);
Cookie newAccessToken = cookieUtil.createCookie(JwtUtil.ACCESS_TOKEN_NAME,newToken);
httpServletResponse.addCookie(newAccessToken);
}
}
}catch(ExpiredJwtException e){
}
filterChain.doFilter(httpServletRequest,httpServletResponse);
}
}
Flow는 다음과 같다.
1. 로그인 한 사용자는 access token과 refresh token을 가지고 있다.
2. Access Token이 유효하면 AccessToken내 payload를 읽어 사용자와 관련있는 UserDetail을 생성
3. Access Token이 유효하지 않으면 Refresh Token값을 읽어드림.
4. Refresh Token을 읽어 Access Token을 사용자에게 재생성하고, 요청을 허가시킴.
먼저 프로그램을 만들고 블로그를 작성하다보니 redis에 관련된 부분을 제외해버렸다.
그래서 추가한다.
위 과정에서 Redis를 사용하는 이유는 다음과 같다.
Refresh Token을 서버에서 어디에다 저장할 것인가?
이미 정답을 제시하고 문제를 냈기 때문에 답하는데 김이 빠지긴 하지만 이유를 설명하자면 Refresh Token은 만료되어야 하기 때문이다. 그럼 휘발성을 가진 데이터 베이스가 무엇이 있을까? 정답 : Redis
Redis를 사용하기 위해선 Dependency를 추가해준다.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
application.properties
spring.cache.type=redis
spring.redis.host =localhost
spring.redis.port=6379
에 서버 설정값을 저장하면 사용준비 완료.
@Service
public class RedisUtil {
@Autowired
private StringRedisTemplate stringRedisTemplate;
public String getData(String key){
ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
return valueOperations.get(key);
}
public void setData(String key, String value){
ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
valueOperations.set(key,value);
}
public void setDataExpire(String key,String value,long duration){
ValueOperations<String,String> valueOperations = stringRedisTemplate.opsForValue();
Duration expireDuration = Duration.ofSeconds(duration);
valueOperations.set(key,value,expireDuration);
}
public void deleteData(String key){
stringRedisTemplate.delete(key);
}
}
를 이용하여 Redis를 Key Value 값으로 가져오도록 설정한다.
그럼 위 프로젝트에서 레디스에 관련된 부분은 해결됐으리라 생각 된다.
작성하신 글과 깃허브 너무 잘 보았습니다. 한가지 궁굼한 점은 redis 관련 설정인데요. postman 로그인 요청 시 Unable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException: Unable to connect to localhost:6379 와 같은 에러가 발생합니다. 이유를 알 수 있을까요??
addFilterBefore(new JwtRequestFilter(jwtTokenProvider),UsernamePasswordAuthenticationFilter.class)
이렇게 필터를 앞에 넣어줬는데 원래 모든 요청에 대해 JwtRequestFilter를 거치나요?
사이트에서 페이지를 이동할때마다 실행되던데 그럼 요청마다 SecurityContextHolder를 설정하는게 뭔가 이상한거 같아서 질문드립니다.
Spring Security를 공부하면서 인증관련 개발을 진행하고 있습니다
글을 보고 대략적인 흐름을 알 수 있어서 좋았습니다
궁금한점이 있어서 댓글을 달게 되었습니다
refresh token을 검증하는 부분에 있어서 Redis에서 값을 가져와서 유저네임을 인증하지 않고
토큰을 파싱해서 claim 부분에서 username 부분만 가져와서 userDetailService에서 사용자의 이름을 넣어서 인증되면 넘기고 아니면 오류를 던져도 가능은할것 같은데
그렇게 되면 refresh token을 서버측에 저장 해야하는 이유가 없을것 같다는 생각이 드는데
제가 아직 이부분에 대해서 잘알지는 못해서 놓치는 부분이 있는것 같은데
왜 서버측에 refresh token을 저장하는지에 대해서 설명 부탁드려도 될까요?
accessToken이 만료되고 refreshToken이 유효한지 확인하고 로그인을 연장하는 부분이
expiredException catch문 안에 있는데, 이 때 customAuthenticationEntryPoint로 먼저 갔다가
catch문이 실행되서 액세스토큰 만료후 api 실행시 entryPoint에서 설정한 response가 나옵니다.
이것은 어떻게 해결해야할까요..?
로그인 연장 api를 따로 만들어서 프론트엔드에서 인터셉터 설정하는 방법밖에 없을까요?
안녕하세요. Oauth관련 공부를 하고 있는데 ... 예제를 따라하는데 막히는 부분이 있어서요 ... git에 올리신 소스가 있을까요? ㅠ