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 .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
HELP.md
.gradle
.env
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
Expand Down
10 changes: 10 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,20 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client:3.2.4'
implementation 'com.nimbusds:nimbus-jose-jwt:9.37.2'
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2' // 또는 jjwt-gson 사용 시 'io.jsonwebtoken:jjwt-gson:0.11.2'
compileOnly 'org.projectlombok:lombok:1.18.30' // 최신 버전 확인 후 사용
annotationProcessor 'org.projectlombok:lombok:1.18.30'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testCompileOnly 'org.projectlombok:lombok:1.18.30'
testAnnotationProcessor 'org.projectlombok:lombok:1.18.30'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package com.mtvs.devlinkbackend.config;

import com.mtvs.devlinkbackend.oauth2.service.EpicGamesTokenService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.util.Map;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
private final EpicGamesTokenService epicGamesTokenService;

public JwtAuthenticationFilter(JwtUtil jwtUtil, EpicGamesTokenService epicGamesTokenService) {
this.jwtUtil = jwtUtil;
this.epicGamesTokenService = epicGamesTokenService;
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws IOException, ServletException {

// Authorization 헤더에서 Bearer 토큰 추출
String authorizationHeader = request.getHeader("Authorization");
String token = null;

// 헤더에서 액세스 토큰 추출
if (StringUtils.hasText(authorizationHeader) && authorizationHeader.startsWith("Bearer ")) {
token = authorizationHeader.substring(7); // "Bearer " 이후의 토큰 부분 추출
} else {
// 헤더에 액세스 토큰이 없는 경우, 쿠키에서 리프레시 토큰 추출
token = getRefreshTokenFromCookies(request);
if (token != null) {
// 리프레시 토큰으로 새 액세스 토큰 발급
token = epicGamesTokenService.getAccessTokenByRefreshToken(token);

// 새로 발급된 액세스 토큰을 Authorization 헤더에 추가
response.setHeader("Authorization", "Bearer " + token);
} else {
System.out.println("refrehToken으로 AccessToken 발급하려다가 refreshToken 없어서 실패");
}
}

if (token != null) { // refreshToken도 없어 AccessToken이 아예 없는 경우 지나가기
try {
// 토큰 검증 | 검증 성공 시 SecurityContext에 인증 정보 저장
String userPrincipal = jwtUtil.getSubjectFromToken(token);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userPrincipal, null, null);

SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (Exception e) {
// 검증 실패 시 401 에러 설정
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
}

// 필터 체인 진행
chain.doFilter(request, response);
}

// 쿠키에서 리프레시 토큰을 추출하는 메서드
private String getRefreshTokenFromCookies(HttpServletRequest request) {
if (request.getCookies() != null) {
for (Cookie cookie : request.getCookies()) {
if ("refreshToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
}
88 changes: 88 additions & 0 deletions src/main/java/com/mtvs/devlinkbackend/config/JwtUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.mtvs.devlinkbackend.config;

import com.mtvs.devlinkbackend.oauth2.component.EpicGamesJWKCache;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSVerifier;
import com.nimbusds.jose.crypto.RSASSAVerifier;
import com.nimbusds.jose.jwk.JWK;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.nimbusds.jwt.proc.BadJWTException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.*;

@Component
public class JwtUtil {

private static final String ISSUER_URL = "https://api.epicgames.dev";

@Value("${spring.security.oauth2.client.registration.epicgames.client-id}")
private String clientId;

private final EpicGamesJWKCache jwkCache;

public JwtUtil(EpicGamesJWKCache jwkCache) {
this.jwkCache = jwkCache;
}

// JWT 서명 및 검증을 통한 Claims 추출
public Map<String, Object> getClaimsFromToken(String token) throws Exception {
SignedJWT signedJWT = SignedJWT.parse(token);
JWK jwk = jwkCache.getCachedJWKSet().getKeyByKeyId(signedJWT.getHeader().getKeyID());

if (jwk == null || !JWSAlgorithm.RS256.equals(jwk.getAlgorithm())) {
throw new RuntimeException("JWK key is missing or invalid algorithm");
}

JWSVerifier verifier = new RSASSAVerifier(jwk.toRSAKey());
if (!signedJWT.verify(verifier)) {
throw new RuntimeException("JWT signature verification failed");
}

// Claims 검증
JWTClaimsSet claims = signedJWT.getJWTClaimsSet();
validateClaims(claims);

// 검증이 완료되었을 경우 모든 Claims을 Map으로 변환하여 반환
return convertClaimsToMap(claims);
}

// 'sub' 값 검증 (예제)
public String getSubjectFromToken(String token) throws Exception {
Map<String, Object> claims = getClaimsFromToken(token);
return (String) claims.get("sub");
}

private void validateClaims(JWTClaimsSet claims) throws BadJWTException {
// 'iss' 검증
if (claims.getIssuer() == null || !claims.getIssuer().startsWith(ISSUER_URL)) {
throw new BadJWTException("Invalid issuer");
}

// 'iat' 검증
Date now = new Date();
if (claims.getIssueTime() == null || claims.getIssueTime().after(now)) {
throw new BadJWTException("Invalid issue time");
}

// 'exp' 검증
if (claims.getExpirationTime() == null || claims.getExpirationTime().before(now)) {
throw new BadJWTException("JWT is expired");
}

// 'aud' 검증
List<String> audience = claims.getAudience();
if (audience == null || !audience.contains(clientId)) {
throw new BadJWTException("Invalid audience");
}
}

// Claims를 Map<String, Object> 형식으로 변환하는 메서드
private Map<String, Object> convertClaimsToMap(JWTClaimsSet claims) {
Map<String, Object> claimsMap = new HashMap<>();
claims.getClaims().forEach(claimsMap::put);
return claimsMap;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.mtvs.devlinkbackend.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class SchedulingConfig {
// 스케쥴링 활성화
}
121 changes: 121 additions & 0 deletions src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.mtvs.devlinkbackend.config;

import com.mtvs.devlinkbackend.oauth2.entity.User;
import com.mtvs.devlinkbackend.oauth2.service.UserService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutHandler;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

private final UserService userService;
private final OAuth2AuthorizedClientService authorizedClientService;
private final JwtAuthenticationFilter jwtAuthenticationFilter;

public SecurityConfig(UserService userService, OAuth2AuthorizedClientService authorizedClientService, JwtAuthenticationFilter jwtAuthenticationFilter) {
this.userService = userService;
this.authorizedClientService = authorizedClientService;
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authorizeRequests -> authorizeRequests
.requestMatchers("/", "/login").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2Login -> oauth2Login
.loginPage("/login")
.defaultSuccessUrl("/", true)
.userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint
.userService(oauth2UserService())
)
.successHandler(oauth2AuthenticationSuccessHandler()) // 성공 핸들러 추가
)
.logout(logout -> logout
.logoutUrl("/logout")
.addLogoutHandler(logoutHandler())
.logoutSuccessHandler(logoutSuccessHandler())
)
// 세션을 생성하지 않도록 설정
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// JWT 필터 추가
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}

@Bean
public OAuth2UserService<OAuth2UserRequest, OAuth2User> oauth2UserService() {
return new DefaultOAuth2UserService();
}

@Bean
public AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler() {
return (HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> {
OAuth2User oauthUser = (OAuth2User) authentication.getPrincipal();

// OAuth2AuthorizedClient를 사용하여 액세스 토큰과 리프레시 토큰 가져오기
String clientRegistrationId = ((OAuth2UserRequest) authentication.getDetails()).getClientRegistration().getRegistrationId();
OAuth2AuthorizedClient authorizedClient = authorizedClientService.loadAuthorizedClient(clientRegistrationId, oauthUser.getName());

String accessToken = authorizedClient.getAccessToken().getTokenValue();
String refreshToken = authorizedClient.getRefreshToken() != null ? authorizedClient.getRefreshToken().getTokenValue() : null;

User foundUser = userService.findUserByAccessToken(accessToken);
if(foundUser == null) { // 가입 했던 적이 있는지 확인
foundUser = userService.registUserByAccessToken(accessToken);
foundUser.setRefreshToken(refreshToken);
response.sendRedirect("/user/info"); // 추가 정보 입력 페이지로 이동
} else {
foundUser.setRefreshToken(refreshToken);
response.sendRedirect("/"); // 가입했던 적이 있다면 홈으로 redirect
}
};
}

@Bean
public LogoutHandler logoutHandler() {
return (HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> {
// 쿠키 삭제
deleteCookie(response, "accessToken");
deleteCookie(response, "refreshToken");
};
}

@Bean
public LogoutSuccessHandler logoutSuccessHandler() {
return (HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> {
// 로그아웃 성공 후 리디렉트
response.sendRedirect("/login?logout");
};
}

private void deleteCookie(HttpServletResponse response, String cookieName) {
Cookie cookie = new Cookie(cookieName, null);
cookie.setPath("/");
cookie.setMaxAge(0); // 쿠키 즉시 삭제
cookie.setHttpOnly(true);
cookie.setSecure(true);
response.addCookie(cookie);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.mtvs.devlinkbackend.oauth2.component;

import com.nimbusds.jose.jwk.JWKSet;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.net.URL;
import java.util.concurrent.locks.ReentrantLock;

@Component
public class EpicGamesJWKCache {

private static final String JWKS_URL = "https://api.epicgames.dev/epic/oauth/v2/.well-known/jwks.json";

private JWKSet jwkSet;

private final ReentrantLock lock = new ReentrantLock();

public EpicGamesJWKCache() throws Exception {
// 초기 로드: 애플리케이션이 시작될 때 한 번 로드
loadJWKs();
}

// 1시간에 한 번 공개 키를 갱신하는 스케줄러
@Scheduled(cron = "0 0 * * * ?") // 매일 자정에 실행
public void refreshJWKs() {
try {
loadJWKs();
System.out.println("JWKs have been refreshed successfully.");
} catch (Exception e) {
System.err.println("Failed to refresh JWKs: " + e.getMessage());
}
}

// JWK를 Epic Games로부터 가져오는 메서드
private void loadJWKs() throws Exception {
lock.lock();
try {
URL jwksUrl = new URL(JWKS_URL);
jwkSet = JWKSet.load(jwksUrl);
} finally {
lock.unlock();
}
}

// 캐싱된 JWK를 반환하는 메서드
public JWKSet getCachedJWKSet() {
return jwkSet;
}
}
Loading