diff --git a/build.gradle b/build.gradle index 0f66f0e..fbbe5b2 100644 --- a/build.gradle +++ b/build.gradle @@ -25,10 +25,11 @@ repositories { ext { mapstructVersion = "1.6.3" - springDocVersion = "2.5.0" + springDocVersion = "2.8.9" jjwtVersion = "0.12.6" awsSdkVersion = "2.25.68" springCloudAwsVersion = "3.1.1" + queryDslVersion = "5.1.0" } dependencies { @@ -80,6 +81,15 @@ dependencies { implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:${springCloudAwsVersion}") implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + // queryDsl + implementation "com.querydsl:querydsl-core:${queryDslVersion}" + implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" + annotationProcessor ( + "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta", + // JPA 메타모델 생성용 + "jakarta.persistence:jakarta.persistence-api:3.1.0" + ) + // db runtimeOnly 'com.h2database:h2' runtimeOnly 'com.mysql:mysql-connector-j' @@ -90,11 +100,25 @@ dependencies { // redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // Reactive 웹 프레임워크 WebFlux + implementation 'org.springframework.boot:spring-boot-starter-webflux' + // test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } +/** Q 클래스 생성 경로 지정 **/ +def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile + +sourceSets { + main { java.srcDirs += querydslDir } +} + +tasks.withType(JavaCompile).configureEach { + options.annotationProcessorGeneratedSourcesDirectory = querydslDir +} + tasks.named('test') { useJUnitPlatform() } diff --git a/docker-compose.yml b/docker-compose.yml index 9ac43df..437b27f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ volumes: - redis-data:/data # 데이터 지속성을 위한 볼륨 추가 command: redis-server --appendonly yes + restart: unless-stopped healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 30s diff --git a/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java b/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java index c3c381b..d0cd466 100644 --- a/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java +++ b/src/main/java/com/teamEWSN/gitdeun/GitdeunApplication.java @@ -2,7 +2,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; + +@EnableJpaAuditing +@EnableScheduling @SpringBootApplication public class GitdeunApplication { diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/CacheConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/CacheConfig.java new file mode 100644 index 0000000..6b19cc8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/CacheConfig.java @@ -0,0 +1,35 @@ +package com.teamEWSN.gitdeun.common.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import com.teamEWSN.gitdeun.common.util.CacheType; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Duration; +import java.util.Arrays; + +@Configuration +@EnableCaching +public class CacheConfig { + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); + + // 각 캐시 타입에 대한 설정 등록 + Arrays.stream(CacheType.values()) + .forEach(cacheType -> { + cacheManager.registerCustomCache(cacheType.getCacheName(), + Caffeine.newBuilder() + .recordStats() // 캐시 통계 기록 + .expireAfterWrite(Duration.ofHours(cacheType.getExpiredAfterWrite())) // 항목 만료 시간 + .maximumSize(cacheType.getMaximumSize()) // 최대 크기 + .build() + ); + }); + + return cacheManager; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/QuerydslConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/QuerydslConfig.java new file mode 100644 index 0000000..15c6b32 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.teamEWSN.gitdeun.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java new file mode 100644 index 0000000..ef3bd56 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + @Bean + RestTemplate restTemplate() { + return new RestTemplate(); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java new file mode 100644 index 0000000..56436f8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java @@ -0,0 +1,114 @@ +package com.teamEWSN.gitdeun.common.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.jwt.*; +import com.teamEWSN.gitdeun.common.jwt.CustomAccessDeniedHandler; +import com.teamEWSN.gitdeun.common.oauth.handler.CustomOAuth2FailureHandler; +import com.teamEWSN.gitdeun.common.oauth.handler.CustomOAuth2SuccessHandler; +import com.teamEWSN.gitdeun.common.oauth.service.CustomOAuth2UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; + + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableMethodSecurity(prePostEnabled = true) +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final CustomOAuth2SuccessHandler customOAuth2SuccessHandler; + private final CustomOAuth2FailureHandler customOAuthFailureHandler; + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomAccessDeniedHandler customAccessDeniedHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .headers((headerConfig) -> headerConfig + .frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)); + + // oauth2 로그인 설정 + http + .oauth2Login((oauth2) -> oauth2 + .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig + .userService(customOAuth2UserService)) +// .defaultSuccessUrl("/oauth/success") // 로그인 성공시 이동할 URL + .successHandler(customOAuth2SuccessHandler) +// .failureUrl("/oauth/fail") // 로그인 실패시 이동할 URL + .failureHandler(customOAuthFailureHandler)) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/oauth/logout") // 로그아웃 성공시 해당 url로 이동 + .clearAuthentication(true) // 현재 요청의 SecurityContext 초기화 + .deleteCookies("refreshToken") // JWT RefreshToken 쿠키를 프론트에서 제거 명시 + ); + + // 경로별 인가 작업 + http + .authorizeHttpRequests((auth) -> auth + .requestMatchers(SecurityPath.ADMIN_ENDPOINTS).hasRole("ADMIN") + .requestMatchers(SecurityPath.USER_ENDPOINTS).hasAnyRole("USER", "ADMIN") + .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() + .anyRequest().permitAll() + // .anyRequest().authenticated() + ); + + // 예외 처리 + http + .exceptionHandling(exceptionHandling -> exceptionHandling + .authenticationEntryPoint(customAuthenticationEntryPoint) // 인증 실패 처리 + .accessDeniedHandler(customAccessDeniedHandler)); // 인가 실패 처리 + + // JwtFilter 추가 + http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider, objectMapper), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + // CORS 설정을 위한 Bean 등록 + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = getCorsConfiguration(); + configuration.setAllowedHeaders(java.util.List.of("Authorization", "Content-Type")); + configuration.setExposedHeaders(java.util.List.of("Authorization")); + configuration.setAllowCredentials(true); // 인증 정보 허용 (쿠키 등) + + org.springframework.web.cors.UrlBasedCorsConfigurationSource source = new org.springframework.web.cors.UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); // 모든 경로에 대해 적용 + return source; + } + + + private static CorsConfiguration getCorsConfiguration() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.addAllowedOrigin("http://localhost:3000"); // 개발 환경 + configuration.addAllowedOrigin("https://gitdeun.netlify.app"); + configuration.addAllowedOrigin("https://gitdeun.site"); // 혜택온 도메인 + configuration.addAllowedOrigin("https://www.gitdeun.site"); + configuration.addAllowedMethod("*"); // 모든 HTTP 메서드 허용 + configuration.addAllowedHeader("*"); // 모든 헤더 허용 + return configuration; + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java new file mode 100644 index 0000000..aa72761 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java @@ -0,0 +1,27 @@ +package com.teamEWSN.gitdeun.common.config; + + +public class SecurityPath { + + // permitAll + public static final String[] PUBLIC_ENDPOINTS = { + "/api/signup", + "/api/login", + "/api/token/refresh", + "/api/users/check-duplicate", + "/" + }; + + // hasRole("USER") + public static final String[] USER_ENDPOINTS = { + "/api/users/me", + "/api/users/me/**", + "/api/logout" + }; + + // hasRole("ADMIN") + public static final String[] ADMIN_ENDPOINTS = { + "/api/admin/**" + }; +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java new file mode 100644 index 0000000..f9e78f8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/WebClientConfig.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient() { + return WebClient.builder().build(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/redis/RedisConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/redis/RedisConfig.java new file mode 100644 index 0000000..1fed0d4 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/redis/RedisConfig.java @@ -0,0 +1,49 @@ +package com.teamEWSN.gitdeun.common.config.redis; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Value("${spring.data.redis.password}") + private String redisPassword; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration redisConfig = new RedisStandaloneConfiguration(); + redisConfig.setHostName(redisHost); + redisConfig.setPort(redisPort); + + if (!redisPassword.isEmpty()) { + redisConfig.setPassword(RedisPassword.of(redisPassword)); + } + + return new LettuceConnectionFactory(redisConfig); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + // Redis에 저장되는 데이터의 직렬화 방식을 지정 + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + return redisTemplate; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/converter/CryptoConverter.java b/src/main/java/com/teamEWSN/gitdeun/common/converter/CryptoConverter.java new file mode 100644 index 0000000..b928d0b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/converter/CryptoConverter.java @@ -0,0 +1,59 @@ +package com.teamEWSN.gitdeun.common.converter; + +import jakarta.annotation.PostConstruct; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.security.Key; +import java.util.Base64; + +@Converter +@Component +public class CryptoConverter implements AttributeConverter { + + private static final String ALGORITHM = "AES"; + private static String staticSecretKey; + private Key key; + + @Value("${db.crypto-key}") + public void setStaticSecretKey(String secretKey) { + CryptoConverter.staticSecretKey = secretKey; + } + + @PostConstruct + public void init() { + if (staticSecretKey == null || staticSecretKey.length() < 16) { + throw new IllegalArgumentException("암호화 키는 16자 이상이어야 합니다."); + } + key = new SecretKeySpec(staticSecretKey.substring(0, 16).getBytes(), ALGORITHM); + } + + @Override + public String convertToDatabaseColumn(String attribute) { + if (attribute == null) return null; + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, key); + return Base64.getEncoder().encodeToString(cipher.doFinal(attribute.getBytes())); + } catch (Exception e) { + throw new RuntimeException("Failed to encrypt attribute", e); + } + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (dbData == null) return null; + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, key); + return new String(cipher.doFinal(Base64.getDecoder().decode(dbData))); + } catch (Exception e) { + throw new RuntimeException("Failed to decrypt attribute", e); + } + } +} + diff --git a/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieProperties.java b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieProperties.java new file mode 100644 index 0000000..53eff89 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieProperties.java @@ -0,0 +1,15 @@ +package com.teamEWSN.gitdeun.common.cookie; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.cookie") +@Getter +@Setter +public class CookieProperties { + private boolean secure; + private String sameSite; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieUtil.java b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieUtil.java new file mode 100644 index 0000000..09490ce --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/cookie/CookieUtil.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.cookie; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; +import org.springframework.web.util.WebUtils; + +import java.util.Optional; + +@Component +public class CookieUtil { + + private final CookieProperties cookieProperties; + + public CookieUtil(CookieProperties cookieProperties) { + this.cookieProperties = cookieProperties; + } + // 쿠키 설정 + public void setCookie(HttpServletResponse response, String name, String value, Long maxAge) { + ResponseCookie cookie = ResponseCookie.from(name, value) + .httpOnly(true) + .secure(cookieProperties.isSecure()) + .path("/") + .sameSite(cookieProperties.getSameSite()) + .maxAge(maxAge) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + // 쿠키 삭제 + public void deleteCookie(HttpServletResponse response, String name) { + ResponseCookie cookie = ResponseCookie.from(name, null) + .httpOnly(true) + .secure(cookieProperties.isSecure()) + .path("/") + .sameSite(cookieProperties.getSameSite()) + .maxAge(0) // 즉시 만료 + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + } + + // 특정 쿠키 값 가져오기 + public Optional getCookieValue(HttpServletRequest request, String name) { + Cookie cookie = WebUtils.getCookie(request, name); + return cookie != null ? Optional.of(cookie.getValue()) : Optional.empty(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index 713dae7..1f432bd 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -8,20 +8,30 @@ @AllArgsConstructor public enum ErrorCode { // 인증 관련 - INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "AUTH-001", "비밀번호가 일치하지 않습니다."), - REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH-002", "리프레시 토큰이 만료되었습니다."), - NO_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-003", "토큰이 존재하지 않습니다."), - INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-004", "유효하지 않은 토큰입니다."), + INVALID_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-001", "유효하지 않은 액세스 토큰입니다."), + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-002", "리프레시 토큰이 유효하지 않습니다."), + TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH-003", "요청에 토큰이 존재하지 않습니다."), + FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN, "AUTH-004", "접근 권한이 없습니다."), ACCESS_DENIED(HttpStatus.UNAUTHORIZED, "AUTH-005", "인증되지 않은 유저입니다."), - DELETE_USER_DENIED(HttpStatus.FORBIDDEN, "AUTH-006", "회원 탈퇴가 거부되었습니다."), - ROLE_NOT_FOUND(HttpStatus.FORBIDDEN, "AUTH-007", "권한 정보가 없습니다."), - + INVALID_SECRET_KEY(HttpStatus.UNAUTHORIZED, "AUTH-006", "유효하지 않은 비밀 키입니다."), + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH-007", "유효하지 않은 사용자 정보 또는 비밀번호입니다."), + ROLE_NOT_FOUND(HttpStatus.FORBIDDEN, "AUTH-008", "권한 정보가 없습니다."), + NO_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH-009", "토큰이 존재하지 않습니다."), // 계정 관련 - DUPLICATED_REAL_ID(HttpStatus.CONFLICT, "ACCOUNT-001", "이미 존재하는 아이디입니다."), - USER_NOT_FOUND_BY_REAL_ID(HttpStatus.NOT_FOUND, "ACCOUNT-002", "해당 아이디의 회원을 찾을 수 없습니다."), - FORBIDDEN_ACCESS(HttpStatus.FORBIDDEN, "AUTH-011", "잘못된 접근입니다."), - + USER_NOT_FOUND_BY_ID(HttpStatus.NOT_FOUND, "ACCOUNT-001", "해당 아이디의 회원을 찾을 수 없습니다."), + USER_NOT_FOUND_BY_EMAIL(HttpStatus.NOT_FOUND, "ACCOUNT-002", "해당 이메일의 회원을 찾을 수 없습니다."), + ACCOUNT_ALREADY_LINKED(HttpStatus.CONFLICT, "ACCOUNT-003", "이미 다른 사용자와 연동된 소셜 계정입니다."), + SOCIAL_CONNECTION_NOT_FOUND(HttpStatus.NOT_FOUND, "ACCOUNT-004", "연동된 소셜 계정 정보를 찾을 수 없습니다."), + + // 소셜 로그인 관련 + OAUTH_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-001", "소셜 로그인 처리 중 오류가 발생했습니다."), + UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "OAUTH-002", "지원하지 않는 소셜 로그인 제공자입니다."), + EMAIL_NOT_PROVIDED(HttpStatus.BAD_REQUEST, "OAUTH-003", "소셜 플랫폼에서 이메일 정보를 제공하지 않습니다."), + OAUTH_COMMUNICATION_FAILED(HttpStatus.BAD_GATEWAY, "OAUTH-004", "소셜 플랫폼과의 통신에 실패했습니다."), + SOCIAL_TOKEN_REFRESH_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-005", "소셜 플랫폼의 토큰 갱신에 실패했습니다."), + SOCIAL_ACCOUNT_CONNECT_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "OAUTH-006", "소셜 계정 연동에 실패했습니다."), + GITHUB_TOKEN_REFRESH_NOT_SUPPORTED(HttpStatus.BAD_REQUEST, "OAUTH-007", "GitHub 토큰 갱신은 지원하지 않습니다. 재인증이 필요합니다."), // S3 파일 관련 // Client Errors (4xx) diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/OAuthException.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/OAuthException.java new file mode 100644 index 0000000..f13039e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/OAuthException.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.common.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class OAuthException extends RuntimeException { + ErrorCode errorCode; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/BlacklistService.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/BlacklistService.java new file mode 100644 index 0000000..3625c39 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/BlacklistService.java @@ -0,0 +1,62 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import io.jsonwebtoken.Claims; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Service +@RequiredArgsConstructor +public class BlacklistService { + + private final RedisTemplate redisTemplate; + private final JwtTokenParser jwtTokenParser; + + private static final String ACCESS_TOKEN_BLACKLIST_PREFIX="blacklist:access:"; + + public void addToBlacklist(String accessToken) { + // Access Token 만료 시간 계산 + Claims claims = jwtTokenParser.parseClaims(accessToken); + Date expiredDate = claims.getExpiration(); + String jti = claims.getId(); + long now = System.currentTimeMillis(); + long timeToLive = (expiredDate.getTime() - now) / 1000; + + if (timeToLive > 0) { + // Redis에 블랙리스트 등록 + String redisKey = ACCESS_TOKEN_BLACKLIST_PREFIX + jti; + redisTemplate.opsForValue().set(redisKey, "blacklisted", timeToLive, TimeUnit.SECONDS); + log.debug("Access Token 블랙리스트 추가 - JTI: {}, 만료 시간: {}초 후", jti, timeToLive); + } + + } + + + public boolean isTokenBlacklisted(String jti) { + String redisKey = ACCESS_TOKEN_BLACKLIST_PREFIX + jti; + try { + Boolean exists = redisTemplate.hasKey(redisKey); + if (Boolean.TRUE.equals(exists)) { + log.warn("블랙리스트에 있는 Access Token으로 접근 시도 중 - JTI: {}", jti); + } + return Boolean.TRUE.equals(exists); + } catch (Exception e) { + log.error("Redis 연결 중 오류 발생 - JTI: {}, 오류: {}", jti, e.getMessage()); + return false; // Redis가 죽었을 때 기본값을 false로 + } + } + + + public void removeFromBlacklist(String jti) { + String redisKey = "blacklist:access:" + jti; + redisTemplate.delete(redisKey); + log.debug("Access Token 블랙리스트에서 삭제 - JTI: {}", jti); + } + + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAccessDeniedHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAccessDeniedHandler.java new file mode 100644 index 0000000..47c9be1 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAccessDeniedHandler.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.exception.ErrorResponse; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +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.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + + +// 권한이 부족한 사용자를 접근 금지 역할(로그인 유무와 상관없이 권한이 없는 경우) +@Slf4j +@RequiredArgsConstructor +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + log.error("No Authorities", accessDeniedException); + log.error("Request Uri : {}", request.getRequestURI()); + + // ErrorCode 정의 + ErrorCode errorCode = ErrorCode.FORBIDDEN_ACCESS; + + // ErrorResponse 생성 + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + // HTTP 응답 설정 + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + + // 응답 본문에 JSON 데이터 작성 + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAuthenticationEntryPoint.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAuthenticationEntryPoint.java new file mode 100644 index 0000000..a7873e2 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomAuthenticationEntryPoint.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + + +// 인증되지 않은 사용자의 출입 금지 역할(JWT 없이 접근시) +@Slf4j +@RequiredArgsConstructor +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException { + log.error("Not Authenticated Request", authException); + log.error("Request Uri : {}", request.getRequestURI()); + + // UNAUTHORIZED ErrorCode 사용 + ErrorCode errorCode = ErrorCode.ACCESS_DENIED; + + // ErrorResponse 생성 + ErrorResponse errorResponse = ErrorResponse.builder() + .code(errorCode.getCode()) + .message(errorCode.getMessage()) + .build(); + + // HTTP 응답 설정 + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setStatus(errorCode.getHttpStatus().value()); + response.setCharacterEncoding("UTF-8"); + + // JSON 응답 반환 + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserDetails.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserDetails.java new file mode 100644 index 0000000..75c3179 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserDetails.java @@ -0,0 +1,79 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import com.teamEWSN.gitdeun.user.entity.Role; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +/** + * 인증된 사용자의 신분증(사용자의 정보) + * JwtAuthenticationFilter 토큰 정보를 바탕으로 객체 생성 + */ +@Getter +public class CustomUserDetails implements UserDetails, CustomUserPrincipal { + private final Long id; + private final String email; + private final String nickname; + private final String profileImage; + private final Role role; + private final String name; + + public CustomUserDetails(Long id, String email, String nickname, String profileImage, Role role, String name) { + this.id = id; + this.email = email; + this.nickname = nickname; + this.profileImage = profileImage; + this.role = role; + this.name = name; + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(role.name())); // 문자열 기반 권한 + } + + @Override + public String getRole() { + return role.name(); + } + + @Override + public Map getAttributes() { + return Collections.emptyMap(); // OAuth2가 아니므로 빈 맵 반환 + } + + @Override + public String getPassword() { + return null; // User 엔티티에 비밀번호가 없으므로 null 반환 + } + + @Override + public String getUsername() { + return email; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserPrincipal.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserPrincipal.java new file mode 100644 index 0000000..241a4fd --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/CustomUserPrincipal.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import java.util.Map; + +public interface CustomUserPrincipal { + Long getId(); + String getEmail(); + String getNickname(); + String getRole(); + String getName(); + String getProfileImage(); + Map getAttributes(); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..ebf6aa6 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,50 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.lang.NonNull; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + @Value("${jwt.secret-key}") + private String secretKey; + private final JwtTokenProvider jwtTokenProvider; + private final ObjectMapper objectMapper; + private static final String BEARER = "Bearer"; + + + @Override + protected void doFilterInternal( + HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + + // access token이 있고, BEARER로 시작한다면 + if (authHeader != null && authHeader.startsWith(BEARER)) { + String token = authHeader.substring(BEARER.length()); + // 토큰 검증 + if (jwtTokenProvider.validateToken(token)) { + // 유효한 토큰: 유저 정보 가져옴 + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java new file mode 100644 index 0000000..66a7849 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtToken.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@AllArgsConstructor +@Getter +public class JwtToken { + private String grantType; + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenParser.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenParser.java new file mode 100644 index 0000000..af0bd43 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenParser.java @@ -0,0 +1,45 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; + +@Slf4j +@Component +public class JwtTokenParser { + + private final SecretKey secretKey; + + public JwtTokenParser(@Value("${jwt.secret-key}") String secretKey) { + byte[] keyBytes = secretKey.getBytes(); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + // Access Token에서 Claims 추출 + public Claims parseClaims(String accessToken) { + try { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(accessToken) + .getPayload(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + // 토큰에서 아이디 정보 추출 + public String getRealIdFromToken(String accessToken) { + Claims claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(accessToken) + .getPayload(); + return claims.getSubject(); + }} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..83e2de5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/JwtTokenProvider.java @@ -0,0 +1,145 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.user.entity.Role; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.UUID; + +@Slf4j +@Getter +@Component +public class JwtTokenProvider { + + private final SecretKey secretKey; + + @Autowired + private RefreshTokenService refreshTokenService; + + @Autowired + private BlacklistService blackListService; + + @Autowired + private JwtTokenParser jwtTokenParser; + + @Value("${jwt.access-expired}") + private Long accessTokenExpired; + + @Value("${jwt.refresh-expired}") + private Long refreshTokenExpired; + + + public JwtTokenProvider(@Value("${jwt.secret-key}") String secretKey) { + byte[] keyBytes = secretKey.getBytes(); + this.secretKey = Keys.hmacShaKeyFor(keyBytes); + } + + // 토큰 생성 - 유저 정보 이용 + public JwtToken generateToken(Authentication authentication) { + + long now = (new Date()).getTime(); + Date accessTokenExpiration = new Date(now + accessTokenExpired * 1000); + + CustomUserPrincipal userPrincipal = (CustomUserPrincipal) authentication.getPrincipal(); + + Long userId = ((CustomUserDetails) userPrincipal).getId(); + + String jti = UUID.randomUUID().toString(); + // Access Token 생성 + String accessToken = Jwts.builder() + .subject(String.valueOf(userId)) // Subject를 불변값인 userId로 설정 + .issuedAt(new Date()) // 발행 시간 + .id(jti) // blacklist 관리를 위한 jwt token id + .claim("email", userPrincipal.getEmail()) // 이메일 + .claim("nickname", userPrincipal.getNickname()) // 닉네임 + .claim("role", userPrincipal.getRole()) // 사용자 역할(Role) + .claim("name",userPrincipal.getName()) + .claim("profileImage", userPrincipal.getProfileImage()) // 프로필 이미지 추가 + .expiration(accessTokenExpiration) // 만료 시간 + .signWith(secretKey) // 서명 + .compact(); + + // Refresh Token 생성 (임의의 값 생성) + String refreshToken = UUID.randomUUID().toString(); + + // Redis에 Refresh Token 정보 저장 + refreshTokenService.saveRefreshToken( refreshToken, userPrincipal.getEmail(), refreshTokenExpired); + + + // JWT Token 객체 반환 + return JwtToken.builder() + .grantType("Bearer") + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + + } + + + // 토큰에서 유저 정보 추출 + public Authentication getAuthentication(String accessToken) { + // 토큰에서 Claims 추출 + Claims claims = jwtTokenParser.parseClaims(accessToken); + + // 권한 정보 확인 + if (claims.get("role") == null) { + throw new GlobalException(ErrorCode.ROLE_NOT_FOUND); + } + + // 클레임에서 모든 사용자 정보 추출 + Long id = Long.parseLong(claims.getSubject()); + String email = claims.get("email", String.class); + String nickname = claims.get("nickname", String.class); + String name = claims.get("name", String.class); + String profileImage = claims.get("profileImage", String.class); + Role role = Role.valueOf(claims.get("role", String.class)); + + CustomUserDetails userDetails = new CustomUserDetails(id, email, nickname, profileImage, role, name); + + Collection authorities = + Collections.singletonList(role::name); + + // Authentication 객체 반환 + return new UsernamePasswordAuthenticationToken(userDetails, null, authorities); + + } + + // 토큰 정보 검증 + public boolean validateToken(String token) { + log.debug("validateToken start"); + try { + Jws claims = Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token); + + String jti = claims.getPayload().getId(); // JTI 추출 + return !blackListService.isTokenBlacklisted(jti); + + } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + log.error("Invalid JWT Token", e); + } catch (ExpiredJwtException e) { + log.error("Expired JWT Token", e); + } catch (UnsupportedJwtException e) { + log.error("Unsupported JWT Token", e); + } catch (IllegalArgumentException e) { + log.error("JWT claims string is empty.", e); + } + return false; + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java new file mode 100644 index 0000000..a2ca41f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshToken.java @@ -0,0 +1,27 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@AllArgsConstructor +@Builder +@NoArgsConstructor +@RedisHash("refreshToken") +public class RefreshToken { + + @Id + private String refreshToken; + + private String email; + private Long issuedAt; + + // Time to live (TTL) 설정, Redis에 만료 시간을 설정 + @TimeToLive + private Long ttl; +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenRepository.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenRepository.java new file mode 100644 index 0000000..82b125f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenRepository.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.common.jwt; + +import org.springframework.data.repository.CrudRepository; + +public interface RefreshTokenRepository extends CrudRepository { +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java new file mode 100644 index 0000000..51e4818 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/jwt/RefreshTokenService.java @@ -0,0 +1,37 @@ +package com.teamEWSN.gitdeun.common.jwt; + + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RefreshTokenService { + private final RefreshTokenRepository refreshTokenRepository; + + public void saveRefreshToken(String refreshToken, String email, long refreshTokenExpired) { + RefreshToken token = RefreshToken.builder() + .refreshToken(refreshToken) + .email(email) + .issuedAt(System.currentTimeMillis()) + .ttl(refreshTokenExpired) // @TimeToLive에 사용될 만료 시간 + .build(); + + refreshTokenRepository.save(token); + } + + + public Optional getRefreshToken(String refreshToken) { + return refreshTokenRepository.findById(refreshToken); + } + + + public void deleteRefreshToken(String refreshToken) { + refreshTokenRepository.deleteById(refreshToken); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java new file mode 100644 index 0000000..f0cf9ad --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/OAuth2UserDto.java @@ -0,0 +1,16 @@ +package com.teamEWSN.gitdeun.common.oauth.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +@ToString +@Getter +@Builder +public class OAuth2UserDto { + private String nickname; + private String name; + private String email; + private String role; + private String profileImage; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java new file mode 100644 index 0000000..eb7dcff --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GitHubResponseDto.java @@ -0,0 +1,42 @@ +package com.teamEWSN.gitdeun.common.oauth.dto.provider; + +import java.util.Map; + +public class GitHubResponseDto implements OAuth2ResponseDto { + + private final Map attributes; + + public GitHubResponseDto(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProvider() { + return "github"; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getEmail() { + return attributes.get("email") != null ? attributes.get("email").toString() : null; + } + + @Override + public String getName() { + return attributes.get("name") != null ? attributes.get("name").toString() : attributes.get("login").toString(); + } + + @Override + public String getNickname() { + return attributes.get("login").toString(); + } + + @Override + public String getProfileImageUrl() { + return attributes.get("avatar_url").toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java new file mode 100644 index 0000000..01da254 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/GoogleResponseDto.java @@ -0,0 +1,42 @@ +package com.teamEWSN.gitdeun.common.oauth.dto.provider; + +import java.util.Map; + +public class GoogleResponseDto implements OAuth2ResponseDto { + private final Map attributes; + + public GoogleResponseDto(Map attributes) { + this.attributes = attributes; + } + + @Override + public String getProvider() { + return "google"; + } + + @Override + public String getProviderId() { + return attributes.get("sub").toString(); + } + + @Override + public String getEmail() { + return attributes.get("email").toString(); + } + + @Override + public String getName() { + return attributes.get("name").toString(); + } + + @Override + public String getNickname() { + // 별도 닉네임이 없으므로 이름(name)을 그대로 반환 + return attributes.get("name").toString(); + } + + @Override + public String getProfileImageUrl() { + return attributes.get("picture").toString(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java new file mode 100644 index 0000000..4204c25 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/dto/provider/OAuth2ResponseDto.java @@ -0,0 +1,20 @@ +package com.teamEWSN.gitdeun.common.oauth.dto.provider; + +// 제공자마다 반환 형태가 달라서 interface 생성. 제공자별 구현체 필요 +public interface OAuth2ResponseDto { + + // 제공자 (Ex. google, github) + String getProvider(); + + // 제공자가 발급해주는 고유 ID + String getProviderId(); + + String getEmail(); + + String getName(); + + String getNickname(); + + String getProfileImageUrl(); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java new file mode 100644 index 0000000..a79f1e1 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/CustomOAuth2User.java @@ -0,0 +1,41 @@ +package com.teamEWSN.gitdeun.common.oauth.entity; + +import com.teamEWSN.gitdeun.user.entity.Role; +import lombok.Getter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +@Getter +public class CustomOAuth2User implements OAuth2User { + + private final Long userId; + private final Role role; + + public CustomOAuth2User(Long userId, Role role) { + this.userId = userId; + this.role = role; + } + + @Override + public Map getAttributes() { + // 인증 성공 후 시스템 내부에서만 사용, 소셜 플랫폼의 attributes 필요 x + return Collections.emptyMap(); + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority(this.role.name())); + } + + @Override + public String getName() { + // Spring Security에서 Principal의 이름을 식별하기 위해 우리 서비스의 고유 ID로 사용 + return String.valueOf(this.userId); + } + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java new file mode 100644 index 0000000..f573228 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/OauthProvider.java @@ -0,0 +1,6 @@ +package com.teamEWSN.gitdeun.common.oauth.entity; + +public enum OauthProvider { + GOOGLE, + GITHUB, +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java new file mode 100644 index 0000000..9b1ce2f --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/entity/SocialConnection.java @@ -0,0 +1,52 @@ +package com.teamEWSN.gitdeun.common.oauth.entity; + +import com.teamEWSN.gitdeun.common.converter.CryptoConverter; +import com.teamEWSN.gitdeun.common.util.BaseEntity; +import com.teamEWSN.gitdeun.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "social_connection") +public class SocialConnection extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "provider", nullable = false) + private OauthProvider provider; + + @Column(name = "provider_id", nullable = false) + private String providerId; + + @Convert(converter = CryptoConverter.class) + @Column(name = "access_token", length = 1024, nullable = false) + private String accessToken; + + @Convert(converter = CryptoConverter.class) + @Column(name = "refresh_token", length = 1024) + private String refreshToken; + + + @Builder + public SocialConnection(User user, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + this.user = user; + this.provider = provider; + this.providerId = providerId; + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public void updateTokens(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken != null ? refreshToken : this.refreshToken; + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2FailureHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2FailureHandler.java new file mode 100644 index 0000000..1d66c20 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2FailureHandler.java @@ -0,0 +1,43 @@ +package com.teamEWSN.gitdeun.common.oauth.handler; + + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.stereotype.Component; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.OAuthException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomOAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) { + + // 예외 유형에 따른 적절한 ErrorCode 설정 + ErrorCode errorCode; + + if (exception instanceof BadCredentialsException) { + // 잘못된 자격 증명 (비밀번호 오류) + errorCode = ErrorCode.INVALID_CREDENTIALS; + + } else if (exception instanceof InsufficientAuthenticationException) { + // 인증에 필요한 비밀 키가 유효하지 않음 + errorCode = ErrorCode.INVALID_SECRET_KEY; + + } else { + // 기본적인 Access Denied 처리 + errorCode = ErrorCode.ACCESS_DENIED; + } + + throw new OAuthException(errorCode); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java new file mode 100644 index 0000000..03dc0aa --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/handler/CustomOAuth2SuccessHandler.java @@ -0,0 +1,47 @@ +package com.teamEWSN.gitdeun.common.oauth.handler; + +import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.common.jwt.JwtToken; +import com.teamEWSN.gitdeun.common.jwt.JwtTokenProvider; +import com.teamEWSN.gitdeun.common.oauth.entity.CustomOAuth2User; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + private final CookieUtil cookieUtil; + + @Value("${app.front-url}") + private String frontUrl; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { + + // 우리 서비스의 JWT 생성 + JwtToken jwtToken = jwtTokenProvider.generateToken(authentication); + log.info("JWT가 발급되었습니다. Access Token: {}", jwtToken.getAccessToken()); + + // Refresh Token은 HttpOnly 쿠키에 저장 + cookieUtil.setCookie(response, "refreshToken", jwtToken.getRefreshToken(), jwtTokenProvider.getRefreshTokenExpired()); + + // Access Token은 쿼리 파라미터로 프론트엔드에 전달 + String targetUrl = UriComponentsBuilder.fromUriString(frontUrl + "/oauth/callback") + .queryParam("accessToken", jwtToken.getAccessToken()) + .build().toUriString(); + + getRedirectStrategy().sendRedirect(request, response, targetUrl); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java new file mode 100644 index 0000000..1546c75 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/repository/SocialConnectionRepository.java @@ -0,0 +1,22 @@ +package com.teamEWSN.gitdeun.common.oauth.repository; + +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SocialConnectionRepository extends JpaRepository { + + /** + * 소셜 플랫폼과 해당 플랫폼에서의 고유 ID로 소셜 연동 정보를 조회 + * @param provider 소셜 플랫폼 (GOOGLE, GITHUB 등) + * @param providerId 해당 소셜 플랫폼에서의 사용자 고유 ID + */ + Optional findByProviderAndProviderId(OauthProvider provider, String providerId); + + // 소셜 로그인한 사용자의 소셜 연동 정보 조회 + Optional findByUserIdAndProvider(Long userId, OauthProvider provider); +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..cf9c64e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -0,0 +1,142 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.GoogleResponseDto; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.OAuth2ResponseDto; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.user.entity.Role; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import com.teamEWSN.gitdeun.common.oauth.entity.CustomOAuth2User; +import jakarta.transaction.Transactional; +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 java.util.UUID; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + private final SocialConnectionRepository socialConnectionRepository; + + @Override + @Transactional + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User; + try { + oAuth2User = super.loadUser(userRequest); + } catch (Exception e) { + // 외부 소셜 플랫폼과의 통신 자체에서 에러가 발생한 경우 + log.error("OAuth2 사용자 정보를 불러오는 데 실패했습니다.", e); + throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); + } + + User user = processUserInTransaction(oAuth2User, userRequest); + return new CustomOAuth2User(user.getId(), user.getRole()); + } + + // @Transactional + public User processUserInTransaction(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { + OAuth2ResponseDto oAuth2ResponseDto = getOAuth2ResponseDto(oAuth2User, userRequest); + + // 이메일 정보가 없는 경우 예외 처리 (GitHub 등) + if (oAuth2ResponseDto.getEmail() == null) { + throw new GlobalException(ErrorCode.EMAIL_NOT_PROVIDED); + } + + OauthProvider provider = OauthProvider.valueOf(oAuth2ResponseDto.getProvider().toUpperCase()); + String providerId = oAuth2ResponseDto.getProviderId(); + String accessToken = userRequest.getAccessToken().getTokenValue(); + String refreshToken = (String) userRequest.getAdditionalParameters().get("refresh_token"); + + return socialConnectionRepository.findByProviderAndProviderId(provider, providerId) + .map(connection -> { + log.info("기존 소셜 계정 정보를 업데이트합니다: {}", provider); + connection.updateTokens(accessToken, refreshToken); + return connection.getUser(); + }) + .orElseGet(() -> { + // 다른 사용자가 이미 해당 이메일을 사용 중인지 확인 + userRepository.findByEmailAndDeletedAtIsNull(oAuth2ResponseDto.getEmail()) + .ifPresent(existingUser -> { + // 이메일은 같지만, 소셜 연동 정보가 없는 경우 -> 계정 연동 + log.info("기존 회원 계정에 소셜 계정을 연동합니다: {}", provider); + connectSocialAccount(existingUser, provider, providerId, accessToken, refreshToken); + }); + // 위에서 연동했거나, 완전 신규 유저인 경우를 처리 + // 다시 이메일로 조회하여 최종 유저를 반환하거나 새로 생성 + return userRepository.findByEmailAndDeletedAtIsNull(oAuth2ResponseDto.getEmail()) + .orElseGet(() -> { + log.info("신규 회원 및 소셜 계정을 생성합니다: {}", provider); + return createNewUser(oAuth2ResponseDto, provider, providerId, accessToken, refreshToken); + }); + }); + } + + private static OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + + OAuth2ResponseDto oAuth2ResponseDto; + if (registrationId.equalsIgnoreCase("google")) { + oAuth2ResponseDto = new GoogleResponseDto(oAuth2User.getAttributes()); + } else if (registrationId.equalsIgnoreCase("github")) { + oAuth2ResponseDto = new GitHubResponseDto(oAuth2User.getAttributes()); + } else { + // 지원하지 않는 소셜 로그인 제공자 + throw new GlobalException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER); + } + return oAuth2ResponseDto; + } + + private User createNewUser(OAuth2ResponseDto response, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + // provider별 다른 Nickname 처리 로직 + String nickname = response.getNickname(); + if (provider == OauthProvider.GOOGLE) { + nickname = nickname + "_" + UUID.randomUUID().toString().substring(0, 6); + } + + User newUser = User.builder() + .email(response.getEmail()) + .name(response.getName()) // GitHub의 경우 full name, Google의 경우 name + .nickname(nickname) + .profileImage(response.getProfileImageUrl()) + .role(Role.ROLE_USER) + .build(); + + connectSocialAccount(newUser, provider, providerId, accessToken, refreshToken); + return userRepository.save(newUser); + } + + private void connectSocialAccount(User user, OauthProvider provider, String providerId, String accessToken, String refreshToken) { + socialConnectionRepository.findByProviderAndProviderId(provider, providerId) + .ifPresent(connection -> { + // 이 소셜 계정이 이미 다른 유저와 연결되어 있다면 예외 발생 + if (!connection.getUser().getId().equals(user.getId())) { + throw new GlobalException(ErrorCode.ACCOUNT_ALREADY_LINKED); + } + }); + + SocialConnection connection = SocialConnection.builder() + .user(user) + .provider(provider) + .providerId(providerId) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + socialConnectionRepository.save(connection); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java new file mode 100644 index 0000000..13f5ce0 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GitHubApiHelper.java @@ -0,0 +1,113 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GitHubApiHelper { + + private final WebClient webClient; + + @Value("${spring.security.oauth2.client.registration.github.client-id}") + private String clientId; + + @Value("${spring.security.oauth2.client.registration.github.client-secret}") + private String clientSecret; + + @Value("${spring.security.oauth2.client.registration.github.redirect-uri}") + private String redirectUri; + + /** + * 인가 코드로 GitHub Access Token을 요청합니다. + * @param code GitHub에서 받은 인가 코드 + * @return Access Token 문자열 + */ + public String getAccessToken(String code) { + String tokenUri = "https://github.com/login/oauth/access_token"; + + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", clientId); + formData.add("client_secret", clientSecret); + formData.add("code", code); + formData.add("redirect_uri", redirectUri); + + Map response = webClient.post() + .uri(tokenUri) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(formData) + .retrieve() + // 단순 map이 아닌 정확한 타입 정보를 런타임에도 잃어버리지 않도록 ParameterizedTypeReference를 사용 + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + + if (response == null || response.get("access_token") == null) { + log.error("GitHub Access Token 발급 실패: {}", response); + throw new GlobalException(ErrorCode.OAUTH_PROCESSING_ERROR); + } + + return (String) response.get("access_token"); + } + + /** + * Access Token으로 GitHub 사용자 정보를 조회합니다. + * @param accessToken GitHub Access Token + * @return GitHub 사용자 정보를 담은 DTO + */ + public GitHubResponseDto getUserInfo(String accessToken) { + String userInfoUri = "https://api.github.com/user"; + + Map attributes = webClient.get() + .uri(userInfoUri) + .header(HttpHeaders.AUTHORIZATION, "token " + accessToken) + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + + if (attributes == null) { + throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); + } + + return new GitHubResponseDto(attributes); + } + + + /** + * GitHub OAuth 토큰을 해지합니다. + * @param accessToken 해지할 Access Token + * @return Mono + */ + public Mono revokeToken(String accessToken) { + String revokeUrl = "https://api.github.com/applications/" + clientId + "/token"; + + String credentials = Base64.getEncoder().encodeToString( + (clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8) + ); + + return webClient.post() + .uri(revokeUrl) + .header(HttpHeaders.AUTHORIZATION, "Basic " + credentials) + .header(HttpHeaders.ACCEPT, "application/vnd.github.v3+json") + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("access_token", accessToken)) + .retrieve() + .bodyToMono(Void.class); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java new file mode 100644 index 0000000..cdfdb1d --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java @@ -0,0 +1,21 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class GoogleApiHelper { + + private final WebClient webClient; + + public Mono revokeToken(String accessToken) { + String revokeUrl = "https://accounts.google.com/o/oauth2/revoke"; + return webClient.post() + .uri(uriBuilder -> uriBuilder.path(revokeUrl).queryParam("token", accessToken).build()) + .retrieve() + .bodyToMono(Void.class); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java new file mode 100644 index 0000000..d026f47 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/SocialTokenRefreshService.java @@ -0,0 +1,146 @@ +package com.teamEWSN.gitdeun.common.oauth.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; + + +// 레포 및 마인드맵 호출 시 소셜로그인 토큰 갱신 호출 +@Slf4j +@Service +@RequiredArgsConstructor +public class SocialTokenRefreshService { + + private final SocialConnectionRepository socialConnectionRepository; + private final WebClient webClient; + private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 파싱을 위한 ObjectMapper + + @Value("${spring.security.oauth2.client.registration.google.client-id}") + private String googleClientId; + + @Value("${spring.security.oauth2.client.registration.google.client-secret}") + private String googleClientSecret; + + + // oauth 토큰 갱신 + public void refreshSocialToken(Long userId, OauthProvider provider) { + SocialConnection connection = socialConnectionRepository.findByUserIdAndProvider(userId, provider) + .orElseThrow(() -> new GlobalException(ErrorCode.SOCIAL_CONNECTION_NOT_FOUND)); + + switch (provider) { + case GOOGLE -> refreshGoogleToken(connection); + case GITHUB -> { + log.warn("GitHub는 토큰 갱신을 지원하지 않습니다. 재인증이 필요합니다."); + throw new GlobalException(ErrorCode.GITHUB_TOKEN_REFRESH_NOT_SUPPORTED); + } + } + } + + private void refreshGoogleToken(SocialConnection connection) { + if (connection.getRefreshToken() == null) { + throw new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + try { + // Google Token 갱신 API 호출 + String tokenUrl = "https://oauth2.googleapis.com/token"; + + + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("client_id", googleClientId); + formData.add("client_secret", googleClientSecret); + formData.add("refresh_token", connection.getRefreshToken()); + formData.add("grant_type", "refresh_token"); + + + String response = webClient.post() + .uri(tokenUrl) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .bodyValue(BodyInserters.fromFormData(formData)) + .retrieve() + .bodyToMono(String.class) + .block(); // 결과를 동기적으로 기다림 + + JsonNode tokenNode = objectMapper.readTree(response); + + String newAccessToken = tokenNode.get("access_token").asText(); + // 구글은 리프레시 토큰을 갱신하면 기존 리프레시 토큰을 다시 주지 않는 경우가 대부분 + String newRefreshToken = tokenNode.has("refresh_token") ? + tokenNode.get("refresh_token").asText() : connection.getRefreshToken(); + + connection.updateTokens(newAccessToken, newRefreshToken); + socialConnectionRepository.save(connection); + + log.info("Google 토큰 갱신 완료: userId={}", connection.getUser().getId()); + + } catch (Exception e) { + log.error("Google 토큰 갱신 실패: userId={}, error={}", + connection.getUser().getId(), e.getMessage()); + throw new GlobalException(ErrorCode.SOCIAL_TOKEN_REFRESH_FAILED); + } + } + + /** + * 토큰 유효성 검증 + */ + public boolean isTokenValid(String accessToken, OauthProvider provider) { + try { + return switch (provider) { + case GOOGLE -> validateGoogleToken(accessToken); + case GITHUB -> validateGitHubToken(accessToken); + }; + } catch (Exception e) { + log.error("토큰 유효성 검증 실패: provider={}, error={}", provider, e.getMessage()); + return false; + } + } + + private boolean validateGoogleToken(String accessToken) { + String validateUrl = "https://www.googleapis.com/oauth2/v1/tokeninfo"; + + try { + webClient.get() + .uri(uriBuilder -> uriBuilder + .path(validateUrl) + .queryParam("access_token", accessToken) + .build()) + .retrieve() + .bodyToMono(String.class) + .block(); + return true; + } catch (Exception e) { + return false; + } + } + + private boolean validateGitHubToken(String accessToken) { + String validateUrl = "https://api.github.com/user"; + + try { + webClient.get() + .uri(validateUrl) + .header(HttpHeaders.AUTHORIZATION, "token " + accessToken) + .retrieve() + .bodyToMono(String.class) + .block(); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java b/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java new file mode 100644 index 0000000..02dd611 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/AuthenticateUser.java @@ -0,0 +1,26 @@ +package com.teamEWSN.gitdeun.common.util; + +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; + + +/** + * 현재 인증된 사용자의 정보를 가져옴 + * 컨트롤러가 아닌 다른 서비스 계층 등에서 사용자 ID가 필요할 때 편리하게 사용 + */ +@Component +public class AuthenticateUser { + public Long authenticateUserId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + + if (authentication == null || !authentication.isAuthenticated() || authentication.getPrincipal().equals("anonymousUser")) { + return 0L; // 인증되지 않은 사용자일 경우 0 반환 + } + + // 인증된 사용자의 경우 userId 반환 + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + return userDetails.getId(); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java new file mode 100644 index 0000000..3530db8 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/CacheType.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.common.util; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CacheType { + SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200); // 자동완성 캐시 + + private final String cacheName; + private final int expiredAfterWrite; // 시간(hour) 단위 + private final int maximumSize; // 최대 캐시 항목 수 +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java new file mode 100644 index 0000000..5d06c4b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/AuthController.java @@ -0,0 +1,67 @@ +package com.teamEWSN.gitdeun.user.controller; + +import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.common.jwt.JwtToken; +import com.teamEWSN.gitdeun.user.dto.UserTokenResponseDto; +import com.teamEWSN.gitdeun.user.service.AuthService; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/oauth") +@RequiredArgsConstructor +public class AuthController { + + @Value("${jwt.refresh-expired}") + private Long refreshTokenExpired; + + private final AuthService authService; + private final CookieUtil cookieUtil; + + + // 로그아웃 API + @PostMapping("/logout") + public ResponseEntity logout( + @RequestHeader("Authorization") String authHeader, + @CookieValue(name = "refreshToken", required = false) String refreshToken, + HttpServletResponse response + ) { + // 헤더에서 Access Token 추출 + String accessToken = authHeader.replace("Bearer ", ""); + + // 로그아웃 로직 - AccessToken: Blacklist 등록, RefreshToken: redis에서 삭제 및 쿠키 제거 + authService.logout(accessToken, refreshToken, response); + + return ResponseEntity.noContent().build(); // 204 No Content 응답 + } + + // 토큰 재발급 + @PostMapping("/token/refresh") + public ResponseEntity refreshAccessToken( + @CookieValue(name = "refreshToken") String refreshToken, + HttpServletResponse response) { + JwtToken newJwtToken = authService.refreshTokens(refreshToken); + + cookieUtil.setCookie(response, "refreshToken", newJwtToken.getRefreshToken(), refreshTokenExpired); + return ResponseEntity.ok(new UserTokenResponseDto(newJwtToken.getAccessToken())); + } + + @GetMapping("/connect/github/callback") + public ResponseEntity connectGithubAccountCallback( + @RequestParam("code") String code, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + authService.connectGithubAccount(userDetails.getId(), code); + + return ResponseEntity.ok().build(); + } + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java index 4a54934..130ed5c 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/controller/UserController.java @@ -1,5 +1,54 @@ package com.teamEWSN.gitdeun.user.controller; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.common.oauth.service.CustomOAuth2UserService; +import com.teamEWSN.gitdeun.user.dto.UserResponseDto; +import com.teamEWSN.gitdeun.user.service.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/users/me") +@RequiredArgsConstructor public class UserController { - + + private final UserService userService; + private final CustomOAuth2UserService customOAuth2UserService; + + // 개인 정보 조회 + @GetMapping + public ResponseEntity getMyInfo(@AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = userDetails.getId(); + UserResponseDto userInfo = userService.getMyInfo(userId); + + return ResponseEntity.ok(userInfo); + } + + + // 현재 회원 정보를 바탕으로 회원 탈퇴 + @DeleteMapping + public ResponseEntity deleteCurrentUser( + @AuthenticationPrincipal CustomUserDetails userDetails, + @CookieValue(name = "refreshToken", required = false) String refreshToken, + @RequestHeader("Authorization") String authHeader) { + // JwtAuthenticationFilter가 정상 동작했다면 userDetails는 절대 null이 아님 + if (refreshToken == null || refreshToken.isEmpty()) { + // 리프레시 토큰이 없는 경우에 대한 예외 처리 + throw new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN); + } + + String accessToken = authHeader.replace("Bearer ", ""); + + userService.deleteUser(userDetails.getId(), accessToken, refreshToken); + + return ResponseEntity.noContent().build(); + } + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserDto.java deleted file mode 100644 index 0d992c8..0000000 --- a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserDto.java +++ /dev/null @@ -1,5 +0,0 @@ -package com.teamEWSN.gitdeun.user.dto; - -public class UserDto { - -} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java new file mode 100644 index 0000000..c05807e --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserResponseDto.java @@ -0,0 +1,20 @@ +package com.teamEWSN.gitdeun.user.dto; + +import com.teamEWSN.gitdeun.user.entity.Role; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Builder +public class UserResponseDto { + private Long id; // 사용자 ID + private String name; // 사용자 이름 + private String email; // 이메일 + private String nickname; // 닉네임 + private String profileImage; // image url + private Role role; // 권한 (USER/ADMIN 등) + + +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/dto/UserTokenResponseDto.java b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserTokenResponseDto.java new file mode 100644 index 0000000..97f900b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/dto/UserTokenResponseDto.java @@ -0,0 +1,10 @@ +package com.teamEWSN.gitdeun.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@AllArgsConstructor +@Getter +public class UserTokenResponseDto { + private String accessToken; +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java new file mode 100644 index 0000000..be3d139 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/Role.java @@ -0,0 +1,13 @@ +package com.teamEWSN.gitdeun.user.entity; + +import org.springframework.security.core.GrantedAuthority; + +public enum Role implements GrantedAuthority { + ROLE_USER, + ROLE_ADMIN; + + @Override + public String getAuthority() { + return name(); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java index 2025a52..5e33ea3 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/entity/User.java @@ -1,5 +1,64 @@ package com.teamEWSN.gitdeun.user.entity; -public class User { - +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.util.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "users") +public class User extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 100) + private String name; + + @Column(nullable = false, length = 100, unique = true) + private String nickname; + + @Column(nullable = false, length = 256) + private String email; + + @Column(name="profile_image", length = 512) + private String profileImage; // image url + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Role role; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true) + private List socialConnections = new ArrayList<>(); + + @Column(name = "deleted_at", columnDefinition = "DATETIME(0) DEFAULT CURRENT_TIMESTAMP") + private LocalDateTime deletedAt; + + + @Builder + public User(String name, String nickname, String email, String profileImage, Role role) { + this.name = name; + this.nickname = nickname; + this.email = email; + this.profileImage = profileImage; + this.role = role; + } + + // 회원 탈퇴 처리 + public void markAsDeleted() { + this.deletedAt = LocalDateTime.now(); + } + + // 회원 닉네임 변경 + public void updateNickname(String newNickname) { + this.nickname = newNickname; + } + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java new file mode 100644 index 0000000..a9672d7 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/mapper/UserMapper.java @@ -0,0 +1,14 @@ +package com.teamEWSN.gitdeun.user.mapper; + +import com.teamEWSN.gitdeun.user.dto.UserResponseDto; +import com.teamEWSN.gitdeun.user.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface UserMapper { + + + UserResponseDto toResponseDto(User user); + +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java index cdeeab9..3f4a861 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java @@ -1,5 +1,19 @@ package com.teamEWSN.gitdeun.user.repository; -public class UserRepository { - +import com.teamEWSN.gitdeun.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + // user id로 검색 + Optional findByIdAndDeletedAtIsNull(Long id); + + // user email로 검색 + Optional findByEmailAndDeletedAtIsNull(String email); + + } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java new file mode 100644 index 0000000..d17f8f6 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/AuthService.java @@ -0,0 +1,135 @@ +package com.teamEWSN.gitdeun.user.service; + + +import com.teamEWSN.gitdeun.common.jwt.RefreshToken; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.cookie.CookieUtil; +import com.teamEWSN.gitdeun.common.jwt.*; +import com.teamEWSN.gitdeun.common.oauth.dto.provider.GitHubResponseDto; +import com.teamEWSN.gitdeun.common.oauth.entity.OauthProvider; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.common.oauth.repository.SocialConnectionRepository; +import com.teamEWSN.gitdeun.common.oauth.service.GitHubApiHelper; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenService refreshTokenService; + private final BlacklistService blacklistService; + private final UserService userService; + private final CookieUtil cookieUtil; + private final GitHubApiHelper gitHubApiHelper; + private final UserRepository userRepository; + private final SocialConnectionRepository socialConnectionRepository; + + @Value("${jwt.refresh-expired}") + private Long refreshTokenExpired; + + @Value("${spring.security.oauth2.client.registration.github.client-id}") + private String githubClientId; + + @Value("${spring.security.oauth2.client.registration.github.client-secret}") + private String githubClientSecret; + + @Value("${spring.security.oauth2.client.registration.github.redirect-uri}") + private String githubRedirectUri; + + + // 로그 아웃 + @Transactional + public void logout(String accessToken, String refreshToken, HttpServletResponse response) { + blacklistService.addToBlacklist(accessToken); + refreshTokenService.deleteRefreshToken(refreshToken); + cookieUtil.deleteCookie(response, "refreshToken"); + } + + // 토큰 재발급 + @Transactional + public JwtToken refreshTokens(String refreshToken) { + // Redis에서 Refresh Token 조회 + RefreshToken tokenDetails = refreshTokenService.getRefreshToken(refreshToken) + .orElseThrow(() -> new GlobalException(ErrorCode.INVALID_REFRESH_TOKEN)); + + // 토큰에서 email 정보 추출 + String email = tokenDetails.getEmail(); + + // email로 사용자 정보 조회 + User user = userService.findUserByEmail(email); + Authentication authentication = createAuthentication(user); + + // 기존 리프레시 토큰은 DB에서 제거 (순환) + refreshTokenService.deleteRefreshToken(refreshToken); + + // 새로운 Access/Refresh 토큰 생성 + return jwtTokenProvider.generateToken(authentication); + } + + // User 객체를 사용해 authentication 생성 + private Authentication createAuthentication(User user) { + CustomUserDetails customUserDetails = new CustomUserDetails( + user.getId(), user.getEmail(), user.getNickname(), user.getProfileImage(), user.getRole(), user.getName()); + + return new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities()); + } + + + // 구글 -> Github 계정 연동 + @Transactional + public void connectGithubAccount(Long userId, String code) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + // GitHubApiHelper를 통해 Access Token 요청 + String accessToken = gitHubApiHelper.getAccessToken(code); + + // GitHubApiHelper를 통해 사용자 정보 요청 + GitHubResponseDto githubResponse = gitHubApiHelper.getUserInfo(accessToken); + + String providerId = githubResponse.getProviderId(); + OauthProvider provider = OauthProvider.GITHUB; + + // 이미 다른 계정에 연동되어 있는지 확인 + socialConnectionRepository.findByProviderAndProviderId(provider, providerId) + .ifPresent(connection -> { + if (!connection.getUser().getId().equals(userId)) { + throw new GlobalException(ErrorCode.ACCOUNT_ALREADY_LINKED); + } + }); + + // 현재 사용자에 대한 중복 연동 확인 (이미 연동되어 있으면 추가 로직 없음) + boolean alreadyLinked = user.getSocialConnections().stream() + .anyMatch(conn -> conn.getProvider() == provider && conn.getProviderId().equals(providerId)); + + if (alreadyLinked) { + log.info("이미 현재 사용자와 연동된 GitHub 계정입니다: {}", providerId); + return; // 이미 연동되었으므로 여기서 종료 + } + + // 신규 소셜 연동 정보 생성 및 저장 + SocialConnection newConnection = SocialConnection.builder() + .user(user) + .provider(provider) + .providerId(providerId) + .accessToken(accessToken) + .refreshToken(null) + .build(); + + socialConnectionRepository.save(newConnection); + log.info("사용자(ID:{})에게 GitHub 계정(ProviderId:{}) 연동이 완료되었습니다.", userId, providerId); + } +} diff --git a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java index 45bcdea..9e2cd66 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/service/UserService.java @@ -1,5 +1,90 @@ package com.teamEWSN.gitdeun.user.service; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.jwt.BlacklistService; +import com.teamEWSN.gitdeun.common.jwt.RefreshTokenService; +import com.teamEWSN.gitdeun.common.oauth.service.GitHubApiHelper; +import com.teamEWSN.gitdeun.common.oauth.service.GoogleApiHelper; +import com.teamEWSN.gitdeun.common.oauth.entity.SocialConnection; +import com.teamEWSN.gitdeun.user.dto.UserResponseDto; +import com.teamEWSN.gitdeun.user.entity.User; +import com.teamEWSN.gitdeun.user.mapper.UserMapper; +import com.teamEWSN.gitdeun.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor public class UserService { - + private final UserMapper userMapper; + private final RefreshTokenService refreshTokenService; + private final BlacklistService blacklistService; + private final UserRepository userRepository; + private final GoogleApiHelper googleApiHelper; + private final GitHubApiHelper gitHubApiHelper; + + // 회원 정보 조회 + @Transactional(readOnly = true) + public UserResponseDto getMyInfo(Long userId) { + User user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + return userMapper.toResponseDto(user); + } + + + // 로그인된 회원 탈퇴 처리 + @Transactional + public void deleteUser(Long userId, String accessToken, String refreshToken) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_ID)); + + List connections = user.getSocialConnections(); + + // 모든 소셜 연동 해제 시도 + for (SocialConnection connection : connections) { + try { + switch (connection.getProvider()) { + case GOOGLE: + googleApiHelper.revokeToken(connection.getAccessToken()).block(); + log.info("Google token for user {} has been revoked.", userId); + break; + case GITHUB: + gitHubApiHelper.revokeToken(connection.getAccessToken()).block(); + log.info("GitHub token for user {} has been revoked.", userId); + break; + default: + log.warn("Unsupported provider for token revocation: {}", connection.getProvider()); + } + } catch (Exception e) { + // 특정 플랫폼 연동 해제에 실패하더라도, 다른 플랫폼 및 DB 처리는 계속 진행하기 위해 로그만 남김 + log.error("Failed to revoke token for provider {} and user {}: {}", + connection.getProvider(), userId, e.getMessage()); + } + } + + // redis에서 리프레시 토큰 삭제 + refreshTokenService.deleteRefreshToken(refreshToken); + // access token 블랙리스트에 등록 + blacklistService.addToBlacklist(accessToken); + + // 깃든 서비스 DB에서 soft-delete 처리 + user.markAsDeleted(); + userRepository.save(user); + log.info("User {} has been marked as deleted.", userId); + } + + // 이메일로 회원 검색 + @Transactional(readOnly = true) + public User findUserByEmail(String email) { + return userRepository.findByEmailAndDeletedAtIsNull(email) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND_BY_EMAIL)); + + } } \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 551cef6..69e048c 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -4,12 +4,30 @@ spring: data: redis: host: localhost + security: + oauth2: + client: + registration: + google: + redirect-uri: http://localhost:8080/login/oauth2/code/google + github: + client-id: ${GITHUB_DEV_CLIENT_ID} + client-secret: ${GITHUB_DEV_CLIENT_SECRET} + redirect-uri: http://localhost:8080/login/oauth2/code/github +# provider: +# google: +# authorization-uri: https://accounts.google.com/o/oauth2/v2/auth +# token-uri: https://oauth2.googleapis.com/token +# user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo +# user-name-attribute: sub + jwt: access-expired: 28800 # 8시간 refresh-expired: 86400 # 1일 app: + front-url: http://localhost:3000 cookie: secure: false same-site: Lax \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index ccd364f..a8081ce 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -8,6 +8,16 @@ spring: redis: host: gitdeun-redis password: ${REDIS_PASSWORD} + security: + oauth2: + client: + registration: + google: + redirect-uri: https://api.gitdeun.site/login/oauth2/code/google + github: + client-id: ${GITHUB_PROD_CLIENT_ID} + client-secret: ${GITHUB_PROD_CLIENT_SECRET} + redirect-uri: https://api.gitdeun.site/login/oauth2/code/github jwt: @@ -15,6 +25,7 @@ jwt: refresh-expired: 604800 # 7일(초 단위: 7 * 24 * 60 * 60) app: + front-url: https://gitdeun.site cookie: secure: true same-site: None \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5873ef7..c7fd4e8 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,10 +9,10 @@ spring: password: ${MYSQL_PASSWORD} hikari: connection-timeout: 30000 # 30초 연결 제한 -# h2: -# console: -# enabled: true # H2 웹 콘솔 활성화 -# path: /h2-console # H2 콘솔 URL 경로 (기본값: /h2-console) + # h2: + # console: + # enabled: true # H2 웹 콘솔 활성화 + # path: /h2-console # H2 콘솔 URL 경로 (기본값: /h2-console) jpa: show-sql: true # SQL 로그 출력 hibernate: @@ -26,9 +26,22 @@ spring: order_updates: true jdbc: batch_size: 1000 + security: + oauth2: + client: + registration: + google: + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + scope: profile, email + github: + scope: user:email, repo profiles: active: dev, s3Bucket # logback-spring SpringProfile 설정 및 AWS S3 Bucket 설정 +db: + crypto-key: ${CRYPTO_KEY} jwt: + issuer: Gitdeun.site secret-key: ${JWT_SECRET_KEY}