Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ public class AuthFacade {
private final TokenService tokenService;
private final MemberService memberService;

public TokenResponse kakaoLogin(KakaoLoginRequest request) {
KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code());
public TokenResponse kakaoLogin(KakaoLoginRequest request, String origin) {
KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code(), origin);

Member member =
memberService.getMemberBySocialProviderAndSocialId(
Expand All @@ -32,8 +32,8 @@ public TokenResponse kakaoLogin(KakaoLoginRequest request) {
return tokenService.getTokens(member.getId().toString(), member.getRole().name());
}

public TokenResponse kakaoSignup(KakaoSignupRequest request) {
KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code());
public TokenResponse kakaoSignup(KakaoSignupRequest request, String origin) {
KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code(), origin);
CreateMemberCommand command =
CreateMemberCommand.of(
response.kakaoId(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
public class KakaoLoginService {
private final KakaoOauthProvider kakaoOauthProvider;

public KakaoUserInfoResponse getKakaoUserInfo(String code) {
KakaoTokenResponse response = kakaoOauthProvider.getKakaoTokens(code);
public KakaoUserInfoResponse getKakaoUserInfo(String code, String origin) {
KakaoTokenResponse response = kakaoOauthProvider.getKakaoTokens(code, origin);
return kakaoOauthProvider.getKakaoUserInfo(response.accessToken());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ public class KakaoOauthProvider {
private final KakaoOauthClient kakaoOauthClient;
private final KakaoOauthProperties kakaoOauthProperties;

public KakaoTokenResponse getKakaoTokens(String code) {
public KakaoTokenResponse getKakaoTokens(String code, String origin) {
validateKakaoAuthorizationCode(code);
return kakaoOauthClient
.fetchKakaoTokens(kakaoOauthProperties.tokenUri(), createFormData(code))
.fetchKakaoTokens(kakaoOauthProperties.tokenUri(), createFormData(code, origin))
.block();
}

Expand All @@ -34,12 +34,12 @@ public KakaoUserInfoResponse getKakaoUserInfo(String accessToken) {
.block();
}

private BodyInserters.FormInserter<String> createFormData(String code) {
private BodyInserters.FormInserter<String> createFormData(String code, String origin) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("grant_type", "authorization_code");
formData.add("client_id", kakaoOauthProperties.clientId());
formData.add("client_secret", kakaoOauthProperties.clientSecret());
formData.add("redirect_uri", kakaoOauthProperties.redirectUri());
formData.add("redirect_uri", origin + kakaoOauthProperties.redirectUri());
formData.add("code", code);
return BodyInserters.fromFormData(formData);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,21 @@
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Auth", description = "인증 API")
@RestController
@RequiredArgsConstructor
@RequestMapping("api/auth")
@RequestMapping("/api/auth")
public class AuthController {
private final AuthFacade authFacade;

@Operation(summary = "카카오 로그인", description = "카카오 인가 코드를 이용하여, 엑세스 토큰과 리프레시 토큰을 발급합니다.")
@PostMapping("/login/kakao")
public ResponseEntity<StandardResponse> kakaoLogin(
@RequestAttribute(value = "origin") String origin,
@Valid @RequestBody KakaoLoginRequest request) {
TokenResponse response = authFacade.kakaoLogin(request);
TokenResponse response = authFacade.kakaoLogin(request, origin);

return ResponseEntity.ok(StandardResponse.success(HttpStatus.OK.value(), response));
}
Expand All @@ -39,8 +37,9 @@ public ResponseEntity<StandardResponse> kakaoLogin(
description = "카카오 인가 코드, 카테고리, 닉네임을 이용하여, 엑세스 토큰과 리프레시 토큰을 발급합니다.")
@PostMapping("/signup/kakao")
public ResponseEntity<StandardResponse> kakaoSignup(
@RequestAttribute(value = "origin") String origin,
@Valid @RequestBody KakaoSignupRequest request) {
TokenResponse response = authFacade.kakaoSignup(request);
TokenResponse response = authFacade.kakaoSignup(request, origin);

return ResponseEntity.ok(StandardResponse.success(HttpStatus.OK.value(), response));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.ject.studytrip.global.annotation;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface ClientOrigin {}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
package com.ject.studytrip.global.common.constants;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum UrlConstants {
DEV_API_SERVER_URL("https://dev-api-studytrip.duckdns.org"),
LOCAL_API_SERVER_URL("http://localhost:8080"),
// CORS 허용 도메인
CORS_DOMAINS(
"http://localhost:8080",
"https://dev-api-studytrip.duckdns.org",
"http://localhost:5173",
"https://localhost:5173",
"https://ject-4-client.vercel.app"),

PRODUCTION_CLIENT_URL("https://ject-4-client.vercel.app"),
LOCAL_CLIENT_URL("http://localhost:5173"),
LOCAL_SECURE_CLIENT_URL("https://localhost:5173"),
;
// 정적 리소스 경로
STATIC_RESOURCES(
"/favicon.ico",
"/firebase-messaging-sw.js",
"/v3/api-docs/**",
"/swagger-ui/**",
"/swagger-ui.html"),

private final String value;
// OAuth 콜백 경로
CALLBACK_PATHS("/auth/callback/**"),

// Origin 추출이 필요한 경로
ORIGIN_EXTRACT_PATHS("/api/auth/login/kakao", "/api/auth/signup/kakao"),

// 인증이 필요없는 API 경로
PERMIT_ALL_API_PATHS("/api/auth/**", "/api/trips/categories");

private final String[] urls;

UrlConstants(String... urls) {
this.urls = urls;
}
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
package com.ject.studytrip.global.config;

import com.ject.studytrip.global.common.constants.SwaggerUrlConstants;
import com.ject.studytrip.global.common.constants.UrlConstants;
import static com.ject.studytrip.global.common.constants.UrlConstants.*;

import com.ject.studytrip.auth.application.service.TokenService;
import com.ject.studytrip.global.config.properties.TokenProperties;
import com.ject.studytrip.global.security.CustomAccessDeniedHandler;
import com.ject.studytrip.global.security.CustomAuthenticationEntryPoint;
import com.ject.studytrip.global.security.JwtAuthenticationFilter;
import com.ject.studytrip.global.security.*;
import java.util.Arrays;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
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;
Expand All @@ -21,14 +21,14 @@
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

@Slf4j
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
@EnableConfigurationProperties(TokenProperties.class)
public class WebSecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomAuthenticationEntryPoint authenticationEntryPoint;
private final CustomAccessDeniedHandler accessDeniedHandler;

Expand All @@ -47,23 +47,64 @@ private void defaultFilterChain(HttpSecurity http) throws Exception {
.cors(cors -> cors.configurationSource(corsConfigurationSource()));
}

// 퍼블릭 리소스 URL 필터 체인
@Bean
@Order(0)
public SecurityFilterChain publicFilterChain(HttpSecurity http) throws Exception {
defaultFilterChain(http);

http.securityMatcher(STATIC_RESOURCES.getUrls());
http.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());

return http.build();
}

// 콜백 URL 필터 체인
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
@Order(1)
public SecurityFilterChain callbackFilterChain(HttpSecurity http) throws Exception {
defaultFilterChain(http);

http.securityMatcher(CALLBACK_PATHS.getUrls());
http.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());

return http.build();
}

// Origin 추출이 필요한 URL 필터체인
@Bean
@Order(2)
public SecurityFilterChain originExtractionFilterChain(
HttpSecurity http, SecurityResponseHandler securityResponseHandler) throws Exception {
defaultFilterChain(http);

http.securityMatcher(ORIGIN_EXTRACT_PATHS.getUrls());

// Origin 추출 필터 등록 : CORS 이후 OriginExtractionFilter 실행
http.addFilterAfter(new OriginExtractionFilter(securityResponseHandler), CorsFilter.class);

http.authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll());

return http.build();
}

// 메인 필터체인
@Bean
@Order(3)
public SecurityFilterChain mainFilterChain(HttpSecurity http, TokenService tokenService)
throws Exception {
defaultFilterChain(http);

// JWT 필터 등록 : 인증 이전에 동작해야 하므로 UsernamePasswordAuthenticationFilter 앞에 삽입
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
http.addFilterBefore(
new JwtAuthenticationFilter(tokenService),
UsernamePasswordAuthenticationFilter.class);

// 경로 인가 설정
http.authorizeHttpRequests(
authorize ->
authorize
.requestMatchers(SwaggerUrlConstants.getSwaggerUrls())
.permitAll() // Swagger 경로
.requestMatchers("/api/sample/**", "/api/auth/**")
.permitAll() // 샘플 api 경로
.requestMatchers("/api/trips/categories")
.requestMatchers(PERMIT_ALL_API_PATHS.getUrls())
.permitAll()
.anyRequest()
.authenticated()); // 그 외 요청은 모두 인증 수행
Expand Down Expand Up @@ -92,8 +133,7 @@ public UrlBasedCorsConfigurationSource corsConfigurationSource() {
// - prod: PROD_DOMAIN 만 허용
// - Spring Active Profile 기반 분기 필요
// - 서비스 도메인, 서버 운영 환경 설정 완료 시 작업
List<String> allowedOrigins =
Arrays.stream(UrlConstants.values()).map(UrlConstants::getValue).toList();
List<String> allowedOrigins = Arrays.asList(CORS_DOMAINS.getUrls());
config.setAllowedOrigins(allowedOrigins);

UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ public enum CommonErrorCode implements ErrorCode {
CONSTRAINT_VIOLATION(HttpStatus.BAD_REQUEST, "요청 파라미터 또는 경로 변수 유효성 검증에 실패했습니다."),
METHOD_ARGUMENT_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, "요청한 메서드 파마미터의 타입이 일치하지 않습니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 에러. 관리자에게 문의하세요."),

INVALID_ORIGIN(HttpStatus.BAD_REQUEST, "잘못된 Origin입니다."),
UNSUPPORTED_ORIGIN(HttpStatus.BAD_REQUEST, "지원하지 않는 Origin입니다."),
;

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final TokenService tokenService;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package com.ject.studytrip.global.security;

import static com.ject.studytrip.global.common.constants.UrlConstants.*;

import com.google.common.net.HttpHeaders;
import com.ject.studytrip.global.exception.error.CommonErrorCode;
import com.ject.studytrip.global.util.OriginArgumentUtil;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.filter.OncePerRequestFilter;

@RequiredArgsConstructor
public class OriginExtractionFilter extends OncePerRequestFilter {
private static final String ATTRIBUTE_NAME = "origin";

private final SecurityResponseHandler securityResponseHandler;

@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String origin =
OriginArgumentUtil.resolveOrigin(
request.getHeader(HttpHeaders.ORIGIN),
request.getScheme(),
request.getServerName(),
request.getServerPort());

// origin null 검증
if (origin == null) {
securityResponseHandler.sendResponse(response, CommonErrorCode.INVALID_ORIGIN);
return;
}

// 허용된 origin 검증
List<String> allowedOrigins = Arrays.asList(CORS_DOMAINS.getUrls());
if (!allowedOrigins.contains(origin)) {
securityResponseHandler.sendResponse(response, CommonErrorCode.UNSUPPORTED_ORIGIN);
return;
}

request.setAttribute(ATTRIBUTE_NAME, origin);
filterChain.doFilter(request, response);
}
}
Loading