diff --git a/build.gradle b/build.gradle index dd76ce5f6..b6e8719eb 100644 --- a/build.gradle +++ b/build.gradle @@ -26,10 +26,16 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'com.mysql:mysql-connector-j:8.2.0' implementation 'org.hibernate:hibernate-core:6.3.0.CR1' + implementation 'org.springframework.data:spring-data-redis:3.1.2' + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'org.springframework.security:spring-security-core:6.1.2' + implementation 'org.springframework.security:spring-security-config:6.1.2' + implementation 'org.springframework.security:spring-security-web:6.1.2' + implementation 'io.lettuce:lettuce-core:6.2.5.RELEASE' + implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359' compileOnly 'org.projectlombok:lombok:1.18.26' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' - } tasks.named('test') { diff --git a/src/main/java/com/example/solidconnection/auth/controller/AuthController.java b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java new file mode 100644 index 000000000..e564aa7b7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/controller/AuthController.java @@ -0,0 +1,25 @@ +package com.example.solidconnection.auth.controller; + +import com.example.solidconnection.auth.dto.KakaoCodeDto; +import com.example.solidconnection.auth.dto.KakaoOauthResponseDto; +import com.example.solidconnection.auth.service.KakaoOAuthService; +import com.example.solidconnection.custom.response.CustomResponse; +import com.example.solidconnection.custom.response.DataResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("auth") +@RequiredArgsConstructor +public class AuthController { + private final KakaoOAuthService kakaoOAuthService; + + @PostMapping("/kakao") + public CustomResponse signUp(@RequestBody KakaoCodeDto kakaoCodeDto) { + KakaoOauthResponseDto kakaoOauthResponseDto = kakaoOAuthService.processOauth(kakaoCodeDto.getCode()); + return new DataResponse<>(kakaoOauthResponseDto); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/FirstAccessResponseDto.java b/src/main/java/com/example/solidconnection/auth/dto/FirstAccessResponseDto.java new file mode 100644 index 000000000..a28522320 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/FirstAccessResponseDto.java @@ -0,0 +1,26 @@ +package com.example.solidconnection.auth.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class FirstAccessResponseDto extends KakaoOauthResponseDto { + private boolean registered; + private String nickname; + private String email; + private String profileImageUrl; + private String kakaoOauthToken; + + public static FirstAccessResponseDto fromKakaoUserInfo(KakaoUserInfoDto kakaoUserInfoDto, String kakaoOauthToken){ + return FirstAccessResponseDto.builder() + .registered(false) + .email(kakaoUserInfoDto.getKakaoAccount().getEmail()) + .profileImageUrl(kakaoUserInfoDto.getKakaoAccount().getProfile().getProfileImageUrl()) + .nickname(kakaoUserInfoDto.getKakaoAccount().getProfile().getNickname()) + .kakaoOauthToken(kakaoOauthToken) + .build(); + } +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/KakaoAccount.java b/src/main/java/com/example/solidconnection/auth/dto/KakaoAccount.java new file mode 100644 index 000000000..47e07d23d --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/KakaoAccount.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.auth.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +@AllArgsConstructor +@NoArgsConstructor +public class KakaoAccount { + @JsonProperty("profile") + private KakaoProfile profile; + private String email; +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/auth/dto/KakaoCodeDto.java b/src/main/java/com/example/solidconnection/auth/dto/KakaoCodeDto.java new file mode 100644 index 000000000..625c99969 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/KakaoCodeDto.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class KakaoCodeDto { + private String code; +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/KakaoOauthResponseDto.java b/src/main/java/com/example/solidconnection/auth/dto/KakaoOauthResponseDto.java new file mode 100644 index 000000000..f24255e85 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/KakaoOauthResponseDto.java @@ -0,0 +1,4 @@ +package com.example.solidconnection.auth.dto; + +public class KakaoOauthResponseDto { +} diff --git a/src/main/java/com/example/solidconnection/auth/dto/KakaoProfile.java b/src/main/java/com/example/solidconnection/auth/dto/KakaoProfile.java new file mode 100644 index 000000000..323dbb798 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/KakaoProfile.java @@ -0,0 +1,19 @@ +package com.example.solidconnection.auth.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +@AllArgsConstructor +@NoArgsConstructor +public class KakaoProfile { + private String nickname; + @JsonProperty("profile_image_url") + private String profileImageUrl; +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/auth/dto/KakaoTokenDto.java b/src/main/java/com/example/solidconnection/auth/dto/KakaoTokenDto.java new file mode 100644 index 000000000..f51d9decb --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/KakaoTokenDto.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.auth.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +@AllArgsConstructor +@NoArgsConstructor +public class KakaoTokenDto { + @JsonProperty("access_token") + private String accessToken; + @JsonProperty("refresh_token") + private String refreshToken; +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/auth/dto/KakaoUserInfoDto.java b/src/main/java/com/example/solidconnection/auth/dto/KakaoUserInfoDto.java new file mode 100644 index 000000000..c2cf1d982 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/KakaoUserInfoDto.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.auth.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@JsonIgnoreProperties(ignoreUnknown = true) +@AllArgsConstructor +@NoArgsConstructor +public class KakaoUserInfoDto { + @JsonProperty("kakao_account") + private KakaoAccount kakaoAccount; +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/auth/dto/SignInResponseDto.java b/src/main/java/com/example/solidconnection/auth/dto/SignInResponseDto.java new file mode 100644 index 000000000..55c02509e --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/dto/SignInResponseDto.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.auth.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SignInResponseDto extends KakaoOauthResponseDto { + private boolean registered; + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java b/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java new file mode 100644 index 000000000..37f3b56d4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/auth/service/KakaoOAuthService.java @@ -0,0 +1,107 @@ +package com.example.solidconnection.auth.service; + +import com.example.solidconnection.auth.dto.*; +import com.example.solidconnection.config.security.TokenProvider; +import com.example.solidconnection.config.security.TokenType; +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import static com.example.solidconnection.custom.exception.ErrorCode.*; + +@Service +@RequiredArgsConstructor +public class KakaoOAuthService { + + private final RestTemplate restTemplate; + private final TokenProvider tokenProvider; + private final SiteUserRepository siteUserRepository; + + @Value("${kakao.client_id}") + private String clientId; + @Value("${kakao.redirect_uri}") + private String redirectUri; + @Value("${kakao.token_url}") + private String tokenUrl; + @Value("${kakao.user_info_url}") + private String userInfoUrl; + + public KakaoOauthResponseDto processOauth(String code) { + String kakaoAccessToken = getKakaoAccessToken(code); + KakaoUserInfoDto kakaoUserInfoDto = getKakaoUserInfo(kakaoAccessToken); + String email = kakaoUserInfoDto.getKakaoAccount().getEmail(); + boolean isAlreadyRegistered = siteUserRepository.existsByEmail(email); + if (isAlreadyRegistered) { + return kakaoSignIn(email); + } + String kakaoOauthToken = tokenProvider.generateToken(email, TokenType.KAKAO_OAUTH); + return FirstAccessResponseDto.fromKakaoUserInfo(kakaoUserInfoDto, kakaoOauthToken); + } + + private String getKakaoAccessToken(String code) { + // 카카오 엑세스 토큰 요청 + ResponseEntity response = restTemplate.exchange( + buildTokenUri(code), + HttpMethod.POST, + null, + KakaoTokenDto.class + ); + + // 응답 예외처리 + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + return response.getBody().getAccessToken(); + } else { + throw new CustomException(KAKAO_ACCESS_TOKEN_FAIL); + } + } + + // 카카오에게 엑세스 토큰 발급 요청하는 URI 생성 + private String buildTokenUri(String code) { + return UriComponentsBuilder.fromHttpUrl(tokenUrl) + .queryParam("grant_type", "authorization_code") + .queryParam("client_id", clientId) + .queryParam("redirect_uri", redirectUri) + .queryParam("code", code) + .toUriString(); + } + + private KakaoUserInfoDto getKakaoUserInfo(String accessToken) { + // 카카오 엑세스 토큰을 헤더에 담은 HttpEntity + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + HttpEntity entity = new HttpEntity<>(headers); + + // 사용자의 정보 요청 + ResponseEntity response = restTemplate.exchange( + userInfoUrl, + HttpMethod.GET, + entity, + KakaoUserInfoDto.class + ); + + // 응답 예외처리 + if (response.getStatusCode() == HttpStatus.OK && response.getBody() != null) { + return response.getBody(); + } else { + throw new CustomException(KAKAO_USER_INFO_FAIL); + } + } + + private SignInResponseDto kakaoSignIn(String email) { + siteUserRepository.findByEmail(email) + .orElseThrow(() -> new CustomException(EMAIL_NOT_FOUND)); + + var accessToken = tokenProvider.generateToken(email, TokenType.ACCESS); + var refreshToken = tokenProvider.saveToken(email, TokenType.REFRESH); + return SignInResponseDto.builder() + .registered(true) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } +} diff --git a/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java b/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java new file mode 100644 index 000000000..1c02f7196 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java @@ -0,0 +1,39 @@ +package com.example.solidconnection.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.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@EnableRedisRepositories +public class RedisConfig { + + private final String redisHost; + + private final int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + public RedisConfig(@Value("${spring.data.redis.host}") final String redisHost, + @Value("${spring.data.redis.port}") final int redisPort) { + this.redisHost = redisHost; + this.redisPort = redisPort; + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java b/src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java new file mode 100644 index 000000000..4bd0354d2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/rest/RestTemplateConfig.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.config.rest; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { + return restTemplateBuilder + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(5)) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationEntryPoint.java b/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 000000000..06b6ec785 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,26 @@ +package com.example.solidconnection.config.security; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Authentication Failed: " + authException.getMessage()); + } + + public void customCommence(HttpServletResponse response) throws IOException, ServletException { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Authentication Failed: You are logged out." ); + } +} diff --git a/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationFilter.java b/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationFilter.java new file mode 100644 index 000000000..9bc40afe1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/JwtAuthenticationFilter.java @@ -0,0 +1,79 @@ +package com.example.solidconnection.config.security; + +import com.example.solidconnection.custom.exception.CustomException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.HashSet; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + public static final String TOKEN_HEADER = "Authorization"; + public static final String TOKEN_PREFIX = "Bearer "; + private final TokenProvider tokenProvider; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + AntPathMatcher pathMatcher = new AntPathMatcher(); + + for (String endpoint : getPermitAllEndpoints()) { + if (pathMatcher.match(endpoint, request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + } + + try { + String token = this.resolveTokenFromRequest(request); // 웹 요청에서 토큰 추출 + if (StringUtils.hasText(token) && this.tokenProvider.validateAccessToken(token)) { // 토큰이 실제 텍스트를 가지고 있으며, 유효한지 검증 + Authentication auth = this.tokenProvider.getAuthentication(token); // 토큰에서 인증 정보 가져옴. + SecurityContextHolder.getContext().setAuthentication(auth);// 인증 정보를 보안 컨텍스트에 설정 + } else { + throw new AuthenticationException("Invalid token") {}; // 토큰이 없거나 유효하지 않다면 에러 발생 + } + filterChain.doFilter(request, response); // 다음 필터로 요청과 응답 전달 + } catch (AuthenticationException e) { + jwtAuthenticationEntryPoint.commence(request, response, e); + } catch (CustomException e) { + jwtAuthenticationEntryPoint.customCommence(response); + } + } + + private String resolveTokenFromRequest(HttpServletRequest request) { + String token = request.getHeader(TOKEN_HEADER); + + if (!ObjectUtils.isEmpty(token) && token.startsWith(TOKEN_PREFIX)) { // 토큰이 비어 있지 않고, Bearer로 시작한다면 + return token.substring(TOKEN_PREFIX.length()); // Bearer 제외한 실제 토큰 부분 반환 + } + return null; + } + + private HashSet getPermitAllEndpoints() { + var permitAllEndpoints = new HashSet(); + + permitAllEndpoints.add("/img-upload/profile"); + permitAllEndpoints.add("/img-upload/gpa"); + permitAllEndpoints.add("/img-upload/language"); + + permitAllEndpoints.add("/auth/kakao"); + permitAllEndpoints.add("/auth/sign-up"); + + return permitAllEndpoints; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java new file mode 100644 index 000000000..9a07ffc01 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/SecurityConfiguration.java @@ -0,0 +1,59 @@ +package com.example.solidconnection.config.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +@Configuration +@EnableWebSecurity +@EnableGlobalMethodSecurity(prePostEnabled = true) +@RequiredArgsConstructor +public class SecurityConfiguration { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(Arrays.asList("https://www.solid-connect.net", "http://localhost:8080", "https://www.api.solid-connect.net")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("Authorization", "content-type")); + configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(corsConfigurer -> corsConfigurer.configurationSource(corsConfigurationSource())) + .httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement((session) -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(authorizeRequest + -> authorizeRequest + .requestMatchers( + "/img-upload/profile", "/img-upload/gpa", "/img-upload/language", + "/auth/kakao", "/auth/sign-up") + .permitAll() + .anyRequest().authenticated()) + .addFilterBefore(this.jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .formLogin(AbstractHttpConfigurer::disable); + + return http.build(); + } +} diff --git a/src/main/java/com/example/solidconnection/config/security/TokenProvider.java b/src/main/java/com/example/solidconnection/config/security/TokenProvider.java new file mode 100644 index 000000000..beee3b546 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/TokenProvider.java @@ -0,0 +1,97 @@ +package com.example.solidconnection.config.security; + +import com.example.solidconnection.custom.exception.CustomException; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static com.example.solidconnection.custom.exception.ErrorCode.EMAIL_NOT_FOUND; +import static com.example.solidconnection.custom.exception.ErrorCode.REFRESH_TOKEN_EXPIRED; + +@Component +@RequiredArgsConstructor +public class TokenProvider { + private final RedisTemplate redisTemplate; + private final SiteUserRepository siteUserRepository; + + @Value("${jwt.secret}") + private String secretKey; + + public String generateToken(String email, TokenType tokenType) { + Claims claims = Jwts.claims().setSubject(email); + + + var now = new Date(); + var expiredDate = new Date(now.getTime() + tokenType.getExpireTime()); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(expiredDate) + .signWith(SignatureAlgorithm.HS512, this.secretKey) + .compact(); + } + + public String saveToken(String email, TokenType tokenType) { + String token = generateToken(email, tokenType); + + redisTemplate.opsForValue().set( + tokenType.getPrefix() + email, + token, + tokenType.getExpireTime(), + TimeUnit.MILLISECONDS + ); + return token; + } + + public Authentication getAuthentication(String token) { + UserDetails userDetails = (UserDetails) siteUserRepository.findByEmail(this.getUserEmail(token)) + .orElseThrow(() -> new CustomException(EMAIL_NOT_FOUND)); + return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); + } + + public String getUserEmail(String token) { + return this.parseClaims(token).getSubject(); + } + + public boolean validateAccessToken(String token) { + if (!StringUtils.hasText(token)) { + return false; + } + validateToken(token, TokenType.REFRESH); + return isExpired(token); + } + + private void validateToken(String token, TokenType tokenType) { + if (token.equals(redisTemplate.opsForValue().get(tokenType.getPrefix() + getUserEmail(token)))) { + throw new CustomException(REFRESH_TOKEN_EXPIRED); + } + } + + private Claims parseClaims(String token) { + try { + return Jwts.parser().setSigningKey(this.secretKey).parseClaimsJws(token).getBody(); + } catch (ExpiredJwtException e) { + return e.getClaims(); + } + } + + public boolean isExpired(String token) { + Date expiration = Jwts.parser().setSigningKey(this.secretKey).parseClaimsJws(token).getBody().getExpiration(); + long now = new Date().getTime(); + return (expiration.getTime() - now) < 0; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/config/security/TokenType.java b/src/main/java/com/example/solidconnection/config/security/TokenType.java new file mode 100644 index 000000000..5bef0b7b2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/config/security/TokenType.java @@ -0,0 +1,18 @@ +package com.example.solidconnection.config.security; + +import lombok.Getter; + +@Getter +public enum TokenType { + ACCESS("", 1000 * 60 * 60), + REFRESH("refresh:", 1000 * 60 * 60 * 24 * 7), + KAKAO_OAUTH("kakao:", 1000 * 60 * 60); + + private final String prefix; + private final int expireTime; + + TokenType(String prefix, int expireTime){ + this.prefix = prefix; + this.expireTime = expireTime; + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomException.java b/src/main/java/com/example/solidconnection/custom/exception/CustomException.java new file mode 100644 index 000000000..0805b5444 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/exception/CustomException.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.custom.exception; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CustomException extends RuntimeException { + private int code; + private String message; + + public CustomException(ErrorCode errorCode){ + code = errorCode.getCode(); + message = errorCode.getMessage(); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java b/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java new file mode 100644 index 000000000..3a7c4b6ac --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/exception/CustomExceptionHandler.java @@ -0,0 +1,16 @@ +package com.example.solidconnection.custom.exception; + +import com.example.solidconnection.custom.response.ErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@Slf4j +@ControllerAdvice +public class CustomExceptionHandler { + + @ExceptionHandler(CustomException.class) + protected ErrorResponse handleCustomException(CustomException e) { + return new ErrorResponse<>(e); + } +} diff --git a/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java new file mode 100644 index 000000000..d6644712b --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/exception/ErrorCode.java @@ -0,0 +1,20 @@ +package com.example.solidconnection.custom.exception; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorCode { + EMAIL_NOT_FOUND(HttpStatus.NOT_FOUND.value(), "회원 정보를 찾을 수 없습니다."), + KAKAO_ACCESS_TOKEN_FAIL(HttpStatus.BAD_REQUEST.value(),"카카오 엑세스 토큰 발급에 실패했습니다."), + KAKAO_USER_INFO_FAIL(HttpStatus.BAD_REQUEST.value(),"카카오 사용자 정보 조회에 실패했습니다."), + ACCESS_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(),"액세스 토큰이 만료되었습니다. 재발급 api를 호출해주세요."), + REFRESH_TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED.value(),"리프레시 토큰이 만료되었습니다. 다시 로그인을 진행해주세요."), + KAKAO_AUTH_CODE_EXPIRED(HttpStatus.UNAUTHORIZED.value(),"카카오 인증 코드가 만료되었습니다. 카카오 로그인 후 다시 시도해주세요.") + ; + + private final int code; + private final String message; +} diff --git a/src/main/java/com/example/solidconnection/custom/response/CustomResponse.java b/src/main/java/com/example/solidconnection/custom/response/CustomResponse.java new file mode 100644 index 000000000..18c5d35b2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/response/CustomResponse.java @@ -0,0 +1,4 @@ +package com.example.solidconnection.custom.response; + +public class CustomResponse { +} diff --git a/src/main/java/com/example/solidconnection/custom/response/DataResponse.java b/src/main/java/com/example/solidconnection/custom/response/DataResponse.java new file mode 100644 index 000000000..c92e6ec43 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/response/DataResponse.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.custom.response; + +import lombok.Getter; + +@Getter +public class DataResponse extends CustomResponse { + private final Boolean success = true; + private final T data; + + public DataResponse(T data){ + this.data = data; + } +} diff --git a/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java b/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java new file mode 100644 index 000000000..a13000544 --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/response/ErrorResponse.java @@ -0,0 +1,13 @@ +package com.example.solidconnection.custom.response; + +import lombok.Getter; + +@Getter +public class ErrorResponse extends CustomResponse { + private final Boolean success = false; + private final CustomException exception; + + public ErrorResponse(CustomException exception) { + this.exception = exception; + } +} diff --git a/src/main/java/com/example/solidconnection/custom/response/StatusResponse.java b/src/main/java/com/example/solidconnection/custom/response/StatusResponse.java new file mode 100644 index 000000000..b9cd7684f --- /dev/null +++ b/src/main/java/com/example/solidconnection/custom/response/StatusResponse.java @@ -0,0 +1,12 @@ +package com.example.solidconnection.custom.response; + +import lombok.Getter; + +@Getter +public class StatusResponse extends CustomResponse { + private final Boolean status; + + StatusResponse(Boolean status) { + this.status = status; + } +} diff --git a/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java new file mode 100644 index 000000000..df58d5ac2 --- /dev/null +++ b/src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.siteuser.repository; + +import com.example.solidconnection.entity.SiteUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface SiteUserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); + boolean existsByNickname(String nickname); +} \ No newline at end of file diff --git a/src/main/java/com/example/solidconnection/type/PreparationStatus.java b/src/main/java/com/example/solidconnection/type/PreparationStatus.java index d21530cad..c4f1650e9 100644 --- a/src/main/java/com/example/solidconnection/type/PreparationStatus.java +++ b/src/main/java/com/example/solidconnection/type/PreparationStatus.java @@ -3,5 +3,6 @@ public enum PreparationStatus { CONSIDERING, // 교환학생 지원 고민 상태 PREPARING_FOR_DEPARTURE, // 교환학생 합격 후 파견 준비 상태 - STUDYING_ABROAD // 해외 학교에서 공부중인 상태 + STUDYING_ABROAD, // 해외 학교에서 공부중인 상태 + AFTER_EXCHANGE }