개발블로그

Springboot-Angular-JWT기반 A/A기능 구현(2) 본문

Spring

Springboot-Angular-JWT기반 A/A기능 구현(2)

개발자수니 2019. 4. 24. 20:18

지난 포스트에서 JWT를 기반으로 로그인하는 흐름과 그 코드에 대해 다루었습니다.

2019/03/28 - [Spring] - Springboot-Angular-JWT기반 A/A기능 구현(1)

 

Springboot-Angular-JWT기반 A/A기능 구현(1)

Session을 기반으로 A/A기능을 구현하기 위해 두가지 방법을 이용했었습니다. 1. SpringBoot + Spring Interceptor + Session 기반의 A/A기능 구현 2. SpringBoot + Spring Security + Session 기반의 A/A기능 구..

soon-devblog.tistory.com

 

이번 포스트에서는 지난 포스트에서 미구현했다고 언급한 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의 문법이며 이에 대한 설명은 생략합니다. 자세한 내용은 다음 링크를 참조하세요.)


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

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

@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에 포함시켜선 안됩니다. 

 

Client(Angular) code와 Api Server(Springboot) code는 Github에 있습니다.

 

<difference v1-post_190328 branch and v2-post_200424 branch>

- Angular에서 공유변수인 loginUser를 Observable로 관리.

- AUTH_TOKEN을 ACCESS_TOKEN으로 네이밍 변경.

- String 타입으로 저장했던 token을 TokenSet으로 관리.

- REFRESH_TOKEN을 추가하기 위해 angular, springboot 전반적인 구조 변경

- 로그인 요청에 대한 URI 변경 : /issueToken -> /authenticate

 

<개선사항>

- 권한(Authorization)에 대한 처리

- JwtService에서 예외 처리 정교화

 


2019.08.22

JWT를 업무에 적용하는 도중, 위 구현 방식에 약간의 문제가 있음을 알았습니다.

위 구조는 Client Application에서 JWT가 유효한지 확인을 하고 Api Server로 요청을 보내는 방식입니다. 그러나 Client Application 에서 유효성 판단하는 시각과 Api Server에서 유효성 판단하는 시각이 완전히 동일할 수는 없으므로, Client Application에서는 유효하지만 Api Server에서는 유효하지 않은 경우가 생기게 됩니다. 따라서 Client Application에서는 유효성 체크를 해서는 안됩니다. 

 

따라서 저는 다음과 같이 구조를 변경했습니다. (이 프로젝트와 연결된 코드에는 아직 반영되지 않았습니다.)

처음 로그인 인증이 되었을 때, Api-Server에서는 Refresh Token을 DB에 저장합니다. 

그리고 매 요청마다 Client Application에서는 JWT의 만료여부에 신경쓰지 않고 Api-Server로 전송합니다. Api-Server에서는 그 JWT가 만료됐다면, JWT를 Deserialize한 Data와 매핑되는 Refresh Token을 DB로부터 조회하여 ACCESS_TOKEN을 재발급합니다.

 

Comments