Skip to content
Merged
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
9 changes: 9 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,24 @@ repositories {
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5'
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.6.2'
implementation 'me.paulschwarz:spring-dotenv:4.0.0'

implementation 'org.springframework.security:spring-security-oauth2-client'
implementation 'org.springframework.security:spring-security-oauth2-jose'
implementation 'com.auth0:java-jwt:4.3.0'

compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'


runtimeOnly 'com.h2database:h2'
runtimeOnly 'com.mysql:mysql-connector-j'

testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/org/runimo/runimo/RunimoApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@SpringBootApplication
@EnableJpaAuditing
public class RunimoApplication {

public static void main(String[] args) {
Expand Down
56 changes: 56 additions & 0 deletions src/main/java/org/runimo/runimo/auth/jwt/JwtTokenFactory.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package org.runimo.runimo.auth.jwt;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import lombok.RequiredArgsConstructor;
import org.runimo.runimo.user.domain.User;
import org.runimo.runimo.user.service.dtos.TokenPair;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.Date;

@Component
@RequiredArgsConstructor
public class JwtTokenFactory {

private static final String ISSUER = "RUNIMO_SERVICE";

@Value("${jwt.secret}")
private String jwtSecret;
@Value("${jwt.expiration}")
private long jwtExpiration;
@Value("${jwt.refresh.expiration}")
private long jwtRefreshExpiration;

public String generateAccessToken(User user) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtExpiration);

return JWT.create()
.withSubject(user.getPublicId())
.withIssuedAt(now)
.withExpiresAt(expiryDate)
.withIssuer(ISSUER)
.sign(Algorithm.HMAC256(jwtSecret));
}

public String generateRefreshToken(User user) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + jwtRefreshExpiration);

return JWT.create()
.withSubject(user.getPublicId())
.withIssuedAt(now)
.withExpiresAt(expiryDate)
.withIssuer(ISSUER)
.withClaim("tokenType", "refresh")
.sign(Algorithm.HMAC256(jwtSecret));
}

public TokenPair generateTokenPair(User user) {
String accessToken = generateAccessToken(user);
String refreshToken = generateRefreshToken(user);
return new TokenPair(accessToken, refreshToken);
}
}
12 changes: 12 additions & 0 deletions src/main/java/org/runimo/runimo/auth/jwt/TokenStatus.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.runimo.runimo.auth.jwt;

public enum TokenStatus {
/**
* 토큰에 문제가 없는지 검증을 시도한 적 있으나, 서비스 토큰 발급까지 진행되지 않은 상태
*/
PENDING,
/**
* 토큰에 문제가 없는지 검증이 완료되고, 서비스 토큰 발급까지 완료된 상태
*/
USED
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.runimo.runimo.auth.repository;

import lombok.RequiredArgsConstructor;
import org.runimo.runimo.auth.jwt.TokenStatus;
import org.runimo.runimo.common.CacheEntry;
import org.runimo.runimo.common.InMemoryCache;
import org.runimo.runimo.user.domain.SocialProvider;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.Optional;

@Component
@RequiredArgsConstructor
public class InMemoryOAuthTokenRepository implements OAuthTokenRepository {

private final InMemoryCache<String, TokenStatus> tokenCache;

public void storeNonce(
SocialProvider provider, String sub, String nonce, TokenStatus status, Duration ttl) {
String key = generateKey(provider, sub, nonce);
tokenCache.put(key, status, ttl);
}

public Optional<TokenStatus> getNonceStatus(SocialProvider provider, String sub, String nonce) {
String key = generateKey(provider, sub, nonce);
return tokenCache.get(key);
}

public void updateNonceStatus(
SocialProvider provider, String sub, String nonce, TokenStatus status) {
String key = generateKey(provider, sub, nonce);
CacheEntry<TokenStatus> entry =
tokenCache
.getEntry(key)
.orElseThrow(() -> new IllegalStateException("Token not found for key: " + key));

tokenCache.remove(key);

Duration remainingTtl = Duration.between(java.time.Instant.now(), entry.expiresAt());
tokenCache.put(key, status, remainingTtl);
}

private String generateKey(SocialProvider provider, String sub, String nonce) {
return String.format("auth:%s:%s:%s", provider.name(), sub, nonce);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package org.runimo.runimo.auth.repository;

import org.runimo.runimo.auth.jwt.TokenStatus;
import org.runimo.runimo.user.domain.SocialProvider;

import java.time.Duration;
import java.util.Optional;

public interface OAuthTokenRepository {

void storeNonce(
SocialProvider socialProvider, String sub, String nonce,
TokenStatus status, Duration ttl
);

Optional<TokenStatus> getNonceStatus(SocialProvider socialProvider, String sub, String nonce);

void updateNonceStatus(SocialProvider socialProvider, String sub, String nonce, TokenStatus status);
}
60 changes: 60 additions & 0 deletions src/main/java/org/runimo/runimo/auth/service/OidcNonceService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package org.runimo.runimo.auth.service;

import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.RequiredArgsConstructor;
import org.runimo.runimo.auth.jwt.TokenStatus;
import org.runimo.runimo.auth.repository.OAuthTokenRepository;
import org.runimo.runimo.user.domain.SocialProvider;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.time.Instant;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class OidcNonceService {

private final static String NONCE_CLAIM_KEY = "nonce";
private final OAuthTokenRepository oAuthTokenRepository;

public void checkNonceAndSave(final SocialProvider provider, final DecodedJWT decodedJWT) {
Optional<TokenStatus> existingOidcTokenEntry = oAuthTokenRepository.getNonceStatus(
provider,
decodedJWT.getSubject(),
decodedJWT.getClaim(NONCE_CLAIM_KEY).asString()
);
validateEntryExistsAndUnUsed(existingOidcTokenEntry);
storeNonceWithTTL(provider, decodedJWT, getTTLForNonce(decodedJWT));
}

private Duration getTTLForNonce(DecodedJWT decodedJWT) {
return Duration.between(Instant.now(), decodedJWT.getExpiresAtAsInstant());
}

/**
* Nonce가 없거나 이미 사용되었다면 탈취의 위험이 있으므로 에러를 반환
*/
private void validateEntryExistsAndUnUsed(Optional<TokenStatus> status) {
if (status.isEmpty()) {
throw new IllegalStateException("nonce is not found");
}
if (status.get() == TokenStatus.USED) {
throw new IllegalStateException("nonce is already used");
}
}

private void storeNonceWithTTL(final SocialProvider provider, final DecodedJWT decodedJWT, final Duration ttl) {
oAuthTokenRepository.storeNonce(
provider,
decodedJWT.getSubject(),
String.valueOf(decodedJWT.getClaim(NONCE_CLAIM_KEY)),
TokenStatus.PENDING,
ttl
);
}

public void useNonce(DecodedJWT token, SocialProvider provider) {
oAuthTokenRepository.updateNonceStatus(provider, token.getSubject(), String.valueOf(token.getClaim(NONCE_CLAIM_KEY)), TokenStatus.USED);
}
}
26 changes: 26 additions & 0 deletions src/main/java/org/runimo/runimo/auth/service/OidcService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package org.runimo.runimo.auth.service;

import com.auth0.jwt.interfaces.DecodedJWT;
import lombok.RequiredArgsConstructor;
import org.runimo.runimo.auth.verifier.KakaoTokenVerifier;
import org.runimo.runimo.user.domain.SocialProvider;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class OidcService {

private final KakaoTokenVerifier verifier;
private final OidcNonceService nonceService;

public String validateOidcTokenAndGetProviderId(final DecodedJWT token, final SocialProvider provider) {
DecodedJWT verifyResult;
// APPLE 로그인 추가 예정.
switch (provider) {
case KAKAO -> verifyResult = verifier.verifyToken(token);
default -> throw new IllegalStateException("not supported provider");
}
nonceService.checkNonceAndSave(provider, verifyResult);
return verifyResult.getSubject();
}
}
103 changes: 103 additions & 0 deletions src/main/java/org/runimo/runimo/auth/verifier/KakaoTokenVerifier.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package org.runimo.runimo.auth.verifier;

import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import org.runimo.runimo.auth.repository.OAuthTokenRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;

@Service
@RequiredArgsConstructor
public class KakaoTokenVerifier implements OidcTokenVerifier {

private final OAuthTokenRepository oAuthTokenRepository;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, RSAPublicKey> publicKeys = new HashMap<>();
@Value("${spring.security.oauth2.client.registration.kakao.client-id}")
private String appKey;

@Scheduled(fixedRate = 3600000)
public void refreshPublicKeys() {
String jwksUrl = "https://kauth.kakao.com/.well-known/jwks.json";
String jwksResponse = restTemplate.getForObject(jwksUrl, String.class);
try {
JsonNode jwks = objectMapper.readTree(jwksResponse);
for (JsonNode key : jwks.get("keys")) {
String kid = key.get("kid").asText();
String n = key.get("n").asText();
String e = key.get("e").asText();

RSAPublicKey publicKey = createPublicKey(n, e);

publicKeys.put(kid, publicKey);
}
} catch (Exception e) {
throw new RuntimeException("Failed to refresh public keys", e);
}
}

private RSAPublicKey createPublicKey(String modulusBase64, String exponentBase64)
throws Exception {
byte[] modulusBytes = Base64.getUrlDecoder().decode(modulusBase64);
byte[] exponentBytes = Base64.getUrlDecoder().decode(exponentBase64);

BigInteger modulus = new BigInteger(1, modulusBytes);
BigInteger exponent = new BigInteger(1, exponentBytes);

RSAPublicKeySpec spec = new RSAPublicKeySpec(modulus, exponent);
KeyFactory factory = KeyFactory.getInstance("RSA");
return (RSAPublicKey) factory.generatePublic(spec);
}

@Override
public DecodedJWT verifyToken(DecodedJWT token) {
try {
String nonce = token.getClaim("nonce").asString();

if (nonce == null) {
throw new JWTVerificationException("Token nonce is missing");
}

RSAPublicKey publicKey = publicKeys.get(token.getKeyId());
if (publicKey == null) {
throw new JWTVerificationException("Unable to find appropriate key");
}

Algorithm algorithm = Algorithm.RSA256(publicKey, null);

return JWT.require(algorithm)
.withIssuer("https://kauth.kakao.com")
.withAudience(appKey)
.build()
.verify(token);
} catch (JWTVerificationException exception) {
throw new IllegalArgumentException("ID token verification failed", exception);
}
}

/*
* 카카오 토큰 검증을 위한 공개키를 의존성 주입 이후에 조회하여 캐싱하는 메소드
* */
@PostConstruct
public void initKakaoTokenVerifier() {
refreshPublicKeys();
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package org.runimo.runimo.auth.verifier;

import com.auth0.jwt.interfaces.DecodedJWT;

public interface OidcTokenVerifier {
DecodedJWT verifyToken(DecodedJWT token);

}
Loading
Loading