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/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/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/CustomUserDetails.java b/src/main/java/com/retrip/auth/application/config/CustomUserDetails.java index b026d5c..83952d3 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 { private final Member member; + private Map attributes; + public CustomUserDetails(Member member) { this.member = member; } + + 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() { + return member.getPassword().getValue(); } @@ -34,4 +45,15 @@ public String getPassword() { public String getUsername() { return member.getEmail().getValue(); } -} + + + @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/JwtConfig.java b/src/main/java/com/retrip/auth/application/config/JwtConfig.java index 30cc89f..6960294 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,14 @@ import lombok.RequiredArgsConstructor; import org.springframework.boot.context.properties.ConfigurationProperties; - @Getter @RequiredArgsConstructor @ConfigurationProperties("token.jwt") public class JwtConfig { - private final String secret; + + private final String privateKey; + private final String publicKey; + private final String header; private final String prefix; private final AccessConfig access; @@ -26,4 +28,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 new file mode 100644 index 0000000..566ded7 --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/JwtProvider.java @@ -0,0 +1,185 @@ +package com.retrip.auth.application.config; + +import com.retrip.auth.application.in.response.LoginResponse; +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 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.*; +import java.util.stream.Collectors; +import com.retrip.auth.application.config.CustomUserDetails; + +/** + * JWT 토큰의 생성(Sign) 및 검증(Verify)을 담당하는 클래스 (RSA 방식) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtProvider { + + private final JwtConfig jwtConfig; + + /** + * [생성] 인증 정보를 기반으로 RSA 서명된 Access/Refresh Token 생성 + */ + public LoginResponse.TokenResponse generateTokens(Authentication authentication) { + Instant now = Instant.now(); + String authorities = String.join(",", getAuthorities(authentication)); + + 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( + memberId, // sub (UUID) + email, // claim: username (Email) + authorities, + now, + jwtConfig.getAccess().getExpireMin() + ); + + String refreshToken = createToken( + memberId, // sub (UUID) + email, // claim: username (Email) + authorities, + now, + jwtConfig.getRefresh().getExpireMin() + ); + + return new LoginResponse.TokenResponse(accessToken, refreshToken); + } + + 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) + .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()); + + return new UsernamePasswordAuthenticationToken(username, null, authorities); + + } catch (Exception e) { + throw new RuntimeException("인증 정보 추출 실패", e); + } + } + + 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-----", "") + .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) { + 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..b895dec --- /dev/null +++ b/src/main/java/com/retrip/auth/application/config/OAuth2LoginSuccessHandler.java @@ -0,0 +1,41 @@ +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; + + 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 발급을 시작합니다."); + + LoginResponse.TokenResponse tokenResponse = jwtProvider.generateTokens(authentication); + + 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 62c2968..d96fea1 100644 --- a/src/main/java/com/retrip/auth/application/config/SecurityConfig.java +++ b/src/main/java/com/retrip/auth/application/config/SecurityConfig.java @@ -1,11 +1,9 @@ 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; @@ -14,79 +12,116 @@ 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; +import java.util.List; + @Configuration +@EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); //암호 인코더 정의 + return new BCryptPasswordEncoder(); } + @Bean - public LoginAuthenticationFilter loginAuthenticationFilter(JwtConfig jwtConfig, AuthenticationManager authenticationManager) { - return new LoginAuthenticationFilter(jwtConfig, authenticationManager); + public AuthenticationManager authenticationManager( + HttpSecurity http, + UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider, + MemberQueryService memberQueryService) throws Exception { + + AuthenticationManagerBuilder authenticationManagerBuilder = + http.getSharedObject(AuthenticationManagerBuilder.class); + + authenticationManagerBuilder + .authenticationProvider(usernamePasswordAuthenticationProvider) + .userDetailsService(memberQueryService); + + return authenticationManagerBuilder.build(); } + @Bean - public JwtAuthenticationFilter jwtAuthenticationFilter(JwtConfig jwtConfig) { - return new JwtAuthenticationFilter(jwtConfig); + public LoginAuthenticationFilter loginAuthenticationFilter( + JwtConfig jwtConfig, + AuthenticationManager authenticationManager, + JwtProvider jwtProvider) { + LoginAuthenticationFilter filter = new LoginAuthenticationFilter(jwtConfig, authenticationManager,jwtProvider); + return filter; } @Bean - public AuthenticationManager authenticationManager( + public SecurityFilterChain securityFilterChain( HttpSecurity http, - UsernamePasswordAuthenticationProvider usernamePasswordAuthenticationProvider, - MemberQueryService memberQueryService) throws Exception { - return http.authenticationProvider(usernamePasswordAuthenticationProvider) - .userDetailsService(memberQueryService) - .getSharedObject(AuthenticationManagerBuilder.class) - .build(); + LoginAuthenticationFilter loginAuthenticationFilter) throws Exception { + + http + .csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) + + // 세션 관리: Stateless (JWT 필수 설정) + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // 필터 배치 + .addFilterAt(loginAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + + // OAuth2 설정 + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + ) + .successHandler(oAuth2LoginSuccessHandler) + ) + + // URL 권한 설정 + .authorizeHttpRequests(auth -> auth + + .requestMatchers(HttpMethod.POST, "/users").permitAll() + + .requestMatchers("/login/**", "/oauth2/**", "/auth/reissue", "/auth/logout","/").permitAll() + .requestMatchers( + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**" + ).permitAll() + + .anyRequest().authenticated() + ); + + return http.build(); } @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.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); + config.setExposedHeaders(List.of("Authorization")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); - return source; } - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http, LoginAuthenticationFilter loginAuthenticationFilter, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { - http.csrf(AbstractHttpConfigurer::disable) - .cors(Customizer.withDefaults()) - .addFilterAt(loginAuthenticationFilter, BasicAuthenticationFilter.class) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); - http.authorizeHttpRequests(auth -> { - auth - .requestMatchers("users").permitAll() - .requestMatchers( - "/swagger-ui/**", - "/v3/api-docs/**", - "/swagger-resources/**", - "/webjars/**" - ).permitAll() - .anyRequest().authenticated(); - } - ); - - return http.build(); - } -} +} \ 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..420b715 100644 --- a/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java +++ b/src/main/java/com/retrip/auth/application/config/UsernamePasswordAuthenticationProvider.java @@ -31,8 +31,8 @@ public Authentication authenticate(Authentication authentication) } return new UsernamePasswordAuthenticationToken( - username, - password, + 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/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..01fd95f --- /dev/null +++ b/src/main/java/com/retrip/auth/application/in/OAuthAttributes.java @@ -0,0 +1,72 @@ +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); + } + if ("naver".equals(registrationId)) { + return ofNaver(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(); + } + + private static OAuthAttributes ofNaver(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")) + .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/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/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 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/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 398c679..cc5e073 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,10 +14,59 @@ spring: format_sql: true dialect: org.hibernate.dialect.MySQL8Dialect hibernate: - ddl-auto: create-drop + ddl-auto: update open-in-view: false - #logging + # OAuth2 설정 + security: + oauth2: + client: + registration: + google: + client-id: your-google-client-id + client-secret: your-google-client-secret + scope: + - profile + - email + redirect-uri: "http://localhost:8080/login/oauth2/code/google" + kakao: + client-id: your-kakao-rest-api-key + client-secret: your-kakao-client-secret + client-authentication-method: client_secret_post + authorization-grant-type: authorization_code + scope: + - profile_nickname + - 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 + token-uri: https://kauth.kakao.com/oauth/token + 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: org: @@ -31,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: @@ -42,3 +129,4 @@ token: springdoc: swagger-ui: use-root-path: true + 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