diff --git a/.gitignore b/.gitignore index c2065bc..44a0429 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ HELP.md .gradle +.env build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ diff --git a/build.gradle b/build.gradle index 834dd90..ee2963c 100644 --- a/build.gradle +++ b/build.gradle @@ -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' } diff --git a/src/main/java/com/mtvs/devlinkbackend/config/JwtAuthenticationFilter.java b/src/main/java/com/mtvs/devlinkbackend/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..b776d8f --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/config/JwtAuthenticationFilter.java @@ -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; + } +} diff --git a/src/main/java/com/mtvs/devlinkbackend/config/JwtUtil.java b/src/main/java/com/mtvs/devlinkbackend/config/JwtUtil.java new file mode 100644 index 0000000..fb172dc --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/config/JwtUtil.java @@ -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 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 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 audience = claims.getAudience(); + if (audience == null || !audience.contains(clientId)) { + throw new BadJWTException("Invalid audience"); + } + } + + // Claims를 Map 형식으로 변환하는 메서드 + private Map convertClaimsToMap(JWTClaimsSet claims) { + Map claimsMap = new HashMap<>(); + claims.getClaims().forEach(claimsMap::put); + return claimsMap; + } +} diff --git a/src/main/java/com/mtvs/devlinkbackend/config/SchedulingConfig.java b/src/main/java/com/mtvs/devlinkbackend/config/SchedulingConfig.java new file mode 100644 index 0000000..4236c0a --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/config/SchedulingConfig.java @@ -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 { + // 스케쥴링 활성화 +} diff --git a/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java new file mode 100644 index 0000000..8ac5b20 --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java @@ -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 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/component/EpicGamesJWKCache.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/component/EpicGamesJWKCache.java new file mode 100644 index 0000000..e4cdb80 --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/component/EpicGamesJWKCache.java @@ -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; + } +} diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java new file mode 100644 index 0000000..2abde7b --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java @@ -0,0 +1,92 @@ +package com.mtvs.devlinkbackend.oauth2.controller; + +import com.mtvs.devlinkbackend.oauth2.service.EpicGamesTokenService; +import com.mtvs.devlinkbackend.oauth2.service.UserService; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.*; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +public class Oauth2UserController { + + private final EpicGamesTokenService epicGamesTokenService; + private final UserService userService; + + public Oauth2UserController(EpicGamesTokenService epicGamesTokenService, UserService userService) { + this.epicGamesTokenService = epicGamesTokenService; + this.userService = userService; + } + + // 로컬에 저장된 user 정보 가져오는 API + @GetMapping("/local/user-info") + public ResponseEntity getLocalUserInfo(@RequestHeader("Authorization") String accessToken) { + try { + // JWT 토큰에서 사용자 정보 추출 + Map claims = epicGamesTokenService.validateAndParseToken(extractToken(accessToken)); + + if(userService.findUserByAccessToken(extractToken(accessToken)) != null) { + userService.registUserByAccessToken(extractToken(accessToken)); + } + + return ResponseEntity.ok().build(); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid token"); + } + } + + // epicgames 계정 정보 가져오는 API + @GetMapping("/epicgames/user-info") + public ResponseEntity getEpicGamesUserInfo( + @RequestHeader("Authorization") String accessToken) { + + try { + Map userAccount = + epicGamesTokenService.getEpicGamesUserAccount(extractToken(accessToken)); + + return ResponseEntity.ok(userAccount); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid token"); + } + } + + @PostMapping("/epicgames/callback") + public ResponseEntity handleEpicGamesCallback( + @RequestBody Map payload, HttpServletResponse response) { + + String code = payload.get("code"); + + Map tokenBody = epicGamesTokenService.getAccessTokenAndRefreshTokenByCode(code); + + if (tokenBody != null) { + String accessToken = (String) tokenBody.get("access_token"); + String refreshToken = (String) tokenBody.get("refresh_token"); + + // accessToken과 refreshToken을 쿠키에 담아 return + Cookie accessTokenCookie = new Cookie("access_token", "Bearer " + accessToken); + accessTokenCookie.setMaxAge(15 * 60); + accessTokenCookie.setSecure(true); + Cookie refreshTokenCookie = new Cookie("refresh_token", "Bearer " + refreshToken); + refreshTokenCookie.setMaxAge(3 * 60 * 60); + refreshTokenCookie.setSecure(true); + + response.addCookie(accessTokenCookie); + response.addCookie(refreshTokenCookie); + + return ResponseEntity.ok().build(); + } + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + + private String extractToken(String authorizationHeader) { + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + return authorizationHeader.substring(7); + } else { + throw new IllegalArgumentException("Authorization header must start with 'Bearer '"); + } + } +} diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/dto/UserRegistRequestDTO.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/dto/UserRegistRequestDTO.java new file mode 100644 index 0000000..a819075 --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/dto/UserRegistRequestDTO.java @@ -0,0 +1,12 @@ +package com.mtvs.devlinkbackend.oauth2.dto; + +import lombok.*; + +@Getter @Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class UserRegistRequestDTO { + private String userName; + private String email; +} diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/dto/UserUpdateRequestDTO.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/dto/UserUpdateRequestDTO.java new file mode 100644 index 0000000..b819504 --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/dto/UserUpdateRequestDTO.java @@ -0,0 +1,12 @@ +package com.mtvs.devlinkbackend.oauth2.dto; + +import lombok.*; + +@Getter @Setter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class UserUpdateRequestDTO { + private String userName; + private String email; +} diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java new file mode 100644 index 0000000..bcb0b58 --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java @@ -0,0 +1,41 @@ +package com.mtvs.devlinkbackend.oauth2.entity; + +import jakarta.persistence.*; +import lombok.Getter; + +@Table(name = "USER") +@Entity(name = "USER") +@Getter +public class User { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "USER_ID") + private Long userId; + @Column(name = "ACCOUNT_ID", unique = true) + private String accountId; + @Column(name = "EMAIL") + private String email; + @Column(name = "USER_NAME") + private String userName; + @Column(name = "REFRESH_TOKEN") + private String refreshToken; + // 추후 추가 정보 필요시 Entity에 Column 추가 예정 + + public User() {} + + public User(String accountId) { + this.accountId = accountId; + } + + public void setEmail(String email) { + this.email = email; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public void setRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/repository/UserRepository.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/repository/UserRepository.java new file mode 100644 index 0000000..1c5dc0a --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.mtvs.devlinkbackend.oauth2.repository; + +import com.mtvs.devlinkbackend.oauth2.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface UserRepository extends JpaRepository { + User findUserByAccountId(String accountId); + void deleteUserByAccountId(String accountId); +} diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/service/EpicGamesTokenService.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/service/EpicGamesTokenService.java new file mode 100644 index 0000000..c481234 --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/service/EpicGamesTokenService.java @@ -0,0 +1,124 @@ +package com.mtvs.devlinkbackend.oauth2.service; + +import com.mtvs.devlinkbackend.config.JwtUtil; +import com.mtvs.devlinkbackend.oauth2.component.EpicGamesJWKCache; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Service +public class EpicGamesTokenService { + @Value("${spring.security.oauth2.client.registration.epicgames.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.epicgames.client-secret}") + private String clientSecret; + + private final String redirectUri = ""; + + private final EpicGamesJWKCache jwkCache; + private final JwtUtil jwtUtil; + + // EpicGamesJWKCache를 주입받아 사용합니다. + public EpicGamesTokenService(EpicGamesJWKCache jwkCache, JwtUtil jwtUtil) { + this.jwkCache = jwkCache; + this.jwtUtil = jwtUtil; + } + + // 오프라인 JWT 검증 및 파싱 메서드 + public Map validateAndParseToken(String token) throws Exception { + // JWT 토큰 검증 및 파싱하여 Claims를 추출 + return jwtUtil.getClaimsFromToken(token); + } + + public String getAccessTokenByRefreshToken(String refreshToken) { + // Epic Games의 OAuth2 토큰 엔드포인트 호출 + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + + // Basic Authentication 헤더 추가 + String auth = clientId + ":" + clientSecret; + byte[] encodedAuth = Base64.getEncoder().encode(auth.getBytes(StandardCharsets.UTF_8)); + String authHeader = "Basic " + new String(encodedAuth); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("Authorization", authHeader); + + // 요청 본문 설정 + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "refresh_token"); + body.add("token", refreshToken); + + HttpEntity> request = new HttpEntity<>(body, headers); + + // Epic Games 토큰 엔드포인트에 요청 + ResponseEntity response = restTemplate.postForEntity("https://api.epicgames.dev/epic/oauth/v2/token", request, Map.class); + + return (String) response.getBody().get("sub"); + } + + public Map getAccessTokenAndRefreshTokenByCode(String code) { + // Epic Games의 OAuth2 토큰 엔드포인트 호출 + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + + // Basic Authentication 헤더 추가 + String auth = clientId + ":" + clientSecret; + byte[] encodedAuth = Base64.getEncoder().encode(auth.getBytes(StandardCharsets.UTF_8)); + String authHeader = "Basic " + new String(encodedAuth); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("Authorization", authHeader); + + // 요청 본문 설정 + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("code", code); + body.add("redirect_uri", redirectUri); + + HttpEntity> request = new HttpEntity<>(body, headers); + + // Epic Games 토큰 엔드포인트 요청 + ResponseEntity response = restTemplate.exchange( + "https://api.epicgames.dev/epic/oauth/v2/token", + HttpMethod.POST, + request, + Map.class + ); + + return response.getBody(); + } + + public Map getEpicGamesUserAccount(String accessToken) { + // Epic Games의 OAuth2 토큰 엔드포인트 호출 + RestTemplate restTemplate = new RestTemplate(); + HttpHeaders headers = new HttpHeaders(); + + // Bearer Authentication 헤더 추가 + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.set("Authorization", accessToken); + + HttpEntity> request = new HttpEntity<>(headers); + + // Epic Games 토큰 엔드포인트 요청 + ResponseEntity response; + + try { + response = restTemplate.exchange( + "https://api.epicgames.dev/epic/id/v2/accounts?accountId=" + jwtUtil.getClaimsFromToken(accessToken), + HttpMethod.GET, + request, + Map.class + ); + } catch (Exception e) { + throw new RuntimeException(e); + } + + return response.getBody(); + } +} diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/service/UserService.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/service/UserService.java new file mode 100644 index 0000000..b295f0c --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/service/UserService.java @@ -0,0 +1,62 @@ +package com.mtvs.devlinkbackend.oauth2.service; + +import com.mtvs.devlinkbackend.config.JwtUtil; +import com.mtvs.devlinkbackend.oauth2.dto.UserUpdateRequestDTO; +import com.mtvs.devlinkbackend.oauth2.entity.User; +import com.mtvs.devlinkbackend.oauth2.repository.UserRepository; +import org.springframework.stereotype.Service; + +@Service +public class UserService { + + private final UserRepository userRepository; + private final JwtUtil jwtUtil; + + public UserService(UserRepository userRepository, JwtUtil jwtUtil) { + this.userRepository = userRepository; + this.jwtUtil = jwtUtil; + } + + public User registUserByAccessToken(String accessToken) { + try { + String accountId = jwtUtil.getSubjectFromToken(accessToken); + return userRepository.save(new User( + accountId + )); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public User findUserByAccessToken(String accessToken) { + try { + String accountId = jwtUtil.getSubjectFromToken(accessToken); + return userRepository.findUserByAccountId(accountId); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public void updateUserByAccessToken(String accessToken, UserUpdateRequestDTO userUpdateRequestDTO) { + try { + String accountId = jwtUtil.getSubjectFromToken(accessToken); + User user = userRepository.findUserByAccountId(accountId); + if(user != null) { + user.setEmail(userUpdateRequestDTO.getEmail()); + user.setUserName(userUpdateRequestDTO.getUserName()); + } + else throw new RuntimeException("user not found"); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public void deleteUserByAccessToken(String accessToken) { + try { + String accountId = jwtUtil.getSubjectFromToken(accessToken); + userRepository.deleteUserByAccountId(accountId); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index f14bf7e..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=Devlink-backend diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..709b8c9 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,15 @@ +spring: + application: + name: Devlink-backend + security: + oauth2: + client: + registration: + epicgames: + client-id: ${EPIC_GAMES_CLIENT_ID} + client-secret: ${EPIC_GAMES_CLIENT_SECRET} + scope: basic_profile + authorization-grant-type: authorization_code + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + client-name: Epic Games + client-authentication-method: post \ No newline at end of file