Spring + Security + JWT + Redis를 통한 회원인증/허가 구현 (3) - 로그인 시 Access, Refresh Token 부여/ 사용

600g (Kim Dong Geun)·2020년 7월 22일
23

이전에 작성하던 포스트를 기반으로 작성했습니다.


Access token과 Refresh token은 모두 JWT 를 통해서 발행할 것이다.

JWT란? (Json Web Token)


이름 그대로 JSON을 이용한 Web Token 입니다. 주로 서비스에 대한 인증이나 CSRF 토큰등에 사용될 수 있겠지요. 이런 JWT는 위와 같은 구조를 가집니다.

  • Header : 이 JWT가 어떤 방식으로, 어떤 알고리즘을 사용하여 토큰화 했는지 명시
  • Payload : 토큰에 사용자가 담고자 하는 정보를 담는 곳
  • Signature : 위 토큰이 유효한지 유효하지 않은지에 대한 정보를 가짐. 암호화에 사용되는 키 값은 서버에 저장해놓는다. 그리고 발행된 JWT 값이 서버에 들어왔을 때 두 값을 비교해서 올바른 JWT 토큰이 맞는지 확인한다.
  • pom.xml
        <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을 형성

  • CookieUtil.java
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 부분

  • MemebeController.java
    @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와의 JWT 연동

Spring Security는 세션 방식으로 사용자의 인증/허가를 주로 이루고 있다.
따라서 우리는 기존 방식을 Custom 하여 Token 방식으로 구성해야 할 것이다.

또한 스프링 Security는 사용자의 요청과 응답사이에 여러가지 기능을 수행하는 필터(Filter)를 두어 인증/허가 기능을 수행하고 있다.

간단하게 설명하자면,

  • SecurityContextPersistenceFilter : SecurityContext 객체를 로딩하여 SecurityContextHolder에 저장하고 요청이 끝나면 삭제
  • LogoutFilter : 지정한 경로의 요청이 들어오면 사용자를 로그아웃시킴
  • UsernamePasswordAuthennticationFilter : 로그인 요청이 들어오면 아이디/비밀번호 기반의 인증을 수행한다.
  • FilterSecurityInterceptor : 인증에 성공한 사용자가 해당 리소스에 접근할 권한이 있는지를 검증

우리가 사용할 부분은 UsernamePasswordAuthenticationFilter 앞에 Custom Filter를 두어 세션이 존재하지 않아도 올바른 Jwt 값이 존재하면, SecurityContextHolder에 UserDetail 정보를 넣어 로그인 된 사용자로 인식 하도록 할 것이다.

구현은 다음과 같이 하였다.

  • JwtRequestFilter.java
@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을 사용자에게 재생성하고, 요청을 허가시킴.

  • 발행된 AccessToken의 값은 무조건적으로 명백하다고 생각하여 요청을 허가시킴. But Access Token탈취의 위험이 존재하기 때문에 짧은 유효시간을 두어, Access Token이 탈취 당하더라도 만료되어 사용할 수 없도록 한다.
  • Refresh Token은 서버에서 그 값(Redis)을 저장함. Refresh Token을 사용할 상황이 오면 반드시 서버에서 그 유효성을 판별, 유효하지 않는 경우라면 요청을 거부. 혹은 사용자로부터 탈취 됐다라는 정보가 오면 그 Refrsh Token을 폐기할 수 있도록 설정.

먼저 프로그램을 만들고 블로그를 작성하다보니 redis에 관련된 부분을 제외해버렸다.
그래서 추가한다.

위 과정에서 Redis를 사용하는 이유는 다음과 같다.

Refresh Token을 서버에서 어디에다 저장할 것인가?

이미 정답을 제시하고 문제를 냈기 때문에 답하는데 김이 빠지긴 하지만 이유를 설명하자면 Refresh Token은 만료되어야 하기 때문이다. 그럼 휘발성을 가진 데이터 베이스가 무엇이 있을까? 정답 : Redis

Redis를 사용하기 위해선 Dependency를 추가해준다.

  • pom.xml
        <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

에 서버 설정값을 저장하면 사용준비 완료.

  • RedisUtil.java
@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 값으로 가져오도록 설정한다.
그럼 위 프로젝트에서 레디스에 관련된 부분은 해결됐으리라 생각 된다.

profile
수동적인 과신과 행운이 아닌, 능동적인 노력과 치열함

17개의 댓글

comment-user-thumbnail
2020년 8월 11일

안녕하세요. Oauth관련 공부를 하고 있는데 ... 예제를 따라하는데 막히는 부분이 있어서요 ... git에 올리신 소스가 있을까요? ㅠ

2개의 답글
comment-user-thumbnail
2020년 8월 31일

관련 주제로 공부하고 있었는데, 큰 도움이 됐습니다:)
여러모로 인사이트도 많이 얻고 갑니다.
감사합니다

1개의 답글
comment-user-thumbnail
2020년 10월 1일

작성하신 글과 깃허브 너무 잘 보았습니다. 한가지 궁굼한 점은 redis 관련 설정인데요. postman 로그인 요청 시 Unable to connect to Redis; nested exception is io.lettuce.core.RedisConnectionException: Unable to connect to localhost:6379 와 같은 에러가 발생합니다. 이유를 알 수 있을까요??

2개의 답글
comment-user-thumbnail
2020년 11월 10일

addFilterBefore(new JwtRequestFilter(jwtTokenProvider),UsernamePasswordAuthenticationFilter.class)
이렇게 필터를 앞에 넣어줬는데 원래 모든 요청에 대해 JwtRequestFilter를 거치나요?
사이트에서 페이지를 이동할때마다 실행되던데 그럼 요청마다 SecurityContextHolder를 설정하는게 뭔가 이상한거 같아서 질문드립니다.

1개의 답글
comment-user-thumbnail
2021년 1월 8일

Spring Security를 공부하면서 인증관련 개발을 진행하고 있습니다
글을 보고 대략적인 흐름을 알 수 있어서 좋았습니다
궁금한점이 있어서 댓글을 달게 되었습니다
refresh token을 검증하는 부분에 있어서 Redis에서 값을 가져와서 유저네임을 인증하지 않고
토큰을 파싱해서 claim 부분에서 username 부분만 가져와서 userDetailService에서 사용자의 이름을 넣어서 인증되면 넘기고 아니면 오류를 던져도 가능은할것 같은데
그렇게 되면 refresh token을 서버측에 저장 해야하는 이유가 없을것 같다는 생각이 드는데
제가 아직 이부분에 대해서 잘알지는 못해서 놓치는 부분이 있는것 같은데
왜 서버측에 refresh token을 저장하는지에 대해서 설명 부탁드려도 될까요?

1개의 답글
comment-user-thumbnail
2021년 11월 30일

accessToken이 만료되고 refreshToken이 유효한지 확인하고 로그인을 연장하는 부분이
expiredException catch문 안에 있는데, 이 때 customAuthenticationEntryPoint로 먼저 갔다가
catch문이 실행되서 액세스토큰 만료후 api 실행시 entryPoint에서 설정한 response가 나옵니다.
이것은 어떻게 해결해야할까요..?
로그인 연장 api를 따로 만들어서 프론트엔드에서 인터셉터 설정하는 방법밖에 없을까요?

1개의 답글
comment-user-thumbnail
2022년 2월 14일

작성하신글 많은 도움이 됐습니다 refreshtoken관련해서 의문이 있는데 제공해주신 코드를 보면 토큰이 만료되면 리프레시토큰의 만료를 확인하고 재 갱신해주는 로직으로 이해를 했습니다. 그런데 accesstoken이 탈취 당하면 위로직상 refreshToken이 만료될때까지는 새로운 accesstoken을 발급해 주는것 같은데 이에대한 대비는 어떤식으로 하는지 궁굼합니다

답글 달기