From aa5dfe79c314ef4a88897a9a1461297b89049722 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 28 Nov 2025 12:59:16 +0900 Subject: [PATCH 1/4] =?UTF-8?q?setting:=20redis=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + compose.yaml | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 710349c..bcf0702 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' compileOnly 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' developmentOnly 'org.springframework.boot:spring-boot-docker-compose' diff --git a/compose.yaml b/compose.yaml index 4d2047e..49ee895 100644 --- a/compose.yaml +++ b/compose.yaml @@ -7,4 +7,9 @@ services: - 'MYSQL_ROOT_PASSWORD=verysecret' - 'MYSQL_USER=myuser' ports: - - '3306' + - '33006:3306' + + redis: + image: 'redis:latest' + ports: + - '6379:6379' \ No newline at end of file From a47e779f6b8562801d888c15d311b7c0161beee0 Mon Sep 17 00:00:00 2001 From: yeongbinbae Date: Fri, 28 Nov 2025 13:11:52 +0900 Subject: [PATCH 2/4] =?UTF-8?q?setting:=20redis=20=EB=A7=A4=EB=8B=88?= =?UTF-8?q?=ED=8E=98=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- k8s/app.yaml | 4 +++ k8s/redis.yaml | 84 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 k8s/redis.yaml diff --git a/k8s/app.yaml b/k8s/app.yaml index f28a1aa..5dc63b5 100644 --- a/k8s/app.yaml +++ b/k8s/app.yaml @@ -27,6 +27,10 @@ spec: env: - name: SPRING_PROFILES_ACTIVE value: "prod" + - name: SPRING_REDIS_HOST + value: "redis.redis.svc.cluster.local" + - name: SPRING_REDIS_PORT + value: "6379" envFrom: - secretRef: name: db-secret diff --git a/k8s/redis.yaml b/k8s/redis.yaml new file mode 100644 index 0000000..d9096d6 --- /dev/null +++ b/k8s/redis.yaml @@ -0,0 +1,84 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: redis +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: redis-config + namespace: redis +data: + redis.conf: | + bind 0.0.0.0 + port 6379 + protected-mode yes + appendonly yes + appendfsync everysec + maxmemory 512mb + maxmemory-policy volatile-lru +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: redis-data + namespace: redis +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 2Gi + storageClassName: local-path +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: redis + namespace: redis +spec: + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: redis:7.2 + command: ["redis-server", "/usr/local/etc/redis/redis.conf"] + ports: + - name: redis + containerPort: 6379 + volumeMounts: + - name: redis-config + mountPath: /usr/local/etc/redis + - name: redis-data + mountPath: /data + volumes: + - name: redis-config + configMap: + name: redis-config + items: + - key: redis.conf + path: redis.conf + - name: redis-data + persistentVolumeClaim: + claimName: redis-data +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + namespace: redis +spec: + type: ClusterIP + selector: + app: redis + ports: + - port: 6379 + targetPort: 6379 + protocol: TCP From 0928c0b46fa97e20d549b570a66d46a35a3b094a Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Fri, 28 Nov 2025 16:36:59 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20Redis=EB=A5=BC=20=EC=9D=B4=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=20=EB=A6=AC=ED=94=84=EB=A0=88=EC=8B=9C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=A1=9C=ED=85=8C=EC=9D=B4=EC=85=98=20=ED=95=B5?= =?UTF-8?q?=EC=8B=AC=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/skillboost/auth/JwtProvider.java | 41 ++++++++---- .../skillboost/auth/service/TokenService.java | 65 +++++++++++++++++++ .../skillboost/domain/RefreshToken.java | 18 +++++ .../repository/RefreshTokenRepository.java | 10 +++ 4 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/example/skillboost/auth/service/TokenService.java create mode 100644 src/main/java/com/example/skillboost/domain/RefreshToken.java create mode 100644 src/main/java/com/example/skillboost/repository/RefreshTokenRepository.java diff --git a/src/main/java/com/example/skillboost/auth/JwtProvider.java b/src/main/java/com/example/skillboost/auth/JwtProvider.java index 274ae7a..e0d1432 100644 --- a/src/main/java/com/example/skillboost/auth/JwtProvider.java +++ b/src/main/java/com/example/skillboost/auth/JwtProvider.java @@ -26,7 +26,9 @@ public class JwtProvider { private String secretKeyBase64; @Value("${jwt.expiration-ms}") - private long expirationMs; + private long accessTokenExpirationMs; + + private final long refreshTokenExpirationMs = 14 * 24 * 60 * 60 * 1000L; @PostConstruct protected void init() { @@ -43,17 +45,29 @@ protected void init() { this.key = Keys.hmacShaKeyFor(keyBytes); log.info("JWT Provider 정상 초기화 완료"); } catch (IllegalArgumentException e) { - log.error("Base64 디코딩 실패! 키 값을 확인해주세요. (현재 값: {})", safeKey); + log.error("Base64 디코딩 실패. 키 값을 확인해주세요. (현재 값: {})", safeKey); throw e; } } + /** + * Access Token 생성 (짧은 수명) + */ + public String createAccessToken(String email) { + return createToken(email, accessTokenExpirationMs); + } + /** + * Refresh Token 생성 (긴 수명) + */ + public String createRefreshToken(String email) { + return createToken(email, refreshTokenExpirationMs); + } /** * JWT 토큰 생성 */ - public String createToken(String email) { + public String createToken(String email, long expirationTime) { Date now = new Date(); - Date expiry = new Date(now.getTime() + this.expirationMs); + Date expiry = new Date(now.getTime() + expirationTime); return Jwts.builder() .setSubject(email) @@ -62,6 +76,17 @@ public String createToken(String email) { .signWith(key, SignatureAlgorithm.HS256) .compact(); } + /** + * 토큰에서 사용자 ID(Email) 추출 + */ + public String getUserId(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } /** * JWT 토큰 유효성 검증 @@ -91,13 +116,7 @@ public boolean validateToken(String token) { * JWT 토큰에서 Authentication 객체 생성 */ public Authentication getAuthentication(String token) { - Claims claims = Jwts.parserBuilder() - .setSigningKey(key) - .build() - .parseClaimsJws(token) - .getBody(); - - String email = claims.getSubject(); + String email = getUserId(token); User principal = new User( email, diff --git a/src/main/java/com/example/skillboost/auth/service/TokenService.java b/src/main/java/com/example/skillboost/auth/service/TokenService.java new file mode 100644 index 0000000..66e2a19 --- /dev/null +++ b/src/main/java/com/example/skillboost/auth/service/TokenService.java @@ -0,0 +1,65 @@ +package com.example.skillboost.auth.service; + +import com.example.skillboost.auth.JwtProvider; +import com.example.skillboost.domain.RefreshToken; +import com.example.skillboost.repository.RefreshTokenRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class TokenService { + + private final RefreshTokenRepository refreshTokenRepository; + private final JwtProvider jwtProvider; + + /** + * 1. Refresh Token 저장 + */ + @Transactional + public void saveRefreshToken(String userId, String token) { + RefreshToken refreshToken = new RefreshToken(token, userId); + refreshTokenRepository.save(refreshToken); + } + + /** + * 2. 토큰 재발급 (Refresh Token Rotation) + * - 기존 토큰이 유효한지 확인 + * - Redis에 존재하는지 확인 + * - 기존 토큰 삭제 (Rotation) + * - 새 토큰 발급 및 저장 + */ + @Transactional + public String[] rotateTokens(String oldRefreshToken) { + if (!jwtProvider.validateToken(oldRefreshToken)) { + throw new RuntimeException("유효하지 않은 Refresh Token입니다."); + } + + RefreshToken tokenEntity = refreshTokenRepository.findById(oldRefreshToken) + .orElseThrow(() -> new RuntimeException("이미 사용되었거나 존재하지 않는 Refresh Token입니다. 다시 로그인하세요.")); + + refreshTokenRepository.delete(tokenEntity); + + String userId = tokenEntity.getUserId(); + + String newAccessToken = jwtProvider.createAccessToken(userId); + String newRefreshToken = jwtProvider.createRefreshToken(userId); + saveRefreshToken(userId, newRefreshToken); + + log.info("토큰 Rotation 성공, [ User: {} ]", userId); + return new String[]{newAccessToken, newRefreshToken}; + } + + /** + * 3. 로그아웃 (Redis에서 삭제) + */ + @Transactional + public void logout(String refreshToken) { + refreshTokenRepository.findById(refreshToken) + .ifPresent(refreshTokenRepository::delete); + log.info("로그아웃 처리 완료 (Redis 삭제)"); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/domain/RefreshToken.java b/src/main/java/com/example/skillboost/domain/RefreshToken.java new file mode 100644 index 0000000..0b864a0 --- /dev/null +++ b/src/main/java/com/example/skillboost/domain/RefreshToken.java @@ -0,0 +1,18 @@ +package com.example.skillboost.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +@Getter +@AllArgsConstructor +@RedisHash(value = "refreshToken", timeToLive = 1209600) +public class RefreshToken { + @Id + private String refreshToken; + + @Indexed + private String userId; +} diff --git a/src/main/java/com/example/skillboost/repository/RefreshTokenRepository.java b/src/main/java/com/example/skillboost/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..4c626ec --- /dev/null +++ b/src/main/java/com/example/skillboost/repository/RefreshTokenRepository.java @@ -0,0 +1,10 @@ +package com.example.skillboost.repository; + +import com.example.skillboost.domain.RefreshToken; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface RefreshTokenRepository extends CrudRepository { + Optional findByUserId(String userId); +} From 169ebb9afd364a19ae7ebcf6111112fba9cb1d2c Mon Sep 17 00:00:00 2001 From: JONGTAE02 Date: Fri, 28 Nov 2025 16:37:18 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20API=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=EC=97=90=20=ED=86=A0=ED=81=B0=20=EB=A1=9C=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EA=B8=B0=EB=8A=A5=20=EC=A0=81=EC=9A=A9(#1?= =?UTF-8?q?0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 37 ++++++++++++++++--- .../auth/handler/OAuth2SuccessHandler.java | 21 ++++++----- 2 files changed, 43 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/example/skillboost/auth/controller/AuthController.java b/src/main/java/com/example/skillboost/auth/controller/AuthController.java index 82dc33f..3b01d86 100644 --- a/src/main/java/com/example/skillboost/auth/controller/AuthController.java +++ b/src/main/java/com/example/skillboost/auth/controller/AuthController.java @@ -1,23 +1,48 @@ package com.example.skillboost.auth.controller; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import com.example.skillboost.auth.service.TokenService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + import java.util.Map; -@Tag(name = "깃허브 인증 (Authentication)", description = "소셜 로그인 API") +@Tag(name = "인증 (Authentication)", description = "로그인, 토큰 재발급, 로그아웃") @RestController @RequestMapping("/api/auth") +@RequiredArgsConstructor public class AuthController { + private final TokenService tokenService; @Operation(summary = "GitHub 로그인 URL 반환", description = "프론트엔드에서 이 주소로 GET 요청을 보내면, 사용자가 접속해야 할 GitHub 로그인 페이지 URL을 반환합니다.") @GetMapping("/github-login-url") public Map getGithubLoginUrl() { - String loginUrl = "/oauth2/authorization/github"; + return Map.of("url", "/oauth2/authorization/github"); + } + + + @Operation(summary = "토큰 재발급 (RTR)", description = "Refresh Token을 헤더에 담아 보내면 새로운 Access/Refresh Token을 발급합니다.") + @PostMapping("/reissue") + public ResponseEntity> reissue(@RequestHeader("RefreshToken") String refreshToken) { + String token = refreshToken.startsWith("Bearer ") ? refreshToken.substring(7) : refreshToken; + String[] newTokens = tokenService.rotateTokens(token); + + return ResponseEntity.ok(Map.of( + "accessToken", newTokens[0], + "refreshToken", newTokens[1] + )); + } + + @Operation(summary = "로그아웃", description = "Redis에서 Refresh Token을 삭제하여 더 이상 사용할 수 없게 만듭니다.") + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader("RefreshToken") String refreshToken) { + String token = refreshToken.startsWith("Bearer ") ? refreshToken.substring(7) : refreshToken; + + tokenService.logout(token); - return Map.of("url", loginUrl); + return ResponseEntity.ok("로그아웃 되었습니다."); } } \ No newline at end of file diff --git a/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java index b484cc8..0aeac80 100644 --- a/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/example/skillboost/auth/handler/OAuth2SuccessHandler.java @@ -1,6 +1,7 @@ package com.example.skillboost.auth.handler; import com.example.skillboost.auth.JwtProvider; +import com.example.skillboost.auth.service.TokenService; import com.example.skillboost.domain.User; import com.example.skillboost.repository.UserRepository; import com.fasterxml.jackson.databind.ObjectMapper; @@ -24,6 +25,7 @@ public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { private final JwtProvider jwtProvider; private final UserRepository userRepository; + private final TokenService tokenService; private final ObjectMapper objectMapper = new ObjectMapper(); @Override @@ -36,31 +38,32 @@ public void onAuthenticationSuccess(HttpServletRequest request, OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); String email = (String) oAuth2User.getAttributes().get("email"); - // GitHub에서 이메일을 비공개로 설정한 경우 처리 if (email == null || email.isEmpty()) { String githubId = String.valueOf(oAuth2User.getAttributes().get("id")); email = githubId + "@github.temp"; log.warn("이메일 비공개 사용자 - 임시 이메일 사용: {}", email); } - // Lambda에서 사용하기 위한 final 변수 final String finalEmail = email; - // 사용자 조회 User user = userRepository.findByEmail(finalEmail) .orElseThrow(() -> { log.error("사용자를 찾을 수 없습니다: {}", finalEmail); return new RuntimeException("User not found: " + finalEmail); }); - // JWT 토큰 생성 - String token = jwtProvider.createToken(user.getEmail()); - log.info("JWT 토큰 생성 완료: {}", user.getEmail()); + String accessToken = jwtProvider.createAccessToken(user.getEmail()); + String refreshToken = jwtProvider.createRefreshToken(user.getEmail()); + + tokenService.saveRefreshToken(user.getEmail(), refreshToken); + + log.info("JWT 토큰 생성 및 Redis 저장 완료: {}", user.getEmail()); // JSON 응답 생성 Map responseData = new HashMap<>(); responseData.put("success", true); - responseData.put("token", token); + responseData.put("accessToken", accessToken); + responseData.put("refreshToken", refreshToken); // 프론트엔드에서 저장해야 함 responseData.put("email", user.getEmail()); responseData.put("username", user.getUsername()); @@ -69,7 +72,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, response.setStatus(HttpServletResponse.SC_OK); response.getWriter().write(objectMapper.writeValueAsString(responseData)); - // 프론트엔드로 리다이렉트하려면 아래 주석 해제 - // response.sendRedirect("http://localhost:3000/oauth2/redirect?token=" + token); + // 실제 서비스 배포 시, 사용자를 다시 웹사이트 메인 화면으로 돌려보내기 위해 사용 + // response.sendRedirect("http://localhost:3000/oauth2/redirect?accessToken=" + accessToken + "&refreshToken=" + refreshToken); } } \ No newline at end of file