From a1f25e076c5e29f51523d82bffc5f57d0ff8dcad Mon Sep 17 00:00:00 2001 From: TueBack Date: Sun, 9 Nov 2025 16:22:13 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20OAuth2=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8(=EC=B9=B4=EC=B9=B4=EC=98=A4,=20?= =?UTF-8?q?=EA=B5=AC=EA=B8=80)=20=EA=B8=B0=EB=B3=B8=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build.gradle: oauth2-client, webflux 의존성 추가 - application.yml: 카카오, 구글 OAuth2 클라이언트 설정 추가 - Member 엔티티 및 관련 VO: provider, providerId, isVerified 등 소셜/인증 관련 필드 추가 - SecurityConfig: oauth2Login 설정 활성화 및 CustomOAuth2UserService 등록 - CustomOAuth2UserService: 소셜 사용자 정보 파싱 및 DB 저장/업데이트 로직 구현 - OAuthAttributes: 플랫폼별 사용자 정보 표준화 DTO 구현 --- build.gradle | 9 ++- .../application/config/CustomUserDetails.java | 30 ++++++++-- .../application/config/SecurityConfig.java | 30 ++++++---- .../in/CustomOAuth2UserService.java | 60 +++++++++++++++++++ .../auth/application/in/OAuthAttributes.java | 54 +++++++++++++++++ .../com/retrip/auth/domain/entity/Member.java | 40 ++++++++++++- .../retrip/auth/domain/vo/MemberPassword.java | 17 +++--- src/main/resources/application.yml | 35 ++++++++++- 8 files changed, 246 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/retrip/auth/application/in/CustomOAuth2UserService.java create mode 100644 src/main/java/com/retrip/auth/application/in/OAuthAttributes.java diff --git a/build.gradle b/build.gradle index 4d83c89..08d17c2 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.5.0' + id 'org.springframework.boot' version '3.4.1' id 'io.spring.dependency-management' version '1.1.7' } @@ -28,6 +28,11 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + + // [신규 추가] OAuth2 클라이언트 및 WebClient 의존성 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation "org.springdoc:springdoc-openapi-starter-webmvc-api:2.8.5" implementation "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.5" compileOnly 'org.projectlombok:lombok' @@ -47,4 +52,4 @@ dependencies { tasks.named('test') { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java b/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java index b026d5c..7ddb15a 100644 --- a/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java +++ b/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java @@ -1,23 +1,33 @@ package com.retrip.auth.application.config; - import com.retrip.auth.domain.entity.Member; +import lombok.Getter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; import java.util.Collection; +import java.util.Map; import java.util.stream.Collectors; - -public class CustomUserDetails implements UserDetails { +@Getter +public class CustomUserDetails implements UserDetails, OAuth2User { // [수정] OAuth2User 인터페이스 추가 private final Member member; + private Map attributes; // [신규] OAuth2 제공자로부터 받은 원본 데이터 + // 일반 로그인용 생성자 public CustomUserDetails(Member member) { this.member = member; } + // [신규] OAuth2 로그인용 생성자 + public CustomUserDetails(Member member, Map attributes) { + this.member = member; + this.attributes = attributes; + } + @Override public Collection getAuthorities() { return member.getAuthorities().getValues().stream() @@ -27,6 +37,7 @@ public Collection getAuthorities() { @Override public String getPassword() { + // 소셜 로그인 회원은 비밀번호가 null일 수 있음 return member.getPassword().getValue(); } @@ -34,4 +45,15 @@ public String getPassword() { public String getUsername() { return member.getEmail().getValue(); } -} + + // [신규] OAuth2User 인터페이스 메서드 구현 + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public String getName() { + return member.getId().toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java index 62c2968..9646259 100644 --- a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java +++ b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java @@ -1,15 +1,12 @@ package com.retrip.auth.application.config; +import com.retrip.auth.application.in.CustomOAuth2UserService; import com.retrip.auth.application.in.MemberQueryService; import com.retrip.auth.infra.adapter.in.rest.filter.JwtAuthenticationFilter; import com.retrip.auth.infra.adapter.in.rest.filter.LoginAuthenticationFilter; - -import java.util.List; - import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; @@ -24,12 +21,17 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import java.util.List; + @Configuration @RequiredArgsConstructor public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; // [신규 주입] + @Bean public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); //암호 인코더 정의 + return new BCryptPasswordEncoder(); } @Bean @@ -56,15 +58,12 @@ public AuthenticationManager authenticationManager( @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("http://localhost:3000")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); - return source; } @@ -73,10 +72,19 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, LoginAuthentic http.csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) .addFilterAt(loginAuthenticationFilter, BasicAuthenticationFilter.class) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + + // [신규 추가] OAuth2 로그인 설정 + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + ); + http.authorizeHttpRequests(auth -> { auth - .requestMatchers("users").permitAll() + // [수정] OAuth2 관련 경로 및 루트 경로 허용 (필요시) + .requestMatchers("/users", "/login/**", "/oauth2/**", "/").permitAll() .requestMatchers( "/swagger-ui/**", "/v3/api-docs/**", @@ -89,4 +97,4 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, LoginAuthentic return http.build(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/CustomOAuth2UserService.java b/src/main/java/com/retrip/auth/application/in/CustomOAuth2UserService.java new file mode 100644 index 0000000..fac9833 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/CustomOAuth2UserService.java @@ -0,0 +1,60 @@ +package com.retrip.auth.application.in; + +import com.retrip.auth.application.config.CustomUserDetails; +import com.retrip.auth.application.out.repository.MemberRepository; +import com.retrip.auth.domain.entity.Member; +import com.retrip.auth.domain.vo.MemberEmail; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails() + .getUserInfoEndpoint().getUserNameAttributeName(); + + OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes()); + + Member member = saveOrUpdate(attributes); + + return new CustomUserDetails(member, oAuth2User.getAttributes()); + } + + private Member saveOrUpdate(OAuthAttributes attributes) { + Optional memberOptional = memberRepository.findByEmailAndIsDeletedFalse(new MemberEmail(attributes.getEmail())) + .stream().findFirst(); + + Member member; + if (memberOptional.isPresent()) { + member = memberOptional.get(); + if (member.getProvider() == null || member.getProvider().equals("local")) { + log.warn("기존 로컬 계정({})으로 소셜 로그인을 시도했습니다.", attributes.getEmail()); + // TODO: 추후 계정 연동 정책에 따라 로직 수정 필요 (현재는 정보 업데이트만 수행) + } + member.updateSocialInfo(attributes.getName()); + } else { + member = attributes.toEntity(); + memberRepository.save(member); + log.info("신규 소셜 회원 가입: {} ({})", attributes.getEmail(), attributes.getProvider()); + } + return member; + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java b/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java new file mode 100644 index 0000000..b8496c1 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java @@ -0,0 +1,54 @@ +package com.retrip.auth.application.in; + +import com.retrip.auth.domain.entity.Member; +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; + +@Getter +@Builder +public class OAuthAttributes { + private Map attributes; + private String nameAttributeKey; + private String name; + private String email; + private String provider; + private String providerId; + + public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map attributes) { + if ("kakao".equals(registrationId)) { + return ofKakao(userNameAttributeName, attributes); + } + return ofGoogle(userNameAttributeName, attributes); + } + + private static OAuthAttributes ofGoogle(String userNameAttributeName, Map attributes) { + return OAuthAttributes.builder() + .name((String) attributes.get("name")) + .email((String) attributes.get("email")) + .provider("google") + .providerId((String) attributes.get(userNameAttributeName)) + .attributes(attributes) + .nameAttributeKey(userNameAttributeName) + .build(); + } + + private static OAuthAttributes ofKakao(String userNameAttributeName, Map attributes) { + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + + return OAuthAttributes.builder() + .name((String) profile.get("nickname")) + .email((String) kakaoAccount.get("email")) + .provider("kakao") + .providerId(String.valueOf(attributes.get(userNameAttributeName))) + .attributes(attributes) + .nameAttributeKey(userNameAttributeName) + .build(); + } + + public Member toEntity() { + return Member.createSocialMember(name, email, provider, providerId); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/domain/entity/Member.java b/src/main/java/com/retrip/auth/domain/entity/Member.java index 1dfdc67..544de71 100644 --- a/src/main/java/com/retrip/auth/domain/entity/Member.java +++ b/src/main/java/com/retrip/auth/domain/entity/Member.java @@ -36,6 +36,18 @@ public class Member extends BaseEntity { private Boolean isDeleted; + // [신규 추가] 소셜 로그인 정보 및 본인인증 정보 필드 + @Column(length = 20) + private String provider; // e.g., "local", "kakao", "google" + + @Column(unique = true) + private String providerId; // 소셜 플랫폼의 고유 사용자 ID + + @Column(length = 88, unique = true) + private String ci; // 본인인증 연계정보 (CI) + + @Column(nullable = false) + private boolean isVerified = false; // 본인인증 여부 public static Member create(String name, String email, String password, List authorities) { Member member = Member.builder() @@ -44,12 +56,13 @@ public static Member create(String name, String email, String password, List Date: Sun, 16 Nov 2025 16:28:42 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EC=A4=91=EA=B0=84=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- settings.gradle | 3 + .../auth/application/config/JwtProvider.java | 78 +++++++++++++++++++ .../config/OAuth2LoginSuccessHandler.java | 44 +++++++++++ .../application/config/SecurityConfig.java | 7 +- .../auth/application/in/OAuthAttributes.java | 20 +++++ src/main/resources/application.yml | 22 +++++- 6 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/retrip/auth/application/config/JwtProvider.java create mode 100644 src/main/java/com/retrip/auth/application/config/OAuth2LoginSuccessHandler.java diff --git a/settings.gradle b/settings.gradle index b5a474a..e423a73 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1 +1,4 @@ +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' +} rootProject.name = 'auth' diff --git a/src/main/java/com/retrip/auth/application/config/JwtProvider.java b/src/main/java/com/retrip/auth/application/config/JwtProvider.java new file mode 100644 index 0000000..49b649e --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/JwtProvider.java @@ -0,0 +1,78 @@ +package com.retrip.auth.application.config; + +import com.retrip.auth.application.in.response.LoginResponse; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * JWT 토큰의 생성 및 검증을 담당하는 클래스 + */ +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private final JwtConfig jwtConfig; + + /** + * 인증 정보를 기반으로 Access Token과 Refresh Token을 생성합니다. + * + * @param authentication 인증 객체 + * @return 생성된 토큰 정보를 담은 TokenResponse 객체 + */ + public LoginResponse.TokenResponse generateTokens(Authentication authentication) { + Instant now = Instant.now(); + String authorities = String.join(",", getAuthorities(authentication)); + + String accessToken = createToken( + authentication.getName(), + authorities, + now, + jwtConfig.getAccess().getExpireMin() + ); + + String refreshToken = createToken( + authentication.getName(), + authorities, + now, + jwtConfig.getRefresh().getExpireMin() + ); + + return new LoginResponse.TokenResponse(accessToken, refreshToken); + } + + private String createToken(String subject, String authorities, Instant issuedAt, long expirationMinutes) { + SecretKey key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8)); + Instant expiration = issuedAt.plus(expirationMinutes, ChronoUnit.MINUTES); + + return Jwts.builder() + .subject(subject) + .claims( + Map.of( + "username", subject, + "authorities", authorities + ) + ) + .issuedAt(Date.from(issuedAt)) + .expiration(Date.from(expiration)) + .signWith(key) + .compact(); + } + + private List getAuthorities(Authentication authentication) { + return authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/config/OAuth2LoginSuccessHandler.java b/src/main/java/com/retrip/auth/application/config/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..8e3e48d --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/OAuth2LoginSuccessHandler.java @@ -0,0 +1,44 @@ +package com.retrip.auth.application.config; // SecurityConfig와 같은 위치에 둡니다. + +import com.retrip.auth.application.in.response.LoginResponse; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + + // 프론트엔드에서 토큰을 받을 콜백 URL (프론트엔드와 협의 필요) + private static final String FRONTEND_CALLBACK_URL = "http://localhost:3000/auth/callback"; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + log.info("OAuth2 로그인 성공. JWT 발급을 시작합니다."); + + // 1. JwtProvider를 사용하여 토큰 생성 + LoginResponse.TokenResponse tokenResponse = jwtProvider.generateTokens(authentication); + + // 2. 프론트엔드로 토큰을 담아 리다이렉트 + String targetUrl = UriComponentsBuilder.fromUriString(FRONTEND_CALLBACK_URL) + .queryParam("accessToken", tokenResponse.accessToken()) + .queryParam("refreshToken", tokenResponse.refreshToken()) + .build() + .encode(StandardCharsets.UTF_8) + .toUriString(); + + response.sendRedirect(targetUrl); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java index 9646259..51ba6c3 100644 --- a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java +++ b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java @@ -29,6 +29,9 @@ public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; // [신규 주입] + // [4주차 신규 추가] OAuth2 성공 핸들러 주입 + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -74,11 +77,13 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, LoginAuthentic .addFilterAt(loginAuthenticationFilter, BasicAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - // [신규 추가] OAuth2 로그인 설정 + // [수정] OAuth2 로그인 설정 (SuccessHandler 추가) .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService) ) + // [4주차 신규 추가] 로그인 성공 시 JWT 발급 핸들러 등록 + .successHandler(oAuth2LoginSuccessHandler) ); http.authorizeHttpRequests(auth -> { diff --git a/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java b/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java index b8496c1..ebc549f 100644 --- a/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java +++ b/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java @@ -20,6 +20,10 @@ public static OAuthAttributes of(String registrationId, String userNameAttribute if ("kakao".equals(registrationId)) { return ofKakao(userNameAttributeName, attributes); } + // [4주차 신규 추가] 네이버 분기 + if ("naver".equals(registrationId)) { + return ofNaver(userNameAttributeName, attributes); + } return ofGoogle(userNameAttributeName, attributes); } @@ -48,6 +52,22 @@ private static OAuthAttributes ofKakao(String userNameAttributeName, Map attributes) { + // 네이버 응답은 'response' 키 안에 실제 사용자 정보가 중첩되어 있습니다. + Map response = (Map) attributes.get(userNameAttributeName); + + return OAuthAttributes.builder() + .name((String) response.get("name")) // 네이버는 'name' 필드를 제공 (WBS 스코프에 nickname이 아닌 name으로 되어있어 name 사용) + .email((String) response.get("email")) + .provider("naver") + .providerId((String) response.get("id")) // 네이버 고유 ID + .attributes(attributes) + .nameAttributeKey(userNameAttributeName) // "response" + .build(); + } + + public Member toEntity() { return Member.createSocialMember(name, email, provider, providerId); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index d8bbac4..1011400 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -17,7 +17,7 @@ spring: ddl-auto: update # 스키마 변경을 위해 update로 임시 변경 (운영 환경에서는 validate 또는 none 권장) open-in-view: false - # [신규 추가] OAuth2 설정 + # [수정] OAuth2 설정 (네이버 추가) security: oauth2: client: @@ -39,6 +39,19 @@ spring: - account_email redirect-uri: "http://localhost:8080/login/oauth2/code/kakao" client-name: Kakao + + # [4주차 신규 추가] 네이버 설정 + naver: + client-id: your-naver-client-id + client-secret: your-naver-client-secret + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: + - name + - email + redirect-uri: "http://localhost:8080/login/oauth2/code/naver" + client-name: Naver + provider: kakao: authorization-uri: https://kauth.kakao.com/oauth/authorize @@ -46,6 +59,13 @@ spring: user-info-uri: https://kapi.kakao.com/v2/user/me user-name-attribute: id + # [4주차 신규 추가] 네이버 설정 + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response # 네이버는 response 객체 안에 id, email 등이 있음 + #logging logging: level: From 799db524ecbb4c2fb1a11deecae215fffc49769c Mon Sep 17 00:00:00 2001 From: TueBack Date: Sat, 20 Dec 2025 03:47:36 +0900 Subject: [PATCH 3/4] . --- api-test.http | 50 ++++++ .../auth/application/config/JwtConfig.java | 9 +- .../auth/application/config/JwtProvider.java | 155 ++++++++++++++---- .../application/config/SecurityConfig.java | 125 ++++++++------ ...sernamePasswordAuthenticationProvider.java | 5 +- .../rest/filter/JwtAuthenticationFilter.java | 85 ++++------ .../filter/LoginAuthenticationFilter.java | 91 ++++------ src/main/resources/application.yml | 47 +++++- 8 files changed, 362 insertions(+), 205 deletions(-) create mode 100644 api-test.http diff --git a/api-test.http b/api-test.http new file mode 100644 index 0000000..3c0ddab --- /dev/null +++ b/api-test.http @@ -0,0 +1,50 @@ +### 전역 변수 설정 (필요시 변경) +@auth_host = http://localhost:8080 +@trip_host = http://localhost:8081 +@email = test@naver.com +@password = password1234 + +### 1. [Auth] 회원가입 +POST {{auth_host}}/users +Content-Type: application/json + +{ + "email": "{{email}}", + "password": "{{password}}", + "name": "테스터" +} + +### 2. [Auth] 로그인 및 토큰 발급 +# 주의: LoginAuthenticationFilter 구현에 따라 id, password를 헤더로 전송합니다. +POST {{auth_host}}/login +Content-Type: application/json +id: {{email}} +password: {{password}} + +> {% + // 응답에서 accessToken을 추출하여 전역 변수 'auth_token'에 저장 + client.global.set("auth_token", response.body.data.accessToken); + client.log("Acquired Token: " + response.body.data.accessToken); +%} + +### 3. [Trip] 여행 생성 (토큰 사용) +POST {{trip_host}}/trips +Content-Type: application/json +Authorization: Bearer {{auth_token}} + +{ + "locationId": "550e8400-e29b-41d4-a716-446655440001", + "title": "제주도 우정 여행", + "description": "친구들과 함께하는 즐거운 제주도 여행입니다.", + "start": "2026-07-01", + "end": "2026-07-05", + "open": true, + "maxParticipants": 4, + "category": "DOMESTIC", + "hashTags": ["제주도", "우정여행", "맛집탐방"] +} + +### 4. [Trip] 생성된 여행 목록 조회 (검증) +GET {{trip_host}}/trips +Content-Type: application/json +Authorization: Bearer {{auth_token}} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/config/JwtConfig.java b/src/main/java/com/retrip/auth/application/config/JwtConfig.java index 30cc89f..51bdd4d 100644 --- a/src/main/java/com/retrip/auth/application/config/JwtConfig.java +++ b/src/main/java/com/retrip/auth/application/config/JwtConfig.java @@ -4,12 +4,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.boot.context.properties.ConfigurationProperties; - @Getter @RequiredArgsConstructor @ConfigurationProperties("token.jwt") public class JwtConfig { - private final String secret; + + // 기존 secret 삭제 -> RSA 키 쌍으로 변경 + private final String privateKey; + private final String publicKey; + private final String header; private final String prefix; private final AccessConfig access; @@ -26,4 +29,4 @@ public static class AccessConfig { public static class RefreshConfig { private final int expireMin; } -} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/config/JwtProvider.java b/src/main/java/com/retrip/auth/application/config/JwtProvider.java index 49b649e..4cf68c8 100644 --- a/src/main/java/com/retrip/auth/application/config/JwtProvider.java +++ b/src/main/java/com/retrip/auth/application/config/JwtProvider.java @@ -1,24 +1,30 @@ package com.retrip.auth.application.config; import com.retrip.auth.application.in.response.LoginResponse; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.*; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.stereotype.Component; -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; import java.time.Instant; import java.time.temporal.ChronoUnit; -import java.util.Date; -import java.util.List; -import java.util.Map; +import java.util.*; +import java.util.stream.Collectors; +import com.retrip.auth.application.config.CustomUserDetails; /** - * JWT 토큰의 생성 및 검증을 담당하는 클래스 + * JWT 토큰의 생성(Sign) 및 검증(Verify)을 담당하는 클래스 (RSA 방식) */ +@Slf4j @Component @RequiredArgsConstructor public class JwtProvider { @@ -26,24 +32,33 @@ public class JwtProvider { private final JwtConfig jwtConfig; /** - * 인증 정보를 기반으로 Access Token과 Refresh Token을 생성합니다. - * - * @param authentication 인증 객체 - * @return 생성된 토큰 정보를 담은 TokenResponse 객체 + * [생성] 인증 정보를 기반으로 RSA 서명된 Access/Refresh Token 생성 */ public LoginResponse.TokenResponse generateTokens(Authentication authentication) { Instant now = Instant.now(); String authorities = String.join(",", getAuthorities(authentication)); + // [수정] Principal에서 ID(UUID)와 Email 추출 로직 추가 + String memberId = authentication.getName(); // 기본값 + String email = authentication.getName(); // 기본값 + + Object principal = authentication.getPrincipal(); + if (principal instanceof CustomUserDetails userDetails) { + memberId = userDetails.getName(); // CustomUserDetails.getName()은 UUID(String) 반환 + email = userDetails.getUsername(); // CustomUserDetails.getUsername()은 이메일 반환 + } + String accessToken = createToken( - authentication.getName(), + memberId, // sub (UUID) + email, // claim: username (Email) authorities, now, jwtConfig.getAccess().getExpireMin() ); String refreshToken = createToken( - authentication.getName(), + memberId, // sub (UUID) + email, // claim: username (Email) authorities, now, jwtConfig.getRefresh().getExpireMin() @@ -52,22 +67,102 @@ public LoginResponse.TokenResponse generateTokens(Authentication authentication) return new LoginResponse.TokenResponse(accessToken, refreshToken); } - private String createToken(String subject, String authorities, Instant issuedAt, long expirationMinutes) { - SecretKey key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8)); - Instant expiration = issuedAt.plus(expirationMinutes, ChronoUnit.MINUTES); - - return Jwts.builder() - .subject(subject) - .claims( - Map.of( - "username", subject, - "authorities", authorities - ) - ) - .issuedAt(Date.from(issuedAt)) - .expiration(Date.from(expiration)) - .signWith(key) - .compact(); + // [수정] 파라미터에 username(email) 추가 + private String createToken(String subject, String username, String authorities, Instant issuedAt, long expirationMinutes) { + try { + PrivateKey privateKey = getPrivateKey(jwtConfig.getPrivateKey()); + Instant expiration = issuedAt.plus(expirationMinutes, ChronoUnit.MINUTES); + + return Jwts.builder() + .subject(subject) // 여기에는 UUID가 들어감 + .claims( + Map.of( + "username", username, // 여기에는 이메일이 들어감 + "authorities", authorities + ) + ) + .issuedAt(Date.from(issuedAt)) + .expiration(Date.from(expiration)) + .signWith(privateKey, Jwts.SIG.RS256) + .compact(); + } catch (Exception e) { + throw new RuntimeException("토큰 생성 실패", e); + } + } + + /** + * [검증] 토큰 유효성 검사 (RSA Public Key 사용) + */ + public boolean validateToken(String token) { + try { + PublicKey publicKey = getPublicKey(jwtConfig.getPublicKey()); + Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token); + return true; + } catch (SecurityException | MalformedJwtException e) { + log.info("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.info("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.info("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.info("JWT claims string is empty.", e); + } catch (Exception e) { + log.error("JWT validation error", e); + } + return false; + } + + /** + * [파싱] 토큰에서 인증 객체 추출 + */ + public Authentication getAuthentication(String token) { + try { + PublicKey publicKey = getPublicKey(jwtConfig.getPublicKey()); + Claims claims = Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .getPayload(); + + String username = claims.get("username", String.class); + String authoritiesStr = claims.get("authorities", String.class); + + List authorities = Arrays.stream(authoritiesStr.split(",")) + .map(String::trim) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); + + // UserContext나 CustomUserDetails 대신 표준 토큰 객체 사용 + return new UsernamePasswordAuthenticationToken(username, null, authorities); + + } catch (Exception e) { + throw new RuntimeException("인증 정보 추출 실패", e); + } + } + + // --- Key Parsing Helpers --- + + private PrivateKey getPrivateKey(String key) throws Exception { + String sanitizedKey = key + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + byte[] keyBytes = Base64.getDecoder().decode(sanitizedKey); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + return KeyFactory.getInstance("RSA").generatePrivate(spec); + } + + private PublicKey getPublicKey(String key) throws Exception { + String sanitizedKey = key + .replace("-----BEGIN PUBLIC KEY-----", "") + .replace("-----END PUBLIC KEY-----", "") + .replaceAll("\\s", ""); + byte[] keyBytes = Base64.getDecoder().decode(sanitizedKey); + X509EncodedKeySpec spec = new X509EncodedKeySpec(keyBytes); + return KeyFactory.getInstance("RSA").generatePublic(spec); } private List getAuthorities(Authentication authentication) { diff --git a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java index 51ba6c3..6bf1a62 100644 --- a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java +++ b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java @@ -7,16 +7,18 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 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.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @@ -24,82 +26,111 @@ import java.util.List; @Configuration +@EnableWebSecurity // [권장] Spring Security 활성화 어노테이션 추가 @RequiredArgsConstructor public class SecurityConfig { - private final CustomOAuth2UserService customOAuth2UserService; // [신규 주입] - - // [4주차 신규 추가] OAuth2 성공 핸들러 주입 + private final CustomOAuth2UserService customOAuth2UserService; private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + // [중요] 필터를 new로 생성하지 않고 주입받습니다. (JwtProvider 등의 의존성 해결을 위해) + private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - @Bean - public LoginAuthenticationFilter loginAuthenticationFilter(JwtConfig jwtConfig, AuthenticationManager authenticationManager) { - return new LoginAuthenticationFilter(jwtConfig, authenticationManager); - } - - @Bean - public JwtAuthenticationFilter jwtAuthenticationFilter(JwtConfig jwtConfig) { - return new JwtAuthenticationFilter(jwtConfig); - } - + /** + * AuthenticationManager는 Spring Security 6.x 버전부터 빈으로 직접 등록해주어야 컨트롤러나 필터에서 쓸 수 있습니다. + */ @Bean public AuthenticationManager authenticationManager( HttpSecurity http, UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider, MemberQueryService memberQueryService) throws Exception { - return http.authenticationProvider(usernamePasswordAuthenticationProvider) - .userDetailsService(memberQueryService) - .getSharedObject(AuthenticationManagerBuilder.class) - .build(); + + AuthenticationManagerBuilder authenticationManagerBuilder = + http.getSharedObject(AuthenticationManagerBuilder.class); + + authenticationManagerBuilder + .authenticationProvider(usernamePasswordAuthenticationProvider) + .userDetailsService(memberQueryService); + + return authenticationManagerBuilder.build(); } + /** + * 로컬 로그인 필터는 AuthenticationManager가 필요하므로 @Bean으로 수동 등록하거나, + * SecurityFilterChain 내부에서 생성할 수 있습니다. 여기서는 명시적으로 Bean 등록합니다. + */ @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("http://localhost:3000")); - config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); - config.setAllowedHeaders(List.of("*")); - config.setAllowCredentials(true); - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", config); - return source; + public LoginAuthenticationFilter loginAuthenticationFilter( + JwtConfig jwtConfig, + AuthenticationManager authenticationManager, + JwtProvider jwtProvider) { + LoginAuthenticationFilter filter = new LoginAuthenticationFilter(jwtConfig, authenticationManager,jwtProvider); + // 로그인 API URL 변경이 필요하면 여기서 설정 (기본값: /login) + // filter.setFilterProcessesUrl("/auth/login"); + return filter; } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, LoginAuthenticationFilter loginAuthenticationFilter, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { - http.csrf(AbstractHttpConfigurer::disable) + public SecurityFilterChain securityFilterChain( + HttpSecurity http, + LoginAuthenticationFilter loginAuthenticationFilter) throws Exception { + + http + .csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) - .addFilterAt(loginAuthenticationFilter, BasicAuthenticationFilter.class) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - // [수정] OAuth2 로그인 설정 (SuccessHandler 추가) + // [중요] 세션 관리: Stateless (JWT 필수 설정) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // 필터 배치 + .addFilterAt(loginAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // 기본 로그인 필터 위치 교체 + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // JWT 필터를 그 앞에 배치 + + // OAuth2 설정 .oauth2Login(oauth2 -> oauth2 .userInfoEndpoint(userInfo -> userInfo .userService(customOAuth2UserService) ) - // [4주차 신규 추가] 로그인 성공 시 JWT 발급 핸들러 등록 .successHandler(oAuth2LoginSuccessHandler) - ); + ) + + // URL 권한 설정 + .authorizeHttpRequests(auth -> auth + // [보안 수정] /users 전체 허용은 위험함 (DELETE는 막아야 함) + .requestMatchers(HttpMethod.POST, "/users").permitAll() // 회원가입만 허용 - http.authorizeHttpRequests(auth -> { - auth - // [수정] OAuth2 관련 경로 및 루트 경로 허용 (필요시) - .requestMatchers("/users", "/login/**", "/oauth2/**", "/").permitAll() - .requestMatchers( - "/swagger-ui/**", - "/v3/api-docs/**", - "/swagger-resources/**", - "/webjars/**" - ).permitAll() - .anyRequest().authenticated(); - } - ); + .requestMatchers("/login/**", "/oauth2/**", "/auth/reissue", "/").permitAll() // 재발급 등 허용 + .requestMatchers( + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**" + ).permitAll() + + .anyRequest().authenticated() + ); return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + // 프론트엔드 도메인 허용 (개발용: localhost:3000, 배포용 도메인 추가 필요) + config.setAllowedOrigins(List.of("http://localhost:3000")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); // 쿠키(Refresh Token) 전송 허용 + config.setExposedHeaders(List.of("Authorization")); // 헤더 노출 허용 (필요시) + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java index 1cb7c4e..7129cff 100644 --- a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java @@ -30,9 +30,10 @@ public Authentication authenticate(Authentication authentication) throw new BadCredentialsException("Bad credentials"); } + // principal에 username(String) 대신 user(UserDetails) 객체를 넘겨줍니다. return new UsernamePasswordAuthenticationToken( - username, - password, + user, // <--- 수정된 부분 (기존: username) + password, user.getAuthorities().stream().toList() ); } diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java index 2c25717..f918647 100644 --- a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/JwtAuthenticationFilter.java @@ -1,57 +1,53 @@ package com.retrip.auth.infra.adapter.in.rest.filter; -import com.retrip.auth.application.config.JwtConfig; -import com.retrip.auth.application.config.UsernamePasswordAuthentication; -import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; +import com.retrip.auth.application.config.JwtProvider; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; +import org.springframework.stereotype.Component; // [필수] import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; -import javax.crypto.SecretKey; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; import java.util.List; -import java.util.stream.Collectors; @Slf4j +@Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final String AUTHORIZATION_HEADER = "Authorization"; + + // [유지] 사용자 정의 제외 URI private static final List URI = List.of("/login", "/users"); - private final JwtConfig jwtConfig; + + // JwtConfig 대신 JwtProvider를 주입받아 사용 (책임 분리) + private final JwtProvider jwtProvider; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String token = getToken(request.getHeader(AUTHORIZATION_HEADER)); - SecretKey key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8)); - if (token == null || !validToken(token, key)) { - response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + // 토큰이 유효한 경우에만 인증 처리 + if (StringUtils.hasText(token) && jwtProvider.validateToken(token)) { + // 유효하면 인증 객체 생성 후 SecurityContext에 저장 + Authentication auth = jwtProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(auth); } - Claims payload = Jwts.parser() - .verifyWith(key) - .build() - .parseSignedClaims(token).getPayload(); - String username = String.valueOf(payload.get("username")); - List authorities = getAuthorities(String.valueOf(payload.get("authorities"))); + // 토큰이 없거나 유효하지 않으면 그냥 통과시킴 (SecurityConfig의 .authenticated()에서 걸러짐) + // 만약 여기서 401을 직접 리턴하고 싶다면 else 블록에서 처리하고 return 해야 함. + // 현재 로직은 "인증 정보가 있으면 넣고, 없으면 안 넣고 다음 필터로 넘김" 방식입니다. - UsernamePasswordAuthentication auth = new UsernamePasswordAuthentication(username, null, authorities); - SecurityContextHolder.getContext().setAuthentication(auth); filterChain.doFilter(request, response); } - private String getToken(String authorization) { if (StringUtils.hasText(authorization) && authorization.startsWith("Bearer ")) { return authorization.substring(7); @@ -59,39 +55,14 @@ private String getToken(String authorization) { return null; } - private boolean validToken(String token, SecretKey key) { - try { - Jwts.parser() - .verifyWith(key) - .build() - .parseSignedClaims(token); - return true; // 검증 성공 - } catch (SecurityException | MalformedJwtException e) { - log.info("Invalid JWT Token", e); - } catch (ExpiredJwtException e) { - log.info("Expired JWT Token", e); - } catch (UnsupportedJwtException e) { - log.info("Unsupported JWT Token", e); - } catch (IllegalArgumentException e) { - log.info("JWT claims string is empty.", e); - } - return false; - } - - private List getAuthorities(String authorities) { - return Arrays.stream(authorities.split(",")) - .map(String::trim) - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - } - @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { - //로그인 제외 모든 필터 타기 - return URI.contains(request.getRequestURI()) - || request.getRequestURI().startsWith("/swagger-ui") - || request.getRequestURI().startsWith("/v3/api-docs") - || request.getRequestURI().startsWith("/swagger-resources") - || request.getRequestURI().startsWith("/webjars"); + // [유지] 기존에 작성하신 제외 로직 그대로 사용 + String path = request.getRequestURI(); + return URI.contains(path) + || path.startsWith("/swagger-ui") + || path.startsWith("/v3/api-docs") + || path.startsWith("/swagger-resources") + || path.startsWith("/webjars"); } -} +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java index 29a3667..4b9b232 100644 --- a/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/filter/LoginAuthenticationFilter.java @@ -1,13 +1,11 @@ package com.retrip.auth.infra.adapter.in.rest.filter; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.retrip.auth.application.config.JwtConfig; +import com.retrip.auth.application.config.JwtProvider; // [추가] import com.retrip.auth.application.config.UsernamePasswordAuthentication; import com.retrip.auth.application.in.response.LoginResponse; import com.retrip.auth.infra.adapter.in.rest.common.ApiResponse; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -16,95 +14,64 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.AuthenticationException; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; -import javax.crypto.SecretKey; import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Date; -import java.util.Map; @Slf4j @RequiredArgsConstructor public class LoginAuthenticationFilter extends OncePerRequestFilter { + private final JwtConfig jwtConfig; private final AuthenticationManager manager; + private final JwtProvider jwtProvider; // [추가] 토큰 생성기 주입 @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + + // 1. 요청 검증 String id = request.getHeader("id"); String password = request.getHeader("password"); + if (!StringUtils.hasText(id) || !StringUtils.hasText(password)) { + // 값이 없으면 400 Bad Request가 더 적절하지만, 기존 로직 유지 response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); return; } - Authentication authentication = new UsernamePasswordAuthentication(id, password); - Authentication auth = manager.authenticate(authentication); - LoginResponse.TokenResponse tokenResponse = generateToken(auth); - ApiResponse result = ApiResponse.ok(tokenResponse); - // JSON 직렬화 - String json = getResponseBody(result); - - // 응답 Header 설정 - response.setContentType("application/json"); - response.setCharacterEncoding("UTF-8"); - // 응답에 JSON 데이터 작성 - response.setStatus(HttpServletResponse.SC_OK); - response.getWriter().write(json); - response.getWriter().flush(); - } + try { + // 2. 인증 시도 + Authentication authentication = new UsernamePasswordAuthentication(id, password); + Authentication auth = manager.authenticate(authentication); + // 3. [핵심 변경] JwtProvider를 통해 RSA 서명된 토큰 생성 + // 기존의 복잡한 generateToken 메서드 삭제 -> 위임 + LoginResponse.TokenResponse tokenResponse = jwtProvider.generateTokens(auth); - public LoginResponse.TokenResponse generateToken(Authentication authentication) { - String authorities = String.join(",", authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList()); - LocalDateTime issuedTime = LocalDateTime.now(); - LocalDateTime accessTokenExpireTime = LocalDateTime.now().plusMinutes(jwtConfig.getAccess().getExpireMin()); - LocalDateTime refreshTokenExpireTime = LocalDateTime.now().plusMinutes(jwtConfig.getRefresh().getExpireMin()); + // 4. 응답 작성 + ApiResponse result = ApiResponse.ok(tokenResponse); - Date issuedDate = Date.from(issuedTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()); - Date accessTokenExpireDate = Date.from(accessTokenExpireTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()); - Date refreshTokenExpireDate = Date.from(refreshTokenExpireTime.atZone(ZoneId.of("Asia/Seoul")).toInstant()); - SecretKey key = Keys.hmacShaKeyFor(jwtConfig.getSecret().getBytes(StandardCharsets.UTF_8)); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.setStatus(HttpServletResponse.SC_OK); - String accessToken = Jwts.builder() - .subject(authentication.getName()) - .claims( - Map.of( - "username", - authentication.getName(), - "authorities", - authorities - ) - ) - .expiration(accessTokenExpireDate) - .issuedAt(issuedDate) - .signWith(key) - .compact(); + ObjectMapper objectMapper = new ObjectMapper(); + response.getWriter().write(objectMapper.writeValueAsString(result)); + response.getWriter().flush(); - String refreshToken = Jwts.builder() - .subject(authentication.getName()) - .claims(Map.of("username", authentication.getName(), "authorities", authorities)) - .expiration(refreshTokenExpireDate) - .issuedAt(issuedDate) - .signWith(key) - .compact(); - return new LoginResponse.TokenResponse(accessToken, refreshToken); + } catch (AuthenticationException e) { + // 인증 실패 시 처리 + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + } } - private static String getResponseBody(ApiResponse tokenResponse) throws JsonProcessingException { - ObjectMapper objectMapper = new ObjectMapper(); - return objectMapper.writeValueAsString(tokenResponse); - } @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { - //로그인만 + // /login 경로만 필터 적용 (나머지는 통과 -> true 반환) return !request.getRequestURI().equals("/login"); } -} +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1011400..cc5e073 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,10 +14,10 @@ spring: format_sql: true dialect: org.hibernate.dialect.MySQL8Dialect hibernate: - ddl-auto: update # 스키마 변경을 위해 update로 임시 변경 (운영 환경에서는 validate 또는 none 권장) + ddl-auto: update open-in-view: false - # [수정] OAuth2 설정 (네이버 추가) + # OAuth2 설정 security: oauth2: client: @@ -80,7 +80,45 @@ logging: RestTemplate: DEBUG token: jwt: - secret: Kf9uX!7zQa2$rB3cP#dLmV8@eYtNwZxGjHsTrC1o +# secret: Kf9uX!7zQa2$rB3cP#dLmV8@eYtNwZxGjHsTrC1o + + # [추가] RSA 키 설정 + private-key: | + -----BEGIN PRIVATE KEY----- + MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQCV878Khf0TOGySkApf0AMfTB8R + Bk/isCqm2qrzRbZho4cCde76hTVSs/Rv1xwopLMwJUPB2Zet5xkHR0Us+ehrMwRtU5/oxLCuEZ/G + 6k9GaYmwhiCZDCjz++eNqr8ZgV+cfILLrIovjUb4ZlWqB4p2Gv7DWNTDgv0WzSXYn8BirBPFk6fP + GtTl6s8ZITkN3cDBa39uvG7ADuxF9VLeZtuxjJgTGNSsRuy3xW0Xc+KzflPxh9IMow5SCIOenfQQ + B0ZTVSm7n0nf5OgtGsioizNFmqZqytZMvb+KE87vo9JMgwCwkxJyMDCNRcDjtg47LpWTPLyvds9N + genkLU0q5xbDAgMBAAECggEAFTRcMg1DfdnPRKR4yxa7skvN4tbtKgW2alTmsrMLeOAqgdcSfbuj + kDfhW4VkNn0f17GVVM7Dy7Qvzl8uMY9/ZdVXjWwzYzOZNmxIl1Tf8/mNnnhBGNNm4SWgl2BrWJx6 + XEMhLdMO0W2deRfjikE5u7zShOZFZAZcasKE1Q62Il2ylaPgY1mLfCSSUXozJULMzEL2SSRhYT2+ + rLAdhH2+dmX5cvgTHEBX4f+zRoVEtf141YAOY5qv6eu4aDJnjcSz2yD8DAnH52qDx1oGq5mQRScb + Y2wvnQOCr+KU47EN/4bVpukOsB/xe5G9ybbJigIaHjererENXD2dbfYyRHXR8QKBgQDK8f/Buf0r + cPjyZjzRhcMw71H0Ur8wT8OeLM5Zup3j2e5QjebJpAIUDTRznySa1Vez7JsDNTjNfoVYek3sEeCj + 0tBS0NGEviHUCfukAyv8OIH/woj+XEcCUn8sqr9VXVpSXRr40hud82Vd18/NGIpLtZwyRVFmzasg + teWhfQPJkwKBgQC9JzVtB8oXhGJ73RKZ528Ezd3ATySpcspnGWujq4Tu1UWj2mY1LTdudWcah3I0 + QwbLQCqeUQZoa+EiYzPZMaXPPih2QyJo59oOU37iougweIbzPgsJpGKxWxG7lBObu2k7YNVFas4T + IepngkcKNjkNC0DHV4buZVI67pAl7B78EQKBgQDF2Fnu8HRhL0dieEz+LZr2T7jjqO9+F6SqxR99 + 1jIqeMCdg1jkZqEoDx99QD4dO7K+UwFjhTUVECzK7qCcbWlEDDbPJYe8EudDoV/Sqszsm+IQBgQr + hKYtG2OjlenlPJbbCK1MuPf3adr+O2/3j97yo9/cGjubLxGPWAS/A/L3RQKBgQCaxc9gdIQ3M/rF + sUH8LrPXsX+mUNwFzsixDcrWtIzkRBxkk1sYXfRCbMw9l+CpxMJ1Yv68Zj4hCUzBL30IVih/aDQB + eLNaNYRmPonPdk8ZAjYiKH0tmZWr24GqA+L7haD4liZMU7VlUFYV9jKct3t9Id0Sf5sHzF45nGTU + st0zkQKBgQCcPDI4dRJAeFyUiaggdqZLl8rdJWVxUEov8q/UZm4+nyy/uz4SV0owfXCQrncvmsRo + OLddwAnDT1aBWo9qxkvNEtLd+jwmI3oZqwM8UtjH2SHok7D5ZQ+PwO+A2jNhOvo3T+OqpQ9v0X9Q + +5dLqqZlucrBJ12EccMRWkoZ1DIoig== + -----END PRIVATE KEY----- + + public-key: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAlfO/CoX9EzhskpAKX9ADH0wfEQZP4rAq + ptqq80W2YaOHAnXu+oU1UrP0b9ccKKSzMCVDwdmXrecZB0dFLPnoazMEbVOf6MSwrhGfxupPRmmJ + sIYgmQwo8/vnjaq/GYFfnHyCy6yKL41G+GZVqgeKdhr+w1jUw4L9Fs0l2J/AYqwTxZOnzxrU5erP + GSE5Dd3AwWt/brxuwA7sRfVS3mbbsYyYExjUrEbst8VtF3Pis35T8YfSDKMOUgiDnp30EAdGU1Up + u59J3+ToLRrIqIszRZqmasrWTL2/ihPO76PSTIMAsJMScjAwjUXA47YOOy6Vkzy8r3bPTYHp5C1N + KucWwwIDAQAB + -----END PUBLIC KEY----- + header: Authorization prefix: Bearer access: @@ -90,4 +128,5 @@ token: springdoc: swagger-ui: - use-root-path: true \ No newline at end of file + use-root-path: true + From a582fb177b0de2efd3d084d775102a9dc753dd2e Mon Sep 17 00:00:00 2001 From: TueBack Date: Sat, 20 Dec 2025 05:50:05 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20Auth=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20JWT=20=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/config/CustomUserDetails.java | 12 ++-- .../auth/application/config/JwtConfig.java | 1 - .../auth/application/config/JwtProvider.java | 28 +++++--- .../config/OAuth2LoginSuccessHandler.java | 3 - .../application/config/SecurityConfig.java | 31 +++------ ...sernamePasswordAuthenticationProvider.java | 3 +- .../auth/application/in/AuthService.java | 60 ++++++++++++++++ .../auth/application/in/OAuthAttributes.java | 8 +-- .../repository/RefreshTokenRepository.java | 7 ++ .../auth/domain/entity/RefreshToken.java | 26 +++++++ .../infra/adapter/in/rest/AuthController.java | 62 +++++++++++++++++ .../adapter/in/rest/common/ApiResponse.java | 33 +++++---- .../auth/application/in/AuthServiceTest.java | 69 +++++++++++++++++++ .../in/rest/in/AuthControllerTest.java | 69 +++++++++++++++++++ 14 files changed, 354 insertions(+), 58 deletions(-) create mode 100644 src/main/java/com/retrip/auth/application/in/AuthService.java create mode 100644 src/main/java/com/retrip/auth/application/out/repository/RefreshTokenRepository.java create mode 100644 src/main/java/com/retrip/auth/domain/entity/RefreshToken.java create mode 100644 src/main/java/com/retrip/auth/infra/adapter/in/rest/AuthController.java create mode 100644 src/test/java/com/retrip/auth/application/in/AuthServiceTest.java create mode 100644 src/test/java/com/retrip/auth/infra/adapter/in/rest/in/AuthControllerTest.java diff --git a/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java b/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java index 7ddb15a..83952d3 100644 --- a/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java +++ b/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java @@ -12,17 +12,17 @@ import java.util.stream.Collectors; @Getter -public class CustomUserDetails implements UserDetails, OAuth2User { // [수정] OAuth2User 인터페이스 추가 +public class CustomUserDetails implements UserDetails, OAuth2User { private final Member member; - private Map attributes; // [신규] OAuth2 제공자로부터 받은 원본 데이터 + private Map attributes; + - // 일반 로그인용 생성자 public CustomUserDetails(Member member) { this.member = member; } - // [신규] OAuth2 로그인용 생성자 + public CustomUserDetails(Member member, Map attributes) { this.member = member; this.attributes = attributes; @@ -37,7 +37,7 @@ public Collection getAuthorities() { @Override public String getPassword() { - // 소셜 로그인 회원은 비밀번호가 null일 수 있음 + return member.getPassword().getValue(); } @@ -46,7 +46,7 @@ public String getUsername() { return member.getEmail().getValue(); } - // [신규] OAuth2User 인터페이스 메서드 구현 + @Override public Map getAttributes() { return attributes; diff --git a/src/main/java/com/retrip/auth/application/config/JwtConfig.java b/src/main/java/com/retrip/auth/application/config/JwtConfig.java index 51bdd4d..6960294 100644 --- a/src/main/java/com/retrip/auth/application/config/JwtConfig.java +++ b/src/main/java/com/retrip/auth/application/config/JwtConfig.java @@ -9,7 +9,6 @@ @ConfigurationProperties("token.jwt") public class JwtConfig { - // 기존 secret 삭제 -> RSA 키 쌍으로 변경 private final String privateKey; private final String publicKey; diff --git a/src/main/java/com/retrip/auth/application/config/JwtProvider.java b/src/main/java/com/retrip/auth/application/config/JwtProvider.java index 4cf68c8..566ded7 100644 --- a/src/main/java/com/retrip/auth/application/config/JwtProvider.java +++ b/src/main/java/com/retrip/auth/application/config/JwtProvider.java @@ -38,9 +38,8 @@ public LoginResponse.TokenResponse generateTokens(Authentication authentication) Instant now = Instant.now(); String authorities = String.join(",", getAuthorities(authentication)); - // [수정] Principal에서 ID(UUID)와 Email 추출 로직 추가 - String memberId = authentication.getName(); // 기본값 - String email = authentication.getName(); // 기본값 + String memberId = authentication.getName(); + String email = authentication.getName(); Object principal = authentication.getPrincipal(); if (principal instanceof CustomUserDetails userDetails) { @@ -67,17 +66,16 @@ public LoginResponse.TokenResponse generateTokens(Authentication authentication) return new LoginResponse.TokenResponse(accessToken, refreshToken); } - // [수정] 파라미터에 username(email) 추가 private String createToken(String subject, String username, String authorities, Instant issuedAt, long expirationMinutes) { try { PrivateKey privateKey = getPrivateKey(jwtConfig.getPrivateKey()); Instant expiration = issuedAt.plus(expirationMinutes, ChronoUnit.MINUTES); return Jwts.builder() - .subject(subject) // 여기에는 UUID가 들어감 + .subject(subject) .claims( Map.of( - "username", username, // 여기에는 이메일이 들어감 + "username", username, "authorities", authorities ) ) @@ -135,7 +133,6 @@ public Authentication getAuthentication(String token) { .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); - // UserContext나 CustomUserDetails 대신 표준 토큰 객체 사용 return new UsernamePasswordAuthenticationToken(username, null, authorities); } catch (Exception e) { @@ -143,8 +140,23 @@ public Authentication getAuthentication(String token) { } } - // --- Key Parsing Helpers --- + public Claims parseClaims(String token) { + try { + PublicKey publicKey = getPublicKey(jwtConfig.getPublicKey()); + return Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (ExpiredJwtException e) { + // 만료된 토큰이어도 정보를 꺼내기 위해 Claims 반환 + return e.getClaims(); + } catch (Exception e) { + throw new RuntimeException("토큰 파싱 실패", e); + } + } +//키 파싱 헬퍼 private PrivateKey getPrivateKey(String key) throws Exception { String sanitizedKey = key .replace("-----BEGIN PRIVATE KEY-----", "") diff --git a/src/main/java/com/retrip/auth/application/config/OAuth2LoginSuccessHandler.java b/src/main/java/com/retrip/auth/application/config/OAuth2LoginSuccessHandler.java index 8e3e48d..b895dec 100644 --- a/src/main/java/com/retrip/auth/application/config/OAuth2LoginSuccessHandler.java +++ b/src/main/java/com/retrip/auth/application/config/OAuth2LoginSuccessHandler.java @@ -21,17 +21,14 @@ public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { private final JwtProvider jwtProvider; - // 프론트엔드에서 토큰을 받을 콜백 URL (프론트엔드와 협의 필요) private static final String FRONTEND_CALLBACK_URL = "http://localhost:3000/auth/callback"; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { log.info("OAuth2 로그인 성공. JWT 발급을 시작합니다."); - // 1. JwtProvider를 사용하여 토큰 생성 LoginResponse.TokenResponse tokenResponse = jwtProvider.generateTokens(authentication); - // 2. 프론트엔드로 토큰을 담아 리다이렉트 String targetUrl = UriComponentsBuilder.fromUriString(FRONTEND_CALLBACK_URL) .queryParam("accessToken", tokenResponse.accessToken()) .queryParam("refreshToken", tokenResponse.refreshToken()) diff --git a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java index 6bf1a62..d96fea1 100644 --- a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java +++ b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java @@ -26,14 +26,13 @@ import java.util.List; @Configuration -@EnableWebSecurity // [권장] Spring Security 활성화 어노테이션 추가 +@EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; - // [중요] 필터를 new로 생성하지 않고 주입받습니다. (JwtProvider 등의 의존성 해결을 위해) private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean @@ -41,9 +40,7 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } - /** - * AuthenticationManager는 Spring Security 6.x 버전부터 빈으로 직접 등록해주어야 컨트롤러나 필터에서 쓸 수 있습니다. - */ + @Bean public AuthenticationManager authenticationManager( HttpSecurity http, @@ -60,18 +57,13 @@ public AuthenticationManager authenticationManager( return authenticationManagerBuilder.build(); } - /** - * 로컬 로그인 필터는 AuthenticationManager가 필요하므로 @Bean으로 수동 등록하거나, - * SecurityFilterChain 내부에서 생성할 수 있습니다. 여기서는 명시적으로 Bean 등록합니다. - */ + @Bean public LoginAuthenticationFilter loginAuthenticationFilter( JwtConfig jwtConfig, AuthenticationManager authenticationManager, JwtProvider jwtProvider) { LoginAuthenticationFilter filter = new LoginAuthenticationFilter(jwtConfig, authenticationManager,jwtProvider); - // 로그인 API URL 변경이 필요하면 여기서 설정 (기본값: /login) - // filter.setFilterProcessesUrl("/auth/login"); return filter; } @@ -84,13 +76,13 @@ public SecurityFilterChain securityFilterChain( .csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) - // [중요] 세션 관리: Stateless (JWT 필수 설정) + // 세션 관리: Stateless (JWT 필수 설정) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 필터 배치 - .addFilterAt(loginAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // 기본 로그인 필터 위치 교체 - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // JWT 필터를 그 앞에 배치 + .addFilterAt(loginAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) // OAuth2 설정 .oauth2Login(oauth2 -> oauth2 @@ -102,10 +94,10 @@ public SecurityFilterChain securityFilterChain( // URL 권한 설정 .authorizeHttpRequests(auth -> auth - // [보안 수정] /users 전체 허용은 위험함 (DELETE는 막아야 함) - .requestMatchers(HttpMethod.POST, "/users").permitAll() // 회원가입만 허용 - .requestMatchers("/login/**", "/oauth2/**", "/auth/reissue", "/").permitAll() // 재발급 등 허용 + .requestMatchers(HttpMethod.POST, "/users").permitAll() + + .requestMatchers("/login/**", "/oauth2/**", "/auth/reissue", "/auth/logout","/").permitAll() .requestMatchers( "/swagger-ui/**", "/v3/api-docs/**", @@ -122,12 +114,11 @@ public SecurityFilterChain securityFilterChain( @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - // 프론트엔드 도메인 허용 (개발용: localhost:3000, 배포용 도메인 추가 필요) config.setAllowedOrigins(List.of("http://localhost:3000")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("*")); - config.setAllowCredentials(true); // 쿠키(Refresh Token) 전송 허용 - config.setExposedHeaders(List.of("Authorization")); // 헤더 노출 허용 (필요시) + config.setAllowCredentials(true); + config.setExposedHeaders(List.of("Authorization")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); diff --git a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java index 7129cff..420b715 100644 --- a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java @@ -30,9 +30,8 @@ public Authentication authenticate(Authentication authentication) throw new BadCredentialsException("Bad credentials"); } - // principal에 username(String) 대신 user(UserDetails) 객체를 넘겨줍니다. return new UsernamePasswordAuthenticationToken( - user, // <--- 수정된 부분 (기존: username) + user, password, user.getAuthorities().stream().toList() ); diff --git a/src/main/java/com/retrip/auth/application/in/AuthService.java b/src/main/java/com/retrip/auth/application/in/AuthService.java new file mode 100644 index 0000000..a85f3f4 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/AuthService.java @@ -0,0 +1,60 @@ +package com.retrip.auth.application.in; + +import com.retrip.auth.application.config.JwtProvider; +import com.retrip.auth.application.in.response.LoginResponse; +import com.retrip.auth.application.out.repository.RefreshTokenRepository; +import com.retrip.auth.domain.entity.RefreshToken; +import com.retrip.auth.domain.exception.common.ErrorCode; +import com.retrip.auth.domain.exception.common.InvalidValueException; +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtProvider jwtProvider; + private final RefreshTokenRepository refreshTokenRepository; + + @Transactional + public LoginResponse.TokenResponse reissue(String token) { + if (!jwtProvider.validateToken(token)) { + throw new InvalidValueException(ErrorCode.INVALID_INPUT_VALUE, "유효하지 않거나 만료된 토큰입니다."); + } + + RefreshToken savedToken = refreshTokenRepository.findById(token) + .orElseThrow(() -> new InvalidValueException(ErrorCode.ENTITY_NOT_FOUND, "로그아웃 되었거나 존재하지 않는 토큰입니다.")); + + Claims claims = jwtProvider.parseClaims(token); + String memberId = claims.getSubject(); + String authoritiesStr = (String) claims.get("authorities"); + + Authentication auth = new UsernamePasswordAuthenticationToken( + memberId, + null, + Arrays.stream(authoritiesStr.split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()) + ); + + LoginResponse.TokenResponse newTokens = jwtProvider.generateTokens(auth); + + refreshTokenRepository.delete(savedToken); + refreshTokenRepository.save(new RefreshToken(newTokens.refreshToken(), memberId, authoritiesStr)); + + return newTokens; + } + + @Transactional + public void logout(String token) { + refreshTokenRepository.deleteById(token); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java b/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java index ebc549f..01fd95f 100644 --- a/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java +++ b/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java @@ -20,7 +20,6 @@ public static OAuthAttributes of(String registrationId, String userNameAttribute if ("kakao".equals(registrationId)) { return ofKakao(userNameAttributeName, attributes); } - // [4주차 신규 추가] 네이버 분기 if ("naver".equals(registrationId)) { return ofNaver(userNameAttributeName, attributes); } @@ -52,18 +51,17 @@ private static OAuthAttributes ofKakao(String userNameAttributeName, Map attributes) { - // 네이버 응답은 'response' 키 안에 실제 사용자 정보가 중첩되어 있습니다. + // 네이버 응답은 'response' 키 안에 실제 사용자 정보가 중첩되어 있음 Map response = (Map) attributes.get(userNameAttributeName); return OAuthAttributes.builder() .name((String) response.get("name")) // 네이버는 'name' 필드를 제공 (WBS 스코프에 nickname이 아닌 name으로 되어있어 name 사용) .email((String) response.get("email")) .provider("naver") - .providerId((String) response.get("id")) // 네이버 고유 ID + .providerId((String) response.get("id")) .attributes(attributes) - .nameAttributeKey(userNameAttributeName) // "response" + .nameAttributeKey(userNameAttributeName) .build(); } diff --git a/src/main/java/com/retrip/auth/application/out/repository/RefreshTokenRepository.java b/src/main/java/com/retrip/auth/application/out/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..fa05fd9 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/out/repository/RefreshTokenRepository.java @@ -0,0 +1,7 @@ +package com.retrip.auth.application.out.repository; + +import com.retrip.auth.domain.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RefreshTokenRepository extends JpaRepository { +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/domain/entity/RefreshToken.java b/src/main/java/com/retrip/auth/domain/entity/RefreshToken.java new file mode 100644 index 0000000..b0b888c --- /dev/null +++ b/src/main/java/com/retrip/auth/domain/entity/RefreshToken.java @@ -0,0 +1,26 @@ +package com.retrip.auth.domain.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "refresh_token") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class RefreshToken { + + @Id + private String tokenValue; + + private String memberId; + + private String authorities; + + +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/AuthController.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/AuthController.java new file mode 100644 index 0000000..32aa191 --- /dev/null +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/AuthController.java @@ -0,0 +1,62 @@ +package com.retrip.auth.infra.adapter.in.rest.in; + +import com.retrip.auth.application.in.AuthService; +import com.retrip.auth.application.in.response.LoginResponse; +import com.retrip.auth.infra.adapter.in.rest.common.ApiResponse; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/reissue") + public ApiResponse reissue( + @CookieValue(value = "refreshToken", required = false) String refreshToken, + HttpServletResponse response + ) { + if (refreshToken == null) { + throw new IllegalArgumentException("Refresh Token이 쿠키에 없습니다."); + } + + LoginResponse.TokenResponse tokenResponse = authService.reissue(refreshToken); + + ResponseCookie cookie = ResponseCookie.from("refreshToken", tokenResponse.refreshToken()) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(14 * 24 * 60 * 60) // 14일 + .sameSite("None") + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + + return ApiResponse.success(tokenResponse); + } + + @PostMapping("/logout") + public ApiResponse logout( + @CookieValue(value = "refreshToken", required = false) String refreshToken, + HttpServletResponse response + ) { + if (refreshToken != null) { + authService.logout(refreshToken); + } + + // 쿠키 삭제 (만료시간 0) + ResponseCookie cookie = ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(0) + .sameSite("None") + .build(); + response.addHeader("Set-Cookie", cookie.toString()); + + return ApiResponse.success(null); + } +} \ No newline at end of file diff --git a/src/main/java/com/retrip/auth/infra/adapter/in/rest/common/ApiResponse.java b/src/main/java/com/retrip/auth/infra/adapter/in/rest/common/ApiResponse.java index 098cb94..a475713 100644 --- a/src/main/java/com/retrip/auth/infra/adapter/in/rest/common/ApiResponse.java +++ b/src/main/java/com/retrip/auth/infra/adapter/in/rest/common/ApiResponse.java @@ -1,7 +1,5 @@ package com.retrip.auth.infra.adapter.in.rest.common; -import static org.springframework.http.HttpStatus.*; - import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; @@ -10,32 +8,41 @@ @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) public class ApiResponse { - private boolean success; - private int status; - private String message; - private T data; + private final boolean success; + private final int status; + private final String message; + private final T data; - public static ApiResponse created(T data) { - return success(data, CREATED); + public static ApiResponse ok(T data) { + return success(data, HttpStatus.OK); } - public static ApiResponse ok(T data) { - return success(data, OK); + public static ApiResponse created(T data) { + return success(data, HttpStatus.CREATED); } public static ApiResponse noContent() { - return success(null, NO_CONTENT); + return success(null, HttpStatus.NO_CONTENT); } public static ApiResponse of(T data, HttpStatus status) { return success(data, status); } + public static ApiResponse success(T data) { + return ok(data); + } + private static ApiResponse success(T data, HttpStatus status) { return new ApiResponse<>(true, status.value(), status.getReasonPhrase(), data); } public static ApiResponse of(ErrorResponse errorResponse) { - return new ApiResponse<>(false, errorResponse.getStatus(), valueOf(errorResponse.getStatus()).getReasonPhrase(), errorResponse); + return new ApiResponse<>( + false, + errorResponse.getStatus(), + HttpStatus.valueOf(errorResponse.getStatus()).getReasonPhrase(), + errorResponse + ); } -} +} \ No newline at end of file diff --git a/src/test/java/com/retrip/auth/application/in/AuthServiceTest.java b/src/test/java/com/retrip/auth/application/in/AuthServiceTest.java new file mode 100644 index 0000000..a6f6aab --- /dev/null +++ b/src/test/java/com/retrip/auth/application/in/AuthServiceTest.java @@ -0,0 +1,69 @@ +package com.retrip.auth.application.in; + +import com.retrip.auth.application.config.JwtProvider; +import com.retrip.auth.application.in.response.LoginResponse; +import com.retrip.auth.application.out.repository.RefreshTokenRepository; +import com.retrip.auth.domain.entity.RefreshToken; +import io.jsonwebtoken.Claims; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @InjectMocks + private AuthService authService; + + @Mock + private JwtProvider jwtProvider; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + + @Test + @DisplayName("토큰 재발급(reissue) 성공 테스트") + void reissue_Success() { + // given + String oldRefreshToken = "old-refresh-token"; + String memberId = "test-uuid"; + String authorities = "ROLE_USER"; + + given(jwtProvider.validateToken(oldRefreshToken)).willReturn(true); + + RefreshToken savedToken = new RefreshToken(oldRefreshToken, memberId, authorities); + given(refreshTokenRepository.findById(oldRefreshToken)).willReturn(Optional.of(savedToken)); + + Claims claims = mock(Claims.class); + given(claims.getSubject()).willReturn(memberId); + given(claims.get("authorities")).willReturn(authorities); + + given(jwtProvider.parseClaims(oldRefreshToken)).willReturn(claims); + + LoginResponse.TokenResponse newTokens = new LoginResponse.TokenResponse("new-access", "new-refresh"); + given(jwtProvider.generateTokens(any(Authentication.class))).willReturn(newTokens); + + // when + LoginResponse.TokenResponse result = authService.reissue(oldRefreshToken); + + // then + assertNotNull(result); + assertEquals("new-access", result.accessToken()); + assertEquals("new-refresh", result.refreshToken()); + + verify(refreshTokenRepository).delete(savedToken); + verify(refreshTokenRepository).save(any(RefreshToken.class)); + } +} \ No newline at end of file diff --git a/src/test/java/com/retrip/auth/infra/adapter/in/rest/in/AuthControllerTest.java b/src/test/java/com/retrip/auth/infra/adapter/in/rest/in/AuthControllerTest.java new file mode 100644 index 0000000..f703796 --- /dev/null +++ b/src/test/java/com/retrip/auth/infra/adapter/in/rest/in/AuthControllerTest.java @@ -0,0 +1,69 @@ +package com.retrip.auth.infra.adapter.in.rest.in; + +import com.retrip.auth.application.config.JwtProvider; +import com.retrip.auth.application.in.AuthService; +import com.retrip.auth.application.in.response.LoginResponse; +import jakarta.servlet.http.Cookie; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(controllers = AuthController.class, + excludeAutoConfiguration = { + SecurityAutoConfiguration.class, + UserDetailsServiceAutoConfiguration.class, + OAuth2ClientAutoConfiguration.class + } +) +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AuthService authService; + + @MockBean + private JwtProvider jwtProvider; + + @Test + @DisplayName("토큰 재발급 API 성공 - 쿠키 설정 확인") + void reissue_Api_Success() throws Exception { + // given + String oldRefreshToken = "old-cookie-token"; + Cookie cookie = new Cookie("refreshToken", oldRefreshToken); + + LoginResponse.TokenResponse newTokens = new LoginResponse.TokenResponse("new-access", "new-refresh"); + + given(authService.reissue(anyString())).willReturn(newTokens); + + // when + ResultActions result = mockMvc.perform(post("/auth/reissue") + .cookie(cookie) + .contentType(MediaType.APPLICATION_JSON) + .with(csrf())); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.data.accessToken").value("new-access")) + .andExpect(cookie().value("refreshToken", "new-refresh")) + .andExpect(cookie().httpOnly("refreshToken", true)) + .andDo(print()); + } +} \ No newline at end of file