diff --git a/.gitignore b/.gitignore index 86419177..1be6683c 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ application.properties ### VS Code ### .vscode/ + +**/src/main/generated/ diff --git a/build.gradle b/build.gradle index 79416189..f0004841 100644 --- a/build.gradle +++ b/build.gradle @@ -53,6 +53,15 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'com.sun.xml.bind:jaxb-impl:4.0.1' + implementation 'com.sun.xml.bind:jaxb-core:4.0.1' + implementation 'javax.xml.bind:jaxb-api:2.4.0-b180830.0359' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index c2c4781d..89cc6449 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,4 +5,10 @@ services: container_name: numberone restart: always ports: - - 8080:8080 \ No newline at end of file + - 8080:8080 + + redis: + hostname: redis + image: redis:latest + ports: + - 6379:6379 \ No newline at end of file diff --git a/src/main/java/com/numberone/backend/LoginTestController.java b/src/main/java/com/numberone/backend/LoginTestController.java new file mode 100644 index 00000000..863d4d30 --- /dev/null +++ b/src/main/java/com/numberone/backend/LoginTestController.java @@ -0,0 +1,14 @@ +package com.numberone.backend; + +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class LoginTestController { + @RequestMapping("/logintest") + public String test(Authentication authentication){ + return "Hello "+authentication.getName(); + } +} diff --git a/src/main/java/com/numberone/backend/config/RedisConfig.java b/src/main/java/com/numberone/backend/config/RedisConfig.java new file mode 100644 index 00000000..5c8c7ea8 --- /dev/null +++ b/src/main/java/com/numberone/backend/config/RedisConfig.java @@ -0,0 +1,28 @@ +package com.numberone.backend.config; + +import com.numberone.backend.properties.RedisProperties; +import lombok.RequiredArgsConstructor; +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.serializer.StringRedisSerializer; + +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + private final RedisProperties redisProperties; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisProperties.getHost(), redisProperties.getPort()); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } +} diff --git a/src/main/java/com/numberone/backend/config/RestTemplateConfig.java b/src/main/java/com/numberone/backend/config/RestTemplateConfig.java new file mode 100644 index 00000000..31aa8ccd --- /dev/null +++ b/src/main/java/com/numberone/backend/config/RestTemplateConfig.java @@ -0,0 +1,15 @@ +package com.numberone.backend.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + + } +} \ No newline at end of file diff --git a/src/main/java/com/numberone/backend/config/SecurityConfig.java b/src/main/java/com/numberone/backend/config/SecurityConfig.java index 4612fec7..d912e7a6 100644 --- a/src/main/java/com/numberone/backend/config/SecurityConfig.java +++ b/src/main/java/com/numberone/backend/config/SecurityConfig.java @@ -1,26 +1,60 @@ package com.numberone.backend.config; +import com.numberone.backend.config.auth.JwtFilter; +import com.numberone.backend.properties.JwtProperties; +import lombok.RequiredArgsConstructor; +import com.numberone.backend.config.auth.JwtFilter; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.Customizer; 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.configuration.WebSecurityCustomizer; import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint; +import org.springframework.security.oauth2.server.resource.web.access.BearerTokenAccessDeniedHandler; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; @Slf4j @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + private final JwtFilter jwtFilter; @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception { + MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector); http + .httpBasic(httpBasic -> httpBasic.disable()) + .csrf(csrf -> csrf.disable()) + .cors(Customizer.withDefaults()) .authorizeHttpRequests((auth) -> auth .anyRequest().authenticated() // 모든 요청에 대한 인증 처리하도록 설정 ) - .httpBasic(HttpBasicConfigurer::disable); + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) + .exceptionHandling(exception -> exception + .authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) + .accessDeniedHandler(new BearerTokenAccessDeniedHandler())); + ; return http.build(); } @@ -32,7 +66,9 @@ public WebSecurityCustomizer webSecurityCustomizer() { "/error", "/swagger-ui/**", "/swagger-resources/**", - "/v3/api-docs/**") - .requestMatchers("/**"); // 인증 처리 하지 않을 케이스 + "/v3/api-docs/**", + "/token/**", + "/shelter/**"); + //.requestMatchers("/**"); // 인증 처리 하지 않을 케이스 } } diff --git a/src/main/java/com/numberone/backend/config/auth/JwtFilter.java b/src/main/java/com/numberone/backend/config/auth/JwtFilter.java new file mode 100644 index 00000000..51c98674 --- /dev/null +++ b/src/main/java/com/numberone/backend/config/auth/JwtFilter.java @@ -0,0 +1,54 @@ +package com.numberone.backend.config.auth; + +import com.numberone.backend.domain.member.entity.Member; +import com.numberone.backend.domain.member.service.MemberService; +import com.numberone.backend.domain.token.util.JwtUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +@RequiredArgsConstructor +@Component +public class JwtFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; + private final MemberService memberService; + + //토큰이 유효하지 않다면 setAuthentication이 진행되지 않아 UsernamePasswordAuthenticationFilter에서 인증이 되지 않음 + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String authorizationHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + + String token = authorizationHeader.split(" ")[1]; + if (jwtUtil.isExpired(token)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().write("Token has expired"); + filterChain.doFilter(request, response); + return; + } + + String email = jwtUtil.getEmail(token); + Member member = memberService.findByEmail(email); + + UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken( + member.getEmail(), null, Collections.emptyList()); + authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authenticationToken); + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/numberone/backend/domain/member/entity/Member.java b/src/main/java/com/numberone/backend/domain/member/entity/Member.java new file mode 100644 index 00000000..9eebf31f --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/member/entity/Member.java @@ -0,0 +1,32 @@ +package com.numberone.backend.domain.member.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class Member { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String email; + + @Builder + public Member(String email) { + this.email = email; + } + + public static Member of(String email){ + return Member.builder() + .email(email) + .build(); + } +} diff --git a/src/main/java/com/numberone/backend/domain/member/repository/MemberRepository.java b/src/main/java/com/numberone/backend/domain/member/repository/MemberRepository.java new file mode 100644 index 00000000..e0850ce1 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/member/repository/MemberRepository.java @@ -0,0 +1,12 @@ +package com.numberone.backend.domain.member.repository; + +import com.numberone.backend.domain.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); +} diff --git a/src/main/java/com/numberone/backend/domain/member/service/MemberService.java b/src/main/java/com/numberone/backend/domain/member/service/MemberService.java new file mode 100644 index 00000000..67851a0d --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/member/service/MemberService.java @@ -0,0 +1,24 @@ +package com.numberone.backend.domain.member.service; + +import com.numberone.backend.domain.member.entity.Member; +import com.numberone.backend.domain.member.repository.MemberRepository; +import com.numberone.backend.exception.notfound.NotFoundMemberException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +@Transactional(readOnly = true) +public class MemberService { + private final MemberRepository memberRepository; + + public Member findByEmail(String email) { + return memberRepository.findByEmail(email) + .orElseThrow(NotFoundMemberException::new); + } + + public void create(String email) { + memberRepository.save(Member.of(email)); + } +} diff --git a/src/main/java/com/numberone/backend/domain/token/controller/TokenController.java b/src/main/java/com/numberone/backend/domain/token/controller/TokenController.java new file mode 100644 index 00000000..bbbc11e8 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/controller/TokenController.java @@ -0,0 +1,57 @@ +package com.numberone.backend.domain.token.controller; + +import com.numberone.backend.domain.token.dto.request.TokenRequest; +import com.numberone.backend.domain.token.dto.response.TokenResponse; +import com.numberone.backend.domain.token.service.TokenService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +@Tag(name = "token", description = "토큰 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/token") +public class TokenController { + private final TokenService tokenService; + + @Operation(summary = "카카오 토큰을 이용하여 서버 JWT 토큰 발급받기", description = + """ + 카카오 토큰을 body 에 담아서 post 요청 해주세요. + + 앞으로 서버 요청 시에 사용할 수 있는 JWT 토큰이 발급됩니다. + + 이후 서버에 API 요청시 이 JWT 토큰을 같이 담아서 요청해야 정상적으로 API가 호출 됩니다. + """) + @PostMapping("/kakao") + public TokenResponse loginKakao(@RequestBody TokenRequest tokenRequest) { + return tokenService.loginKakao(tokenRequest); + } + + @Operation(summary = "네이버 토큰을 이용하여 서버 JWT 토큰 발급받기", description = + """ + 네이버 토큰을 body 에 담아서 post 요청 해주세요. + + 앞으로 서버 요청 시에 사용할 수 있는 JWT 토큰이 발급됩니다. + + 이후 서버에 API 요청시 이 JWT 토큰을 같이 담아서 요청해야 정상적으로 API가 호출 됩니다. + """) + @PostMapping("/naver") + public TokenResponse loginNaver(@RequestBody TokenRequest tokenRequest) { + return tokenService.loginNaver(tokenRequest); + } + + @Operation(summary = "만료된 JWT 토큰 갱신하기", description = + """ + 만료된 JWT 토큰을 body 에 담아서 post 요청 해주세요. + + 새로 사용할 수 있는 JWT 토큰이 발급됩니다. + """) + @PostMapping("/refresh") + public TokenResponse refresh(@RequestBody TokenRequest tokenRequest) { + return tokenService.refresh(tokenRequest); + } +} diff --git a/src/main/java/com/numberone/backend/domain/token/dto/request/TokenRequest.java b/src/main/java/com/numberone/backend/domain/token/dto/request/TokenRequest.java new file mode 100644 index 00000000..a700cc6d --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/dto/request/TokenRequest.java @@ -0,0 +1,10 @@ +package com.numberone.backend.domain.token.dto.request; + +import lombok.*; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TokenRequest { + private String token; +} diff --git a/src/main/java/com/numberone/backend/domain/token/dto/response/KakaoInfoResponse.java b/src/main/java/com/numberone/backend/domain/token/dto/response/KakaoInfoResponse.java new file mode 100644 index 00000000..ddf88a50 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/dto/response/KakaoInfoResponse.java @@ -0,0 +1,49 @@ +package com.numberone.backend.domain.token.dto.response; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class KakaoInfoResponse { + private Long id; + private String connected_at; + private Properties properties; + private KakaoAccount kakao_account; + + @ToString + @Getter + @NoArgsConstructor + public class Properties { + private String nickname; + private String profile_image; + private String thumbnail_image; + } + + @ToString + @Getter + @NoArgsConstructor + public class KakaoAccount { + static class profile { + private String nickname; + private String thumbnail_image_url; + private String profile_image_url; + private String is_default_image; + } + + private Boolean profile_nickname_needs_agreement; + private Boolean profile_image_needs_agreement; + private profile profile; + private Boolean has_email; + private Boolean email_needs_agreement; + private Boolean is_email_valid; + private Boolean is_email_verified; + private String email; + } +} + + + diff --git a/src/main/java/com/numberone/backend/domain/token/dto/response/KakaoTokenResponse.java b/src/main/java/com/numberone/backend/domain/token/dto/response/KakaoTokenResponse.java new file mode 100644 index 00000000..c634c489 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/dto/response/KakaoTokenResponse.java @@ -0,0 +1,19 @@ +package com.numberone.backend.domain.token.dto.response; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class KakaoTokenResponse { + private String token_type; + private String access_token; + private String id_token; + private String refresh_token; + private Integer expires_in; + private Integer refresh_token_expires_in; + private String scope; +} diff --git a/src/main/java/com/numberone/backend/domain/token/dto/response/NaverInfoResponse.java b/src/main/java/com/numberone/backend/domain/token/dto/response/NaverInfoResponse.java new file mode 100644 index 00000000..4cceb11b --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/dto/response/NaverInfoResponse.java @@ -0,0 +1,25 @@ +package com.numberone.backend.domain.token.dto.response; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NaverInfoResponse { + private String resultcode; + private String message; + private Response response; + + @ToString + @Getter + @NoArgsConstructor + public class Response { + private String id; + private String nickname; + private String email; + private String name; + } +} diff --git a/src/main/java/com/numberone/backend/domain/token/dto/response/NaverTokenResponse.java b/src/main/java/com/numberone/backend/domain/token/dto/response/NaverTokenResponse.java new file mode 100644 index 00000000..47993aab --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/dto/response/NaverTokenResponse.java @@ -0,0 +1,16 @@ +package com.numberone.backend.domain.token.dto.response; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@ToString +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NaverTokenResponse { + private String access_token; + private String refresh_token; + private String token_type; + private String expires_in; +} diff --git a/src/main/java/com/numberone/backend/domain/token/dto/response/TokenResponse.java b/src/main/java/com/numberone/backend/domain/token/dto/response/TokenResponse.java new file mode 100644 index 00000000..c942186b --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/dto/response/TokenResponse.java @@ -0,0 +1,23 @@ +package com.numberone.backend.domain.token.dto.response; + +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TokenResponse { + private String token; + + @Builder + public TokenResponse(String token) { + this.token = token; + } + + public static TokenResponse of(String token){ + return TokenResponse.builder() + .token(token) + .build(); + } +} diff --git a/src/main/java/com/numberone/backend/domain/token/entity/Token.java b/src/main/java/com/numberone/backend/domain/token/entity/Token.java new file mode 100644 index 00000000..c65cacf5 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/entity/Token.java @@ -0,0 +1,42 @@ +package com.numberone.backend.domain.token.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import org.hibernate.annotations.Comment; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.index.Indexed; + +import java.io.Serializable; + +@Getter +@RedisHash(value = "token", timeToLive = 60 * 60 * 24 * 14)//@Entity가 RDBMS에 테이블을 생성했다면 @RedisHash는 Redis에 테이블(?)을 생성 +public class Token implements Serializable { + @Id + private String email; + + @Indexed//특정 컬럼을 인덱스(보조 id)로 지정하여 효율적인 검색이 가능하게 함 + private String accessToken; + + private String refreshToken; + + @Builder + public Token(String email, String accessToken, String refreshToken) { + this.email = email; + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } + + public static Token of(String email, String accessToken, String refreshToken){ + return Token.builder() + .email(email) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public void updateAccessToken(String accessToken) { + this.accessToken = accessToken; + } +} diff --git a/src/main/java/com/numberone/backend/domain/token/repository/TokenRepository.java b/src/main/java/com/numberone/backend/domain/token/repository/TokenRepository.java new file mode 100644 index 00000000..a8d45c98 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/repository/TokenRepository.java @@ -0,0 +1,10 @@ +package com.numberone.backend.domain.token.repository; + +import com.numberone.backend.domain.token.entity.Token; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface TokenRepository extends CrudRepository { + Optional findByAccessToken(String accessToken); +} diff --git a/src/main/java/com/numberone/backend/domain/token/service/TokenService.java b/src/main/java/com/numberone/backend/domain/token/service/TokenService.java new file mode 100644 index 00000000..4b7e87a6 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/service/TokenService.java @@ -0,0 +1,76 @@ +package com.numberone.backend.domain.token.service; + +import com.numberone.backend.domain.member.repository.MemberRepository; +import com.numberone.backend.domain.member.service.MemberService; +import com.numberone.backend.domain.token.dto.request.TokenRequest; +import com.numberone.backend.domain.token.dto.response.*; +import com.numberone.backend.domain.token.entity.Token; +import com.numberone.backend.domain.token.repository.TokenRepository; +import com.numberone.backend.exception.badrequest.BadRequestTokenException; +import com.numberone.backend.properties.KakaoProperties; +import com.numberone.backend.properties.NaverProperties; +import com.numberone.backend.domain.token.util.JwtUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TokenService { + private final JwtUtil jwtUtil; + private final RestTemplate restTemplate; + private final KakaoProperties kakaoProperties; + private final NaverProperties naverProperties; + private final MemberService memberService; + private final TokenRepository tokenRepository; + private final MemberRepository memberRepository; + + public TokenResponse loginKakao(TokenRequest tokenRequest) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-type", "application/x-www-form-urlencoded; charset=utf-8"); + headers.add("Authorization", "Bearer " + tokenRequest.getToken()); + + ResponseEntity response = restTemplate.exchange(kakaoProperties.getUser_api_url(), HttpMethod.GET, new HttpEntity<>(null, headers), KakaoInfoResponse.class); + String email = response.getBody().getKakao_account().getEmail(); + return TokenResponse.of(getAccessToken(email)); + } + + public TokenResponse loginNaver(TokenRequest tokenRequest) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.add("Authorization", "Bearer " + tokenRequest.getToken()); + + ResponseEntity response = restTemplate.exchange(naverProperties.getUser_api_url(), HttpMethod.GET, new HttpEntity<>(null, headers), NaverInfoResponse.class); + String email = response.getBody().getResponse().getEmail(); + return TokenResponse.of(getAccessToken(email)); + } + + @Transactional + public TokenResponse refresh(TokenRequest tokenRequest) { + String email = jwtUtil.getEmail(tokenRequest.getToken()); + Token token = tokenRepository.findById(email) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 토큰")); + String newToken = jwtUtil.createToken(email, 1000L * 60 * 60 * 24 * 14); + token.updateAccessToken(newToken); + tokenRepository.save(token);//redis의 경우 jpa와 달리 transactional을 이용해도 데이터 수정시에 명시적으로 save를 해줘야 함 + return TokenResponse.of(newToken); + } + + private String getAccessToken(String email) { + if (!memberRepository.existsByEmail(email)) + memberService.create(email); + if (tokenRepository.existsById(email)) { + Token token = tokenRepository.findById(email) + .orElseThrow(BadRequestTokenException::new); + return token.getAccessToken(); + } else { + String refreshToken = jwtUtil.createToken(email, 1000L * 60 * 60 * 24 * 14);//14일 + String accessToken = jwtUtil.createToken(email, 1000L * 60 * 30);//30분 + tokenRepository.save(Token.of(email, accessToken, refreshToken)); + return accessToken; + } + } +} diff --git a/src/main/java/com/numberone/backend/domain/token/util/JwtUtil.java b/src/main/java/com/numberone/backend/domain/token/util/JwtUtil.java new file mode 100644 index 00000000..61c1fa59 --- /dev/null +++ b/src/main/java/com/numberone/backend/domain/token/util/JwtUtil.java @@ -0,0 +1,45 @@ +package com.numberone.backend.domain.token.util; + +import com.numberone.backend.domain.token.service.TokenService; +import com.numberone.backend.properties.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Base64; +import java.util.Date; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtUtil { + private final JwtProperties jwtProperties; + + public String createToken(String email, long period) { + Claims claims = Jwts.claims().setSubject(email); + Date now = new Date(); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + period)) + .signWith(SignatureAlgorithm.HS256, jwtProperties.getSecret().getBytes()) + .compact(); + } + + public String getEmail(String token) { + return getClaims(token).getSubject(); + } + + public boolean isExpired(String token) { + return getClaims(token).getExpiration().before(new Date()); + } + + private Claims getClaims(String token) { + return Jwts.parser().setSigningKey(jwtProperties.getSecret().getBytes()).parseClaimsJws(token).getBody(); + } +} diff --git a/src/main/java/com/numberone/backend/exception/badrequest/BadRequestTokenException.java b/src/main/java/com/numberone/backend/exception/badrequest/BadRequestTokenException.java new file mode 100644 index 00000000..b3ebb219 --- /dev/null +++ b/src/main/java/com/numberone/backend/exception/badrequest/BadRequestTokenException.java @@ -0,0 +1,11 @@ +package com.numberone.backend.exception.badrequest; + +import com.numberone.backend.exception.context.ExceptionContext; + +import static com.numberone.backend.exception.context.CustomExceptionContext.BAD_REQUEST_TOKEN; + +public class BadRequestTokenException extends BadRequestException{ + public BadRequestTokenException() { + super(BAD_REQUEST_TOKEN); + } +} diff --git a/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java b/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java index 2f26ee7a..337a3b00 100644 --- a/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java +++ b/src/main/java/com/numberone/backend/exception/context/CustomExceptionContext.java @@ -9,9 +9,10 @@ public enum CustomExceptionContext implements ExceptionContext { // MEMBER 관련 예외 NOT_FOUND_MEMBER("존재하지 않는 회원을 조회할 수 없습니다.", 1000), + BAD_REQUEST_TOKEN("존재하지 않는 토큰을 요청하였습니다.", 2000), // SHELTER 관련 예외 - NOT_FOUND_SHELTER("주변에 가까운 대피소가 존재하지 않습니다.", 2000) + NOT_FOUND_SHELTER("주변에 가까운 대피소가 존재하지 않습니다.", 3000) ; diff --git a/src/main/java/com/numberone/backend/properties/JwtProperties.java b/src/main/java/com/numberone/backend/properties/JwtProperties.java new file mode 100644 index 00000000..8e51b032 --- /dev/null +++ b/src/main/java/com/numberone/backend/properties/JwtProperties.java @@ -0,0 +1,12 @@ +package com.numberone.backend.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "spring.jwt") +public class JwtProperties { + private String secret; +} diff --git a/src/main/java/com/numberone/backend/properties/KakaoProperties.java b/src/main/java/com/numberone/backend/properties/KakaoProperties.java new file mode 100644 index 00000000..c0b64c9b --- /dev/null +++ b/src/main/java/com/numberone/backend/properties/KakaoProperties.java @@ -0,0 +1,15 @@ +package com.numberone.backend.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "spring.kakao") +public class KakaoProperties { + private String client_id; + private String redirect_uri; + private String token_api_url; + private String user_api_url; +} diff --git a/src/main/java/com/numberone/backend/properties/NaverProperties.java b/src/main/java/com/numberone/backend/properties/NaverProperties.java new file mode 100644 index 00000000..87f22bda --- /dev/null +++ b/src/main/java/com/numberone/backend/properties/NaverProperties.java @@ -0,0 +1,16 @@ +package com.numberone.backend.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "spring.naver") +public class NaverProperties { + private String client_id; + private String client_secret; + private String redirect_uri; + private String token_api_url; + private String user_api_url; +} diff --git a/src/main/java/com/numberone/backend/properties/RedisProperties.java b/src/main/java/com/numberone/backend/properties/RedisProperties.java new file mode 100644 index 00000000..d49f7b6f --- /dev/null +++ b/src/main/java/com/numberone/backend/properties/RedisProperties.java @@ -0,0 +1,13 @@ +package com.numberone.backend.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "spring.redis") +public class RedisProperties { + private String host; + private int port; +}