Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT/#16] JWT Provider를 구현합니다. #17

Open
wants to merge 5 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions domain/src/main/java/sopt/makers/authentication/user/Activity.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

public record Activity(int generation, Team team, Part part, Role role) {

public Activity(int generation, final Team team, final Part part) {
this(generation, team, part, Role.MEMBER);
}
public Activity(final int generation, final Team team, final Part part) {
this(generation, team, part, Role.MEMBER);
}
hyunw9 marked this conversation as resolved.
Show resolved Hide resolved

public void validateActivityContentsEmpty() {
if (this.role == null) {
throw new IllegalArgumentException("활동 정보가 비어있습니다.");
}
public void validateActivityContentsEmpty() {
if (this.role == null) {
throw new IllegalArgumentException("활동 정보가 비어있습니다.");
}

if (this.role.isPartRequired() && this.part == null) {
throw new IllegalArgumentException("해당 Role은 part 필드가 필수입니다.");
}
}
if (this.role.isPartRequired() && this.part == null) {
throw new IllegalArgumentException("해당 Role은 part 필드가 필수입니다.");
}
}
}
3 changes: 3 additions & 0 deletions support/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ dependencies {
implementation ("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-security")

//JWT & Nimbus
implementation ("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2

오 저도 쓰고 싶었던 라이브러리인데요!
다만 조금 우려되는 부분이 한 가지 있습니다!

현재 저희가 구현중인 프로젝트의 경우,
프로덕트 Client 측에서 auth_code와 함께 로그인 요청이 들어오면 저희가 OAuth 벤더의 Resource Server에 요청하여 사용자 정보를 받아오는 "OAuth2Client"의 역할과 흡사한 상태입니다.

또한 보통 Resource Server를 구현할 때, Jwk 조회 api도 구현해주는데요!
(인증 체계를 활용하는 타 프로덕트 서비스에서 해당 api 호출을 통해 Jwk를 가져와 활용하기 위해서)

어찌보면 OAuth 2 Client의 역할을 겸하고 있는 상황에서 Resource Server의 역할까지 겸하게 된다면 모든 api를 한 WAS에서 설계하고 처리해줘야 하기에
이것이 바람직할지 한 번 같이 논의해보는 것이 좋을 것 같습니다!
(제가 잘못 알고 있는 부분이 있다면 가감없이 지적 부탁드립니다!!)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제가 기억하기로는 이전에

  1. 비대칭 키 각 팀에 배부
  2. API 형식으로 pubKey 호출하기
    두가지 방식을 논의했던 것으로 기억합니다 !

그 중 1번을 채택하기로 해서 JWK(public key)를 각 서버별로 들고있는 방식으로 이해하고 있었습니다. !
저희가 구현하는 인증 서버는 파싱에 privateKey가 필요하구요.

Oauth2 Client와 Resource Server의 역할을 겸한다.. 가 사실 제가 이해를 못했습니다.. 하하
pub Key를 API로 제공할 경우에 말씀하신건가요??

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이번에 짚은 이유는 oauth2-resource-server 라이브러리를 사용했기 때문이에요!!

JWT 기반 인증 흐름에서 위 라이브러리를 사용하게 된다면
해당 프로젝트는 "리소스 서버"로서의 역할을 수행하겠다는 의미가 될거에요!!

그럴 경우, 간단하게 JWT 기반 인증 flow를 얘기해본다면 아래와 같을텐데

  1. 각 API 서버로 보내는 요청에 클라이언트들은 발급받은 JWT를 같이 전송
  2. 각 API 서버에서 JWT의 유효성을 검증
    • JWT발급자 (우리 서버)가 제공하는 URL에서 JWK 형식으로 된 public key를 다운로드 후 서명 일치 여부 확인 (각 팀에게 Private 키를 배부하는 것은 동일)
    • 각 서버마다kid가 할당되어 있을 것이고 이를 통해 식별 (kid가 매칭이 안되면 우리의 예상 접근자가 아니기 때문에 에러를 발생시켜야 함)
  3. 검증 통과시 JWT에서 유저ID를 추출
  4. 해당 요청이 추출된 유저ID 사용자가 보낸 것임을 알 수 있음 (인증 완료)

만약 위와 같은 구조(해당 라이브러리가 JWK 보관 등과 같이 특화된 기능들을 지원하는 이유)를 충족하지 못한다면
단순 시큐리티 호환성이 좋은 것 하나만으로 도입하는 것은 조금 근거가 부족하다고 생각했어요!!
(작은 사이즈의 라이브러리도 아닐뿐더러 Java 자료구조를 잘 사용하면 충분히 보관 기능은 구현 가능할 것 같다는...? 개인적 생각)


//test
testImplementation("org.springframework.boot:spring-boot-starter-test")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
package sopt.makers.authentication.support.util.code.failure;

import sopt.makers.authentication.support.common.code.Failure;

import org.springframework.http.HttpStatus;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import sopt.makers.authentication.support.common.code.Failure;

@Getter
@RequiredArgsConstructor(access = AccessLevel.PRIVATE)
public enum TokenFailure implements Failure {

;
private final HttpStatus status;
private final String message;
TOKEN_EXPIRED(HttpStatus.BAD_REQUEST, "토큰이 만료되었습니다."),
UNSUPPORTED_ISSUER(HttpStatus.BAD_REQUEST, "신뢰할 수 없는 발급자입니다."),
INVALID_SUBJECT(HttpStatus.BAD_REQUEST, "주체 정보가 잘못되었습니다."),
INVALID_PREFIX(HttpStatus.BAD_REQUEST, "토큰 접두사가 잘못되었습니다."),
;
sung-silver marked this conversation as resolved.
Show resolved Hide resolved
private final HttpStatus status;
private final String message;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package sopt.makers.authentication.support.util.jwt;

import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "rsa")
public record JwtKeyProperties(RSAPublicKey publicKey, RSAPrivateKey privateKey) {}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

import java.io.IOException;

public interface JwtProvider <T>{
String generate(final T value);
public interface JwtProvider<T> {
String generateAccessToken(final T value);

T parse(final String token) throws IOException;
String generateRefreshToken(final T value);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2

Refresh 토큰의 경우, 어떤 값을 활용해서 생성할지 궁금합니다!
본 Provider는 제네릭을 통해 <T>로 선언해서 위 generateAccessToken(final T value) 경우와 동일한 데이터가 필요해지게 된다는 것인데
AccessToken은 권한 식별 값이 포함되어 있는 값이 필요하기 때문에 파라미터로 T 타입 value를 받았지만
RefreshToken을 생성할 때까지 해당 T 타입 value가 필요할지는 개인적으로 모르겠습니다!!

이 부분 설명해주시면 감사하겠습니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그렇네요.. RefreshToken은 AccessToken의 갱신만을 위한 정보를 가지고 생성해야 하는게 목표인데,
전반적으로 제 구현을 보면 ATK ≈ RTK로 이해하고 있었던 것 같습니다 !!

제 생각에는 UserId + Expire 정도만 들어가면 좋을 것 같은데, 어떻게 생각하시나요 ?!


T parse(final String token) throws IOException;
}

This file was deleted.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3

전체적으로 단일 클래스 내부에 함수가 많다고 생각이 듭니다!!
validate 함수의 경우, 특정한 관심사가 존재하는 행위이기 때문에 검증 객체로 어느정도 책임과 역할의 분리가 가능해보이는데

예를 들어 JwtTokenValidator 와 같은 검증 객체를 구현하는 것은 어떻게 생각하시나요?!!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3

저도 Validator를 분리하는거에 동의합니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다 분리하겠습니다 ~ !

hyunw9 marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package sopt.makers.authentication.support.util.jwt.provider;

import static sopt.makers.authentication.support.util.code.failure.TokenFailure.TOKEN_EXPIRED;
import static sopt.makers.authentication.support.util.code.failure.TokenFailure.UNSUPPORTED_ISSUER;

import sopt.makers.authentication.support.system.security.authentication.CustomAuthentication;
import sopt.makers.authentication.support.util.code.failure.TokenFailure;
import sopt.makers.authentication.support.util.exception.TokenException;
import sopt.makers.authentication.support.util.jwt.JwtProvider;
import sopt.makers.authentication.support.value.JwtConstant;

import java.io.IOException;
import java.time.Instant;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Component;

import lombok.RequiredArgsConstructor;

@Component
@RequiredArgsConstructor
public class JwtTokenProvider implements JwtProvider<CustomAuthentication> {

private final JwtDecoder jwtDecoder;
private final JwtEncoder jwtEncoder;

@Override
public String generateAccessToken(final CustomAuthentication value) {
String subject = value.getPrincipal().toString();
String issuer = JwtConstant.ISSUER;
Instant now = Instant.now();
Instant expiration = now.plusSeconds(JwtConstant.ACCESS_TOKEN_EXPIRATION);
List<String> roles =
value.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toUnmodifiableList());
JwtClaimsSet claimsSet = generateClaimSet(subject, issuer, now, expiration);
return jwtEncoder.encode(JwtEncoderParameters.from(claimsSet)).getTokenValue();
}

@Override
public String generateRefreshToken(CustomAuthentication value) {
String subject = value.getPrincipal().toString();
String issuer = JwtConstant.ISSUER;
Instant now = Instant.now();
Instant expiration = now.plusSeconds(JwtConstant.REFRESH_TOKEN_EXPIRATION);
List<String> roles =
value.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toUnmodifiableList());
Comment on lines +59 to +62
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1

RefreshToken에 까지 유저의 권한 정보(주요 정보)가 주입되는 이유가 무엇일까요?
RefreshToken의 쓰임은 그저 "AccessToken 갱신"을 위함이라고 생각합니다.

그렇기에 Subject로 주입되는 Principal 값부터 roles의 값이 "AccessToken 갱신"용 토큰에 주입되면 바람직하지 않다고 생각합니다.
(현재 구조는 개인적으로 AccessToken을 갱신하기 위해 AccessToken을 활용하는 구조가 되었다고 보여집니다.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

맞는거같습니다! 너무 많은 값이 삽입되고 있는거 같아요! 이 부분도 수정하겠습니다 !

JwtClaimsSet claimsSet = generateClaimSet(subject, issuer, now, expiration);
return jwtEncoder.encode(JwtEncoderParameters.from(claimsSet)).getTokenValue();
}

@Override
public CustomAuthentication parse(final String token) throws IOException {
validatePrefix(token);
String seperatedToken = separatePrefix(token);
Jwt jwt = jwtDecoder.decode(seperatedToken);
validateJwt(jwt);
return resolveAuthentication(jwt);
}

private String separatePrefix(String token) {
return token.substring(JwtConstant.TOKEN_HEADER.length());
}

private void validatePrefix(String token) {
if (!token.startsWith(JwtConstant.TOKEN_HEADER)) {
hyunw9 marked this conversation as resolved.
Show resolved Hide resolved
throw new TokenException(TokenFailure.INVALID_PREFIX);
}
}

public void validateJwt(final Jwt jwt) {
validateExpiration(jwt);
validateIssuer(jwt);
validateSubject(jwt);
}

private JwtClaimsSet generateClaimSet(
String subject, String issuer, Instant issuedAt, Instant expiresAt) {
return JwtClaimsSet.builder()
.subject(subject)
.issuer(issuer)
.issuedAt(issuedAt)
.expiresAt(expiresAt)
.build();
}

private void validateExpiration(final Jwt jwt) {
Date expiration = Date.from(jwt.getExpiresAt());
if (expiration == null || expiration.before(new Date())) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3

이 부분도 boolean validExpiration = expiration == null | expiration.before(new Date()); 로 분리할 수 있을 것 같아요!

throw new TokenException(TOKEN_EXPIRED);
}
}

private void validateIssuer(final Jwt jwt) {
String issuer = jwt.getClaim(JwtClaimNames.ISS);

Optional.ofNullable(issuer)
.filter(i -> Arrays.asList(JwtConstant.ISSUERS).contains(i))
.orElseThrow(() -> new TokenException(UNSUPPORTED_ISSUER));
}

private void validateSubject(final Jwt jwt) {
// DB연결 필요 ? (논의)
}

private CustomAuthentication resolveAuthentication(final Jwt jwt) {
String subject = jwt.getSubject();
Long userId = Long.parseLong(subject);
List<GrantedAuthority> authorities = extractAuthorities(jwt);
return new CustomAuthentication(subject, authorities);
}

private List<GrantedAuthority> extractAuthorities(final Jwt jwt) {
List<String> roles = jwt.getClaim("roles");
return roles.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toUnmodifiableList());
}

private String appendTokenHeader(final String IncompleteToken) {
return JwtConstant.TOKEN_HEADER.concat(IncompleteToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package sopt.makers.authentication.support.value;

public final class JwtConstant {

public static final String[] ISSUERS = {"operation", "playground"};
public static final String ISSUER = "operation";

public static final long ACCESS_TOKEN_EXPIRATION = 1000L * 60 * 10;
public static final long REFRESH_TOKEN_EXPIRATION = 1000L * 60 * 60 * 24 * 7;
public static final String TOKEN_HEADER = "Bearer ";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package sopt.makers.authentication.support.jwt;

import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;

import com.nimbusds.jwt.SignedJWT;

@ExtendWith(MockitoExtension.class)
public class JwtDecodeTest {

@Mock private JwtDecoder jwtDecoder;

@Test
@DisplayName("JWT 디코딩 호출을 확인한다")
public void test() {
// Given
String testToken = "testToken";
SignedJWT signedJWT = mock(SignedJWT.class);
Jwt jwt = JwtTokenHelper.generateJwt();

// When
when(jwtDecoder.decode(testToken)).thenReturn(jwt);
Jwt decoderJwt = jwtDecoder.decode(testToken);

// Then
verify(jwtDecoder, times(1)).decode(testToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package sopt.makers.authentication.support.jwt;

import sopt.makers.authentication.support.system.security.authentication.CustomAuthentication;
import sopt.makers.authentication.support.value.JwtConstant;

import java.time.Instant;
import java.util.Date;

import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;

import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.gen.RSAKeyGenerator;

public class JwtTokenHelper {

public static Jwt generateJwt() {

try {
RSAKey rsaKey = new RSAKeyGenerator(2048).generate();
} catch (JOSEException e) {
throw new RuntimeException(e);
}

Jwt jwt =
Jwt.withTokenValue("test")
.expiresAt(new Date(new Date().getTime() + 60 * 1000).toInstant()) // 1분 후 만료
.claim("userId", "1")
.subject("testToken")
.header("TestHeader", "headerValue")
.build();
return jwt;
}

public static CustomAuthentication createFakeAuthentication(String userId) {
return new CustomAuthentication(userId, "password");
}

public static Jwt createFakeAccessTokenJwt(JwtClaimsSet claimsSet) {
String tokenValue = "mockAccessToken";
String subject = claimsSet.getSubject();
String issuer = JwtConstant.ISSUER;
Instant issuedAt = claimsSet.getIssuedAt();
Instant expiresAt = claimsSet.getExpiresAt();
Object roles = claimsSet.getClaim("roles");

// Jwt 객체 생성
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p1

코드로 알 수 있는 정보에 대한 주석은 지우면 좋을 것 같아요!

Jwt jwt =
Jwt.withTokenValue(tokenValue)
.header("TestHeader", "headerValue") // 헤더 설정
.subject(subject)
.issuer(issuer)
.issuedAt(issuedAt)
.expiresAt(expiresAt)
.claim("roles", roles)
.build();

return jwt;
}

public static Jwt createFakeRefreshTokenJwt(JwtClaimsSet claimsSet) {
String tokenValue = "mockRefreshToken"; // 실제 JWT 토큰 값
String subject = claimsSet.getSubject(); // subject 추출
String issuer = JwtConstant.ISSUER; // issuer 추출
Instant issuedAt = claimsSet.getIssuedAt(); // issuedAt 추출
Instant expiresAt = claimsSet.getExpiresAt(); // expiresAt 추출
Object roles = claimsSet.getClaim("roles"); // roles 클레임 추출
Comment on lines +63 to +68
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p1

여기 주석도 변수 선언을 통해 알 수 있는 부분인 것 같습니다..!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

확인했습니다.. ! 클린코드 클린코드


// Jwt 객체 생성
Jwt jwt =
Jwt.withTokenValue(tokenValue)
.header("TestHeader", "headerValue") // 헤더 설정
.subject(subject)
.issuer(issuer)
.issuedAt(issuedAt)
.expiresAt(expiresAt)
.claim("roles", roles)
.build();

return jwt;
}
}
Loading