diff --git a/build.gradle b/build.gradle index 206f5904..fff4a0ef 100644 --- a/build.gradle +++ b/build.gradle @@ -33,9 +33,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-websocket' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' @@ -46,10 +48,10 @@ dependencies { testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - //JWT - implementation 'io.jsonwebtoken:jjwt-api:0.12.6' - implementation 'io.jsonwebtoken:jjwt-impl:0.12.6' - implementation 'io.jsonwebtoken:jjwt-jackson:0.12.6' + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' // mySQL runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/docker-compose.yaml b/docker-compose.yaml index 12164669..65b83c5a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -11,7 +11,7 @@ services: MYSQL_PASSWORD: ${MYSQL_PASSWORD} MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} ports: - - "3306:3306" + - "3308:3306" volumes: - ./mysql_data:/var/lib/mysql command: diff --git a/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java b/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java index 7aeff704..186e7a1d 100644 --- a/src/main/java/com/example/RealMatch/global/config/SecurityConfig.java +++ b/src/main/java/com/example/RealMatch/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.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; @@ -14,65 +15,88 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import com.example.RealMatch.global.config.jwt.JwtAuthenticationFilter; +import com.example.RealMatch.global.config.oauth.OAuth2SuccessHandler; +import com.example.RealMatch.global.config.service.CustomOAuth2UserService; import com.example.RealMatch.global.presentation.advice.CustomAccessDeniedHandler; import com.example.RealMatch.global.presentation.advice.CustomAuthEntryPoint; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomAuthEntryPoint customAuthEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; private static final String[] PERMIT_ALL_URL_ARRAY = { + "/", "/error", "/favicon.ico", + "/css/**", "/js/**", "/images/**", + "/login/**", "/oauth2/**", "/api/test", - "/api/chat/**", - "/ws/**", - "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/swagger-ui.html" - }; + "/api/login/success", + "/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**", "/swagger-ui.html"}; private static final String[] REQUEST_AUTHENTICATED_ARRAY = { "/api/test-auth" }; + @Value("${swagger.server-url}") + private String swaggerUrl; + @Value("${cors.allowed-origin}") private String allowedOrigin; - @Value("${swagger.server-url}") - String swaggerUrl; @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, CustomAuthEntryPoint customAuthEntryPoint, CustomAccessDeniedHandler customAccessDeniedHandler) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + log.info("Configuring Security Filter Chain"); + http .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exception -> exception - .authenticationEntryPoint(customAuthEntryPoint) // 401 - .accessDeniedHandler(customAccessDeniedHandler) // 403 - ) + .authenticationEntryPoint(customAuthEntryPoint) + .accessDeniedHandler(customAccessDeniedHandler)) .authorizeHttpRequests(auth -> auth .requestMatchers(REQUEST_AUTHENTICATED_ARRAY).authenticated() .requestMatchers(PERMIT_ALL_URL_ARRAY).permitAll() - .anyRequest().denyAll() - ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .anyRequest().authenticated()) + + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler((request, response, exception) -> { + log.error("OAuth2 login failed", exception); + response.sendRedirect("/api/test?error=" + exception.getMessage()); + })); + return http.build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOrigins(List.of(allowedOrigin, swaggerUrl)); + configuration.setAllowedOrigins(List.of(allowedOrigin, "http://localhost:8080", swaggerUrl)); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); - configuration.setAllowCredentials(true); // 쿠키/인증정보 포함 요청 + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", configuration); return source; } } - diff --git a/src/main/java/com/example/RealMatch/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/RealMatch/global/config/jwt/JwtAuthenticationFilter.java index 37964ae8..9d47fa01 100644 --- a/src/main/java/com/example/RealMatch/global/config/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/RealMatch/global/config/jwt/JwtAuthenticationFilter.java @@ -1,6 +1,8 @@ package com.example.RealMatch.global.config.jwt; import java.io.IOException; +import java.util.Arrays; +import java.util.List; import org.springframework.http.HttpHeaders; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -20,12 +22,32 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; + // JWT 검증을 건너뛸 경로들 + private static final List EXCLUDED_PATHS = Arrays.asList( + "/login", + "/oauth2/", + "/login/oauth2/", + "/oauth/callback", + "/api/v1/test", + "/v3/api-docs", + "/swagger-ui", + "/swagger-resources" + ); + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String requestURI = request.getRequestURI(); + + // OAuth2 관련 경로는 JWT 검증 건너뛰기 + if (shouldNotFilter(requestURI)) { + filterChain.doFilter(request, response); + return; + } + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); if (authHeader == null || !authHeader.startsWith("Bearer ")) { @@ -59,4 +81,8 @@ protected void doFilterInternal(HttpServletRequest request, filterChain.doFilter(request, response); } + private boolean shouldNotFilter(String requestURI) { + return EXCLUDED_PATHS.stream() + .anyMatch(requestURI::startsWith); + } } diff --git a/src/main/java/com/example/RealMatch/global/config/oauth/CustomOAuth2User.java b/src/main/java/com/example/RealMatch/global/config/oauth/CustomOAuth2User.java new file mode 100644 index 00000000..d3c0c56c --- /dev/null +++ b/src/main/java/com/example/RealMatch/global/config/oauth/CustomOAuth2User.java @@ -0,0 +1,39 @@ +package com.example.RealMatch.global.config.oauth; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import com.example.RealMatch.match.domain.entity.User; + +import lombok.Getter; + +@Getter +public class CustomOAuth2User implements OAuth2User { + + private final User user; + private final Map attributes; + + public CustomOAuth2User(User user, Map attributes) { + this.user = user; + this.attributes = attributes; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return Collections.emptyList(); + } + + @Override + public String getName() { + return user.getProviderId(); + } +} diff --git a/src/main/java/com/example/RealMatch/global/config/oauth/OAuth2SuccessHandler.java b/src/main/java/com/example/RealMatch/global/config/oauth/OAuth2SuccessHandler.java new file mode 100644 index 00000000..8ca2992e --- /dev/null +++ b/src/main/java/com/example/RealMatch/global/config/oauth/OAuth2SuccessHandler.java @@ -0,0 +1,60 @@ +package com.example.RealMatch.global.config.oauth; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.example.RealMatch.global.config.jwt.JwtProvider; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + + try { + CustomOAuth2User oAuth2User = (CustomOAuth2User) authentication.getPrincipal(); + + // 토큰 생성 + String accessToken = jwtProvider.createAccessToken( + oAuth2User.getUser().getId(), + oAuth2User.getUser().getProvider(), + oAuth2User.getUser().getRole()); + String refreshToken = jwtProvider.createRefreshToken( + oAuth2User.getUser().getId(), + oAuth2User.getUser().getProvider(), + oAuth2User.getUser().getRole()); + + // 테스트를 위해 백엔드 컨트롤러 주소로 직접 리다이렉트 + // 실제 연동 시 프론트엔드 서버 주소와 토큰을 받을 경로를 지정 + String redirectUrl = String.format("http://localhost:8080/api/login/success?accessToken=%s&refreshToken=%s", + URLEncoder.encode(accessToken, StandardCharsets.UTF_8), + URLEncoder.encode(refreshToken, StandardCharsets.UTF_8) + ); + + log.info("Redirecting to: {}", redirectUrl); + response.sendRedirect(redirectUrl); + + } catch (Exception e) { + log.error("Error during OAuth2 success handling", e); + response.sendRedirect("http://localhost:8080/api/test?error=" + URLEncoder.encode(e.getMessage(), StandardCharsets.UTF_8)); + } + } +} diff --git a/src/main/java/com/example/RealMatch/global/config/service/CustomOAuth2UserService.java b/src/main/java/com/example/RealMatch/global/config/service/CustomOAuth2UserService.java new file mode 100644 index 00000000..beb33a26 --- /dev/null +++ b/src/main/java/com/example/RealMatch/global/config/service/CustomOAuth2UserService.java @@ -0,0 +1,70 @@ +package com.example.RealMatch.global.config.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 org.springframework.transaction.annotation.Transactional; + +import com.example.RealMatch.global.config.oauth.CustomOAuth2User; +import com.example.RealMatch.match.domain.entity.User; +import com.example.RealMatch.match.domain.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + //xs 기본 loadUser 호출 + OAuth2User oAuth2User = super.loadUser(userRequest); + + // 정보 추출 + String provider = userRequest.getClientRegistration().getRegistrationId(); // "kakao" + Map attributes = oAuth2User.getAttributes(); + + // 카카오는 id가 Long으로 오기 때문에 String.valueOf로 안전하게 변환 + String providerId = String.valueOf(attributes.get("id")); + + Map kakaoAccount = (Map) attributes.get("kakao_account"); + String email = (kakaoAccount != null) ? (String) kakaoAccount.get("email") : null; + + String name = null; + if (kakaoAccount != null) { + Map profile = (Map) kakaoAccount.get("profile"); + if (profile != null) { + name = (String) profile.get("nickname"); + } + } + + // DB 저장 및 업데이트 + String finalName = name; + User user = userRepository.findByProviderIdAndProvider(providerId, provider) + .orElseGet(() -> { + User newUser = User.builder() + .providerId(providerId) + .provider(provider) + .email(email) + .name(finalName) + .role("USER") + .build(); + return userRepository.save(newUser); + }); + + if (email != null && !email.equals(user.getEmail())) { + user.updateProfile(email, name); + userRepository.save(user); + } + + // CustomOAuth2User 반환 + return new CustomOAuth2User(user, attributes); + } +} diff --git a/src/main/java/com/example/RealMatch/global/controller/TestController.java b/src/main/java/com/example/RealMatch/global/controller/TestController.java index 3ee96566..c39b492f 100644 --- a/src/main/java/com/example/RealMatch/global/controller/TestController.java +++ b/src/main/java/com/example/RealMatch/global/controller/TestController.java @@ -1,11 +1,15 @@ package com.example.RealMatch.global.controller; +import java.util.Map; + import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.example.RealMatch.global.config.jwt.CustomUserDetails; +import com.example.RealMatch.global.config.jwt.JwtProvider; import com.example.RealMatch.global.presentation.CustomResponse; import com.example.RealMatch.global.presentation.code.GeneralSuccessCode; @@ -19,6 +23,12 @@ @RequestMapping("/api") public class TestController { + private final JwtProvider jwtProvider; + + public TestController(JwtProvider jwtProvider) { + this.jwtProvider = jwtProvider; + } + @Operation(summary = "api 테스트 확인", description = """ 테스트용 api입니다. @@ -52,7 +62,11 @@ public CustomResponse testAuth( String response = "Hello from Spring Boot 👋"; return CustomResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, response); } - - + @GetMapping("/login/success") + public CustomResponse> loginSuccess( + @RequestParam("accessToken") String accessToken, + @RequestParam("refreshToken") String refreshToken + ) { + return CustomResponse.onSuccess(GeneralSuccessCode.GOOD_REQUEST, Map.of("accessToken", accessToken, "refreshToken", refreshToken)); + } } - diff --git a/src/main/java/com/example/RealMatch/match/domain/entity/User.java b/src/main/java/com/example/RealMatch/match/domain/entity/User.java new file mode 100644 index 00000000..fbfbc773 --- /dev/null +++ b/src/main/java/com/example/RealMatch/match/domain/entity/User.java @@ -0,0 +1,48 @@ +package com.example.RealMatch.match.domain.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "users") +@Getter +@NoArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String providerId; // 카카오 고유 ID + + @Column(nullable = false) + private String provider; // kakao + + private String email; + private String name; + + @Column(nullable = false) + private String role = "USER"; + + @Builder + public User(String providerId, String provider, String email, String name, String role) { + this.providerId = providerId; + this.provider = provider; + this.email = email; + this.name = name; + this.role = role != null ? role : "USER"; + } + + public void updateProfile(String email, String name) { + this.email = email; + this.name = name; + } +} diff --git a/src/main/java/com/example/RealMatch/match/domain/repository/UserRepository.java b/src/main/java/com/example/RealMatch/match/domain/repository/UserRepository.java new file mode 100644 index 00000000..d3ab10da --- /dev/null +++ b/src/main/java/com/example/RealMatch/match/domain/repository/UserRepository.java @@ -0,0 +1,11 @@ +package com.example.RealMatch.match.domain.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.RealMatch.match.domain.entity.User; + +public interface UserRepository extends JpaRepository { + Optional findByProviderIdAndProvider(String providerId, String provider); +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 074981e3..0d442b41 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -36,6 +36,24 @@ spring: security: refresh-token: expire-days: 14 + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_ID} + client-secret: ${KAKAO_SECRET} + redirect-uri: ${KAKAO_REDIRECT_URI} + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: + - profile_nickname + - account_email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id logging: level: diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index d2881f5b..04018fe8 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -35,6 +35,24 @@ spring: security: refresh-token: expire-days: 14 + oauth2: + client: + registration: + kakao: + client-id: client + client-secret: secret + redirect-uri: http://redirect-uri-test + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: + - profile_nickname + - account_email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id jwt: secret: ${JWT_SECRET} diff --git a/src/main/resources/static/favicon.ico b/src/main/resources/static/favicon.ico new file mode 100644 index 00000000..e69de29b diff --git a/src/test/java/com/example/RealMatch/test/TestController.java b/src/test/java/com/example/RealMatch/test/TestController.java new file mode 100644 index 00000000..21113577 --- /dev/null +++ b/src/test/java/com/example/RealMatch/test/TestController.java @@ -0,0 +1,13 @@ +package com.example.RealMatch.test; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TestController { + + @GetMapping("/success") + public String success() { + return "KAKAO LOGIN SUCCESS"; + } +}