※ 사내 예약 시스템을 분석하다가 처음으로 JWT를 사용하는 것을 보고 궁금해서 찾아보고 적용하는 계기가 되었습니다.
이번에 분석을 맡은 spring boot + react로 된 새로운 프로젝트에서는 jwt를 사용하여 로그인을 사용하고 있었습니다. 제가 알고 있던 방식으로는 cookie와 session 방식정도만 알고 있었는데, jwt는 어떤 장점이 있어서 사용하는 걸까? 라는 생각이 들었습니다. 물론 각각의 방식에 대해서 장단점이 있으니 프로젝트의 환경에 맞는것을 적절히 골라서 사용하면 되겠습니다.
로그인 방식은 무엇인가?
한번쯤은 무상태(stateless)에 대해서 들어보셨을 거라고 생각합니다. 이는 비 연결적인 특성입니다. 연결이 해제되면 동시에 server 및 clinet는 clinet가 이전에 요청한 결과에 대해서 잊게 된다. 따라서 요청 시, 서버에 연결이 필요하다.
Http의 이러한 특성 때문에 stateless protocol 이라고 불리기도 하며 웹 사이트는 매 페이지에서 로그인이 되어 있는지 상태를 확인하는 인증 방식이 필요하게 된다.
Authentication vs Authorization
먼저 로그인 시스템에서 중요한 역할을 하는 인증 및 인가에 대해서 아는게 좋다.
- Authentication : 인증
- Authorization : 인가 (권한 부여)
웹 사이트에 로그인을 하는 것은 Authentication (인증)이라고 한다. 한번 로그인을 하고 나면 로그인 상태가 유지 되어야 하고 이 역할을 Auhorization (인가)라고 한다. 권한 부여인 인가를 받아야지만 웹 서비스의 기능을 사용할 수 있게 된다. 매요청마다 로그인을 할 수는 없기 때문에 로그인 상태를 유지시켜주는 기능이 필요하다.
이때 사용되는 것이 session 및 JWT 이다. 그럼 session과 jwt에 대해서 간단히 알아보자.
Session 이란
방문자가 웹서버에 접속해 있는 상태를 하나의 단위로 보며 그것을 session 이라고 한다. 즉, 브라우저에 웹 서버가 연결되어서 해당 브라우저의 종료시점 까지를 의미한다.
웹서버는 각 단위에 session id를 부여하고 같은 브라우저인지 구별한다. 사용자는 session id를 담은 cookie를 이후의 요청부터 계속 함께 전달하여 server가 clinet를 식별할 수 있게 하는 인증방식이다. 브라우저를 닫거나 서버에서 session id가 들어있는 cookie 를 찾아 삭제했을 경우 제거된다. 쿠키에 중요 정보를 넣지 않아 탈취를 당해도 의미없는 session id뿐이 없으므로 의미없는 행동이 된다.
cookie vs session
위에서 본 것 처럼 세션을 사용한다고 하여 쿠키를 사용하지 않는 것이 아니다.세션은 대부분 브라우저 종료 시 만료 된다.
서로의 차이점이 있다면, 저장 위치라고 할 수 있다. 쿠키는 클라이언트에 저장이 되고 세션은 서버측에 저장된다. 그러나 상반된 개념이라기 보다 session id를 쿠키에 담아서 통신한다는 점에서 세션은 쿠키를 사용한다고 볼 수 있다. 쿠키에 로그인 정보를 담는다고 하면 노출 위험이 있기 때문에 서버에 저장하는 방식인 세션을 많이 사용한다.
session의 장단점
장점 |
|
단점 |
|
토큰 기반 인증(JWT)
특정 사용자가 서버에 접근을 했을 때, 이 사용자가 인증된 사용자인지 구분하기 위해서는 여러 방법을 사용할 수 있는데요. 위에서 session인 서버 기반 인증은 살펴보았고, JWT를 사용하는 토큰 기반 인증이 있습니다.
- 서버 기반 인증 (session)
- 토큰 기반 인증 (jwt)
토큰을 사용한다는 것은 요청과 응답에 토큰을 함께 보내 이 사용자가 유효한 사용자인지를 검색하는 방법입니다. 이때, 보통 Json Web Token(JWT)를 사용해서 토큰을 전달합니다. jwt는 쉽게 말해서 확인서라고 생각하면 되겠습니다. 우리가 로그인을 성공하면 Authentication(인증)이 일어나고 서버는 jwt를 제공합니다. 그리고 우리는 매 요청마다 jwt와 함께 서버에 요청을 보내면 서버는 jwt만 확인하여 authorization(인가) 하게 됩니다.
조금 더 들어가서 어떻게 동작하는지 살펴볼까요?
- 클라이언트가 아이디와 비밀번호를 서버에게 전달하며 인증을 요청한다.
- 서버는 아이디와 비밀번호를 통해 유효한 사용자인지 검증하고, 유효한 사용자인 경우 토큰을 생성해서 응답한다.
- 클라이언트는 토큰을 저장해두었다가, 인증이 필요한 api에 요청할 때 토큰 정보와 함께 요청한다.
- 서버는 토큰이 유효한지 검증하고, 유효한 경우에는 응답을 해준다.
2. 토큰 사용 방식의 특징
2-1. 무상태성
사용자의 인증 정보가 담겨있는 토큰을 클라이언트에 저장하기 때문에 서버에서 별도의 저장소가 필요 없어, 완전한 무상태(stateless)를 가질 수 있습니다. 그리고 이로 인해 서버를 확장할 때 용이합니다.
2-2. 확장성
토큰 기반 인증을 사용하는 다른 시스템에 접근이 가능합니다. (ex. Facebook 로그인, Google 로그인)
2-3. 무결성
HMAC(Hash-based Message Authentication) 기법이라고도 불리며, 발급 후의 토큰의 정보를 변경하는 행위가 불가능합니다. 즉, 토큰이 변조되면 바로 알아차릴 수 있습니다.
2-4. 보안성
클라이언트가 서버에 요청을 보낼 때, 쿠키를 전달하지 않기 때문에 쿠키의 취약점은 사라집니다.
3. JWT?
이제 토큰 기반 인증에 대해 대충 알아보았으니, JWT가 무엇이고, 어떻게 구성이 되어있는지 알아보도록 하겠습니다. JWT는 아까 서술했듯이, 토큰 기반 인증 시스템의 대표적인 구현체입니다. Java를 포함한 많은 프로그래밍 언어에서 이를 지원하며, 보통 회원 인증을 할 때에 사용됩니다.
JWT는 .을 기준으로 헤더(header) - 내용(payload) - 서명(signature)으로 이루어져 있습니다. 각각 무슨 역할을 하는지 간단하게 알아보도록 하겠습니다.
3-1. 헤더(header)
헤더는 토큰의 타입과 해싱 알고리즘을 지정하는 정보를 포함합니다.
- typ : 토큰의 타입을 지정합니다. JWT라는 문자열이 들어가게 됩니다.
- alg: 해상 알고리즘을 지정합니다.
{
"typ": "JWT",
"alg": "HS256"
}
위 예제를 해석하면, JWT 토큰으로 이루어져 있고, 해당 토큰은 HS256으로 해상 알고리즘으로 사용되었다는 것을 알 수 있습니다.
3-2. 정보(payload)
토큰에 담을 정보가 들어갑니다. 정보의 한 덩어리를 클레임(claim)이라고 부르며, 클레임은 key-value의 한 쌍으로 이루어져 있습니다. 클레임의 종류는 세 종류로 나눌 수 있습니다.
- 등록된(registered) 클레임
- 토큰에 대한 정보를 담기 위한 클레임들이며, 이미 이름이 등록되어 있는 클레임
- iss : 토큰 발급자(issuer)
- sub : 토큰 제목(subject)
- aud : 토큰 대상자(audience)
- exp : 토큰의 만료시간(expiraton). 시간은 NumericDate 형식으로 되어있어야 하며, (예: 1480849147370) 항상 현재 시간보다 이후로 설정되어있어야 한다.
- nbf : Not Before를 의미하며, 토큰의 활성 날짜와 비슷한 개념. NumericDate 형식으로 날짜를 지정하며, 이 날짜가 지나기 전까지는 토큰이 처리되지 않는다.
- iat : 토큰이 발급된 시간 (issued at)
- jti : JWT의 고유 식별자로서, 주로 일회용 토큰에 사용한다.
- 공개(public) 클레임
- 말 그대로 공개된 클레임, 충돌을 방지할 수 있는 이름을 가져야 하며, 보통 클레임 이름을 URI로 짓는다.
- 비공개(private) 클레임
- 클라이언트 - 서버 간에 통신을 위해 사용되는 클레임
→ 예제 Payload
{
"iss": "ajufresh@gmail.com", // 등록된(registered) 클레임
"iat": 1622370878, // 등록된(registered) 클레임
"exp": 1622372678, // 등록된(registered) 클레임
"https://shinsunyoung.com/jwt_claims/is_admin": true, // 공개(public) 클레임
"email": "ajufresh@gmail.com", // 비공개(private) 클레임
"hello": "안녕하세요!" // 비공개(private) 클레임
}
3-3. 서명(signature)
해당 토큰이 조작되었거나 변경되지 않았음을 확인하는 용도로 사용하며, 헤더(header)의 인코딩 값과 정보(payload)의 인코딩값을 합친 후에 주어진 비밀키를 통해 해쉬값을 생성합니다.
HMACSHA256(
base64UrlEncode(header) + "." +
base64UrlEncode(payload),
secret)
Spring 환경에서 JWT를 다루기 위해 사용하는 jsonwebtoken 사용법에 대해서는 가장 마지막에 정리해 두었습니다.
session vs jwt 무엇을 사용해야 하는가?
session
세션 방식에서 서버는 로그인 된 유저 정보를 모두 저장하고 있고, 유저의 통제가 JWT보다 비교적 쉬워 집니다. 만약에 PC와 모바일 기기에서 동시 접근하는 것을 막고 싶을 경우 강제 로그아웃을 시키는 기능을 세션을 통해서 구현가능합니다.
JWT
JWT는 서버가 발급 후에 JWT를 관리하지 않습니다. 오직 JWT를 받았을 때, JWT가 유효한 것인지 확인하여 서버 자원과 비용을 절감 할 수 있습니다. 그러나 JWT가 수명이 길어서 제 3자에게 탈취당할 경우가 발생할 수 있는데 이를 보안하는 방법이 Access Token 과 Refresh Token의 방식이 있습니다. 또한 토큰을 검증하는 서명(signature)이 정말 중요하며 서명에 사용되는 secret_key를 잘 관리해야 합니다.
session? JWT? 나는 무엇을 선택했나?
저는 최근 관리자 페이지의 로그인을 구현하는 부분을 맡게 되었습니다. 단순히 JWT가 보안에 더 좋다고 생각하여 이를 사용하여 최근 관리자 페이지의 로그인을 구현했습니다.
그런데 다른 팀원들고 고민을 하던 도중 JWT 보다는 session 방식을 선택하자는 방안이 나오게 되었습니다. 이는 세션의 단점들로 지적되고 있던 부분들이 어느정도 해소되었다고 생각하고, 단점이라고 생각하던 부분들이 저희가 구현해야할 관리자 페이지 로그인에서는 크게 단점으로 작용하지 않는 다는 것이었습니다.
위에서 살펴본 세션 방식의 가장 큰 단점은 서버 부하, 확장 등에 막혀있음 등 이었습니다. 관리자 페이지에서는 많은 유저들이 접속하지 않는다는 것과 큰 확장이 필요없다는 생각이 들었습니다. 그리고 확장성은 session id를 서버 내부 메모리에 저장하지 않고 중립적인 외부 공간으로 대응이 가능했습니다. Spring Session 이라는 기능이 있어 Redis, RDBMS 등 원하는 데이터베이스를 지정하면 세션을 쉽게 다를 수 있다는 장점이 있다고 합니다. 그리고 중립적인 공간의 특성상 세션이 증가해도 서버에 부담이 증가하지 않게 된다고 해요.
이러한 단점들이 해결된다면, 세션의 가장 장력한 장점인 서버의 로그인 유지 통제 용이성을 가져갈 수 있다고 생각하여 session으로 변경하게 되었습니다.
1. 의존성 추가
jsonwebtoken을 사용하기 위해 의존성을 추가해 줍니다.
implementation 'io.jsonwebtoken:jjwt:0.9.1'
2. JWT 토큰 만들기
public String makeJwtToken() {
Date now = new Date();
return Jwts.builder()
.setHeaderParam(Header.TYPE, Header.JWT_TYPE) // (1)
.setIssuer("fresh") // (2)
.setIssuedAt(now) // (3)
.setExpiration(new Date(now.getTime() + Duration.ofMinutes(30).toMillis())) // (4)
.claim("id", "아이디") // (5)
.claim("email", "ajufresh@gmail.com")
.signWith(SignatureAlgorithm.HS256, "secret") // (6)
.compact();
}
- 헤더의 타입(typ)을 지정할 수 있습니다. jwt를 사용하기 때문에 Header.JWT_TYPE로 사용해 줍니다.
- 등록된 클레임 중, 토큰 발급자(iss)를 설정할 수 있습니다.
- 등록된 클레임 중, 발급 시간(iat)을 설정할 수 있습니다. Date 타입만 추가가 가능합니다.
- 등록된 클레임 중, 만료 시간(exp)을 설정할 수 있습니다. 마찬가지로 Date 타입만 추가가 가능합니다.
- 비공개 클레임을 설정할 수 있습니다. (key-value)
- 해싱 알고리즘과 시크릿 키를 설정할 수 있습니다.
더 많은 기능이 있지만, 제가 사용해 본 메서드 위주로 설명을 적어두었습니다. 모든 설정이 끝나면 compact()를 통해 JWT 토큰을 만들 수 있습니다.
참고
토큰에는 무엇을 담아야 하는가? 사례로 알아보기
https://creampuffy.tistory.com/164
그 이후에 실행하면,
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJmcmVzaCIsImlhdCI6MTYyMjkwNjg0NSwiZXhwIjoxNjIyOTA4NjQ1LCJpZCI6IuyVhOydtOuUlCIsImVtYWlsIjoiYWp1ZnJlc2hAZ21haWwuY29tIn0.ucTS9OgA7Z751a6aNzttcEXRfEhG_hsZPzZZTHhbUrA
위와 같은 토큰을 획득할 수 있습니다.
3. JWT 토큰 파싱하기
클라이언트에서 토큰을 저장해 두었다가 Authorization 헤더에 Bearer라는 문자열을 붙여 토큰을 보내게 되는데요!
Authorization : Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJmcmVzaCIsImlhdCI6MTYyMjkwNjg0NSwiZXhwIjoxNjIyOTA4NjQ1LCJpZCI6IuyVhOydtOuUlCIsImVtYWlsIjoiYWp1ZnJlc2hAZ21haWwuY29tIn0.ucTS9OgA7Z751a6aNzttcEXRfEhG_hsZPzZZTHhbUrA
전달받은 토큰을 해석해서 유효한 토큰인지 확인이 가능합니다. 👀
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException {
String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
Claims claims = jwtTokenProvider.parseJwtToken(authorizationHeader);
filterChain.doFilter(request, response);
}
public Claims parseJwtToken(String authorizationHeader) {
validationAuthorizationHeader(authorizationHeader); // (1)
String token = extractToken(authorizationHeader); // (2)
return Jwts.parser()
.setSigningKey("secret") // (3)
.parseClaimsJws(token) // (4)
.getBody();
}
private void validationAuthorizationHeader(String header) {
if (header == null || !header.startsWith("Bearer ")) {
throw new IllegalArgumentException();
}
}
private String extractToken(String authorizationHeader) {
return authorizationHeader.substring("Bearer ".length());
}
- 헤더가 'Bearer'로 시작하는지 검사합니다.
- 'Bearer'을 제외한 문자열만 반환해 주도록 처리해 줍니다.
- 시크릿 키를 넣어주어 토큰을 해석할 수 있습니다.
- 해석할 토큰을 문자열(String) 형태로 넣어줍니다.
위와 같은 정보를 넣어준 후에 getBody()를 호출하게 되면, Claim 타입의 결과 객체를 반환하게 되는데, 여기에서 저장된 클레임 정보들을 확인할 수 있습니다.
발생할 수 있는 예외는 다음과 같습니다.
- UnsupportedJwtException : 예상하는 형식과 다른 형식이거나 구성의 JWT일 때
- MalformedJwtException : JWT가 올바르게 구서오디지 않았을 때
- ExpiredJwtException : JWT를 생성할 때 지정한 유효기간이 초과되었을 때
- SignatureException : JWT의 기존 서명을 확인하지 못했을 때
- IllegalArgumentException
위의 예외에 대해 적절한 처리를 해주는 것이 좋습니다!
👏 참고
'Java' 카테고리의 다른 글
[Spring] StringUtils 클래스로 String 다루기 (0) | 2022.07.14 |
---|---|
[Java] for문 List의 Null 체크 (0) | 2022.07.12 |
Apache Tomcat 최신 버전 업데이트 방법 (0) | 2022.06.23 |
[라이브러리] FullCalendar 사용하기 (0) | 2022.06.03 |
[js] 페이지 이동하기, 새 창 띄우기 (0) | 2022.06.03 |