-
Notifications
You must be signed in to change notification settings - Fork 0
feat(#2): Kakao OAuth2 login #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0612f16
a0ad3ad
ec3aa97
28db726
f35c763
8267e9c
d9c540b
a568596
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,72 +7,96 @@ | |
| 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; | ||
| import org.springframework.web.cors.CorsConfigurationSource; | ||
| 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()); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| })); | ||
|
|
||
| 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; | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String> EXCLUDED_PATHS = Arrays.asList( | ||
| "/login", | ||
| "/oauth2/", | ||
| "/login/oauth2/", | ||
| "/oauth/callback", | ||
| "/api/v1/test", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| "/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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, Object> attributes; | ||
|
|
||
| public CustomOAuth2User(User user, Map<String, Object> attributes) { | ||
| this.user = user; | ||
| this.attributes = attributes; | ||
| } | ||
|
|
||
| @Override | ||
| public Map<String, Object> getAttributes() { | ||
| return attributes; | ||
| } | ||
|
|
||
| @Override | ||
| public Collection<? extends GrantedAuthority> getAuthorities() { | ||
| return Collections.emptyList(); | ||
| } | ||
|
|
||
| @Override | ||
| public String getName() { | ||
| return user.getProviderId(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| ); | ||
|
Comment on lines
+47
to
+50
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| 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)); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<String, Object> attributes = oAuth2User.getAttributes(); | ||||||||||||||||||
|
|
||||||||||||||||||
| // 카카오는 id가 Long으로 오기 때문에 String.valueOf로 안전하게 변환 | ||||||||||||||||||
| String providerId = String.valueOf(attributes.get("id")); | ||||||||||||||||||
|
|
||||||||||||||||||
| Map<String, Object> kakaoAccount = (Map<String, Object>) attributes.get("kakao_account"); | ||||||||||||||||||
| String email = (kakaoAccount != null) ? (String) kakaoAccount.get("email") : null; | ||||||||||||||||||
|
|
||||||||||||||||||
| String name = null; | ||||||||||||||||||
| if (kakaoAccount != null) { | ||||||||||||||||||
| Map<String, Object> profile = (Map<String, Object>) 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); | ||||||||||||||||||
| } | ||||||||||||||||||
|
Comment on lines
+62
to
+65
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 현재 로직은 사용자의 이메일이 기존과 다를 경우에만 프로필 정보(이메일, 이름)를 업데이트합니다. 만약 사용자가 카카오에서 닉네임만 변경했다면, 우리 서비스의 사용자 이름이 업데이트되지 않는 문제가 발생합니다. 이름이나 이메일 중 하나라도 변경되었다면 프로필을 업데이트하도록 조건을 수정하는 것이 좋습니다.
Suggested change
|
||||||||||||||||||
|
|
||||||||||||||||||
| // CustomOAuth2User 반환 | ||||||||||||||||||
| return new CustomOAuth2User(user, attributes); | ||||||||||||||||||
| } | ||||||||||||||||||
| } | ||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JWT 라이브러리
jjwt의 버전을0.12.3으로 사용하고 계십니다. 현재 최신 안정 버전은0.12.5입니다. 특별한 이유가 없다면, 알려진 취약점이 없고 최신 기능 및 버그 수정이 포함된 최신 버전으로 업데이트하는 것을 권장합니다.