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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
7 changes: 6 additions & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,9 @@ services:
- 'MYSQL_ROOT_PASSWORD=verysecret'
- 'MYSQL_USER=myuser'
ports:
- '3306'
- '33006:3306'

redis:
image: 'redis:latest'
ports:
- '6379:6379'
4 changes: 4 additions & 0 deletions k8s/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions k8s/redis.yaml
Original file line number Diff line number Diff line change
@@ -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
41 changes: 30 additions & 11 deletions src/main/java/com/example/skillboost/auth/JwtProvider.java
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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)
Expand All @@ -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 토큰 유효성 검증
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String> 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<Map<String, String>> 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<String> 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("로그아웃 되었습니다.");
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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<String, Object> 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());

Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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 삭제)");
}
}
Loading