diff --git a/build.gradle b/build.gradle index e06efc0..2666f39 100644 --- a/build.gradle +++ b/build.gradle @@ -31,6 +31,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + // Web Client + implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Spring Security & OAuth2 implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' diff --git a/src/main/java/com/olive/pribee/global/common/filter/JwtAuthenticationFilter.java b/src/main/java/com/olive/pribee/global/common/filter/JwtAuthenticationFilter.java index b9c0a9a..bc9446c 100644 --- a/src/main/java/com/olive/pribee/global/common/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/olive/pribee/global/common/filter/JwtAuthenticationFilter.java @@ -40,7 +40,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // accessToken 이 필요없는 경우 필터링 없이 처리 if (requestURI.startsWith("/api/auth/token") || - requestURI.startsWith("/oauth2")) { // oauth2/authorization/facebook + requestURI.startsWith("/api/auth/login/facebook")) { chain.doFilter(request, response); return; } diff --git a/src/main/java/com/olive/pribee/global/config/OpenApiConfig.java b/src/main/java/com/olive/pribee/global/config/OpenApiConfig.java index 84cdf55..bfb57fe 100644 --- a/src/main/java/com/olive/pribee/global/config/OpenApiConfig.java +++ b/src/main/java/com/olive/pribee/global/config/OpenApiConfig.java @@ -14,7 +14,7 @@ public class OpenApiConfig { private static final String BEARER_TOKEN_PREFIX = "bearer"; - private static String securityJwtName = "JWT"; + private static final String securityJwtName = "JWT"; @Bean public OpenAPI customOpenAPI() { diff --git a/src/main/java/com/olive/pribee/global/config/SecurityConfig.java b/src/main/java/com/olive/pribee/global/config/SecurityConfig.java index 1faab4f..ad7a441 100644 --- a/src/main/java/com/olive/pribee/global/config/SecurityConfig.java +++ b/src/main/java/com/olive/pribee/global/config/SecurityConfig.java @@ -7,6 +7,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.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -15,9 +16,6 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import com.olive.pribee.global.common.filter.JwtAuthenticationFilter; -import com.olive.pribee.module.auth.handler.OAuth2AuthenticationFailureHandler; -import com.olive.pribee.module.auth.handler.OAuth2AuthenticationSuccessHandler; -import com.olive.pribee.module.auth.service.CustomOAuth2UserService; import lombok.RequiredArgsConstructor; @@ -26,36 +24,34 @@ @RequiredArgsConstructor public class SecurityConfig { - @Value("${front.url}") + @Value("${url.test}") + private String TEST_URL; + + @Value("${url.front}") private String FRONT_URL; + @Value("${url.domain}") + private String DOMAIN_URL; + private final JwtAuthenticationFilter jwtAuthenticationFilter; - private final CustomOAuth2UserService customOAuth2UserService; - private final OAuth2AuthenticationSuccessHandler successHandler; - private final OAuth2AuthenticationFailureHandler failureHandler; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers( "/api/auth/token", - "/oauth2/**", + "/api/auth/login/facebook", "/swagger-ui/**", "/webjars/**", "/swagger-ui.html", - "/v3/api-docs/**").permitAll() + "/v3/api-docs/**" + ).permitAll() .anyRequest().authenticated() ) - - // OAuth2 로그인 설정 - .oauth2Login(oauth2 -> oauth2 - .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) - .successHandler(successHandler) - .failureHandler(failureHandler) - ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); @@ -65,12 +61,12 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti public CorsConfigurationSource corsConfigurationSource() { // 허용할 출처, HTTP 메서드, 헤더 설정 및 자격 증명 포함 설정 CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of(FRONT_URL)); + configuration.setAllowedOrigins(List.of(TEST_URL, FRONT_URL, DOMAIN_URL)); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(List.of("Authorization", "Content-Type")); + configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); - // 특정 API 경로에 대해 CORS 정책을 적용 + // 특정 API 경로에 대해 CORS 정책 제외 UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", configuration); source.registerCorsConfiguration("/swagger-ui/**", configuration); diff --git a/src/main/java/com/olive/pribee/global/config/WebClientConfig.java b/src/main/java/com/olive/pribee/global/config/WebClientConfig.java new file mode 100644 index 0000000..a340391 --- /dev/null +++ b/src/main/java/com/olive/pribee/global/config/WebClientConfig.java @@ -0,0 +1,17 @@ +package com.olive.pribee.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient.Builder webClientBuilder() { + return WebClient.builder() + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + } +} diff --git a/src/main/java/com/olive/pribee/global/error/GlobalErrorCode.java b/src/main/java/com/olive/pribee/global/error/GlobalErrorCode.java index ac4d450..006d876 100644 --- a/src/main/java/com/olive/pribee/global/error/GlobalErrorCode.java +++ b/src/main/java/com/olive/pribee/global/error/GlobalErrorCode.java @@ -6,6 +6,7 @@ @Getter public enum GlobalErrorCode implements ErrorCode { + INVALID_FACEBOOK_CODE(HttpStatus.UNAUTHORIZED, "facebook code가 유효하지 않습니다."), AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "인증에 실패하였습니다."), AUTHORIZATION_FAILED(HttpStatus.UNAUTHORIZED, "인가에 실패하였습니다."), ACCESS_TOKEN_REQUIRED(HttpStatus.UNAUTHORIZED, "Access Token이 필요합니다."), diff --git a/src/main/java/com/olive/pribee/module/auth/controller/MemberController.java b/src/main/java/com/olive/pribee/module/auth/controller/MemberController.java index 8da13af..817809f 100644 --- a/src/main/java/com/olive/pribee/module/auth/controller/MemberController.java +++ b/src/main/java/com/olive/pribee/module/auth/controller/MemberController.java @@ -24,9 +24,15 @@ public class MemberController implements MemberControllerDocs { private final MemberService memberService; + @GetMapping("/login/facebook") + public ResponseEntity getLogin(@RequestHeader("facebook-code") String code){ + LoginResDto resDto = memberService.getAccessToken(code); + return ResponseEntity.status(201).body(DataResponseDto.of(resDto, 201)); + } + @GetMapping("/token") public ResponseEntity getAccessToken(@RequestHeader("Authorization-Refresh") String refreshToken) { - LoginResDto resDto = memberService.getAccessToken(refreshToken); + LoginResDto resDto = memberService.getNewAccessToken(refreshToken); return ResponseEntity.status(201).body(DataResponseDto.of(resDto, 201)); } diff --git a/src/main/java/com/olive/pribee/module/auth/controller/MemberControllerDocs.java b/src/main/java/com/olive/pribee/module/auth/controller/MemberControllerDocs.java index b8ef3a6..8e65591 100644 --- a/src/main/java/com/olive/pribee/module/auth/controller/MemberControllerDocs.java +++ b/src/main/java/com/olive/pribee/module/auth/controller/MemberControllerDocs.java @@ -16,7 +16,29 @@ @Tag(name = "Member", description = "사용자 관련 API") public interface MemberControllerDocs { - @Operation(summary = "accessToken 발급", description = "리프레시 토큰을 이용해 엑세스 토큰을 발급합니다.") + @Operation(summary = "로그인", description = "facebook code 를 통해 로그인을 발급합니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Created", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{ \"code\": 201, \"message\": \"Created\" }") + ) + ), + @ApiResponse(responseCode = "401", description = "인증에 실패하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ResponseDto.class), + examples = + @ExampleObject(value = "{ \"code\": 401, \"message\": \"facebook code가 유효하지 않습니다.\" }") + + ) + ) + }) + ResponseEntity getLogin(String code); + + @Operation(summary = "accessToken 재발급", description = "리프레시 토큰을 이용해 엑세스 토큰을 발급합니다.") @ApiResponses({ @ApiResponse(responseCode = "201", description = "Created", content = @Content( diff --git a/src/main/java/com/olive/pribee/module/auth/domain/entity/CustomOAuth2User.java b/src/main/java/com/olive/pribee/module/auth/domain/entity/CustomOAuth2User.java deleted file mode 100644 index 57447ce..0000000 --- a/src/main/java/com/olive/pribee/module/auth/domain/entity/CustomOAuth2User.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.olive.pribee.module.auth.domain.entity; - -import java.util.Collection; -import java.util.Collections; -import java.util.Map; - -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.oauth2.core.user.OAuth2User; - -public record CustomOAuth2User( - Member member, - Map attributes -) implements OAuth2User { - - @Override - public Map getAttributes() { - return attributes; - } - - @Override - public Collection getAuthorities() { - return Collections.singleton(new SimpleGrantedAuthority(member.getRole().name())); - } - - @Override - public String getName() { - return member.getFacebookId(); - } -} diff --git a/src/main/java/com/olive/pribee/module/auth/domain/entity/Member.java b/src/main/java/com/olive/pribee/module/auth/domain/entity/Member.java index 0c210c9..933bc54 100644 --- a/src/main/java/com/olive/pribee/module/auth/domain/entity/Member.java +++ b/src/main/java/com/olive/pribee/module/auth/domain/entity/Member.java @@ -41,7 +41,6 @@ public class Member extends BaseTime { @NotNull private String name; - @NotNull private String email; @NotNull @@ -51,7 +50,7 @@ public class Member extends BaseTime { @Enumerated(EnumType.STRING) private MemberRole role; - public static Member of(@NotNull String facebookId,@NotNull String name, @NotNull String email, @NotNull String profilePictureUrl) { + public static Member of(@NotNull String facebookId,@NotNull String name, String email, @NotNull String profilePictureUrl) { return Member.builder() .facebookId(facebookId) .name(name) diff --git a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookAuthRes.java b/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookAuthRes.java new file mode 100644 index 0000000..5ed285c --- /dev/null +++ b/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookAuthRes.java @@ -0,0 +1,26 @@ +package com.olive.pribee.module.auth.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookAuthRes { + + private String facebookId; + private String longTermToken; + + @Builder + public FacebookAuthRes( + @JsonProperty("facebookId") String facebookId, + @JsonProperty("longTermToken") String longTermToken + + ) { + this.facebookId = facebookId; + this.longTermToken = longTermToken; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookTokenRes.java b/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookTokenRes.java new file mode 100644 index 0000000..9436447 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookTokenRes.java @@ -0,0 +1,27 @@ +package com.olive.pribee.module.auth.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookTokenRes { + private String accessToken; + private String tokenType; + private long expiresIn; + + @Builder + public FacebookTokenRes( + @JsonProperty("access_token") String accessToken, + @JsonProperty("token_type") String tokenType, + @JsonProperty("expires_in") long expiresIn + ) { + this.accessToken = accessToken; + this.tokenType = tokenType; + this.expiresIn = expiresIn; + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoPictureRes.java b/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoPictureRes.java new file mode 100644 index 0000000..a230398 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoPictureRes.java @@ -0,0 +1,42 @@ +package com.olive.pribee.module.auth.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookUserInfoPictureRes { + + private Data data; + + @Builder + public FacebookUserInfoPictureRes(@JsonProperty("data") Data data) { + this.data = data; + } + + @Getter + @NoArgsConstructor(access = AccessLevel.PROTECTED) + public static class Data { + private int height; + private boolean isSilhouette; + private String url; + private int width; + + @Builder + public Data( + @JsonProperty("height") int height, + @JsonProperty("is_silhouette") boolean isSilhouette, + @JsonProperty("url") String url, + @JsonProperty("width") int width + ) { + this.height = height; + this.isSilhouette = isSilhouette; + this.url = url; + this.width = width; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoRes.java b/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoRes.java new file mode 100644 index 0000000..ab756a8 --- /dev/null +++ b/src/main/java/com/olive/pribee/module/auth/dto/res/FacebookUserInfoRes.java @@ -0,0 +1,31 @@ +package com.olive.pribee.module.auth.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class FacebookUserInfoRes { + private String id; + private String name; + private String email; + private FacebookUserInfoPictureRes picture; + + @Builder + public FacebookUserInfoRes( + @JsonProperty("id") String id, + @JsonProperty("name") String name, + @JsonProperty("email") String email, + @JsonProperty("picture") FacebookUserInfoPictureRes picture + ) { + this.id = id; + this.name = name; + this.email = email; + this.picture = picture; + } + +} diff --git a/src/main/java/com/olive/pribee/module/auth/handler/OAuth2AuthenticationFailureHandler.java b/src/main/java/com/olive/pribee/module/auth/handler/OAuth2AuthenticationFailureHandler.java deleted file mode 100644 index 45b6647..0000000 --- a/src/main/java/com/olive/pribee/module/auth/handler/OAuth2AuthenticationFailureHandler.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.olive.pribee.module.auth.handler; - -import java.io.IOException; - -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; -import org.springframework.stereotype.Component; - -import com.olive.pribee.global.util.ResponseUtil; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Component -@RequiredArgsConstructor -@Slf4j -public class OAuth2AuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { - - @Override - public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, - AuthenticationException exception) throws IOException { - log.error(exception.getMessage()); - ResponseUtil.setDataResponse(response, HttpServletResponse.SC_UNAUTHORIZED, exception.getMessage()); - } -} diff --git a/src/main/java/com/olive/pribee/module/auth/handler/OAuth2AuthenticationSuccessHandler.java b/src/main/java/com/olive/pribee/module/auth/handler/OAuth2AuthenticationSuccessHandler.java deleted file mode 100644 index 5c5ba8b..0000000 --- a/src/main/java/com/olive/pribee/module/auth/handler/OAuth2AuthenticationSuccessHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.olive.pribee.module.auth.handler; - -import java.io.IOException; - -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; -import org.springframework.stereotype.Component; - -import com.olive.pribee.global.enums.JwtVo; -import com.olive.pribee.global.util.RedisUtil; -import com.olive.pribee.global.util.ResponseUtil; -import com.olive.pribee.module.auth.JwtTokenProvider; -import com.olive.pribee.module.auth.domain.entity.CustomOAuth2User; -import com.olive.pribee.module.auth.domain.entity.Member; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class OAuth2AuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - private final JwtTokenProvider jwtTokenProvider; - private final RedisUtil redisUtil; - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, - Authentication authentication) throws IOException { - CustomOAuth2User oAuth2User = (CustomOAuth2User)authentication.getPrincipal(); - Member member = oAuth2User.member(); - - // JWT 생성 - JwtVo jwtVo = jwtTokenProvider.generateTokens(member); - - // Redis에 토큰 저장 - redisUtil.setOpsForValue(member.getId() + "_refresh", jwtVo.getRefreshToken(), - jwtTokenProvider.getREFRESH_TOKEN_EXPIRATION()); - - // 클라이언트에 응답 - ResponseUtil.setDataResponse(response, HttpServletResponse.SC_OK, jwtVo); - - clearAuthenticationAttributes(request); - } -} diff --git a/src/main/java/com/olive/pribee/module/auth/service/CustomOAuth2UserService.java b/src/main/java/com/olive/pribee/module/auth/service/CustomOAuth2UserService.java deleted file mode 100644 index 580bcc1..0000000 --- a/src/main/java/com/olive/pribee/module/auth/service/CustomOAuth2UserService.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.olive.pribee.module.auth.service; - -import java.util.Map; - -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Service; - -import com.olive.pribee.module.auth.domain.entity.CustomOAuth2User; -import com.olive.pribee.module.auth.domain.entity.Member; -import com.olive.pribee.module.auth.domain.repository.MemberRepository; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Service -@RequiredArgsConstructor -public class CustomOAuth2UserService extends DefaultOAuth2UserService { - private final MemberRepository memberRepository; - - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - OAuth2User oAuth2User = super.loadUser(userRequest); - String accessToken = userRequest.getAccessToken().getTokenValue(); - - // 받아올 정보 정의 - String facebookId = oAuth2User.getAttribute("id"); - String facebookName = oAuth2User.getAttribute("name"); - String facebookEmail = oAuth2User.getAttribute("email"); - String profilePictureUrl = getProfilePictureUrl(oAuth2User.getAttributes()); - - // 멤버 없다면 회원가입 - Member member = memberRepository.findByFacebookId(facebookId) - .orElseGet(() -> createNewMember(facebookId, facebookName, facebookEmail, profilePictureUrl)); - - return new CustomOAuth2User(member, oAuth2User.getAttributes()); - } - - // 프로필 사진 URL을 가져오는 헬퍼 메소드 - private String getProfilePictureUrl(Map attributes) { - Map pictureData = (Map)attributes.get("picture"); - return pictureData != null ? (String)((Map)pictureData.get("data")).get("url") : null; - } - - // 새로운 회원을 생성하는 메소드 - private Member createNewMember(String facebookId, String name, String email, String profilePictureUrl) { - Member newMember = Member.of(facebookId, name, email, profilePictureUrl); - return memberRepository.save(newMember); - } -} diff --git a/src/main/java/com/olive/pribee/module/auth/service/FacebookAuthService.java b/src/main/java/com/olive/pribee/module/auth/service/FacebookAuthService.java new file mode 100644 index 0000000..ce5678b --- /dev/null +++ b/src/main/java/com/olive/pribee/module/auth/service/FacebookAuthService.java @@ -0,0 +1,145 @@ +package com.olive.pribee.module.auth.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; + +import com.fasterxml.jackson.databind.JsonNode; +import com.olive.pribee.global.error.GlobalErrorCode; +import com.olive.pribee.global.error.exception.AppException; +import com.olive.pribee.module.auth.dto.res.FacebookAuthRes; +import com.olive.pribee.module.auth.dto.res.FacebookTokenRes; +import com.olive.pribee.module.auth.dto.res.FacebookUserInfoRes; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Slf4j +public class FacebookAuthService { + + private final String FB_EXCHANGE_TOKEN = "fb_exchange_token"; + + @Value("${url.facebook}") + private String FACEBOOK_BASE_URL; + + @Value("${facebook.clientId}") + private String FACEBOOK_CLIENT_ID; + + @Value("${facebook.clientSecret}") + private String FACEBOOK_CLIENT_SECRET; + + @Value("${facebook.redirect-uri}") + private String FACEBOOK_REDIRECT_URI; + + private final WebClient.Builder webClientBuilder; + + // Facebook WebClient 생성 + private WebClient getFacebookWebClient() { + return webClientBuilder.baseUrl(FACEBOOK_BASE_URL).build(); + } + + public Mono getFacebookIdWithToken(String code) { + return exchangeCodeForAccessToken(code) // (1) + .flatMap(shortLivedToken -> + exchangeForLongTermAccessToken(shortLivedToken) // (2) + .flatMap(longTermToken -> + fetchFacebookId(longTermToken) // (3) + .map(facebookId -> new FacebookAuthRes(facebookId, longTermToken)) + ) + ) + .onErrorResume(Exception.class, ex -> { + if (ex instanceof AppException) { + return Mono.error(ex); + } + + log.error("[Facebook] Facebook ID & Long Term Token 가져오기 실패: {}", ex.getMessage(), ex); + return Mono.error(new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR)); + }); + } + + // (1) Facebook Authorization Code → Short-Lived Access Token 변환 + private Mono exchangeCodeForAccessToken(String code) { + return getFacebookWebClient().get() + .uri(uriBuilder -> uriBuilder + .path("/oauth/access_token") + .queryParam("client_id", FACEBOOK_CLIENT_ID) + .queryParam("client_secret", FACEBOOK_CLIENT_SECRET) + .queryParam("redirect_uri", FACEBOOK_REDIRECT_URI) + .queryParam("code", code) + .build()) + .retrieve() + .onStatus(status -> + status == HttpStatus.UNAUTHORIZED || status == HttpStatus.BAD_REQUEST, response ->{ + log.error("[Facebook] Invalid Facebook Code: {}", code); + return Mono.error(new AppException(GlobalErrorCode.INVALID_FACEBOOK_CODE)); + }) + .bodyToMono(FacebookTokenRes.class) + .map(FacebookTokenRes::getAccessToken) // Access Token 추출 + .onErrorResume(Exception.class, ex -> { + if (ex instanceof AppException) { + return Mono.error(ex); + } + + log.error("[Facebook] AccessToken 요청 실패: {}", ex.getMessage(), ex); + return Mono.error(new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR)); + }); + } + + // (2) Short-Lived Token → Long-Term Token 변환 + private Mono exchangeForLongTermAccessToken(String shortLivedToken) { + return getFacebookWebClient().get() + .uri(uriBuilder -> uriBuilder + .path("/oauth/access_token") + .queryParam("grant_type", FB_EXCHANGE_TOKEN) + .queryParam("client_id", FACEBOOK_CLIENT_ID) + .queryParam("client_secret", FACEBOOK_CLIENT_SECRET) + .queryParam("fb_exchange_token", shortLivedToken) + .build()) + .retrieve() + .bodyToMono(FacebookTokenRes.class) + .map(FacebookTokenRes::getAccessToken) + .onErrorResume(Exception.class, ex -> { + log.error("[Facebook] Long Term AccessToken 요청 실패: {}", ex.getMessage(), ex); + return Mono.error(new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR)); + }); + } + + // (3) Long-Term Token으로 Facebook ID 조회 + private Mono fetchFacebookId(String accessToken) { + return getFacebookWebClient().get() + .uri(uriBuilder -> uriBuilder + .path("/me") + .queryParam("fields", "id") + .queryParam("access_token", accessToken) + .build()) + .retrieve() + .bodyToMono(JsonNode.class) + .map(json -> json.get("id").asText()) // JSON에서 id 값 추출 + .onErrorResume(Exception.class, ex -> { + log.error("[Facebook] Facebook ID 조회 실패: {}", ex.getMessage(), ex); + return Mono.error(new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR)); + }); + } + + // (4-1) Facebook 사용자 정보 조회 + public Mono fetchFacebookUserInfo(String accessToken) { + return getFacebookWebClient().get() + .uri(uriBuilder -> uriBuilder + .path("/me") + .queryParam("fields", "id,name,email,picture.width(1000).height(1000)") + .queryParam("access_token", accessToken) + .build()) + .retrieve() + .bodyToMono(FacebookUserInfoRes.class) + .onErrorResume(Exception.class, ex -> { + log.error("[Facebook] Facebook 사용자 정보 조회 실패: {}", ex.getMessage(), ex); + return Mono.error(new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR)); + }); + } +} diff --git a/src/main/java/com/olive/pribee/module/auth/service/MemberService.java b/src/main/java/com/olive/pribee/module/auth/service/MemberService.java index 77fd27f..bd624f3 100644 --- a/src/main/java/com/olive/pribee/module/auth/service/MemberService.java +++ b/src/main/java/com/olive/pribee/module/auth/service/MemberService.java @@ -11,6 +11,8 @@ import com.olive.pribee.module.auth.JwtTokenProvider; import com.olive.pribee.module.auth.domain.entity.Member; import com.olive.pribee.module.auth.domain.repository.MemberRepository; +import com.olive.pribee.module.auth.dto.res.FacebookAuthRes; +import com.olive.pribee.module.auth.dto.res.FacebookUserInfoRes; import com.olive.pribee.module.auth.dto.res.LoginResDto; import io.jsonwebtoken.ExpiredJwtException; @@ -23,13 +25,55 @@ @RequiredArgsConstructor @Slf4j public class MemberService { + private final FacebookAuthService facebookAuthService; private final JwtTokenProvider jwtTokenProvider; private final RedisUtil redisUtil; private final MemberRepository memberRepository; + // facebook code 기반 facebook 로그인을 통한 접근 jwt 발급 + @Transactional + public LoginResDto getAccessToken(String code) { + // code 기반 facebook ID 조회 + FacebookAuthRes facebookAuthRes = facebookAuthService.getFacebookIdWithToken(code).block(); + if (facebookAuthRes == null) { + log.error("[Facebook] facebookAuthRes is null in memberService -- " + code); + throw new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR); + } + + // facebook ID 기반 DB에서 회원 조회 + Member member = memberRepository.findByFacebookId(facebookAuthRes.getFacebookId()) + .orElseGet(() -> { + // 저장된 회원이 없으면 Facebook API에서 회원 정보 조회 + FacebookUserInfoRes userInfo = facebookAuthService.fetchFacebookUserInfo( + facebookAuthRes.getLongTermToken()).block(); + if (userInfo == null) { + log.error( + "[Facebook] facebook userInfo is null in memberService -- " + facebookAuthRes.getFacebookId()); + throw new AppException(GlobalErrorCode.INTERNAL_SERVER_ERROR); + } + + return memberRepository.save(Member.of( + userInfo.getId(), + userInfo.getName(), + userInfo.getEmail(), + userInfo.getPicture().getData().getUrl() + )); + }); + + // facebook long live accessToken Redis 에 저장 + redisUtil.setOpsForValue(member.getId() + "_fb_access", facebookAuthRes.getLongTermToken(), 5184000); + + // JWT 토큰 생성 및 refreshToken 저장 + JwtVo jwtVo = jwtTokenProvider.generateTokens(member); + redisUtil.setOpsForValue(member.getId() + "_refresh", jwtVo.getRefreshToken(), + jwtTokenProvider.getREFRESH_TOKEN_EXPIRATION()); + + return LoginResDto.of(jwtVo.getAccessToken(), jwtVo.getRefreshToken()); + } + // refresh token 으로 새로운 accessToken 발급 @Transactional - public LoginResDto getAccessToken(String refreshToken) { + public LoginResDto getNewAccessToken(String refreshToken) { if (refreshToken.isBlank()) { throw new AppException(GlobalErrorCode.REFRESH_TOKEN_REQUIRED); }