From 7e630ecb1448b0e2903c5078de16e578824cb335 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Fri, 4 Oct 2024 18:12:41 +0900 Subject: [PATCH 1/8] =?UTF-8?q?Feat:=20SpringSecurity=20=EA=B0=9C=EB=B0=9C?= =?UTF-8?q?=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + build.gradle | 2 + .../devlinkbackend/config/SecurityConfig.java | 78 +++++++++++++++++++ .../com/mtvs/devlinkbackend/oauth2/User.java | 17 ++++ src/main/resources/application.properties | 1 - src/main/resources/application.yml | 15 ++++ 6 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java create mode 100644 src/main/java/com/mtvs/devlinkbackend/oauth2/User.java delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml 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..f713040 100644 --- a/build.gradle +++ b/build.gradle @@ -21,6 +21,8 @@ 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' developmentOnly 'org.springframework.boot:spring-boot-devtools' runtimeOnly 'com.mysql:mysql-connector-j' testImplementation 'org.springframework.boot:spring-boot-starter-test' 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..6fde1e5 --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java @@ -0,0 +1,78 @@ +package com.mtvs.devlinkbackend.config; + + +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.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.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; +import org.springframework.security.web.SecurityFilterChain; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(authorizeRequests -> authorizeRequests + .requestMatchers("/", "/login").permitAll() // 새로운 메서드 사용 + .anyRequest().authenticated() + ) + .oauth2Login(oauth2Login -> oauth2Login + .loginPage("/login") // 로그인 페이지 URL + .defaultSuccessUrl("/", true) // 로그인 성공 후 리디렉션 + .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint + .userService(oauth2UserService()) // 커스텀 유저 서비스 + ) + ); + + return http.build(); + } + + @Bean + public OAuth2UserService oauth2UserService() { + return new DefaultOAuth2UserService() { + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) { + OAuth2User oauth2User = super.loadUser(userRequest); + + // 사용자 정보 가져오기 + Map attributes = oauth2User.getAttributes(); + String userId = (String) attributes.get("sub"); // "sub"는 Epic Games 사용자 ID + String name = oauth2User.getAttribute("name"); + String email = oauth2User.getAttribute("email"); + + // 필요한 경우 추가 로직 구현 (사용자 등록, 데이터베이스 연동 등) + + // 데이터베이스에서 sub로 사용자 조회 +// User user = userRepository.findBySub(sub); +// +// if (user == null) { +// // 새로운 사용자인 경우 저장 +// user = new User(sub, name, email); +// userRepository.save(user); +// } else { +// // 기존 사용자의 정보 업데이트 (필요 시) +// user.setName(name); +// user.setEmail(email); +// userRepository.save(user); +// } + + Set authorities = new HashSet<>(); + authorities.add(new OAuth2UserAuthority(attributes)); + + // OAuth2User 객체 반환 (사용자 정보 포함) + return new DefaultOAuth2User(authorities, attributes, "sub"); + } + }; + } +} diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/User.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/User.java new file mode 100644 index 0000000..b9a3a44 --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/User.java @@ -0,0 +1,17 @@ +package com.mtvs.devlinkbackend.oauth2; + +import jakarta.persistence.*; + +@Table +@Entity +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; + +} 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 From 39cc991234bb6966169f40a1578c1c3aecbe9cbd Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:18:19 +0900 Subject: [PATCH 2/8] =?UTF-8?q?Feat:=20Oauth2User=20-=20=EC=95=A1=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=ED=86=A0=ED=81=B0=20=EC=83=9D=EC=84=B1=EC=9A=A9=20?= =?UTF-8?q?=EC=BD=9C=EB=B0=B1=20API=20=EA=B5=AC=ED=98=84=20&=20=EC=95=A1?= =?UTF-8?q?=EC=84=B8=EC=8A=A4=20=ED=86=A0=ED=81=B0=EC=9C=BC=EB=A1=9C=20Epi?= =?UTF-8?q?cGames=20=EA=B3=84=EC=A0=95=EC=A0=95=EB=B3=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20&=20=EC=95=A1=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=ED=86=A0=ED=81=B0=20=EC=98=A4=ED=94=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20&=20Scheduler=EB=A1=9C=20JWK=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 ++ .../mtvs/devlinkbackend/config/JwtUtil.java | 88 ++++++++++++++ .../config/SchedulingConfig.java | 10 ++ .../com/mtvs/devlinkbackend/oauth2/User.java | 17 --- .../oauth2/component/EpicGamesJWKCache.java | 50 ++++++++ .../controller/Oauth2UserController.java | 75 ++++++++++++ .../devlinkbackend/oauth2/entity/User.java | 40 +++++++ .../oauth2/service/EpicGamesTokenService.java | 108 ++++++++++++++++++ 8 files changed, 379 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/mtvs/devlinkbackend/config/JwtUtil.java create mode 100644 src/main/java/com/mtvs/devlinkbackend/config/SchedulingConfig.java delete mode 100644 src/main/java/com/mtvs/devlinkbackend/oauth2/User.java create mode 100644 src/main/java/com/mtvs/devlinkbackend/oauth2/component/EpicGamesJWKCache.java create mode 100644 src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java create mode 100644 src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java create mode 100644 src/main/java/com/mtvs/devlinkbackend/oauth2/service/EpicGamesTokenService.java diff --git a/build.gradle b/build.gradle index f713040..ee2963c 100644 --- a/build.gradle +++ b/build.gradle @@ -23,10 +23,18 @@ dependencies { 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/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/oauth2/User.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/User.java deleted file mode 100644 index b9a3a44..0000000 --- a/src/main/java/com/mtvs/devlinkbackend/oauth2/User.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.mtvs.devlinkbackend.oauth2; - -import jakarta.persistence.*; - -@Table -@Entity -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; - -} 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..12efab5 --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java @@ -0,0 +1,75 @@ +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; + } + + // 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.getAccessTokenAndRefreshToken(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/entity/User.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java new file mode 100644 index 0000000..cf0e13e --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java @@ -0,0 +1,40 @@ +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; + // 추후 추가 정보 필요시 Entity에 Column 추가 예정 + + public User() {} + + public User(String accountId) { + this.accountId = accountId; + } + + public User(String accountId, String email) { + this.accountId = accountId; + this.email = email; + } + + public void setEmail(String email) { + this.email = email; + } + + public void setUserName(String userName) { + this.userName = userName; + } +} 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..0762bf4 --- /dev/null +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/service/EpicGamesTokenService.java @@ -0,0 +1,108 @@ +package com.mtvs.devlinkbackend.oauth2.service; + +import com.mtvs.devlinkbackend.config.JwtUtil; +import com.mtvs.devlinkbackend.oauth2.component.EpicGamesJWKCache; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.JWTClaimsSet; +import com.nimbusds.jwt.SignedJWT; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +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.HashMap; +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 Map getAccessTokenAndRefreshToken(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(); + } +} From 4571b905d5113be66f1e38a1dbaff4f9d935384e Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:40:05 +0900 Subject: [PATCH 3/8] =?UTF-8?q?Feat:=20OAuth2=20SpringSecurity=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlinkbackend/config/SecurityConfig.java | 107 ++++++++++-------- 1 file changed, 61 insertions(+), 46 deletions(-) diff --git a/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java index 6fde1e5..da9b154 100644 --- a/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java +++ b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java @@ -1,78 +1,93 @@ package com.mtvs.devlinkbackend.config; - +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.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.DefaultOAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2UserAuthority; import org.springframework.security.web.SecurityFilterChain; - -import java.util.HashSet; -import java.util.Map; -import java.util.Set; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; @Configuration @EnableWebSecurity public class SecurityConfig { + + private final OAuth2AuthorizedClientService authorizedClientService; + + public SecurityConfig(OAuth2AuthorizedClientService authorizedClientService) { + this.authorizedClientService = authorizedClientService; + } + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .authorizeHttpRequests(authorizeRequests -> authorizeRequests - .requestMatchers("/", "/login").permitAll() // 새로운 메서드 사용 - .anyRequest().authenticated() - ) - .oauth2Login(oauth2Login -> oauth2Login - .loginPage("/login") // 로그인 페이지 URL - .defaultSuccessUrl("/", true) // 로그인 성공 후 리디렉션 - .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint - .userService(oauth2UserService()) // 커스텀 유저 서비스 + .authorizeHttpRequests(authorizeRequests -> authorizeRequests + .requestMatchers("/", "/login").permitAll() + .anyRequest().authenticated() ) - ); - + .oauth2Login(oauth2Login -> oauth2Login + .loginPage("/login") + .defaultSuccessUrl("/", true) + .userInfoEndpoint(userInfoEndpoint -> userInfoEndpoint + .userService(oauth2UserService()) + ) + .successHandler(oauth2AuthenticationSuccessHandler()) // 성공 핸들러 추가 + ); return http.build(); } @Bean public OAuth2UserService oauth2UserService() { - return new DefaultOAuth2UserService() { - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) { - OAuth2User oauth2User = super.loadUser(userRequest); + return new DefaultOAuth2UserService(); + } + + @Bean + public AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler(UserService userService) { + 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()); - // 사용자 정보 가져오기 - Map attributes = oauth2User.getAttributes(); - String userId = (String) attributes.get("sub"); // "sub"는 Epic Games 사용자 ID - String name = oauth2User.getAttribute("name"); - String email = oauth2User.getAttribute("email"); + String accessToken = authorizedClient.getAccessToken().getTokenValue(); + String refreshToken = authorizedClient.getRefreshToken() != null ? authorizedClient.getRefreshToken().getTokenValue() : null; - // 필요한 경우 추가 로직 구현 (사용자 등록, 데이터베이스 연동 등) + // 액세스 토큰 쿠키 설정 + Cookie accessTokenCookie = new Cookie("accessToken", accessToken); + accessTokenCookie.setHttpOnly(true); + accessTokenCookie.setSecure(true); + accessTokenCookie.setPath("/"); + accessTokenCookie.setMaxAge(60 * 60); // 1시간 - // 데이터베이스에서 sub로 사용자 조회 -// User user = userRepository.findBySub(sub); -// -// if (user == null) { -// // 새로운 사용자인 경우 저장 -// user = new User(sub, name, email); -// userRepository.save(user); -// } else { -// // 기존 사용자의 정보 업데이트 (필요 시) -// user.setName(name); -// user.setEmail(email); -// userRepository.save(user); -// } + // 리프레시 토큰 쿠키 설정 + if (refreshToken != null) { + Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); + refreshTokenCookie.setHttpOnly(true); + refreshTokenCookie.setSecure(true); + refreshTokenCookie.setPath("/"); + refreshTokenCookie.setMaxAge(3 * 60 * 60); // 3시간 + response.addCookie(refreshTokenCookie); + } - Set authorities = new HashSet<>(); - authorities.add(new OAuth2UserAuthority(attributes)); + response.addCookie(accessTokenCookie); - // OAuth2User 객체 반환 (사용자 정보 포함) - return new DefaultOAuth2User(authorities, attributes, "sub"); + if(userService.findUserByAccessToken(accessToken) != null) { // 가입 했던 적이 있는지 확인 + userService.registUserByAccessToken(accessToken); + response.sendRedirect("/user/info"); // 추가 정보 입력 페이지로 이동 + } else { + response.sendRedirect("/"); // 가입했던 적이 있다면 홈으로 redirect } }; } -} +} \ No newline at end of file From f7c5c8a284d7e2d712a45bc187216027c756eaaa Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:57:13 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Feat:=20OAuth2=20SpringSecurityConfig=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth2/dto/UserRegistRequestDTO.java | 12 ++++ .../oauth2/dto/UserUpdateRequestDTO.java | 12 ++++ .../oauth2/repository/UserRepository.java | 11 ++++ .../oauth2/service/UserService.java | 62 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 src/main/java/com/mtvs/devlinkbackend/oauth2/dto/UserRegistRequestDTO.java create mode 100644 src/main/java/com/mtvs/devlinkbackend/oauth2/dto/UserUpdateRequestDTO.java create mode 100644 src/main/java/com/mtvs/devlinkbackend/oauth2/repository/UserRepository.java create mode 100644 src/main/java/com/mtvs/devlinkbackend/oauth2/service/UserService.java 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/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/UserService.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/service/UserService.java new file mode 100644 index 0000000..75d66b8 --- /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 void registUserByAccessToken(String accessToken) { + try { + String accountId = jwtUtil.getSubjectFromToken(accessToken); + 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); + } + } +} From a125712c2768bf8fe89084e7f4882eab8c0101e2 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Mon, 7 Oct 2024 17:57:49 +0900 Subject: [PATCH 5/8] =?UTF-8?q?Feat:=20OAuth2=20SpringSecurityConfig=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devlinkbackend/config/SecurityConfig.java | 6 ++++-- .../oauth2/controller/Oauth2UserController.java | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java index da9b154..310e311 100644 --- a/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java +++ b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java @@ -22,9 +22,11 @@ @EnableWebSecurity public class SecurityConfig { + private final UserService userService; private final OAuth2AuthorizedClientService authorizedClientService; - public SecurityConfig(OAuth2AuthorizedClientService authorizedClientService) { + public SecurityConfig(UserService userService, OAuth2AuthorizedClientService authorizedClientService) { + this.userService = userService; this.authorizedClientService = authorizedClientService; } @@ -52,7 +54,7 @@ public OAuth2UserService oauth2UserService() { } @Bean - public AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler(UserService userService) { + public AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler() { return (HttpServletRequest request, HttpServletResponse response, Authentication authentication) -> { OAuth2User oauthUser = (OAuth2User) authentication.getPrincipal(); diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java index 12efab5..cb77b77 100644 --- a/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java @@ -21,6 +21,23 @@ public Oauth2UserController(EpicGamesTokenService epicGamesTokenService, UserSer 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( From 018127e82c280ed33d0cf2d0a599f11ca51b6601 Mon Sep 17 00:00:00 2001 From: in seong Park <74069492+Hexeong@users.noreply.github.com> Date: Mon, 7 Oct 2024 18:16:23 +0900 Subject: [PATCH 6/8] =?UTF-8?q?Refactor:=20token=20Cookie=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mtvs/devlinkbackend/config/SecurityConfig.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java index 310e311..b98d3cf 100644 --- a/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java +++ b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java @@ -67,15 +67,13 @@ public AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler() { // 액세스 토큰 쿠키 설정 Cookie accessTokenCookie = new Cookie("accessToken", accessToken); - accessTokenCookie.setHttpOnly(true); accessTokenCookie.setSecure(true); accessTokenCookie.setPath("/"); - accessTokenCookie.setMaxAge(60 * 60); // 1시간 + accessTokenCookie.setMaxAge(15 * 60); // 1시간 // 리프레시 토큰 쿠키 설정 if (refreshToken != null) { Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); - refreshTokenCookie.setHttpOnly(true); refreshTokenCookie.setSecure(true); refreshTokenCookie.setPath("/"); refreshTokenCookie.setMaxAge(3 * 60 * 60); // 3시간 From 68250cdde0569fbf984ae29652c323b4f0ad38f3 Mon Sep 17 00:00:00 2001 From: in seong Park <123macanic@naver.com> Date: Mon, 7 Oct 2024 18:26:12 +0900 Subject: [PATCH 7/8] =?UTF-8?q?User=20Entity=20=ED=95=84=EC=9A=94=EC=97=86?= =?UTF-8?q?=EB=8A=94=20Column=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mtvs/devlinkbackend/oauth2/entity/User.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java index cf0e13e..463dd84 100644 --- a/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java @@ -25,11 +25,6 @@ public User(String accountId) { this.accountId = accountId; } - public User(String accountId, String email) { - this.accountId = accountId; - this.email = email; - } - public void setEmail(String email) { this.email = email; } From 57149653bc501f07f164c53710b58d22d69dac74 Mon Sep 17 00:00:00 2001 From: in seong Park <123macanic@naver.com> Date: Tue, 8 Oct 2024 11:29:58 +0900 Subject: [PATCH 8/8] =?UTF-8?q?Feat:=20JwtAuthenticationFilter=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20&=20createAccessTokenByRefreshToken=20Logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/JwtAuthenticationFilter.java | 83 +++++++++++++++++++ .../devlinkbackend/config/SecurityConfig.java | 70 +++++++++++----- .../controller/Oauth2UserController.java | 2 +- .../devlinkbackend/oauth2/entity/User.java | 6 ++ .../oauth2/service/EpicGamesTokenService.java | 36 +++++--- .../oauth2/service/UserService.java | 4 +- 6 files changed, 167 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/mtvs/devlinkbackend/config/JwtAuthenticationFilter.java 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/SecurityConfig.java b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java index b98d3cf..8ac5b20 100644 --- a/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java +++ b/src/main/java/com/mtvs/devlinkbackend/config/SecurityConfig.java @@ -1,5 +1,6 @@ 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; @@ -8,6 +9,7 @@ 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; @@ -17,6 +19,9 @@ 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 @@ -24,10 +29,12 @@ public class SecurityConfig { private final UserService userService; private final OAuth2AuthorizedClientService authorizedClientService; + private final JwtAuthenticationFilter jwtAuthenticationFilter; - public SecurityConfig(UserService userService, OAuth2AuthorizedClientService authorizedClientService) { + public SecurityConfig(UserService userService, OAuth2AuthorizedClientService authorizedClientService, JwtAuthenticationFilter jwtAuthenticationFilter) { this.userService = userService; this.authorizedClientService = authorizedClientService; + this.jwtAuthenticationFilter = jwtAuthenticationFilter; } @Bean @@ -44,7 +51,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .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(); } @@ -65,29 +81,41 @@ public AuthenticationSuccessHandler oauth2AuthenticationSuccessHandler() { String accessToken = authorizedClient.getAccessToken().getTokenValue(); String refreshToken = authorizedClient.getRefreshToken() != null ? authorizedClient.getRefreshToken().getTokenValue() : null; - // 액세스 토큰 쿠키 설정 - Cookie accessTokenCookie = new Cookie("accessToken", accessToken); - accessTokenCookie.setSecure(true); - accessTokenCookie.setPath("/"); - accessTokenCookie.setMaxAge(15 * 60); // 1시간 - - // 리프레시 토큰 쿠키 설정 - if (refreshToken != null) { - Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); - refreshTokenCookie.setSecure(true); - refreshTokenCookie.setPath("/"); - refreshTokenCookie.setMaxAge(3 * 60 * 60); // 3시간 - response.addCookie(refreshTokenCookie); - } - - response.addCookie(accessTokenCookie); - - if(userService.findUserByAccessToken(accessToken) != null) { // 가입 했던 적이 있는지 확인 - userService.registUserByAccessToken(accessToken); + 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/controller/Oauth2UserController.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java index cb77b77..2abde7b 100644 --- a/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/controller/Oauth2UserController.java @@ -59,7 +59,7 @@ public ResponseEntity handleEpicGamesCallback( String code = payload.get("code"); - Map tokenBody = epicGamesTokenService.getAccessTokenAndRefreshToken(code); + Map tokenBody = epicGamesTokenService.getAccessTokenAndRefreshTokenByCode(code); if (tokenBody != null) { String accessToken = (String) tokenBody.get("access_token"); diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java index 463dd84..bcb0b58 100644 --- a/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/entity/User.java @@ -17,6 +17,8 @@ public class User { private String email; @Column(name = "USER_NAME") private String userName; + @Column(name = "REFRESH_TOKEN") + private String refreshToken; // 추후 추가 정보 필요시 Entity에 Column 추가 예정 public User() {} @@ -32,4 +34,8 @@ public void setEmail(String 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/service/EpicGamesTokenService.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/service/EpicGamesTokenService.java index 0762bf4..c481234 100644 --- a/src/main/java/com/mtvs/devlinkbackend/oauth2/service/EpicGamesTokenService.java +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/service/EpicGamesTokenService.java @@ -2,14 +2,6 @@ import com.mtvs.devlinkbackend.config.JwtUtil; import com.mtvs.devlinkbackend.oauth2.component.EpicGamesJWKCache; -import com.nimbusds.jose.JWSAlgorithm; -import com.nimbusds.jose.jwk.source.ImmutableJWKSet; -import com.nimbusds.jose.jwk.source.JWKSource; -import com.nimbusds.jose.proc.JWSVerificationKeySelector; -import com.nimbusds.jose.proc.SecurityContext; -import com.nimbusds.jwt.JWTClaimsSet; -import com.nimbusds.jwt.SignedJWT; -import com.nimbusds.jwt.proc.DefaultJWTProcessor; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.*; import org.springframework.stereotype.Service; @@ -19,7 +11,6 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; -import java.util.HashMap; import java.util.Map; @Service @@ -47,7 +38,32 @@ public Map validateAndParseToken(String token) throws Exception return jwtUtil.getClaimsFromToken(token); } - public Map getAccessTokenAndRefreshToken(String code) { + 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(); diff --git a/src/main/java/com/mtvs/devlinkbackend/oauth2/service/UserService.java b/src/main/java/com/mtvs/devlinkbackend/oauth2/service/UserService.java index 75d66b8..b295f0c 100644 --- a/src/main/java/com/mtvs/devlinkbackend/oauth2/service/UserService.java +++ b/src/main/java/com/mtvs/devlinkbackend/oauth2/service/UserService.java @@ -17,10 +17,10 @@ public UserService(UserRepository userRepository, JwtUtil jwtUtil) { this.jwtUtil = jwtUtil; } - public void registUserByAccessToken(String accessToken) { + public User registUserByAccessToken(String accessToken) { try { String accountId = jwtUtil.getSubjectFromToken(accessToken); - userRepository.save(new User( + return userRepository.save(new User( accountId )); } catch (Exception e) {