Spring boot에서 JWT 토큰 발급 구현하기

도입 배경

우아한테크코스에 프로젝트를 진행하는데 사용자의 정보를 기억해야 하는 상황이 필요한 경우가 있다.

예를 들면 사용자가 작성한 리뷰를 삭제하거나 수정할 수 있어야 한다. 그러려면 현재 접속한 사용자가 이전에 리뷰를 작성한 사용자임을 알아낼 수 있어야 한다.

일단 사용자를 구분하기 위해서 로그인을 해서 사용자를 구분해서 관리할 수 있도록 했다.

이때 사용자 인증하는 역할은 깃허브Oauth를 활용해서 깃허브에서 사용자를 인증하고 사용자의 정보를 반환한다.

이제 사용자에 대한 정보를 우리 프로젝트 서버로 가져왔다. 이 정보를 어딘가에 기억해놔야 사용자가 다음에 요청을 했을 때 같은 사용자임을 알 수 있다.

세션, JWT 토큰

정보를 어디에 저장할 지, 어떻게 저장할 지에 따라 세션, JWT 토큰을 활용하는 방법이 있다.
(쿠키에 사용자 정보를 그대로 저장하는 방법도 있지만 보안에 취약해서 제외했다.)

세션

세션 방식은 서버의 세션에 사용자의 정보를 저장하고 해당 세션에 저장할 때 발급되는 JSESSIONID를 클라이언트에게 전달해서 클라이언트는 JSESSIONID를 요청 보낼 때 같이 보낸다. 서버는 요청과 함께 온 JSESSIONID를 통해 서버의 세션에 저장된 정보를 찾아 사용자를 식별한다.즉 세션 방식은 사용자의 정보를 서버에 저장하는 방식이다.

문제는 만약 서버가 다중화가 되는 경우다.

필자가 사용하는 Spring의 경우 세션을 톰캣 내부에 저장하는 게 기본이다. 그럴 경우 사용자가 다른 서버로 요청을 보내게 되면 해당 서버의 세션에는 사용자 정보가 저장된 적 없으니 문제가 생긴다. 즉 여러 서버의 세션이 동기화를 해줘야 한다.

그래서 세션을 사용하는 실 서비스에서는 세션을 위한 DB 서버를 따로 관리한다. 세션을 위한 저장소를 따로 관리하는 경우 기존의 API 서버와 IO 비용을 생각해야 한다. 특히 여러 API 서버가 한 세션 서버에 요청을 보낼 경우 세션 서버에 지나친 부하가 생길 수 있다.

JWT 토큰

JWT 토큰은 JWT 토큰이라는 것에 사용자의 정보를 인코딩하여 담고 서버가 가진 시크릿키로 서명해서 만든 다음 클라이언트 쪽에서 저장하는 방법이다.

클라이언트는 자신이 가진 JWT 토큰을 요청을 보낼 때 같이 보내서 서버가 이를 받아 디코딩해서 사용하는 방식이다.

문제는 JWT 토큰에 담긴 정보는 누구나 디코딩 할 수 있다는 점이다.

JWT 토큰의 페이로드에 담긴 값은 애초에 암호화한 값을 집어넣는 것이 아니라면 누구나 그 값이 어떤 값인지 디코딩 해서 볼 수 있다.

그래서 보안에 민감한 값은 JWT 토큰에 담지 않도록 한다.

더 큰 문제는 시크릿키가 노출되면 공격자가 JWT 토큰을 얼마든지 만들 수 있다는 점이다.

그래서 JWT 토큰을 사용할 때는 시크릿키를 깃허브같은 곳에 노출되지 않도록 주의해야 한다.

쿠키에 사용자 정보 그대로 저장 (비추천)

쿠키에 사용자의 정보를 그대로 저장하는 방법은 추천하지 않는다.

일반적인 쿠키는 JS로 접근해서 내용을 볼 수 있기 때문에 XSS(Cross Site Scripting)을 통해 탈취 될 수 있다.

또한 스니핑(Sniffing, 서버와 클라이언트의 네트워크 중간에서 패킷을 탈취해서 도청) 공격을 통해 탈취 될 수 있다.

위 두 문제점은 막으려면

1. 쿠키의 Http-Only 옵션을 켜서 JS가 접근할 수 없도록 해야 한다.

2. 서버와 클라이언트의 통신을 HTTPS로 설정해서 중간에 도청해도 알 수 없도록 해야 한다.

JWT 토큰을 고른 이유

JWT 토큰은 서버에서 토큰을 어떻게 저장할 지 고민하지 않아도 되는 점이 컸다.

특히 서비스의 특성에 따라 달라지는데, 우리 서비스는 단순 리뷰 서비스이기 때문에 지금 단계에서는 JWT 토큰으로 간단하게 사용자를 식별해도 괜찮다고 판단했다.

JWT 토큰에 사용되는 시크릿키는 서브 모듈로 따로 Private 레포지토리를 만들어서 관리하면 해결된다고 판단했다. 

Spring Boot에서 JWT 토큰 발급하기

일단 JWT 토큰에 어떤 값을 저장해서 서버와 클라이언트가 주고 받을 것인지 결정해야 한다.

우리 서비스는 사용자의 정보를 DB에 저장할 때 생성된 MemberID를 페이로드에 담았다.

로그인을 할 때 인증 인가 로직 흐름은 다음과 같다.

  1. 클라이언트에서 깃허브에 코드를 요청한다.
  2. 깃허브는 해당 클라이언트가 깃허브의 사용자인지 확인하고 코드를 반환한다.
  3. 클라이언트는 서버에 깃허브에서 받은 코드를 넘겨서 로그인 요청을 날린다.
  4. 서버는 코드를 통해 깃허브 API용 access token을 요청한다.
  5. 깃허브는 코드를 확인하고 깃허브 API에 접근할 수 있는 access token을 발급한다.
  6. 서버는  깃허브 API access token을 통해 사용자의 정보를 요청한다.
  7. 깃허브는 access token을 확인하고 해당 사용자의 정보를 반환한다.
  8. 서버는 사용자의 정보를 받고 이를 DB에 최신화한다. (만약 깃허브 이름이 변했을 경우 반영해주기 위함)
  9. 서버는 DB에 저장된 사용자의 ID를 통해 JWT 토큰을 만들어서 클라이언트에게 반환한다.
  10. 이제 클라이언트는 인가가 필요한 요청에 받은 JWT 토큰을 요청 헤더(Authorization)에 담아서 보낸다.
  11. 서버는 클라이언트의 Authorization 헤더에 저장된 JWT 토큰이 우리 서버가 발급한 것인지, 유효한 것인지 확인하고 요청을 수행한다.

서버 입장에서는 3, 4, 6, 8, 9, 11을 처리해주면 된다.

AuthController

먼저 3번 먼저 구현해보자. 로그인 요청을 받는 AuthController 예시이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@RestController
@RequestMapping("/api/v1")
public class AuthController {

private final AuthService authService;

public AuthController(final AuthService authService) {
this.authService = authService;
}

@GetMapping("/login")
public ResponseEntity<LoginResponse> login(@RequestParam final String code) {
final LoginResult loginResult = authService.login(code);
return ResponseEntity.ok(LoginResponse.from(loginResult));
}
}

여기서 파라미터로 오는 코드는 깃허브에서 사용자 인증에 성공했을 경우 돌려받는 코드이다.

AuthService에서 코드를 전달해서 로그인 처리를 위임하고 있다.

AuthService

AuthService에서 logIn을 어떻게 처리하는 지 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Service
@Transactional(readOnly = true)
public class AuthService {

private final GitHubOauthClient gitHubOauthClient;
private final MemberRepository memberRepository;
private final JwtProvider jwtProvider;

public AuthService(final GitHubOauthClient gitHubOauthClient, final MemberRepository memberRepository,
final JwtProvider jwtProvider) {
this.gitHubOauthClient = gitHubOauthClient;
this.memberRepository = memberRepository;
this.jwtProvider = jwtProvider;
}

@Transactional
public LoginResult login(final String code) {
final GitHubProfileResponse gitHubProfileResponse = getGitHubProfileResponse(code);
final Member member = addOrUpdateMember(gitHubProfileResponse);
final Long memberId = member.getId();
final String applicationAccessToken = jwtProvider.createAccessToken(memberId);
return new LoginResult(applicationAccessToken, member);
}

private GitHubProfileResponse getGitHubProfileResponse(final String code) {
final String gitHubAccessToken = gitHubOauthClient.getAccessToken(code);
return gitHubOauthClient.getProfile(gitHubAccessToken);
}

private Member addOrUpdateMember(final GitHubProfileResponse gitHubProfileResponse) {
final Member requestedMember = gitHubProfileResponse.toMember();
final Member member = memberRepository.findByGitHubId(gitHubProfileResponse.getGitHubId())
.orElseGet(() -> memberRepository.save(requestedMember));
member.update(requestedMember);
return member;
}
}

AuthService의 getGitHubProfileResponse 메서드는 4번과 6번을 GithubOauthClient에게 위임하고 있다.

(GithubOauthClient는 다른 포스트로 더 설명해보고자 한다.)

AuthService의 addOrUpdateMember 메서드는 8번을 수행하고 있다.

AuthService의 login 메서드에서 JwtProvider를 통해 9번을 수행하고 있다.

그리고 LoginResult라는 DTO에 결과를 담아서 컨트롤러로 반환하고 있다.

JwtProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@Component
public class JwtProvider {

private static final String TOKEN_TYPE = "Bearer";
private static final String ACCESS_TOKEN_SUBJECT = "AccessToken";

private final AuthTokenExtractor authTokenExtractor;
private final Key secretKey;
private final long validityInMilliseconds;

public JwtProvider(final AuthTokenExtractor authTokenExtractor,
@Value("${security.jwt.secret-key}") final String secretKey,
@Value("${security.jwt.expire-length}") final long validityInMilliseconds) {
this.authTokenExtractor = authTokenExtractor;
this.secretKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8));
this.validityInMilliseconds = validityInMilliseconds;
}

public String createAccessToken(final Long id, final Role role) {
final Date now = new Date();
final Date validity = new Date(now.getTime() + validityInMilliseconds);

return Jwts.builder()
.setSubject(ACCESS_TOKEN_SUBJECT)
.setIssuedAt(now)
.setExpiration(validity)
.claim("id", id)
.claim("role", role)
.signWith(secretKey, SignatureAlgorithm.HS256)
.compact();
}

public boolean isValidToken(final String authorizationHeader) {
final String token = authTokenExtractor.extractToken(authorizationHeader, TOKEN_TYPE);
try {
final Jws<Claims> claims = getClaimsJws(token);
return isAccessToken(claims) && isNotExpired(claims);
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}

private Jws<Claims> getClaimsJws(final String token) {
return Jwts.parserBuilder()
.setSigningKey(secretKey)
.build()
.parseClaimsJws(token);
}

private boolean isAccessToken(final Jws<Claims> claims) {
return claims.getBody()
.getSubject()
.equals(ACCESS_TOKEN_SUBJECT);
}

private boolean isNotExpired(final Jws<Claims> claims) {
return claims.getBody()
.getExpiration()
.after(new Date());
}

public MemberPayload getPayload(final String authorizationHeader) {
final String token = authTokenExtractor.extractToken(authorizationHeader, TOKEN_TYPE);
Claims body = getClaimsJws(token).getBody();
try {
Long id = body.get("id", Long.class);
Role role = Role.valueOf(body.get("role", String.class));
return new MemberPayload(id, role);
} catch (RequiredTypeException | NullPointerException | IllegalArgumentException e) {
throw new TokenInvalidFormatException();
}
}
}

return claims.getBody()
.getExpiration()
.after(new Date());
}

public MemberPayload getPayload(final String authorizationHeader) {
final String token = authTokenExtractor.extractToken(authorizationHeader, TOKEN_TYPE);
Claims body = getClaimsJws(token).getBody();
try {
Long id = body.get("id", Long.class);
Role role = Role.valueOf(body.get("role", String.class));
return new MemberPayload(id, role);
} catch (RequiredTypeException | NullPointerException | IllegalArgumentException e) {
throw new TokenInvalidFormatException();
}
}
}

JwtProvider는 Jwt 토큰을 만들고, 검증하는 역할을 한다.

이때 JwtProvider에는 io.jsonwebtoken 라이브러리를 사용한다.

이를 위해서는 build.gradle에 다음과 같이 의존성을 추가해주자.

1
2
3
4
5
6
7
8
dependencies {
// 생략...

// jwt
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

JwtProvider는 생성자로 시크릿키, 만료 시간, AuthTokenExtractor를 주입받는다.

AuthTokenExtractor는 Authorization 헤더 형식에서 토큰값만 추출하는 책임을 진다.

(https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization)

만료시간은 변경해줘야 할 경우 JwtProvider의 코드를 살펴보면서 바꾸면 위험하기 때문에 따로 주입해주는 방식을 썼다.

문제는 시크릿키이다.

시크릿키는 한번 유출되면 누구나 서버가 만든 JWT 토큰과 동일한 서명을 가진 JWT 토큰을 만들어 낼 수 있다.

그래서 따로 설정파일 (yaml)을 만들어서 보안이 중요한 내용을 관리하도록 했다.

이런 보안이 중요한 파일들은 .gitignore로 깃에 등록되지 않도록 관리하거나, 서브 모듈을 활용해서 private repository에서 버전 관리를 할 수 있다.

1
2
3
4
security:
jwt:
secret-key: secretKeyExample
expire-length: 3600000

참고

https://github.com/woowacourse-teams/2022-f12

Share