Auth (Authentication & Authorization)

Springboot-Angular-JWT 기반 Auth 구현 - refresh token 추가

kellis 2020. 10. 12. 11:19

이번 포스트에서는 지난 포스트에서 미구현했다고 언급한 refresh token을 추가하여, 인증 구조를 변경하겠습니다.

 

일반적으로 세션을 사용하는 경우에는 사용자의 액션이 있으면 세션의 만료 시각이 액션이 일어난 시각으로부터 재설정됩니다. 그러나 이전 포스트의 소스코드는 로그인한 시점에 JWT를 생성했고, 그 시점에 token의 만료 시각이 정해집니다. 따라서 로그인 이후, 사용자의 액션이 계속 일어나도 만료 시각이 지나면 로그인이 해제되는 구조가 됩니다. 

이러한 구조를 보완하기 위해 refresh token이라는 또 다른 token을 생성합니다. (지금까지 token이라고 명명했던 것을 앞으로는 access token이라고 칭하겠습니다.)

refresh token은 access token의 만료 기간보다 길고, refresh token이 유효하다면 access token이 만료되어도 재발급을 해주는 구조입니다. (access token과 refresh token의 만료 기간은 정하기 나름이지만, 여기서는 access token은 30분, refresh token은 14일로 정했습니다.) 

 

그러면 "access token을 14일로 지정하고, refresh token을 안 쓰면 되지 않나?"라는 의문점이 생깁니다. 이렇게 token을 분리한 이유는 요청 시 token이 탈취되는 위험을 줄이기 위한 것입니다. access token은 매 요청마다 보내야 하는데, 탈취의 위험이 높습니다. 

만약 access token의 만료 기간을 30분으로 제한하고 refresh token을 통해 access token을 재발급해준다면, access token이 탈취되더라도 해커가 access token을 이용할 수 있는 시간이 짧습니다. 또한 refresh token은 access token에 대해 재발급을 요청할 때만 서버에 보내므로, 비교적 탈취의 위험이 낮습니다. 

이런 이유로 access token만 이용하기보다는 refresh token을 함께 이용해 인증합니다.

 

access token과 refresh token을 이용한 인증(Authentication)의 흐름은 다음과 같습니다. 

[로그인 요청]

1. Client application는 Api server로 로그인 요청을 합니다.

2. Api server는 로그인 요청 정보의 ID/PW가 올바른지 체크하고, 올바르다면 TokenSet(access token, refresh token)을 생성하여 응답/전달합니다. 이때, refresh token은 DB에 저장합니다. 

3. Client application에서는 응답받은 TokenSet을 LocalStorage에 저장합니다. 그리고 access token을 디코딩해 User 상세 정보를 공유 변수에 저장합니다. (Angular에서는 Service의 필드로 저장할 수 있습니다.)

 

[발급받은 TokenSet을 기반으로, 인증을 필요로 하는 요청]

1. Client application에서는 LocalStorage에 저장한 access token을 Request header에 추가하여 요청합니다. 단, access token이 만료되었고 refresh token은 아직 만료되지 않은 상태라면, refresh token을 Request header에 추가하여 요청합니다. 이는 서버에게 access token이 만료되었으니, refresh token을 통해 재발급을 해달라는 신호입니다. 

2. Api server는 Request header를 읽어 refresh token이 있는지 확인합니다. 있다면, refresh token을 통해 access token을 재발급하고 이후 요청을 수행합니다. 이때, refresh token의 만료 기간이 7일 이내로 남아있으면 refresh token도 재발급해줍니다. 

3. Api server는 해당 요청이 인증을 필요로 하는 요청이라고 판단하면, Request header를 읽어 access token이 존재하는지/유효한지 판단합니다. 존재/유효하지 않다면 401 에러를 발생시킵니다.

4-1. Client application이 요청에 대해 401 에러를 응답받으면, 로그인 화면으로 이동시킵니다. 

4-2. Client application이 요청에 대해 정상적인 응답을 받았는데, access token 혹은 refresh token을 재발급받은 경우 LocalStorage의 값을 갱신합니다. 

 


위 흐름을 코드와 함께 살펴보겠습니다.

[로그인 요청]

1. Client application는 Api server로 로그인 요청을 합니다.

export class LoginComponent implements OnInit {
  constructor(
    private _http: HttpClient
    , private router : Router
    , private jwtService : JwtService
    , private userService : UserService) {
  }
  ngOnInit() {
  }
  login(userId, password){
    const user:User = new User();
    user.userId=userId;
    user.password=password;
    this._http.post("http://localhost:8080/authenticate", user).pipe(
      tap((res :any) => {
        localStorage.setItem("ACCESS_TOKEN", res.data.accessToken);
        localStorage.setItem("REFRESH_TOKEN", res.data.refreshToken);
        this.userService.setLoginUser(this.jwtService.decodeToUser(res.data.accessToken));
      })
    ).subscribe(res =>{
      this.router.navigate(['main']);
    });
  }
}
  • 16라인: 로그인 폼에 입력한 유저 정보를 확인하기 위해 Api server(localhost:8080)의 issueToken api로 요청합니다. 

 

2. Api server는 로그인 요청 정보의 ID/PW가 올바른지 체크하고, 올바르다면 TokenSet(access token, refresh token)을 생성하여 응답/전달합니다. 이때, refresh token은 DB에 저장합니다. 

이전 포스트의 코드와 달리 access token과 refresh token을 관리할 수 있도록 TokenSet 클래스를 만들었습니다. 

public class JwtService {
    //access token secret key
    public static final String AT_SECRET_KEY = "CREATEDBYSUJIN_AT";
    //refresh token secret key
    private static final String RT_SECRET_KEY = "CREATEDBYSUJIN_RT";
    private static final String DATA_KEY = "user";
     
    @Autowired
    private MongoOperations mongoOperations;
    public TokenSet createTokenSet(User user) {
        long curTime = System.currentTimeMillis();
         
        TokenSet tokenSet = TokenSet.create().refreshToken(Jwts.builder()
                        .setHeaderParam("typ", "JWT")
                        .setExpiration(new Date(curTime + (1000*60*60*24*14)))
                        .setIssuedAt(new Date(curTime))
                        .claim(DATA_KEY, user)
                        .signWith(SignatureAlgorithm.HS256, this.generateKey(RT_SECRET_KEY))
                        .compact());
         
        mongoOperations.insert(tokenSet, "refreshToken");
         
        return tokenSet
                  .accessToken(Jwts.builder()
                        .setHeaderParam("typ", "JWT")
                        .setExpiration(new Date(curTime + (1000*60*30)))
                        .setIssuedAt(new Date(curTime))
                        .claim(DATA_KEY, user)
                        .signWith(SignatureAlgorithm.HS256, this.generateKey(AT_SECRET_KEY))
                        .compact());
    }
}
  • 23라인: refresh token만 담긴 TokenSet을 DB에 저장합니다. 이 이유는 refresh token으로 access token을 재발급받을 때, refresh token이 서버가 발급한 정상적인 토큰인지 다시 한번 검증하기 위한 것입니다.

 

3. Client application에서는 응답받은 TokenSet을 LocalStorage에 저장합니다. 그리고 access token을 디코딩해 User 상세 정보를 공유 변수에 저장합니다. (Angular에서는 Service의 필드로 저장할 수 있습니다.)

[로그인 요청]-1 과 같은 코드입니다. 

this._http.post("http://localhost:8080/authenticate", user).pipe(
      tap((res :any) => {
        localStorage.setItem("ACCESS_TOKEN", res.data.accessToken);
        localStorage.setItem("REFRESH_TOKEN", res.data.refreshToken);
        this.userService.setLoginUser(this.jwtService.decodeToUser(res.data.accessToken));
      })
    ).subscribe(res =>{
      this.router.navigate(['main']);
    });
  • 3,4라인: 응답받은 TokenSet을 LocalStorage에 저장합니다. 
  • 5라인: access token을 디코딩해 User 상세 정보를 공유 변수에 저장합니다. 이전 포스트의 코드에서는 단순히 User 타입의 변수에 저장했는데 여기서는 rxjs의 BehaviorSubject를 이용하도록 변경했습니다. (이는 rxjs의 문법이며 이에 대한 설명은 생략합니다. 자세한 내용은 다음 링크를 참조하세요.)
@Injectable()
export class RequestInterceptor implements HttpInterceptor {
  private user: User;
  refreshTokenUrl = "http://localhost:8080/refreshAccessToken";
  constructor(private router : Router,
              private jwtService : JwtService){
  }
  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return this.processResponse(this.makeRequest(request), next);
  }
  makeRequest(request: HttpRequest<any>) : HttpRequest<any>{
   
    let accessToken = localStorage.getItem('ACCESS_TOKEN');
    let refreshToken = localStorage.getItem('REFRESH_TOKEN');
    let requestHeaders = request.headers;
    requestHeaders = requestHeaders.append('Content-Type', 'application/json');
    if(accessToken){
      if(request.url!=this.refreshTokenUrl && this.jwtService.isTokenExpired(accessToken)){
        requestHeaders = requestHeaders.append('REFRESH_TOKEN', refreshToken);
      }else{
        requestHeaders = requestHeaders.append('ACCESS_TOKEN', accessToken);
      }
    }
    return request.clone({ headers: requestHeaders });
  }
  processResponse(request: HttpRequest<any>, next: HttpHandler) : Observable<HttpEvent<any>>{
    return next.handle(request).pipe(
      ...생략...
    );
  }
}
  • 19~25라인: access token이 만료되었고 refresh token은 만료되지 않은 상태라면 요청의 헤더에 REFRESH_TOKEN을 추가해 refresh token을 전달합니다. 그렇지 않으면 요청의 헤더에 ACCESS_TOKEN을 추가해 access token을 전달합니다. 

 

2. Api server는 Request header를 읽어 refresh token이 있는지 확인합니다. 있다면, refresh token을 통해 access token을 재발급하고 이후 요청을 수행합니다. 이때, 전달받은 refresh token이 서버가 발급한 refresh token인지 확인합니다. 또한 refresh token의 만료 기간이 7일 이내로 남아있으면 refresh token도 재발급해줍니다. 

@Service
public class JwtService {
    //access token secret key
    public static final String AT_SECRET_KEY = "CREATEDBYSUJIN_AT";
    //refresh token secret key
    private static final String RT_SECRET_KEY = "CREATEDBYSUJIN_RT";
    private static final Logger LOGGER = LoggerFactory.getLogger(JwtService.class);
    private static final String DATA_KEY = "user";
     
    @Autowired
    private MongoOperations mongoOperations;
    ...생략...
     
    public TokenSet refreshAccessToken(String refreshToken) {
        long curTime = System.currentTimeMillis();
        //refreshToken의 만료기간이 남았는지 확인하고,
        if(!isValidToken(refreshToken, RT_SECRET_KEY)) {
            throw new AuthenticationException("로그인되어있지 않습니다.");
        }
        //DB로부터 refreshToken 유효한지 조회
        Query query = new Query();
        query.addCriteria(Criteria.where("refreshToken").is(refreshToken));
        List<TokenSet> validToken = mongoOperations.find(query, TokenSet.class, "refreshToken");
        if(validToken.isEmpty()) {
            throw new AuthenticationException("유효하지 않는 로그인 정보입니다.");
        }
         
        Jws<Claims> claims = null;
        try {
            claims = Jwts.parser().setSigningKey(this.generateKey(RT_SECRET_KEY)).parseClaimsJws(refreshToken);
        } catch (Exception e) {
            throw new JWTException("decodeing failed");
        }
         
        //refreshToken의 만료일 7일 이내면, refreshToken도 재발급
        if(Long.parseLong(String.valueOf(claims.getBody().get("exp"))) * 1000 - curTime <= (1000*60*60*24*7)) {
            return refreshTokenSet(refreshToken);
        }
        return TokenSet.create()
                  .accessToken(Jwts.builder()
                            .setHeaderParam("typ", "JWT")
                            .setExpiration(new Date(curTime + (1000*60*30)))
                            .setIssuedAt(new Date(curTime))
                            .claim(DATA_KEY, getUser(refreshToken, RT_SECRET_KEY))
                            .signWith(SignatureAlgorithm.HS256, this.generateKey(AT_SECRET_KEY))
                            .compact());
    }
     
    public TokenSet refreshTokenSet(String refreshToken) {
        return createTokenSet(getUser(refreshToken, RT_SECRET_KEY));
    }
 }

 

3. Api server는 해당 요청이 인증을 필요로 하는 요청이라고 판단하면, Request header를 읽어 access token이 존재하는지/유효한지 판단합니다. 존재/유효하지 않다면 401 에러를 발생시킵니다.

public class LoginInterceptor extends HandlerInterceptorAdapter {
    private static final String ADMIN = "ADMIN";
    @Autowired
    private JwtService jwtService;
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
            String accessToken = request.getHeader("ACCESS_TOKEN");
            final String refreshToken = request.getHeader("REFRESH_TOKEN");
             
            ...생략...
            if (hm.hasMethodAnnotation(LoginRequired.class)
                && (accessToken == null || !jwtService.isValidToken(accessToken, JwtService.AT_SECRET_KEY))) {
                throw new AuthenticationException("로그인되어있지 않습니다.");
            }
            if (hm.hasMethodAnnotation(AdminOnly.class)
                && !jwtService.getUser(accessToken, JwtService.AT_SECRET_KEY).getAuthority().contains(ADMIN)) {
                throw new AuthorizationException();
            }
        }
        return super.preHandle(request, response, handler);
    }
}

 

4-1. Client application이 요청에 대해 401 에러를 응답받으면, 로그인 화면으로 이동시킵니다. 

4-2. Client application이 요청에 대해 정상적인 응답을 받았는데, access token 혹은 refresh token을 재발급받은 경우 LocalStorage의 값을 갱신합니다. 

@Injectable()
export class RequestInterceptor implements HttpInterceptor {
  private user: User;
  refreshTokenUrl = "http://localhost:8080/refreshAccessToken";
  constructor(private router : Router,
              private jwtService : JwtService){
  }
  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    return this.processResponse(this.makeRequest(request), next);
  }
  makeRequest(request: HttpRequest<any>) : HttpRequest<any>{
    ...생략...
    return request.clone({ headers: requestHeaders });
  }
  processResponse(request: HttpRequest<any>, next: HttpHandler) : Observable<HttpEvent<any>>{
    return next.handle(request).pipe(
      tap(
        (resp: HttpEvent<any>) => {
          if (resp instanceof HttpResponse && resp.headers){
            if( resp.headers.get('ACCESS_TOKEN')) {
              localStorage.setItem('ACCESS_TOKEN', resp.headers.get('ACCESS_TOKEN'));
            }
            if( resp.headers.get('REFRESH_TOKEN')) {
              localStorage.setItem('REFRESH_TOKEN', resp.headers.get('REFRESH_TOKEN'));
            }
          }
        }
      ),
      catchError(errRsp => {
        if (errRsp instanceof HttpErrorResponse) {
            console.log(errRsp.status);
            if(errRsp.status == 401){
              if(confirm(errRsp.error.message)){
                localStorage.removeItem('ACCESS_TOKEN');
                localStorage.removeItem('REFRESH_TOKEN');
                this.router.navigate(['login']);
              }
            }else{
              alert(errRsp.error.message);
            }
        }
        return EMPTY;
      })
    );
  }
}
  • 22~30라인: 응답의 헤더에 ACCESS_TOKEN, REFRESH_TOKEN이 있으면 갱신합니다. 
  • 36~41라인: 401 에러를 응답받으면 로그인 화면으로 이동시킵니다.

refresh token을 함께 이용해 JWT인증의 흐름을 살펴보았습니다. 그러나 refresh token을 이용하더라도, 보안상의 위험이 따르는 것은 마찬가지입니다. 그래서 JWT를 이용할 때에는 반드시 HTTPS 프로토콜로 통신해야 합니다.   

또한, token을 디코딩했을 때 사용자의 정보를 알아낼 수 있으므로 절대 유출되어서는 안 되는 정보(eg:비밀번호)는 token에 포함시켜선 안됩니다. 

 

<개선사항>

- 권한(Authorization)에 대한 처리

- JwtService에서 예외 처리 정교화