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
10 changes: 6 additions & 4 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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'
Comment on lines +52 to +54
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

JWT 라이브러리 jjwt의 버전을 0.12.3으로 사용하고 계십니다. 현재 최신 안정 버전은 0.12.5입니다. 특별한 이유가 없다면, 알려진 취약점이 없고 최신 기능 및 버그 수정이 포함된 최신 버전으로 업데이트하는 것을 권장합니다.

	implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
	runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5'


// mySQL
runtimeOnly 'com.mysql:mysql-connector-j'
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

OAuth2 로그인 실패 시 리다이렉트되는 경로가 /api/test로 하드코딩되어 있습니다. 이 또한 성공 핸들러의 URL과 마찬가지로, 설정 파일(application.yml 등)을 통해 관리하여 유연성을 높이는 것을 권장합니다.

}));

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;
Expand All @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

JWT 검증을 건너뛸 경로 목록(EXCLUDED_PATHS)에 /api/v1/test가 포함되어 있습니다. 하지만 TestController/api/test 경로에 매핑되어 있으므로, 현재 설정으로는 의도한 대로 JWT 검증을 건너뛰지 못합니다. 경로를 /api/test로 수정해야 합니다.

Suggested change
"/api/v1/test",
"/api/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 ")) {
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

로그인 성공 후 리다이렉트 URL이 http://localhost:8080/api/login/success로 하드코딩되어 있습니다. 주석에 언급하신 대로 이 부분은 프론트엔드 주소로 변경되어야 하며, 개발/운영 환경에 따라 달라질 수 있으므로 application.yml 같은 설정 파일에서 값을 주입받아 사용하는 것이 유지보수 및 확장성 측면에서 바람직합니다. 57번째 줄의 에러 리다이렉트 URL도 마찬가지입니다.


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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 로직은 사용자의 이메일이 기존과 다를 경우에만 프로필 정보(이메일, 이름)를 업데이트합니다. 만약 사용자가 카카오에서 닉네임만 변경했다면, 우리 서비스의 사용자 이름이 업데이트되지 않는 문제가 발생합니다. 이름이나 이메일 중 하나라도 변경되었다면 프로필을 업데이트하도록 조건을 수정하는 것이 좋습니다.

Suggested change
if (email != null && !email.equals(user.getEmail())) {
user.updateProfile(email, name);
userRepository.save(user);
}
if ((email != null && !email.equals(user.getEmail())) || (name != null && !name.equals(user.getName()))) {
user.updateProfile(email, name);
userRepository.save(user);
}


// CustomOAuth2User 반환
return new CustomOAuth2User(user, attributes);
}
}
Loading
Loading