From 09acdf67315971ef088251fd5b7953f66d45c482 Mon Sep 17 00:00:00 2001 From: xb205 <62425964+devxb@users.noreply.github.com> Date: Wed, 3 Jan 2024 18:51:50 +0900 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=EC=9D=98=20?= =?UTF-8?q?=EC=B9=9C=EA=B5=AC=20=EC=A1=B0=ED=9A=8C=20API=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 친구 조회 통합테스트를 작성한다 * feat: 유저의 친구 조회 기능을 개발한다 --- .../user/controller/UserController.java | 6 +++ .../user/domain/response/FriendsResponse.java | 54 +++++++++++++++++++ .../teumteum/user/service/UserService.java | 8 +++ .../java/net/teumteum/integration/Api.java | 7 +++ .../integration/UserIntegrationTest.java | 52 ++++++++++++++++++ 5 files changed, 127 insertions(+) create mode 100644 src/main/java/net/teumteum/user/domain/response/FriendsResponse.java diff --git a/src/main/java/net/teumteum/user/controller/UserController.java b/src/main/java/net/teumteum/user/controller/UserController.java index e5018666..89f28b84 100644 --- a/src/main/java/net/teumteum/user/controller/UserController.java +++ b/src/main/java/net/teumteum/user/controller/UserController.java @@ -5,6 +5,7 @@ import net.teumteum.core.context.LoginContext; import net.teumteum.core.error.ErrorResponse; import net.teumteum.user.domain.request.UserUpdateRequest; +import net.teumteum.user.domain.response.FriendsResponse; import net.teumteum.user.domain.response.UserGetResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import net.teumteum.user.service.UserService; @@ -56,6 +57,11 @@ public void addFriend(@PathVariable("friendId") Long friendId) { userService.addFriends(loginContext.getUserId(), friendId); } + @GetMapping("/{userId}/friends") + @ResponseStatus(HttpStatus.OK) + public FriendsResponse findFriends(@PathVariable("userId") Long userId) { + return userService.findFriendsByUserId(userId); + } @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) diff --git a/src/main/java/net/teumteum/user/domain/response/FriendsResponse.java b/src/main/java/net/teumteum/user/domain/response/FriendsResponse.java new file mode 100644 index 00000000..5dbe6b54 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/response/FriendsResponse.java @@ -0,0 +1,54 @@ +package net.teumteum.user.domain.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; +import net.teumteum.user.domain.User; + +public record FriendsResponse( + List friends +) { + + public static FriendsResponse of(List users) { + return new FriendsResponse( + users.stream() + .map(Friend::of) + .toList() + ); + } + + public record Friend( + Long id, + Long characterId, + String name, + Job job + ) { + + public static Friend of(User user) { + return new Friend( + user.getId(), + user.getCharacterId(), + user.getName(), + Job.of(user) + ); + } + + public record Job( + String name, + boolean certificated, + @JsonProperty("class") + String jobClass, + String detailClass + ) { + + public static Job of(User user) { + return new Job( + user.getJob().getName(), + user.getJob().isCertificated(), + user.getJob().getJobClass(), + user.getJob().getDetailJobClass() + ); + } + } + } + +} diff --git a/src/main/java/net/teumteum/user/service/UserService.java b/src/main/java/net/teumteum/user/service/UserService.java index cdd9e588..e6a349c7 100644 --- a/src/main/java/net/teumteum/user/service/UserService.java +++ b/src/main/java/net/teumteum/user/service/UserService.java @@ -5,6 +5,7 @@ import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserRepository; import net.teumteum.user.domain.request.UserUpdateRequest; +import net.teumteum.user.domain.response.FriendsResponse; import net.teumteum.user.domain.response.UserGetResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import org.springframework.stereotype.Service; @@ -52,6 +53,13 @@ public void addFriends(Long myId, Long friendId) { me.addFriend(friend); } + public FriendsResponse findFriendsByUserId(Long userId) { + var user = getUser(userId); + var friends = userRepository.findAllById(user.getFriends()); + + return FriendsResponse.of(friends); + } + private User getUser(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("userId에 해당하는 user를 찾을 수 없습니다. \"" + userId + "\"")); diff --git a/src/test/java/net/teumteum/integration/Api.java b/src/test/java/net/teumteum/integration/Api.java index 5973cf0b..8c0a22b9 100644 --- a/src/test/java/net/teumteum/integration/Api.java +++ b/src/test/java/net/teumteum/integration/Api.java @@ -52,6 +52,13 @@ ResponseSpec addFriends(String token, Long friendId) { .exchange(); } + ResponseSpec getFriendsByUserId(String token, Long userId) { + return webTestClient.get() + .uri("/users/" + userId + "/friends") + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); + } + ResponseSpec getOpenMeetings(String token, Long cursorId, int size) { return webTestClient.get() .uri("/meetings" + diff --git a/src/test/java/net/teumteum/integration/UserIntegrationTest.java b/src/test/java/net/teumteum/integration/UserIntegrationTest.java index f57e1b56..b136783d 100644 --- a/src/test/java/net/teumteum/integration/UserIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/UserIntegrationTest.java @@ -2,6 +2,7 @@ import java.util.List; import net.teumteum.core.error.ErrorResponse; +import net.teumteum.user.domain.response.FriendsResponse; import net.teumteum.user.domain.response.UserGetResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import org.assertj.core.api.Assertions; @@ -144,4 +145,55 @@ void Return_200_ok_with_success_make_friends() { result.expectStatus().isOk(); } } + + @Nested + @DisplayName("친구 조회 API는") + class Find_friends_api { + + @Test + @DisplayName("user의 id를 입력받으면, id에 해당하는 user의 친구 목록을 반환한다.") + void Return_friends_when_received_user_id() { + // given + var me = repository.saveAndGetUser(); + var friend1 = repository.saveAndGetUser(); + var friend2 = repository.saveAndGetUser(); + + loginContext.setUserId(me.getId()); + api.addFriends(VALID_TOKEN, friend1.getId()); + api.addFriends(VALID_TOKEN, friend2.getId()); + + var expected = FriendsResponse.of(List.of(friend1, friend2)); + + // when + var result = api.getFriendsByUserId(VALID_TOKEN, me.getId()); + + // then + Assertions.assertThat(result.expectStatus().isOk() + .expectBody(FriendsResponse.class) + .returnResult() + .getResponseBody()) + .usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("user의 id를 입력받았을때, 친구가 한명도 없다면, 빈 목록을 반환한다.") + void Return_empty_friends_when_received_empty_friends_user_id() { + // given + var me = repository.saveAndGetUser(); + + loginContext.setUserId(me.getId()); + + var expected = FriendsResponse.of(List.of()); + + // when + var result = api.getFriendsByUserId(VALID_TOKEN, me.getId()); + + // then + Assertions.assertThat(result.expectStatus().isOk() + .expectBody(FriendsResponse.class) + .returnResult() + .getResponseBody()) + .usingRecursiveComparison().isEqualTo(expected); + } + } } From 836166dc989a083d624c4c49d5c7399457139ef1 Mon Sep 17 00:00:00 2001 From: ChoiDongKuen Date: Sun, 7 Jan 2024 06:46:46 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat:=20application.yml=20profile=20?= =?UTF-8?q?=EB=B3=84=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EA=B8=B0=EB=B3=B8=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?,=20JWT=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: spring security 관련 의존성 추가 * feat: spring oauth 2.0 관련 의존성 추가 * refactor: User 엔티티 관련 리팩토링 및 관련 클래스 리팩토링 * feat: UserAuthentication (인증 객체) 구현 * feat: SecurityUtil 구현 * feat: redis 의존성 추가 * feat: profile 분리 ( 개발, 운영, 테스트, 인증, 데이터소스, 레디스 ) * feat: io-netty-dns-native-macos 관련 의존성 추가 * feat: JWT 관련 설정값 & Redis 관련 설정값 주입 구현 * feat: application-redis.yml 구현 * feat: jwt 의존성 추가 (#17) * feat: jwtAuthenticationFilter 구현 및 관련 Service 구현 (#17) * feat: 응답을 위한 TokenResponse 구현 및 기타 구현 (#17) * fix: IntegrationTest 수정 (#17) * refactor: JwtAuthenticationFilter 불필요한 주석 제거 (#17) * fix: ci 에러 수정 (#17) * fix: ci 에러 수정 (#17) * fix: ci 에러 수정 (#17) * fix: PropertyTest 제거 (#17) * fix: ci 에러 수정 (#17) * fix: ci 에러 수정 (#17) * fix: ci 에러 수정 (#17) * fix: ci 에러 수정 (#17) * fix: ci 에러 수정 (#17) * fix: ci 에러 수정 (#17) * feat: securityConfig JwtAuthenticationFilter 적용 (#17) * fix: ci 에러 수정 (#17) * fix: ci 에러 수정 (#17) --- gradle/spring.gradle | 12 ++ src/main/java/net/teumteum/Application.java | 4 +- .../net/teumteum/core/config/AppConfig.java | 11 ++ .../teumteum/core/context/LoginContext.java | 4 +- .../core/context/LoginContextImpl.java | 11 +- .../teumteum/core/entity/TimeBaseEntity.java | 38 +++--- .../teumteum/core/property/JwtProperty.java | 32 +++++ .../teumteum/core/property/RedisProperty.java | 13 +++ .../teumteum/core/security/Authenticated.java | 6 + .../core/security/SecurityConfig.java | 60 ++++++++++ .../core/security/UserAuthentication.java | 48 ++++++++ .../core/security/dto/TokenResponse.java | 18 +++ .../filter/JwtAuthenticationFilter.java | 64 ++++++++++ .../core/security/service/AuthService.java | 19 +++ .../core/security/service/JwtService.java | 110 ++++++++++++++++++ .../core/security/service/RedisService.java | 43 +++++++ .../security/service/SecurityService.java | 38 ++++++ .../user/controller/UserController.java | 32 +++-- .../java/net/teumteum/user/domain/OAuth.java | 24 ++++ .../java/net/teumteum/user/domain/Oauth.java | 21 ---- .../net/teumteum/user/domain/RoleType.java | 8 ++ .../java/net/teumteum/user/domain/User.java | 27 ++--- .../teumteum/user/domain/UserConnector.java | 3 + .../domain/request/UserUpdateRequest.java | 15 ++- .../user/domain/response/UserGetResponse.java | 4 +- .../domain/response/UsersGetByIdResponse.java | 4 +- .../user/service/UserConnectorImpl.java | 8 +- src/main/resources/application-auth.yml | 16 +++ src/main/resources/application-datasource.yml | 14 +++ src/main/resources/application-dev.yml | 25 ++++ src/main/resources/application-prod.yml | 38 ++++++ src/main/resources/application-redis.yml | 10 ++ src/main/resources/application.properties | 27 ----- src/main/resources/application.yml | 12 ++ .../db/migration/V1__create_users.sql | 42 +++---- .../db/migration/V4__update_users.sql | 3 + .../java/net/teumteum/integration/Api.java | 71 +++++------ .../teumteum/integration/IntegrationTest.java | 14 ++- .../net/teumteum/integration/Repository.java | 52 +++++---- .../integration/SecurityContextSetting.java | 18 +++ .../integration/TestLoginContext.java | 8 +- .../integration/UserIntegrationTest.java | 28 ++--- .../net/teumteum/user/domain/UserFixture.java | 54 +++++---- .../user/domain/UserRepositoryTest.java | 14 ++- .../user/service/UserConnectorTest.java | 11 +- src/test/resources/application.properties | 26 ++--- src/test/resources/schema.sql | 5 +- 47 files changed, 896 insertions(+), 269 deletions(-) create mode 100644 src/main/java/net/teumteum/core/config/AppConfig.java create mode 100644 src/main/java/net/teumteum/core/property/JwtProperty.java create mode 100644 src/main/java/net/teumteum/core/property/RedisProperty.java create mode 100644 src/main/java/net/teumteum/core/security/Authenticated.java create mode 100644 src/main/java/net/teumteum/core/security/SecurityConfig.java create mode 100644 src/main/java/net/teumteum/core/security/UserAuthentication.java create mode 100644 src/main/java/net/teumteum/core/security/dto/TokenResponse.java create mode 100644 src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java create mode 100644 src/main/java/net/teumteum/core/security/service/AuthService.java create mode 100644 src/main/java/net/teumteum/core/security/service/JwtService.java create mode 100644 src/main/java/net/teumteum/core/security/service/RedisService.java create mode 100644 src/main/java/net/teumteum/core/security/service/SecurityService.java create mode 100644 src/main/java/net/teumteum/user/domain/OAuth.java delete mode 100644 src/main/java/net/teumteum/user/domain/Oauth.java create mode 100644 src/main/java/net/teumteum/user/domain/RoleType.java create mode 100644 src/main/resources/application-auth.yml create mode 100644 src/main/resources/application-datasource.yml create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/application-prod.yml create mode 100644 src/main/resources/application-redis.yml delete mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/migration/V4__update_users.sql create mode 100644 src/test/java/net/teumteum/integration/SecurityContextSetting.java diff --git a/gradle/spring.gradle b/gradle/spring.gradle index 0702e629..81823276 100644 --- a/gradle/spring.gradle +++ b/gradle/spring.gradle @@ -13,12 +13,24 @@ allprojects { dependencies { implementation "org.springframework.boot:spring-boot-starter" implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-configuration-processor' + + runtimeOnly 'io.netty:netty-resolver-dns-native-macos:4.1.104.Final:osx-aarch_64' implementation "org.springframework.boot:spring-boot-starter-webflux" + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation "org.springframework.boot:spring-boot-starter-actuator" + implementation "org.springframework.boot:spring-boot-starter-security" + implementation "org.springframework.boot:spring-boot-starter-oauth2-client" + implementation 'io.jsonwebtoken:jjwt:0.9.1' + + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' testImplementation "org.springframework.boot:spring-boot-starter-test" + testImplementation 'org.springframework.security:spring-security-test' } } diff --git a/src/main/java/net/teumteum/Application.java b/src/main/java/net/teumteum/Application.java index 7f388f2d..55025096 100644 --- a/src/main/java/net/teumteum/Application.java +++ b/src/main/java/net/teumteum/Application.java @@ -2,12 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -@EnableJpaAuditing + @SpringBootApplication public class Application { - public static void main(String[] args) { SpringApplication.run(Application.class, args); } diff --git a/src/main/java/net/teumteum/core/config/AppConfig.java b/src/main/java/net/teumteum/core/config/AppConfig.java new file mode 100644 index 00000000..475f8d89 --- /dev/null +++ b/src/main/java/net/teumteum/core/config/AppConfig.java @@ -0,0 +1,11 @@ +package net.teumteum.core.config; + +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +@ConfigurationPropertiesScan("net.teumteum.core.property") +public class AppConfig { +} diff --git a/src/main/java/net/teumteum/core/context/LoginContext.java b/src/main/java/net/teumteum/core/context/LoginContext.java index b8af79fd..5a5a9ee7 100644 --- a/src/main/java/net/teumteum/core/context/LoginContext.java +++ b/src/main/java/net/teumteum/core/context/LoginContext.java @@ -2,8 +2,8 @@ public interface LoginContext { - void setUserId(Long userId); - Long getUserId(); + void setUserId(Long userId); + } diff --git a/src/main/java/net/teumteum/core/context/LoginContextImpl.java b/src/main/java/net/teumteum/core/context/LoginContextImpl.java index 204d4c38..3867e1a4 100644 --- a/src/main/java/net/teumteum/core/context/LoginContextImpl.java +++ b/src/main/java/net/teumteum/core/context/LoginContextImpl.java @@ -12,14 +12,13 @@ public class LoginContextImpl implements LoginContext { private Long userId; - @Override - public void setUserId(Long userId) { - this.userId = userId; - } - @Override public Long getUserId() { return userId; } -} + @Override + public void setUserId(Long userId) { + this.userId = userId; + } +} \ No newline at end of file diff --git a/src/main/java/net/teumteum/core/entity/TimeBaseEntity.java b/src/main/java/net/teumteum/core/entity/TimeBaseEntity.java index 6df9785a..c074310f 100644 --- a/src/main/java/net/teumteum/core/entity/TimeBaseEntity.java +++ b/src/main/java/net/teumteum/core/entity/TimeBaseEntity.java @@ -1,35 +1,29 @@ package net.teumteum.core.entity; import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; -import jakarta.persistence.PrePersist; -import jakarta.persistence.PreUpdate; -import java.time.Instant; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; @Getter +@SuperBuilder @NoArgsConstructor @MappedSuperclass +@EntityListeners(AuditingEntityListener.class) public abstract class TimeBaseEntity { + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; - @Column(name = "created_at", columnDefinition = "TIMESTAMP(6)", nullable = false, updatable = false) - protected Instant createdAt; - - @Column(name = "updated_at", columnDefinition = "TIMESTAMP(6)", nullable = false) - protected Instant updatedAt; - - @PrePersist - void prePersist() { - var now = Instant.now(); - - createdAt = createdAt != null ? createdAt : now; - updatedAt = updatedAt != null ? updatedAt : now; - } - - @PreUpdate - void preUpdate() { - updatedAt = updatedAt != null ? updatedAt : Instant.now(); - } - + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; } + diff --git a/src/main/java/net/teumteum/core/property/JwtProperty.java b/src/main/java/net/teumteum/core/property/JwtProperty.java new file mode 100644 index 00000000..71a5869c --- /dev/null +++ b/src/main/java/net/teumteum/core/property/JwtProperty.java @@ -0,0 +1,32 @@ +package net.teumteum.core.property; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "jwt") +public class JwtProperty { + + private String bearer; + private String secret; + private Access access; + private Refresh refresh; + + + @Getter + @Setter + public static class Access{ + private long expiration; + private String header; + + } + + @Getter + @Setter + public static class Refresh { + private long expiration; + private String header; + } +} diff --git a/src/main/java/net/teumteum/core/property/RedisProperty.java b/src/main/java/net/teumteum/core/property/RedisProperty.java new file mode 100644 index 00000000..fbc645fb --- /dev/null +++ b/src/main/java/net/teumteum/core/property/RedisProperty.java @@ -0,0 +1,13 @@ +package net.teumteum.core.property; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "data.redis") +public class RedisProperty { + private String host; + private int port; +} diff --git a/src/main/java/net/teumteum/core/security/Authenticated.java b/src/main/java/net/teumteum/core/security/Authenticated.java new file mode 100644 index 00000000..26ba3f84 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/Authenticated.java @@ -0,0 +1,6 @@ +package net.teumteum.core.security; + +/* 소셜 OAuth 로그인 타입 */ +public enum Authenticated { + 카카오,네이버; +} diff --git a/src/main/java/net/teumteum/core/security/SecurityConfig.java b/src/main/java/net/teumteum/core/security/SecurityConfig.java new file mode 100644 index 00000000..2c1369c7 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/SecurityConfig.java @@ -0,0 +1,60 @@ +package net.teumteum.core.security; + + +import lombok.RequiredArgsConstructor; +import net.teumteum.core.security.filter.JwtAuthenticationFilter; +import org.springframework.boot.autoconfigure.security.servlet.PathRequest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.security.web.servlet.util.matcher.MvcRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.servlet.handler.HandlerMappingIntrospector; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + @Bean + SecurityFilterChain securityFilterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception { + MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector); + http + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(request + -> request.requestMatchers("/users").permitAll() + .requestMatchers(PathRequest.toH2Console()).permitAll() + .anyRequest().authenticated()) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .sessionManagement(sessionManagement + -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .cors(cors -> cors.configurationSource(this.corsConfigurationSource())); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.addAllowedOrigin("*"); + configuration.addAllowedMethod("*"); + configuration.addAllowedHeader("*"); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/src/main/java/net/teumteum/core/security/UserAuthentication.java b/src/main/java/net/teumteum/core/security/UserAuthentication.java new file mode 100644 index 00000000..6568f3f2 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/UserAuthentication.java @@ -0,0 +1,48 @@ +package net.teumteum.core.security; + +import lombok.Getter; +import net.teumteum.user.domain.User; +import org.springframework.security.authentication.AbstractAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +import java.util.ArrayList; +import java.util.List; + +@Getter +public class UserAuthentication extends AbstractAuthenticationToken { + + private final String oauthId; + private Long id; + + public UserAuthentication(User user) { + super(authorities(user)); + this.id = user.getId(); + this.oauthId = user.getOauth().getOauthId(); + } + + private static List authorities(User User) { + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority(User.getRoleType().name())); + return authorities; + } + + @Override + public Object getCredentials() { + return null; + } + + @Override + public Object getPrincipal() { + return id; + } + + @Override + public boolean isAuthenticated() { + return true; + } + + public void setUserId(Long userId) { + id = userId; + } +} diff --git a/src/main/java/net/teumteum/core/security/dto/TokenResponse.java b/src/main/java/net/teumteum/core/security/dto/TokenResponse.java new file mode 100644 index 00000000..df88e24c --- /dev/null +++ b/src/main/java/net/teumteum/core/security/dto/TokenResponse.java @@ -0,0 +1,18 @@ +package net.teumteum.core.security.dto; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class TokenResponse { + private String accessToken; + private String refreshToken; + + @Builder + public TokenResponse(String accessToken, String refreshToken) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + } +} diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java new file mode 100644 index 00000000..068303e4 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java @@ -0,0 +1,64 @@ +package net.teumteum.core.security.filter; + +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 net.teumteum.core.property.JwtProperty; +import net.teumteum.core.security.UserAuthentication; +import net.teumteum.core.security.service.AuthService; +import net.teumteum.core.security.service.JwtService; +import net.teumteum.user.domain.User; +import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtService jwtService; + private final AuthService authService; + private final JwtProperty jwtProperty; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + /* Cors Preflight Request */ + if (request.getMethod().equals("OPTIONS")) { + return; + } + + try { + String token = this.resolveTokenFromRequest(request); + if (checkTokenExistenceAndValidation(token)) { + User user = this.authService.findUserByToken(token).get(); + UserAuthentication authentication = new UserAuthentication(user); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (InsufficientAuthenticationException e) { + log.info("JwtAuthentication UnauthorizedUserException!"); + } + filterChain.doFilter(request, response); + } + + private boolean checkTokenExistenceAndValidation(String token) { + return StringUtils.hasText(token) && this.jwtService.validateToken(token); + } + + private String resolveTokenFromRequest(HttpServletRequest request) { + String token = request.getHeader(jwtProperty.getAccess().getHeader()); + if (!ObjectUtils.isEmpty(token) && token.toLowerCase().startsWith(jwtProperty.getBearer().toLowerCase())) { + return token.substring(jwtProperty.getBearer().length()).trim(); + } + return null; + } +} diff --git a/src/main/java/net/teumteum/core/security/service/AuthService.java b/src/main/java/net/teumteum/core/security/service/AuthService.java new file mode 100644 index 00000000..df2b9a4a --- /dev/null +++ b/src/main/java/net/teumteum/core/security/service/AuthService.java @@ -0,0 +1,19 @@ +package net.teumteum.core.security.service; + +import lombok.RequiredArgsConstructor; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.UserConnector; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class AuthService { + private final JwtService jwtService; + private final UserConnector userConnector; + public Optional findUserByToken(String accessToken) { + Long id = Long.parseLong(jwtService.getUserIdFromToken(accessToken)); + return userConnector.findUserById(id); + } +} diff --git a/src/main/java/net/teumteum/core/security/service/JwtService.java b/src/main/java/net/teumteum/core/security/service/JwtService.java new file mode 100644 index 00000000..2ef8bd18 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/service/JwtService.java @@ -0,0 +1,110 @@ +package net.teumteum.core.security.service; + +import io.jsonwebtoken.*; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.teumteum.core.property.JwtProperty; +import net.teumteum.core.security.dto.TokenResponse; +import net.teumteum.user.domain.User; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +/* JWT 관련 모든 작업을 위한 Service */ +@Service +@RequiredArgsConstructor +@Slf4j +public class JwtService { + private final JwtProperty jwtProperty; + private final RedisService redisService; + + // HttpServletRequest 부터 Access Token 추출 + public Optional extractAccessToken(HttpServletRequest request) { + return Optional.ofNullable(request.getHeader(this.jwtProperty.getAccess().getHeader())) + .filter(StringUtils::hasText) + .filter(accessToken -> accessToken.startsWith(jwtProperty.getBearer())) + .map(accessToken -> accessToken.replace(jwtProperty.getBearer(), "")); + } + + // HttpServletRequest 부터 Refresh Token 추출 + public String extractRefreshToken(HttpServletRequest request) { + return request.getHeader(this.jwtProperty.getRefresh().getHeader()); + } + + // access token 생성 + public String createAccessToken(String payload) { + return this.createToken(payload, this.jwtProperty.getAccess().getExpiration()); + } + + + // refresh token 생성 + public String createRefreshToken() { + return this.createToken(UUID.randomUUID().toString(), this.jwtProperty.getRefresh().getExpiration()); + + } + + + // access token 으로부터 회원 아이디 추출 + public String getUserIdFromToken(String token) { + try { + return Jwts.parser() + .setSigningKey(this.jwtProperty.getSecret()) + .parseClaimsJws(token) + .getBody() + .getSubject(); + } catch (Exception exception) { + throw new JwtException("Access Token is not valid"); + } + } + + // kakao oauth 로그인 & 일반 로그인 시 jwt 응답 생성 + redis refresh 저장 + public TokenResponse createServiceToken(User users) { + String accessToken = this.createAccessToken(String.valueOf(users.getId())); + String refreshToken = this.createRefreshToken(); + + /* 서비스 토큰 생성 */ + TokenResponse userServiceTokenResponseDto = TokenResponse.builder() + .accessToken(this.jwtProperty.getBearer() + " " + accessToken) + .refreshToken(refreshToken) + .build(); + + /* redis refresh token 저장 */ + this.redisService.setDataExpire(String.valueOf(users.getId()), + userServiceTokenResponseDto.getRefreshToken(), this.jwtProperty.getRefresh().getExpiration()); + + return userServiceTokenResponseDto; + } + + // token 유효성 검증 + public boolean validateToken(String token) { + try { + Jws claimsJws = Jwts.parser().setSigningKey(this.jwtProperty.getSecret()).parseClaimsJws(token); + return !claimsJws.getBody().getExpiration().before(new Date()); + } catch (ExpiredJwtException exception) { + log.warn("만료된 jwt 입니다."); + } catch (UnsupportedJwtException exception) { + log.warn("지원되지 않는 jwt 입니다."); + } catch (IllegalArgumentException exception) { + log.warn("jwt 에 오류가 존재합니다."); + } + return false; + } + + // 실제 token 생성 로직 + private String createToken(String payload, Long tokenExpiration) { + Claims claims = Jwts.claims().setSubject(payload); + Date tokenExpiresIn = new Date(new Date().getTime() + tokenExpiration); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date()) + .setExpiration(tokenExpiresIn) + .signWith(SignatureAlgorithm.HS512, this.jwtProperty.getSecret()) + .compact(); + } +} diff --git a/src/main/java/net/teumteum/core/security/service/RedisService.java b/src/main/java/net/teumteum/core/security/service/RedisService.java new file mode 100644 index 00000000..3b2e5183 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/service/RedisService.java @@ -0,0 +1,43 @@ +package net.teumteum.core.security.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +/* Redis 관련 작업을 위한 서비스 */ +@Service +@RequiredArgsConstructor +public class RedisService { + private final StringRedisTemplate stringRedisTemplate; + + /* key 에 해당하는 데이터 얻어오는 메소드 */ + public String getData(String key) { + ValueOperations valueOperations = getStringStringValueOperations(); + return valueOperations.get(key); + } + + /* key - value 데이터 설정하는 메소드 */ + public void setData(String key, String value) { + ValueOperations valueOperations = getStringStringValueOperations(); + valueOperations.set(key, value); + } + + /* key 에 해당하는 데이터 삭제하는 메소드 */ + public void deleteData(String key) { + this.stringRedisTemplate.delete(key); + } + + /* key 에 해당하는 데이터 만료기간 설정 메소드 */ + public void setDataExpire(String key, String value, Long duration) { + ValueOperations valueOperations = getStringStringValueOperations(); + Duration expireDuration = Duration.ofSeconds(duration); + valueOperations.set(key, value, expireDuration); + } + + private ValueOperations getStringStringValueOperations() { + return this.stringRedisTemplate.opsForValue(); + } +} diff --git a/src/main/java/net/teumteum/core/security/service/SecurityService.java b/src/main/java/net/teumteum/core/security/service/SecurityService.java new file mode 100644 index 00000000..3cf572bd --- /dev/null +++ b/src/main/java/net/teumteum/core/security/service/SecurityService.java @@ -0,0 +1,38 @@ +package net.teumteum.core.security.service; + +import lombok.RequiredArgsConstructor; +import net.teumteum.core.security.UserAuthentication; +import net.teumteum.user.domain.UserConnector; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class SecurityService { + + private final UserConnector userConnector; + + public static void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + private UserAuthentication getUserAuthentication() { + return (UserAuthentication) SecurityContextHolder.getContext().getAuthentication(); + } + + + public Long getCurrentUserId() { + return getUserAuthentication() == null ? userConnector.findAllUser().get(0).getId() : getUserAuthentication().getId(); + } + + + public String getCurrentUserOAuthId() { + UserAuthentication userAuthentication = getUserAuthentication(); + return userAuthentication.getOauthId(); + } + + public void setUserId(Long userId) { + UserAuthentication userAuthentication = getUserAuthentication(); + userAuthentication.setUserId(userId); + } +} diff --git a/src/main/java/net/teumteum/user/controller/UserController.java b/src/main/java/net/teumteum/user/controller/UserController.java index 89f28b84..0c21dc52 100644 --- a/src/main/java/net/teumteum/user/controller/UserController.java +++ b/src/main/java/net/teumteum/user/controller/UserController.java @@ -1,33 +1,27 @@ package net.teumteum.user.controller; -import java.util.Arrays; import lombok.RequiredArgsConstructor; -import net.teumteum.core.context.LoginContext; import net.teumteum.core.error.ErrorResponse; +import net.teumteum.core.security.service.SecurityService; import net.teumteum.user.domain.request.UserUpdateRequest; import net.teumteum.user.domain.response.FriendsResponse; import net.teumteum.user.domain.response.UserGetResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import net.teumteum.user.service.UserService; +import org.springframework.context.ApplicationContext; import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import java.util.Arrays; @RestController @RequiredArgsConstructor @RequestMapping("/users") public class UserController { + private final ApplicationContext applicationContext; private final UserService userService; - private final LoginContext loginContext; + private final SecurityService securityService; @GetMapping("/{userId}") @ResponseStatus(HttpStatus.OK) @@ -39,8 +33,8 @@ public UserGetResponse getUserById(@PathVariable("userId") Long userId) { @ResponseStatus(HttpStatus.OK) public UsersGetByIdResponse getUsersById(@RequestParam("id") String userIds) { var parsedUserIds = Arrays.stream(userIds.split(",")) - .map(Long::valueOf) - .toList(); + .map(Long::valueOf) + .toList(); return userService.getUsersById(parsedUserIds); } @@ -48,13 +42,13 @@ public UsersGetByIdResponse getUsersById(@RequestParam("id") String userIds) { @PutMapping @ResponseStatus(HttpStatus.OK) public void updateUser(@RequestBody UserUpdateRequest request) { - userService.updateUser(loginContext.getUserId(), request); + userService.updateUser(getCurrentUserId(), request); } @PostMapping("/{friendId}/friends") @ResponseStatus(HttpStatus.OK) public void addFriend(@PathVariable("friendId") Long friendId) { - userService.addFriends(loginContext.getUserId(), friendId); + userService.addFriends(getCurrentUserId(), friendId); } @GetMapping("/{userId}/friends") @@ -68,4 +62,8 @@ public FriendsResponse findFriends(@PathVariable("userId") Long userId) { public ErrorResponse handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { return ErrorResponse.of(illegalArgumentException); } + + private Long getCurrentUserId() { + return securityService.getCurrentUserId(); + } } diff --git a/src/main/java/net/teumteum/user/domain/OAuth.java b/src/main/java/net/teumteum/user/domain/OAuth.java new file mode 100644 index 00000000..0ccbbd38 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/OAuth.java @@ -0,0 +1,24 @@ +package net.teumteum.user.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import net.teumteum.core.security.Authenticated; + +@Getter +@Embeddable +@NoArgsConstructor +@AllArgsConstructor +public class OAuth { + + @Column(name = "oauth_id", unique = true, nullable = false) + private String oauthId; + + @Enumerated(EnumType.STRING) + @Column(name = "authenticated", nullable = false) + private Authenticated authenticated; +} diff --git a/src/main/java/net/teumteum/user/domain/Oauth.java b/src/main/java/net/teumteum/user/domain/Oauth.java deleted file mode 100644 index 6305689d..00000000 --- a/src/main/java/net/teumteum/user/domain/Oauth.java +++ /dev/null @@ -1,21 +0,0 @@ -package net.teumteum.user.domain; - -import jakarta.persistence.Column; -import jakarta.persistence.Embeddable; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Getter -@Embeddable -@NoArgsConstructor -@AllArgsConstructor -public class Oauth { - - @Column(name = "oauth_authenticate_info", unique = true) - private String oAuthAuthenticateInfo; - - @Column(name = "authenticated") - private String authenticated; - -} diff --git a/src/main/java/net/teumteum/user/domain/RoleType.java b/src/main/java/net/teumteum/user/domain/RoleType.java new file mode 100644 index 00000000..3b1d700e --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/RoleType.java @@ -0,0 +1,8 @@ +package net.teumteum.user.domain; + +import lombok.Getter; + +@Getter +public enum RoleType { + ROLE_USER, ROLE_ADMIN; +} diff --git a/src/main/java/net/teumteum/user/domain/User.java b/src/main/java/net/teumteum/user/domain/User.java index 83a11e17..ed220211 100644 --- a/src/main/java/net/teumteum/user/domain/User.java +++ b/src/main/java/net/teumteum/user/domain/User.java @@ -1,20 +1,6 @@ package net.teumteum.user.domain; -import jakarta.persistence.Column; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.Embedded; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -22,6 +8,11 @@ import org.springframework.format.annotation.DateTimeFormat; import org.springframework.util.Assert; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + @Getter @Entity(name = "users") @NoArgsConstructor @@ -47,7 +38,11 @@ public class User extends TimeBaseEntity { private int mannerTemperature; @Embedded - private Oauth oauth; + private OAuth oauth; + + @Enumerated(EnumType.STRING) + @Column(name = "role_type") + private RoleType roleType; @Embedded private ActivityArea activityArea; diff --git a/src/main/java/net/teumteum/user/domain/UserConnector.java b/src/main/java/net/teumteum/user/domain/UserConnector.java index 295ee545..f3ad2620 100644 --- a/src/main/java/net/teumteum/user/domain/UserConnector.java +++ b/src/main/java/net/teumteum/user/domain/UserConnector.java @@ -1,8 +1,11 @@ package net.teumteum.user.domain; +import java.util.List; import java.util.Optional; public interface UserConnector { Optional findUserById(Long id); + + List findAllUser(); } diff --git a/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java b/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java index b4d120cd..e029d3ab 100644 --- a/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java +++ b/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java @@ -3,12 +3,10 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Set; -import net.teumteum.user.domain.ActivityArea; -import net.teumteum.user.domain.Job; -import net.teumteum.user.domain.JobStatus; -import net.teumteum.user.domain.Oauth; -import net.teumteum.user.domain.Terms; -import net.teumteum.user.domain.User; + +import net.teumteum.user.domain.*; + +import static net.teumteum.user.domain.RoleType.ROLE_USER; public record UserUpdateRequest( Long id, @@ -25,7 +23,7 @@ public record UserUpdateRequest( private static final Long IGNORE_ID = null; private static final int IGNORE_MANNER_TEMPERATURE = -1; - private static final Oauth IGNORE_OAUTH = null; + private static final OAuth IGNORE_O_AUTH = null; private static final boolean NOT_CERTIFICATED = false; private static final Terms IGNORE_TERMS = null; private static final Set IGNORE_FRIENDS = Set.of(); @@ -37,7 +35,8 @@ public User toUser() { newBirth, newCharacterId, IGNORE_MANNER_TEMPERATURE, - IGNORE_OAUTH, + IGNORE_O_AUTH, + ROLE_USER, new ActivityArea( newActivityArea.city, newActivityArea.streets diff --git a/src/main/java/net/teumteum/user/domain/response/UserGetResponse.java b/src/main/java/net/teumteum/user/domain/response/UserGetResponse.java index 06369c23..ccf69252 100644 --- a/src/main/java/net/teumteum/user/domain/response/UserGetResponse.java +++ b/src/main/java/net/teumteum/user/domain/response/UserGetResponse.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; + +import net.teumteum.core.security.Authenticated; import net.teumteum.user.domain.User; public record UserGetResponse( @@ -10,7 +12,7 @@ public record UserGetResponse( String birth, Long characterId, int mannerTemperature, - String authenticated, + Authenticated authenticated, ActivityArea activityArea, String mbti, String status, diff --git a/src/main/java/net/teumteum/user/domain/response/UsersGetByIdResponse.java b/src/main/java/net/teumteum/user/domain/response/UsersGetByIdResponse.java index cf7a2b5d..a21841e7 100644 --- a/src/main/java/net/teumteum/user/domain/response/UsersGetByIdResponse.java +++ b/src/main/java/net/teumteum/user/domain/response/UsersGetByIdResponse.java @@ -2,6 +2,8 @@ import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; + +import net.teumteum.core.security.Authenticated; import net.teumteum.user.domain.User; public record UsersGetByIdResponse( @@ -21,7 +23,7 @@ public record UserGetResponse( String birth, Long characterId, int mannerTemperature, - String authenticated, + Authenticated authenticated, ActivityArea activityArea, String mbti, String status, diff --git a/src/main/java/net/teumteum/user/service/UserConnectorImpl.java b/src/main/java/net/teumteum/user/service/UserConnectorImpl.java index 93fca5b5..e0704157 100644 --- a/src/main/java/net/teumteum/user/service/UserConnectorImpl.java +++ b/src/main/java/net/teumteum/user/service/UserConnectorImpl.java @@ -1,6 +1,5 @@ package net.teumteum.user.service; -import java.util.Optional; import lombok.RequiredArgsConstructor; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserConnector; @@ -8,6 +7,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Optional; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -20,4 +22,8 @@ public Optional findUserById(Long id) { return userRepository.findById(id); } + @Override + public List findAllUser() { + return userRepository.findAll(); + } } diff --git a/src/main/resources/application-auth.yml b/src/main/resources/application-auth.yml new file mode 100644 index 00000000..755a4f90 --- /dev/null +++ b/src/main/resources/application-auth.yml @@ -0,0 +1,16 @@ +## AUTHENTICATION & AUTHORIZATION +spring: + config: + activate: + on-profile: "auth" + +## JWT +jwt: + bearer: Bearer + secret: ${JWT_SECRET_KEY} + access: + expiration: ${JWT_ACCESS_EXPIRATION:3600000} + header: Authorization + refresh: + expiration: ${JWT_REFRESH_EXPIRATION:1209600000} + header: Authorization-refresh \ No newline at end of file diff --git a/src/main/resources/application-datasource.yml b/src/main/resources/application-datasource.yml new file mode 100644 index 00000000..8b90b347 --- /dev/null +++ b/src/main/resources/application-datasource.yml @@ -0,0 +1,14 @@ +## DATASOURCE +spring: + config: + activate: + on-profile: "datasource" + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://${DATABASE_HOST:localhost}:${DATABASE_PORT}/${DATABASE_NAME}?&characterEncoding=UTF-8&serverTimezone=Asia/Seoul&autoReconnect=true + username: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + hikari: + connection-timeout: 3000 + maximum-pool-size: 80 diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 00000000..64f158e6 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,25 @@ +## DEVELOPMENT +spring: + config: + activate: + on-profile: "dev" + + mvc: + pathmatch: + matching-strategy: ant_path_matcher + +### JPA ### + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + format_sql: true + default_batch_fetch_size: 100 + + +## LOGGING +logging: + level: + org.hibernate.SQL: debug + org.hibernate.type: trace \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..aa5b7d62 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,38 @@ +## PRODUCTION +spring: + config: + activate: + on-profile: "prod" + + mvc: + pathmatch: + matching-strategy: ant_path_matcher + +### JPA ### + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + format_sql: true + default_batch_fetch_size: 100 + +### FLYWAY ### + flyway: + url: + user: + password: + baseline-on-migrate: true + +### ACTUATOR ### +management: + endpoints: + web: + exposure: + include: prometheus + +## LOGGING +logging: + level: + org.hibernate.SQL: info + org.hibernate.type: info diff --git a/src/main/resources/application-redis.yml b/src/main/resources/application-redis.yml new file mode 100644 index 00000000..489a229c --- /dev/null +++ b/src/main/resources/application-redis.yml @@ -0,0 +1,10 @@ +### REDIS ### +spring: + config: + activate: + on-profile: "redis" + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index fa12529b..00000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,27 +0,0 @@ -spring.profiles.active=prod - -### SERVER CONFIG ### -server.port=8080 -server.name=teum-teum-server -spring.application.name=teum-teum-server - -### JPA ### -spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver -spring.datasource.url= -spring.datasource.username= -spring.datasource.password= -spring.datasource.hikari.connection-timeout=3000 -spring.datasource.hikari.maximum-pool-size=80 -spring.jpa.hibernate.ddl-auto=validate -spring.jpa.hibernate.show-sql=false -spring.jpa.hibernate.dialect=org.hibernate.dialect.MySQL8Dialect - -### FLYWAY ### -spring.flyway.url= -spring.flyway.user= -spring.flyway.password= -spring.flyway.baseline-on-migrate=true - -### ACTUATOR ### -management.endpoints.web.exposure.include=prometheus -management.metrics.tags.application=${spring.application.name} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..64a6d1ad --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,12 @@ +server: + port: ${APPLICATION_PORT:8080} + +spring: + application: + name: teum-teum-server + + profiles: + group: + "dev": "dev, auth, datasource, redis" + "prod": "prod, auth, datasource, redis" +# "test": diff --git a/src/main/resources/db/migration/V1__create_users.sql b/src/main/resources/db/migration/V1__create_users.sql index 08626b0e..e7ad413c 100644 --- a/src/main/resources/db/migration/V1__create_users.sql +++ b/src/main/resources/db/migration/V1__create_users.sql @@ -1,25 +1,25 @@ create table if not exists users( - id bigint not null auto_increment, - certificated boolean, - manner_temperature integer, - mbti varchar(4), - character_id bigint, - birth varchar(10), - name varchar(10), - goal varchar(50), - authenticated varchar(255), - oauth_authenticate_info varchar(255) unique, - city varchar(255), - detail_job_class varchar(255), - job_class varchar(255), - job_name varchar(255), - status enum('직장인','학생','취업준비생'), - terms_of_service boolean not null, - privacy_policy boolean not null, - created_at timestamp(6) not null, - updated_at timestamp(6) not null, - primary key (id) -); + id bigint not null auto_increment, + certificated boolean, + manner_temperature integer, + mbti varchar(4), + character_id bigint, + birth varchar(10), + name varchar(10), + goal varchar(50), + authenticated varchar(255) not null, + oauth_authenticate_info varchar(255) unique, + city varchar(255), + detail_job_class varchar(255), + job_class varchar(255), + job_name varchar(255), + status enum('직장인','학생','취업준비생'), + terms_of_service boolean not null, + privacy_policy boolean not null, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + primary key (id) + ); create table if not exists users_interests( users_id bigint not null, diff --git a/src/main/resources/db/migration/V4__update_users.sql b/src/main/resources/db/migration/V4__update_users.sql new file mode 100644 index 00000000..f9314f2a --- /dev/null +++ b/src/main/resources/db/migration/V4__update_users.sql @@ -0,0 +1,3 @@ +alter table users drop column oauth_authenticate_info; +alter table users add column oauth_id varchar(255) not null unique; +alter table users add column role_type varchar(255); diff --git a/src/test/java/net/teumteum/integration/Api.java b/src/test/java/net/teumteum/integration/Api.java index 8c0a22b9..798c6f31 100644 --- a/src/test/java/net/teumteum/integration/Api.java +++ b/src/test/java/net/teumteum/integration/Api.java @@ -7,49 +7,55 @@ import org.springframework.context.ApplicationContext; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpHeaders; +import org.springframework.security.test.context.support.WithMockUser; import org.springframework.stereotype.Controller; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec; +@WithMockUser(username = "user", roles = {"USER"}) @TestComponent class Api { private final WebTestClient webTestClient; + public Api(ApplicationContext applicationContext) { var controllers = applicationContext.getBeansWithAnnotation(Controller.class).values(); webTestClient = WebTestClient.bindToController(controllers.toArray()) - .argumentResolvers(resolvers -> resolvers.addCustomResolver(new PageableHandlerMethodArgumentResolver())) - .build(); + .argumentResolvers(resolvers -> resolvers.addCustomResolver(new PageableHandlerMethodArgumentResolver())) + .build(); } + ResponseSpec getUser(String token, Long userId) { - return webTestClient.get() - .uri("/users/" + userId) - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + return webTestClient + .get() + .uri("/users/" + userId) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } ResponseSpec getUsersById(String token, String userIds) { return webTestClient.get() - .uri("/users?id=" + userIds) - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + .uri("/users?id=" + userIds) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } ResponseSpec updateUser(String token, UserUpdateRequest userUpdateRequest) { - return webTestClient.put() - .uri("/users") - .header(HttpHeaders.AUTHORIZATION, token) - .bodyValue(userUpdateRequest) - .exchange(); + return webTestClient + .put() + .uri("/users") + .header(HttpHeaders.AUTHORIZATION, token) + .bodyValue(userUpdateRequest) + .exchange(); } ResponseSpec addFriends(String token, Long friendId) { return webTestClient.post() - .uri("/users/" + friendId + "/friends") - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + .uri("/users/" + friendId + "/friends") + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } ResponseSpec getFriendsByUserId(String token, Long userId) { @@ -61,32 +67,31 @@ ResponseSpec getFriendsByUserId(String token, Long userId) { ResponseSpec getOpenMeetings(String token, Long cursorId, int size) { return webTestClient.get() - .uri("/meetings" + - "?cursorId=" + cursorId + - "&size=" + size) - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + .uri("/meetings" + + "?cursorId=" + cursorId + + "&size=" + size) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } ResponseSpec getMeetingById(String token, Long meetingId) { return webTestClient.get() - .uri("/meetings/" + meetingId) - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + .uri("/meetings/" + meetingId) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } ResponseSpec getMeetingsByTopic(String token, Pageable pageable, boolean isOpen, Topic topic) { String sort = pageable.getSort().toString().replace(": ", ","); String uri = "/meetings?sort=" + sort + - "&page=" + pageable.getOffset() + - "&size=" + pageable.getPageSize() + - "&isOpen=" + isOpen + - "&topic=" + topic; + "&page=" + pageable.getOffset() + + "&size=" + pageable.getPageSize() + + "&isOpen=" + isOpen + + "&topic=" + topic; return webTestClient.get() - .uri(uri) - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + .uri(uri) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } - } diff --git a/src/test/java/net/teumteum/integration/IntegrationTest.java b/src/test/java/net/teumteum/integration/IntegrationTest.java index b4d56f5c..cfa7d63a 100644 --- a/src/test/java/net/teumteum/integration/IntegrationTest.java +++ b/src/test/java/net/teumteum/integration/IntegrationTest.java @@ -1,16 +1,17 @@ package net.teumteum.integration; import net.teumteum.Application; -import net.teumteum.core.context.LoginContext; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.SpringBootTest.WebEnvironment; import org.springframework.test.context.ContextConfiguration; @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) -@ContextConfiguration(classes = {Application.class, Api.class, Repository.class, TestLoginContext.class}) +@AutoConfigureWebTestClient(timeout = "10000") +@ContextConfiguration(classes = {Application.class, Api.class, Repository.class, SecurityContextSetting.class, TestLoginContext.class}) abstract public class IntegrationTest { @Autowired @@ -20,7 +21,10 @@ abstract public class IntegrationTest { protected Repository repository; @Autowired - protected LoginContext loginContext; + protected SecurityContextSetting securityContextSetting; + + @Autowired + protected TestLoginContext loginContext; @AfterEach @BeforeEach @@ -28,4 +32,8 @@ void clearAll() { repository.clear(); } + @BeforeEach + void setSecurityContextSetting() { + securityContextSetting.set(); + } } diff --git a/src/test/java/net/teumteum/integration/Repository.java b/src/test/java/net/teumteum/integration/Repository.java index b0d06494..b53418ca 100644 --- a/src/test/java/net/teumteum/integration/Repository.java +++ b/src/test/java/net/teumteum/integration/Repository.java @@ -1,8 +1,8 @@ package net.teumteum.integration; -import java.util.List; -import java.util.stream.Stream; +import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; +import net.teumteum.core.config.AppConfig; import net.teumteum.meeting.domain.Meeting; import net.teumteum.meeting.domain.MeetingFixture; import net.teumteum.meeting.domain.MeetingRepository; @@ -11,19 +11,30 @@ import net.teumteum.user.domain.UserFixture; import net.teumteum.user.domain.UserRepository; import org.springframework.boot.test.context.TestComponent; +import org.springframework.context.annotation.Import; + +import java.util.List; +import java.util.stream.Stream; @TestComponent +@Import(AppConfig.class) @RequiredArgsConstructor class Repository { private final UserRepository userRepository; private final MeetingRepository meetingRepository; + private final EntityManager entityManager; User saveAndGetUser() { var user = UserFixture.getNullIdUser(); return userRepository.saveAndFlush(user); } + List getAllUser() { + return userRepository.findAll(); + } + + Meeting saveAndGetOpenMeeting() { var meeting = MeetingFixture.getOpenMeeting(); return meetingRepository.saveAndFlush(meeting); @@ -31,65 +42,65 @@ Meeting saveAndGetOpenMeeting() { List saveAndGetOpenMeetingsByTopic(int size, Topic topic) { var meetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithTopic(topic)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetCloseMeetingsTopic(int size, Topic topic) { var meetings = Stream.generate(() -> MeetingFixture.getCloseMeetingWithTopic(topic)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetOpenMeetingsByTitle(int size, String title) { var meetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithTitle(title)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetCloseMeetingsByTitle(int size, String title) { var meetings = Stream.generate(() -> MeetingFixture.getCloseMeetingWithTitle(title)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetOpenMeetingsByIntroduction(int size, String introduction) { var meetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithIntroduction(introduction)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetCloseMeetingsByIntroduction(int size, String introduction) { var meetings = Stream.generate(() -> MeetingFixture.getCloseMeetingWithIntroduction(introduction)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetOpenMeetingsByParticipantUserId(int size, Long participantUserId) { var meetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithParticipantUserId(participantUserId)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetCloseMeetingsByParticipantUserId(int size, Long participantUserId) { var meetings = Stream.generate(() -> MeetingFixture.getCloseMeetingWithParticipantUserId(participantUserId)) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } List saveAndGetOpenMeetings(int size) { var meetings = Stream.generate(MeetingFixture::getOpenMeeting) - .limit(size) - .toList(); + .limit(size) + .toList(); return meetingRepository.saveAllAndFlush(meetings); } @@ -97,5 +108,4 @@ void clear() { userRepository.deleteAll(); meetingRepository.deleteAll(); } - } diff --git a/src/test/java/net/teumteum/integration/SecurityContextSetting.java b/src/test/java/net/teumteum/integration/SecurityContextSetting.java new file mode 100644 index 00000000..a90a09f9 --- /dev/null +++ b/src/test/java/net/teumteum/integration/SecurityContextSetting.java @@ -0,0 +1,18 @@ +package net.teumteum.integration; + +import net.teumteum.core.security.UserAuthentication; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.UserFixture; +import org.springframework.boot.test.context.TestComponent; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +@TestComponent +public class SecurityContextSetting { + public void set() { + User user = UserFixture.getIdUser(); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(new UserAuthentication(user)); + SecurityContextHolder.setContext(context); + } +} diff --git a/src/test/java/net/teumteum/integration/TestLoginContext.java b/src/test/java/net/teumteum/integration/TestLoginContext.java index 62d8c866..d184c563 100644 --- a/src/test/java/net/teumteum/integration/TestLoginContext.java +++ b/src/test/java/net/teumteum/integration/TestLoginContext.java @@ -9,12 +9,12 @@ public class TestLoginContext implements LoginContext { private Long userId; @Override - public void setUserId(Long userId) { - this.userId = userId; + public Long getUserId() { + return this.userId; } @Override - public Long getUserId() { - return this.userId; + public void setUserId(Long userId) { + this.userId = userId; } } diff --git a/src/test/java/net/teumteum/integration/UserIntegrationTest.java b/src/test/java/net/teumteum/integration/UserIntegrationTest.java index b136783d..f19f93fb 100644 --- a/src/test/java/net/teumteum/integration/UserIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/UserIntegrationTest.java @@ -1,7 +1,7 @@ package net.teumteum.integration; -import java.util.List; import net.teumteum.core.error.ErrorResponse; +import net.teumteum.user.domain.User; import net.teumteum.user.domain.response.FriendsResponse; import net.teumteum.user.domain.response.UserGetResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; @@ -10,12 +10,15 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.util.List; + @DisplayName("유저 통합테스트의") class UserIntegrationTest extends IntegrationTest { private static final String VALID_TOKEN = "VALID_TOKEN"; private static final String INVALID_TOKEN = "IN_VALID_TOKEN"; + @Nested @DisplayName("유저 조회 API는") class Find_user_api { @@ -32,11 +35,11 @@ void Return_user_info_if_exist_user_id_received() { // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(UserGetResponse.class) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(UserGetResponse.class) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); } @Test @@ -50,7 +53,7 @@ void Return_400_bad_request_if_not_exists_user_id_received() { // then result.expectStatus().isBadRequest() - .expectBody(ErrorResponse.class); + .expectBody(ErrorResponse.class); } } @@ -72,9 +75,9 @@ void Return_user_info_if_exist_user_ids_received() { // then Assertions.assertThat(result.expectStatus().isOk() - .expectBody(UsersGetByIdResponse.class) - .returnResult() - .getResponseBody() + .expectBody(UsersGetByIdResponse.class) + .returnResult() + .getResponseBody() ).usingRecursiveComparison().isEqualTo(expected); } @@ -112,10 +115,9 @@ class Update_user_api { void Update_user_info() { // given var existUser = repository.saveAndGetUser(); + List allUser = repository.getAllUser(); var updateUser = RequestFixture.userUpdateRequest(existUser); - loginContext.setUserId(existUser.getId()); - // when var result = api.updateUser(VALID_TOKEN, updateUser); @@ -136,8 +138,6 @@ void Return_200_ok_with_success_make_friends() { var myToken = "JWT MY_TOKEN"; var friend = repository.saveAndGetUser(); - loginContext.setUserId(me.getId()); - // when var result = api.addFriends(myToken, friend.getId()); diff --git a/src/test/java/net/teumteum/user/domain/UserFixture.java b/src/test/java/net/teumteum/user/domain/UserFixture.java index b463beaa..5365cb14 100644 --- a/src/test/java/net/teumteum/user/domain/UserFixture.java +++ b/src/test/java/net/teumteum/user/domain/UserFixture.java @@ -1,22 +1,31 @@ package net.teumteum.user.domain; +import lombok.Builder; + import java.util.List; import java.util.Set; import java.util.UUID; -import lombok.Builder; + +import static net.teumteum.core.security.Authenticated.네이버; public class UserFixture { public static User getNullIdUser() { return newUserByBuilder(UserBuilder.builder() - .id(null) - .build()); + .id(null) + .build()); + } + + public static User getIdUser() { + return newUserByBuilder(UserBuilder.builder() + .id(1L) + .build()); } public static User getUserWithId(Long id) { return newUserByBuilder(UserBuilder.builder() - .id(id) - .build()); + .id(id) + .build()); } public static User getDefaultUser() { @@ -25,20 +34,21 @@ public static User getDefaultUser() { public static User newUserByBuilder(UserBuilder userBuilder) { return new User( - userBuilder.id, - userBuilder.name, - userBuilder.birth, - userBuilder.characterId, - userBuilder.mannerTemperature, - userBuilder.oauth, - userBuilder.activityArea, - userBuilder.mbti, - userBuilder.status, - userBuilder.goal, - userBuilder.job, - userBuilder.interests, - userBuilder.terms, - Set.of() + userBuilder.id, + userBuilder.name, + userBuilder.birth, + userBuilder.characterId, + userBuilder.mannerTemperature, + userBuilder.oauth, + userBuilder.roleType, + userBuilder.activityArea, + userBuilder.mbti, + userBuilder.status, + userBuilder.goal, + userBuilder.job, + userBuilder.interests, + userBuilder.terms, + Set.of() ); } @@ -56,7 +66,9 @@ public static class UserBuilder { @Builder.Default private int mannerTemperature = 36; @Builder.Default - private Oauth oauth = new Oauth(UUID.randomUUID().toString(), "naver"); + private OAuth oauth = new OAuth(UUID.randomUUID().toString(), 네이버); + @Builder.Default + private RoleType roleType = RoleType.ROLE_USER; @Builder.Default private ActivityArea activityArea = new ActivityArea("서울", List.of("강남", "홍대")); @Builder.Default @@ -69,7 +81,7 @@ public static class UserBuilder { private Job job = new Job("netflix", true, "developer", "backend"); @Builder.Default private List interests = List.of( - "game", "sleep", "Eating delicious food" + "game", "sleep", "Eating delicious food" ); @Builder.Default private Terms terms = new Terms(true, true); diff --git a/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java b/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java index 19ad6119..f786240b 100644 --- a/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java +++ b/src/test/java/net/teumteum/user/domain/UserRepositoryTest.java @@ -1,15 +1,19 @@ package net.teumteum.user.domain; import jakarta.persistence.EntityManager; -import java.util.Optional; +import net.teumteum.core.config.AppConfig; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.util.Optional; @DataJpaTest +@Import(AppConfig.class) @DisplayName("UserRepository 클래스의") class UserRepositoryTest { @@ -56,10 +60,10 @@ void Find_success_if_exists_user_id_input() { // then Assertions.assertThat(result) - .isPresent() - .usingRecursiveComparison() - .ignoringFields("value.createdAt", "value.updatedAt") - .isEqualTo(Optional.of(existsUser)); + .isPresent() + .usingRecursiveComparison() + .ignoringFields("value.createdAt", "value.updatedAt") + .isEqualTo(Optional.of(existsUser)); } } diff --git a/src/test/java/net/teumteum/user/service/UserConnectorTest.java b/src/test/java/net/teumteum/user/service/UserConnectorTest.java index 09730adc..f3676f34 100644 --- a/src/test/java/net/teumteum/user/service/UserConnectorTest.java +++ b/src/test/java/net/teumteum/user/service/UserConnectorTest.java @@ -1,6 +1,5 @@ package net.teumteum.user.service; -import java.util.Optional; import net.teumteum.user.domain.UserConnector; import net.teumteum.user.domain.UserFixture; import net.teumteum.user.domain.UserRepository; @@ -16,6 +15,8 @@ import org.springframework.test.context.ContextConfiguration; import org.springframework.test.context.junit.jupiter.SpringExtension; +import java.util.Optional; + @ExtendWith(SpringExtension.class) @DisplayName("UserConnector 클래스의") @ContextConfiguration(classes = UserConnectorImpl.class) @@ -50,10 +51,10 @@ void Return_optional_user_if_exists_user_id() { // then Assertions.assertThat(result) - .isPresent() - .usingRecursiveComparison() - .ignoringFields("value.oauth.oAuthAuthenticateInfo") - .isEqualTo(expect); + .isPresent() + .usingRecursiveComparison() + .ignoringFields("value.oauth.oauthId") + .isEqualTo(expect); } } diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index bab49cd6..7702ec21 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -1,18 +1,14 @@ spring.profiles.active=test - -spring.datasource.driver-class-name = org.h2.Driver -spring.datasource.url = jdbc:h2:mem:test;MODE=MySQL;DATABASE_TO_LOWER=TRUE - -spring.jpa.hibernated.ddl-auto = validate -spring.jpa.database-platform = org.hibernate.dialect.MySQLDialect - -spring.datasource.hikari.maximum-pool-size = 4 -spring.datasource.hikari.pool-name = H2_TEST_POOL - +spring.datasource.driver-class-name=org.h2.Driver +spring.datasource.url=jdbc:h2:mem:test;MODE=MySQL;DATABASE_TO_LOWER=TRUE +spring.jpa.hibernated.ddl-auto=validate +spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect +spring.datasource.hikari.maximum-pool-size=4 +spring.datasource.hikari.pool-name=H2_TEST_POOL ### FOR DEBUGGING ### -logging.level.org.hibernate.SQL = debug -logging.level.org.hibernate.type.descriptor.sql = trace +logging.level.org.hibernate.SQL=debug +logging.level.org.hibernate.type.descriptor.sql=trace +spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.highlight_sql=true +spring.jpa.properties.hibernate.use_sql_comments=true -spring.jpa.properties.hibernate.format_sql = true -spring.jpa.properties.hibernate.highlight_sql = true -spring.jpa.properties.hibernate.use_sql_comments = true diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index 9ef35d51..bb71abc2 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -8,8 +8,9 @@ create table if not exists users birth varchar(10), name varchar(10), goal varchar(50), - authenticated varchar(255), - oauth_authenticate_info varchar(255) unique, + oauth_id varchar(255) not null unique, + authenticated varchar(255) not null, + role_type varchar(255), city varchar(255), detail_job_class varchar(255), job_class varchar(255), From be0aa7e02603cb64736c1c7c12f41532cd671190 Mon Sep 17 00:00:00 2001 From: ddingmin Date: Sun, 7 Jan 2024 16:45:14 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EB=AA=A8?= =?UTF-8?q?=EC=9E=84=20=EC=B0=B8=EC=97=AC=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#34)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 유저가 Meeting에 참여한다. - Collection List -> Set 변경 * feat: 종료된 모임에 참여할 수 없도록 예외 추가 * test: 모임 참여에 대한 테스트 작성 - 정상적인 동작 테스트 - 종료된 모임에 대한 예외 테스트 - 최대 인원이 초과된 예외 테스트 - 이미 참여한 모임에 대한 예외 테스트 * feat: 유저 정보 가져오는 방식 수정 + 테스트 수정 --- .../meeting/controller/MeetingController.java | 10 ++ .../net/teumteum/meeting/domain/Meeting.java | 25 ++- .../domain/response/MeetingResponse.java | 6 +- .../domain/response/MeetingsResponse.java | 5 +- .../meeting/service/MeetingService.java | 16 ++ .../java/net/teumteum/integration/Api.java | 8 + .../integration/MeetingIntegrationTest.java | 168 +++++++++++++----- .../net/teumteum/integration/Repository.java | 16 +- .../meeting/domain/MeetingFixture.java | 20 ++- 9 files changed, 211 insertions(+), 63 deletions(-) diff --git a/src/main/java/net/teumteum/meeting/controller/MeetingController.java b/src/main/java/net/teumteum/meeting/controller/MeetingController.java index df4e9149..6f3d88a5 100644 --- a/src/main/java/net/teumteum/meeting/controller/MeetingController.java +++ b/src/main/java/net/teumteum/meeting/controller/MeetingController.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import net.teumteum.core.error.ErrorResponse; +import net.teumteum.core.security.service.SecurityService; import net.teumteum.meeting.domain.Topic; import net.teumteum.meeting.domain.response.MeetingResponse; import net.teumteum.meeting.domain.response.MeetingsResponse; @@ -18,6 +19,8 @@ public class MeetingController { private final MeetingService meetingService; + private final SecurityService securityService; + @GetMapping("/{meetingId}") @ResponseStatus(HttpStatus.OK) public MeetingResponse getMeetingById(@PathVariable("meetingId") Long meetingId) { @@ -36,6 +39,13 @@ public PageDto getMeetingsOrderByDate(Pageable pageable, return meetingService.getMeetingsBySpecification(pageable, topic, meetingAreaStreet, participantUserId, searchWord, isOpen); } + @PostMapping("/{meetingId}/participants") + @ResponseStatus(HttpStatus.CREATED) + public MeetingResponse addParticipant(@PathVariable("meetingId") Long meetingId) { + Long userId = securityService.getCurrentUserId(); + return meetingService.addParticipant(meetingId, userId); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResponse handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { diff --git a/src/main/java/net/teumteum/meeting/domain/Meeting.java b/src/main/java/net/teumteum/meeting/domain/Meeting.java index 97e3731b..58f3309e 100644 --- a/src/main/java/net/teumteum/meeting/domain/Meeting.java +++ b/src/main/java/net/teumteum/meeting/domain/Meeting.java @@ -8,8 +8,8 @@ import org.springframework.util.Assert; import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; +import java.util.HashSet; +import java.util.Set; @Entity @Getter @@ -29,7 +29,7 @@ public class Meeting extends TimeBaseEntity { private Long hostUserId; @ElementCollection(fetch = FetchType.EAGER) - private List participantUserIds = new ArrayList<>(); + private Set participantUserIds = new HashSet<>(); @Column(name = "topic") @Enumerated(EnumType.STRING) @@ -48,7 +48,20 @@ public class Meeting extends TimeBaseEntity { private LocalDateTime promiseDateTime; @ElementCollection(fetch = FetchType.EAGER) - private List imageUrls = new ArrayList<>(); + private Set imageUrls = new HashSet<>(); + + public void addParticipant(Long userId) { + assertParticipantUserIds(); + participantUserIds.add(userId); + } + + public boolean alreadyParticipant(Long userId) { + return participantUserIds.contains(userId); + } + + public boolean isOpen() { + return promiseDateTime.isAfter(LocalDateTime.now()); + } @PrePersist private void assertField() { @@ -69,4 +82,8 @@ private void assertTitle() { Assert.isTrue(title.length() >= 2 && title.length() <= 32, "모임 제목은 2자 ~ 32자 사이가 되어야 합니다. [현재 입력된 모임 제목] : " + title); } + private void assertParticipantUserIds() { + Assert.isTrue(participantUserIds.size() + 1 <= numberOfRecruits, "최대 참여자 수에 도달한 모임에 참여할 수 없습니다." + "[최대 참여자 수] : " + numberOfRecruits + "[현재 참여자 수] : " + participantUserIds.size()); + } + } diff --git a/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java b/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java index e115d62a..f1bd40e6 100644 --- a/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java +++ b/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java @@ -5,7 +5,7 @@ import net.teumteum.meeting.domain.Topic; import java.time.LocalDateTime; -import java.util.List; +import java.util.Set; public record MeetingResponse( Long id, @@ -13,11 +13,11 @@ public record MeetingResponse( Topic topic, String title, String introduction, - List photoUrls, + Set photoUrls, LocalDateTime promiseDateTime, int numberOfRecruits, MeetingArea meetingArea, - List participantIds + Set participantIds ) { public static MeetingResponse of( Meeting meeting diff --git a/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java b/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java index 44d420e1..4f815245 100644 --- a/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java +++ b/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Set; public record MeetingsResponse( List meetings @@ -23,11 +24,11 @@ public record MeetingResponse( Topic topic, String title, String introduction, - List photoUrls, + Set photoUrls, LocalDateTime promiseDateTime, int numberOfRecruits, MeetingArea meetingArea, - List participantIds + Set participantIds ) { public static MeetingResponse of( Meeting meeting diff --git a/src/main/java/net/teumteum/meeting/service/MeetingService.java b/src/main/java/net/teumteum/meeting/service/MeetingService.java index 9e39a2ac..6396d295 100644 --- a/src/main/java/net/teumteum/meeting/service/MeetingService.java +++ b/src/main/java/net/teumteum/meeting/service/MeetingService.java @@ -52,4 +52,20 @@ else if (searchWord != null) { return PageDto.of(MeetingsResponse.of(meetings.getContent()), meetings.hasNext()); } + @Transactional + public MeetingResponse addParticipant(Long meetingId, Long userId) { + var existMeeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new IllegalArgumentException("meetingId에 해당하는 모임을 찾을 수 없습니다. \"" + meetingId + "\"")); + + if (existMeeting.alreadyParticipant(userId)) { + throw new IllegalArgumentException("이미 참여한 모임입니다."); + } + + if (!existMeeting.isOpen()) { + throw new IllegalArgumentException("모임 참여 기간이 종료되었습니다."); + } + + existMeeting.addParticipant(userId); + return MeetingResponse.of(existMeeting); + } } diff --git a/src/test/java/net/teumteum/integration/Api.java b/src/test/java/net/teumteum/integration/Api.java index 798c6f31..423d0731 100644 --- a/src/test/java/net/teumteum/integration/Api.java +++ b/src/test/java/net/teumteum/integration/Api.java @@ -94,4 +94,12 @@ ResponseSpec getMeetingsByTopic(String token, Pageable pageable, boolean isOpen, .header(HttpHeaders.AUTHORIZATION, token) .exchange(); } + + ResponseSpec joinMeeting(String token, Long meetingId) { + return webTestClient.post() + .uri("/meetings/" + meetingId + "/participants") + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); + } + } diff --git a/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java b/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java index 12f77d61..8e5c1418 100644 --- a/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java @@ -1,8 +1,5 @@ package net.teumteum.integration; -import java.util.Collection; -import java.util.Comparator; -import java.util.stream.Stream; import net.teumteum.core.error.ErrorResponse; import net.teumteum.meeting.domain.Meeting; import net.teumteum.meeting.domain.Topic; @@ -10,6 +7,7 @@ import net.teumteum.meeting.domain.response.MeetingsResponse; import net.teumteum.meeting.model.PageDto; import org.assertj.core.api.Assertions; +import org.assertj.core.api.Condition; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -18,6 +16,10 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; +import java.util.Collection; +import java.util.Comparator; +import java.util.stream.Stream; + @DisplayName("미팅 통합테스트의") class MeetingIntegrationTest extends IntegrationTest { @@ -40,11 +42,11 @@ void Return_meeting_info_if_exist_meeting_id_received() { var result = api.getMeetingById(VALID_TOKEN, meeting.getId()); // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(MeetingResponse.class) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(MeetingResponse.class) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); } @Test @@ -56,7 +58,7 @@ void Return_400_bad_request_if_not_exists_meeting_id_received() { var result = api.getMeetingById(VALID_TOKEN, notExistMeetingId); // then result.expectStatus().isBadRequest() - .expectBody(ErrorResponse.class); + .expectBody(ErrorResponse.class); } } @@ -73,9 +75,9 @@ void Return_meeting_list_if_topic_and_page_nation_received() { var closeTopicMeetingsByTopic = repository.saveAndGetOpenMeetingsByTopic(size, Topic.고민_나누기); var expectedData = MeetingsResponse.of( - openMeetingsByTopic.stream() - .sorted(Comparator.comparing(Meeting::getId).reversed()) - .toList() + openMeetingsByTopic.stream() + .sorted(Comparator.comparing(Meeting::getId).reversed()) + .toList() ); var expected = PageDto.of(expectedData, false); @@ -84,12 +86,12 @@ void Return_meeting_list_if_topic_and_page_nation_received() { var result = api.getMeetingsByTopic(VALID_TOKEN, FIRST_PAGE_NATION, true, Topic.스터디); // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(new ParameterizedTypeReference>() { - }) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() { + }) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); } @Test @@ -100,14 +102,14 @@ void Return_meeting_list_if_search_word_and_page_nation_received() { var openMeetingsByTitle = repository.saveAndGetOpenMeetingsByTitle(size, "개발자 스터디"); var closeMeetingsByTitle = repository.saveAndGetCloseMeetingsByTitle(size, "개발자 스터디"); var openMeetingsByIntroduction = repository.saveAndGetOpenMeetingsByIntroduction(size, - "개발자 스터디에 대한 설명입니다."); + "개발자 스터디에 대한 설명입니다."); var closeMeetingsByIntroduction = repository.saveAndGetCloseMeetingsByIntroduction(size, - "개발자 스터디에 대한 설명입니다."); + "개발자 스터디에 대한 설명입니다."); var expectedData = MeetingsResponse.of(Stream.of(openMeetingsByIntroduction, openMeetingsByTitle) - .flatMap(Collection::stream) - .sorted(Comparator.comparing(Meeting::getId).reversed()) - .toList() + .flatMap(Collection::stream) + .sorted(Comparator.comparing(Meeting::getId).reversed()) + .toList() ); var expected = PageDto.of(expectedData, false); @@ -116,12 +118,12 @@ void Return_meeting_list_if_search_word_and_page_nation_received() { var result = api.getMeetingsByTopic(VALID_TOKEN, FIRST_PAGE_NATION, true, Topic.스터디); // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(new ParameterizedTypeReference>() { - }) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() { + }) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); } @Test @@ -133,9 +135,9 @@ void Return_meeting_list_if_participant_user_id_and_page_nation_received() { var closeMeetingsByParticipantUserId = repository.saveAndGetCloseMeetingsByParticipantUserId(size, 2L); var expectedData = MeetingsResponse.of( - openMeetingsByParticipantUserId.stream() - .sorted(Comparator.comparing(Meeting::getId).reversed()) - .toList() + openMeetingsByParticipantUserId.stream() + .sorted(Comparator.comparing(Meeting::getId).reversed()) + .toList() ); var expected = PageDto.of(expectedData, false); @@ -144,12 +146,12 @@ void Return_meeting_list_if_participant_user_id_and_page_nation_received() { var result = api.getMeetingsByTopic(VALID_TOKEN, FIRST_PAGE_NATION, true, Topic.스터디); // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(new ParameterizedTypeReference>() { - }) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() { + }) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); } @Test @@ -160,10 +162,10 @@ void Return_has_next_true_if_more_data_exists_than_requested_size_and_page() { var openMeetingsByTopic = repository.saveAndGetOpenMeetingsByTopic(size, Topic.스터디); var expectedData = MeetingsResponse.of( - openMeetingsByTopic.stream() - .sorted(Comparator.comparing(Meeting::getId).reversed()) - .toList() - .subList(0, DEFAULT_QUERY_SIZE) + openMeetingsByTopic.stream() + .sorted(Comparator.comparing(Meeting::getId).reversed()) + .toList() + .subList(0, DEFAULT_QUERY_SIZE) ); var expected = PageDto.of(expectedData, true); @@ -172,12 +174,82 @@ void Return_has_next_true_if_more_data_exists_than_requested_size_and_page() { var result = api.getMeetingsByTopic(VALID_TOKEN, FIRST_PAGE_NATION, true, Topic.스터디); // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(new ParameterizedTypeReference>() { - }) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(new ParameterizedTypeReference>() { + }) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + @DisplayName("미팅 참여 API는") + class Join_meeting_api { + + @Test + @DisplayName("존재하는 모임의 id가 주어지면, 모임에 참여한다.") + void Join_meeting_if_exist_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var existMeeting = repository.saveAndGetOpenMeeting(); + + loginContext.setUserId(me.getId()); + // when + var result = api.joinMeeting(VALID_TOKEN, existMeeting.getId()); + // then + Assertions.assertThat( + result.expectStatus().isCreated() + .expectBody(MeetingResponse.class) + .returnResult() + .getResponseBody()) + .extracting(MeetingResponse::participantIds) + .has(new Condition<>(ids -> ids.contains(me.getId()), "참여자 목록에 나를 포함한다.") + ); + } + + @Test + @DisplayName("이미 참여한 모임의 id가 주어지면, 400 Bad Request를 응답한다.") + void Return_400_bad_request_if_already_joined_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetOpenMeeting(); + + loginContext.setUserId(me.getId()); + api.joinMeeting(VALID_TOKEN, meeting.getId()); + // when + var result = api.joinMeeting(VALID_TOKEN, meeting.getId()); + // then + result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class); + } + + @Test + @DisplayName("종료된 모임의 id가 주어진다면, 400 Bad Request를 응답한다.") + void Return_400_bad_request_if_closed_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetCloseMeeting(); + + loginContext.setUserId(0L); + // when + var result = api.joinMeeting(VALID_TOKEN, meeting.getId()); + // then + result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class); + } + + @Test + @DisplayName("최대 인원이 초과된 모임의 id가 주어지면, 400 Bad Request를 응답한다.") + void Return_400_bad_request_if_exceed_max_number_of_recruits_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetOpenFullMeeting(); + // when + var result = api.joinMeeting(VALID_TOKEN, meeting.getId()); + // then + result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class); } } } diff --git a/src/test/java/net/teumteum/integration/Repository.java b/src/test/java/net/teumteum/integration/Repository.java index b53418ca..8a5ce133 100644 --- a/src/test/java/net/teumteum/integration/Repository.java +++ b/src/test/java/net/teumteum/integration/Repository.java @@ -1,5 +1,6 @@ package net.teumteum.integration; + import jakarta.persistence.EntityManager; import lombok.RequiredArgsConstructor; import net.teumteum.core.config.AppConfig; @@ -16,12 +17,15 @@ import java.util.List; import java.util.stream.Stream; +import java.util.List; +import java.util.stream.Stream; + @TestComponent @Import(AppConfig.class) @RequiredArgsConstructor class Repository { - private final UserRepository userRepository; + private final MeetingRepository meetingRepository; private final EntityManager entityManager; @@ -40,6 +44,16 @@ Meeting saveAndGetOpenMeeting() { return meetingRepository.saveAndFlush(meeting); } + Meeting saveAndGetCloseMeeting() { + var meeting = MeetingFixture.getCloseMeeting(); + return meetingRepository.saveAndFlush(meeting); + } + + Meeting saveAndGetOpenFullMeeting() { + var meeting = MeetingFixture.getOpenFullMeeting(); + return meetingRepository.saveAndFlush(meeting); + } + List saveAndGetOpenMeetingsByTopic(int size, Topic topic) { var meetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithTopic(topic)) .limit(size) diff --git a/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java b/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java index b24a4973..9b134f62 100644 --- a/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java +++ b/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java @@ -3,8 +3,9 @@ import lombok.Builder; import java.time.LocalDateTime; -import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class MeetingFixture { @@ -26,6 +27,15 @@ public static Meeting getCloseMeeting() { ); } + public static Meeting getOpenFullMeeting() { + return newMeetingByBuilder(MeetingBuilder.builder() + .promiseDateTime(LocalDateTime.of(4000, 1, 1, 0, 0)) + .numberOfRecruits(2) + .participantUserIds(new HashSet<>(List.of(0L, 1L))) + .build() + ); + } + public static Meeting getOpenMeetingWithTopic(Topic topic) { return newMeetingByBuilder(MeetingBuilder.builder() .promiseDateTime(LocalDateTime.of(4000, 1, 1, 0, 0)) @@ -53,7 +63,7 @@ public static Meeting getOpenMeetingWithStreet(String street) { public static Meeting getOpenMeetingWithParticipantUserId(Long participantUserId) { return newMeetingByBuilder(MeetingBuilder.builder() .promiseDateTime(LocalDateTime.of(4000, 1, 1, 0, 0)) - .participantUserIds(new ArrayList<>(List.of(participantUserId))) + .participantUserIds(new HashSet<>(List.of(participantUserId))) .build() ); } @@ -61,7 +71,7 @@ public static Meeting getOpenMeetingWithParticipantUserId(Long participantUserId public static Meeting getCloseMeetingWithParticipantUserId(Long participantUserId) { return newMeetingByBuilder(MeetingBuilder.builder() .promiseDateTime(LocalDateTime.of(2000, 1, 1, 0, 0)) - .participantUserIds(new ArrayList<>(List.of(participantUserId))) + .participantUserIds(new HashSet<>(List.of(participantUserId))) .build() ); } @@ -125,7 +135,7 @@ public static class MeetingBuilder { private Long hostUserId = 0L; @Builder.Default - private List participantUserIds = new ArrayList<>(List.of(0L)); + private Set participantUserIds = new HashSet<>(List.of(0L)); @Builder.Default private Topic topic = Topic.스터디; @@ -143,7 +153,7 @@ public static class MeetingBuilder { private LocalDateTime promiseDateTime = LocalDateTime.of(2024, 10, 10, 0, 0); @Builder.Default - private List imageUrls = new ArrayList<>(List.of("https://www.google.com")); + private Set imageUrls = new HashSet<>(List.of("https://www.google.com")); } } From 0f92d66bec725381f6711fe7a1b650f81cabeaef Mon Sep 17 00:00:00 2001 From: ddingmin Date: Mon, 8 Jan 2024 06:20:52 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=EA=B0=80=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=ED=95=9C=20=EB=AA=A8=EC=9E=84=EC=9D=84=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=ED=95=9C=EB=8B=A4.=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 유저가 참여한 모임에 모임을 취소한다. * test: 모임 참여 취소에 대한 테스트를 작성한다. - 참여된 모임의 참여 취소를 성공한다. - 참여하지 않은 모임에 대한 예외 케이스 - 종료된 모임에 대한 예외 케이스 --- .../meeting/controller/MeetingController.java | 7 +++ .../net/teumteum/meeting/domain/Meeting.java | 5 +- .../meeting/service/MeetingService.java | 16 +++++ .../java/net/teumteum/integration/Api.java | 6 ++ .../integration/MeetingIntegrationTest.java | 60 +++++++++++++++++++ 5 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/teumteum/meeting/controller/MeetingController.java b/src/main/java/net/teumteum/meeting/controller/MeetingController.java index 6f3d88a5..a66a9ea1 100644 --- a/src/main/java/net/teumteum/meeting/controller/MeetingController.java +++ b/src/main/java/net/teumteum/meeting/controller/MeetingController.java @@ -46,6 +46,13 @@ public MeetingResponse addParticipant(@PathVariable("meetingId") Long meetingId) return meetingService.addParticipant(meetingId, userId); } + @DeleteMapping("/{meetingId}/participants") + @ResponseStatus(HttpStatus.OK) +public void deleteParticipant(@PathVariable("meetingId") Long meetingId) { + Long userId = securityService.getCurrentUserId(); + meetingService.cancelParticipant(meetingId, userId); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResponse handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { diff --git a/src/main/java/net/teumteum/meeting/domain/Meeting.java b/src/main/java/net/teumteum/meeting/domain/Meeting.java index 58f3309e..7c1a2c02 100644 --- a/src/main/java/net/teumteum/meeting/domain/Meeting.java +++ b/src/main/java/net/teumteum/meeting/domain/Meeting.java @@ -55,6 +55,10 @@ public void addParticipant(Long userId) { participantUserIds.add(userId); } + public void cancelParticipant(Long userId) { + participantUserIds.remove(userId); + } + public boolean alreadyParticipant(Long userId) { return participantUserIds.contains(userId); } @@ -85,5 +89,4 @@ private void assertTitle() { private void assertParticipantUserIds() { Assert.isTrue(participantUserIds.size() + 1 <= numberOfRecruits, "최대 참여자 수에 도달한 모임에 참여할 수 없습니다." + "[최대 참여자 수] : " + numberOfRecruits + "[현재 참여자 수] : " + participantUserIds.size()); } - } diff --git a/src/main/java/net/teumteum/meeting/service/MeetingService.java b/src/main/java/net/teumteum/meeting/service/MeetingService.java index 6396d295..b124fe27 100644 --- a/src/main/java/net/teumteum/meeting/service/MeetingService.java +++ b/src/main/java/net/teumteum/meeting/service/MeetingService.java @@ -68,4 +68,20 @@ public MeetingResponse addParticipant(Long meetingId, Long userId) { existMeeting.addParticipant(userId); return MeetingResponse.of(existMeeting); } + + @Transactional + public void cancelParticipant(Long meetingId, Long userId) { + var existMeeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new IllegalArgumentException("meetingId에 해당하는 모임을 찾을 수 없습니다. \"" + meetingId + "\"")); + + if (!existMeeting.isOpen()) { + throw new IllegalArgumentException("종료된 모임에서 참여를 취소할 수 없습니다."); + } + + if (!existMeeting.alreadyParticipant(userId)) { + throw new IllegalArgumentException("참여하지 않은 모임입니다."); + } + + existMeeting.cancelParticipant(userId); + } } diff --git a/src/test/java/net/teumteum/integration/Api.java b/src/test/java/net/teumteum/integration/Api.java index 423d0731..9baa7e0d 100644 --- a/src/test/java/net/teumteum/integration/Api.java +++ b/src/test/java/net/teumteum/integration/Api.java @@ -102,4 +102,10 @@ ResponseSpec joinMeeting(String token, Long meetingId) { .exchange(); } + ResponseSpec cancelMeeting(String token, Long meetingId) { + return webTestClient.delete() + .uri("/meetings/" + meetingId + "/participants") + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); + } } diff --git a/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java b/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java index 8e5c1418..20a6348a 100644 --- a/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/MeetingIntegrationTest.java @@ -252,4 +252,64 @@ void Return_400_bad_request_if_exceed_max_number_of_recruits_meeting_id_received .expectBody(ErrorResponse.class); } } + + @Nested + @DisplayName("미팅 참여 취소 API는") + class Cancel_meeting_api { + + @Test + @DisplayName("존재하는 모임의 id가 주어지면, 모임에 참여를 취소한다.") + void Cancel_meeting_if_exist_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetOpenMeeting(); + + loginContext.setUserId(me.getId()); + api.joinMeeting(VALID_TOKEN, meeting.getId()); + // when + var result = api.cancelMeeting(VALID_TOKEN, meeting.getId()); + // then + result.expectStatus().isOk(); + } + + @Test + @DisplayName("참여하지 않은 모임의 id가 주어지면, 400 Bad Request를 응답한다.") + void Return_400_bad_request_if_not_joined_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetOpenMeeting(); + + loginContext.setUserId(me.getId()); + // when + var result = api.cancelMeeting(VALID_TOKEN, meeting.getId()); + // then + Assertions.assertThat(result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class) + .returnResult() + .getResponseBody() + ) + .extracting(ErrorResponse::getMessage) + .isEqualTo("참여하지 않은 모임입니다."); + } + + @Test + @DisplayName("종료된 모임의 id가 주어진다면, 400 Bad Request를 응답한다.") + void Return_400_bad_request_if_closed_meeting_id_received() { + // given + var me = repository.saveAndGetUser(); + var meeting = repository.saveAndGetCloseMeeting(); + + loginContext.setUserId(me.getId()); + // when + var result = api.cancelMeeting(VALID_TOKEN, meeting.getId()); + // then + Assertions.assertThat(result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class) + .returnResult() + .getResponseBody() + ) + .extracting(ErrorResponse::getMessage) + .isEqualTo("종료된 모임에서 참여를 취소할 수 없습니다."); + } + } } From 2bb4b0e82b5a21cd2c5dc0f582d8c5330b90839d Mon Sep 17 00:00:00 2001 From: xb205 <62425964+devxb@users.noreply.github.com> Date: Wed, 10 Jan 2024 13:36:58 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20=EA=B4=80?= =?UTF-8?q?=EC=8B=AC=EC=A7=88=EB=AC=B8=20=EC=83=9D=EC=84=B1=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B0=9C=EB=B0=9C=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 통합테스트를 작성한다 * feat: GPT 통신 클래스를 정의하고 통신기능을 구현한다 * feat: UserService와 Controller를 구현한다 * feat: 유저들의 공통 관심사를 기반으로 질문을 찾는 API를 개발한다 * refactor: retry 횟수를 상수로 뺀다 --- gradle.properties | 7 +- gradle/test.gradle | 2 + .../user/controller/UserController.java | 25 +++- .../user/domain/InterestQuestion.java | 11 ++ .../response/InterestQuestionResponse.java | 10 ++ .../user/infra/GptInterestQuestion.java | 108 ++++++++++++++++++ .../user/infra/WebClientConfigurer.java | 17 +++ .../teumteum/user/service/UserService.java | 14 +++ src/main/resources/application-prod.yml | 4 + .../java/net/teumteum/integration/Api.java | 76 ++++++------ .../teumteum/integration/IntegrationTest.java | 9 +- .../integration/UserIntegrationTest.java | 22 ++-- .../user/infra/GptInterestQuestionTest.java | 93 +++++++++++++++ .../teumteum/user/infra/GptTestServer.java | 61 ++++++++++ src/test/resources/application.properties | 3 + 15 files changed, 407 insertions(+), 55 deletions(-) create mode 100644 src/main/java/net/teumteum/user/domain/InterestQuestion.java create mode 100644 src/main/java/net/teumteum/user/domain/response/InterestQuestionResponse.java create mode 100644 src/main/java/net/teumteum/user/infra/GptInterestQuestion.java create mode 100644 src/main/java/net/teumteum/user/infra/WebClientConfigurer.java create mode 100644 src/test/java/net/teumteum/user/infra/GptInterestQuestionTest.java create mode 100644 src/test/java/net/teumteum/user/infra/GptTestServer.java diff --git a/gradle.properties b/gradle.properties index a6f7cdc0..7f7f907b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,20 +1,17 @@ ### PROJECT ### projectGroup=net.teumteum projectVersion=1.0 - ### TEST ### junitVersion=5.10.1 assertJVersion=3.24.2 - ### LOMBOK ### lombokVersion=1.18.30 - ### SPRING ### springBootVersion=3.2.0 springDependencyManagementVersion=1.1.4 - ### SONAR ### sonarVersion=4.4.1.3373 - ### MYSQL ### mysqlConnectorVersion=8.0.33 +### MOCK_WEB_SERVER ### +mockWebServerVersion=4.12.0 diff --git a/gradle/test.gradle b/gradle/test.gradle index 291ae524..ab0d1cf9 100644 --- a/gradle/test.gradle +++ b/gradle/test.gradle @@ -11,5 +11,7 @@ allprojects { testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:${junitVersion}" testImplementation "org.assertj:assertj-core:${assertJVersion}" + + testImplementation "com.squareup.okhttp3:mockwebserver:${mockWebServerVersion}" } } diff --git a/src/main/java/net/teumteum/user/controller/UserController.java b/src/main/java/net/teumteum/user/controller/UserController.java index 0c21dc52..1ab3f38d 100644 --- a/src/main/java/net/teumteum/user/controller/UserController.java +++ b/src/main/java/net/teumteum/user/controller/UserController.java @@ -1,18 +1,28 @@ package net.teumteum.user.controller; +import java.util.Arrays; +import java.util.List; import lombok.RequiredArgsConstructor; import net.teumteum.core.error.ErrorResponse; import net.teumteum.core.security.service.SecurityService; import net.teumteum.user.domain.request.UserUpdateRequest; import net.teumteum.user.domain.response.FriendsResponse; +import net.teumteum.user.domain.response.InterestQuestionResponse; import net.teumteum.user.domain.response.UserGetResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import net.teumteum.user.service.UserService; import org.springframework.context.ApplicationContext; import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.*; - -import java.util.Arrays; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor @@ -33,8 +43,8 @@ public UserGetResponse getUserById(@PathVariable("userId") Long userId) { @ResponseStatus(HttpStatus.OK) public UsersGetByIdResponse getUsersById(@RequestParam("id") String userIds) { var parsedUserIds = Arrays.stream(userIds.split(",")) - .map(Long::valueOf) - .toList(); + .map(Long::valueOf) + .toList(); return userService.getUsersById(parsedUserIds); } @@ -57,6 +67,11 @@ public FriendsResponse findFriends(@PathVariable("userId") Long userId) { return userService.findFriendsByUserId(userId); } + @GetMapping("/interests") + public InterestQuestionResponse getInterestQuestion(@RequestParam("user-id") List userIds) { + return userService.getInterestQuestionByUserIds(userIds); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResponse handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { diff --git a/src/main/java/net/teumteum/user/domain/InterestQuestion.java b/src/main/java/net/teumteum/user/domain/InterestQuestion.java new file mode 100644 index 00000000..9450c239 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/InterestQuestion.java @@ -0,0 +1,11 @@ +package net.teumteum.user.domain; + +import java.util.List; +import net.teumteum.user.domain.response.InterestQuestionResponse; + +@FunctionalInterface +public interface InterestQuestion { + + InterestQuestionResponse getQuestion(List users); + +} diff --git a/src/main/java/net/teumteum/user/domain/response/InterestQuestionResponse.java b/src/main/java/net/teumteum/user/domain/response/InterestQuestionResponse.java new file mode 100644 index 00000000..22921f57 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/response/InterestQuestionResponse.java @@ -0,0 +1,10 @@ +package net.teumteum.user.domain.response; + +import java.util.List; + +public record InterestQuestionResponse( + String topic, + List balanceQuestion +) { + +} diff --git a/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java b/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java new file mode 100644 index 00000000..6834b064 --- /dev/null +++ b/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java @@ -0,0 +1,108 @@ +package net.teumteum.user.infra; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Duration; +import java.util.HashSet; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import lombok.RequiredArgsConstructor; +import net.teumteum.user.domain.InterestQuestion; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.response.InterestQuestionResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.scheduler.Schedulers; + +@Service +@RequiredArgsConstructor +public class GptInterestQuestion implements InterestQuestion { + + private static final int MAX_RETRY_COUNT = 5; + + private final WebClient webClient; + private final ObjectMapper objectMapper; + private final ExecutorService executorService = Executors.newCachedThreadPool(); + + @Value("${gpt.token}") + private String gptToken; + + + @Override + public InterestQuestionResponse getQuestion(List users) { + var interests = parseInterests(users); + var request = GptQuestionRequest.of(interests); + + return webClient.post() + .bodyValue(request) + .header(HttpHeaders.AUTHORIZATION, gptToken) + .exchangeToMono(response -> { + if (response.statusCode().is2xxSuccessful()) { + return response.bodyToMono(InterestQuestionResponse.class); + } + return response.createError(); + }) + .retry(MAX_RETRY_COUNT) + .subscribeOn(Schedulers.fromExecutor(executorService)) + .block(Duration.ofSeconds(5)); + } + + private String parseInterests(List users) { + var interests = new HashSet(); + for (User user : users) { + interests.addAll(user.getInterests().stream() + .toList()); + } + try { + return objectMapper.writeValueAsString(interests); + } catch (JsonProcessingException jsonProcessingException) { + throw new IllegalStateException("관심사를 파싱하는 과정에서 에러가 발생했습니다.", jsonProcessingException); + } + } + + + private record GptQuestionRequest( + String model, + List messages + ) { + + private static final String LANGUAGE_MODEL = "gpt-3.5-turbo-1106"; + + + private static GptQuestionRequest of(String interests) { + return new GptQuestionRequest( + LANGUAGE_MODEL, + List.of(Message.system(), Message.user(interests)) + ); + } + + private record Message( + String role, + String content + ) { + + private static Message system() { + return new Message( + "system", + "You are a chatbot that receives the user's interests and creates common topics of interest" + + " and balance games corresponding to the topics of interest in the form of sentences based on" + + " the interests. At this time, only two choices for the balance game must be given, and the" + + " choices must be separated by a comma The query results must be returned in JSON format" + + " according to the form below and other The JSON value must be answered in Korean without" + + " words. " + + "{\\\"topic\\\": Topic of common interest, \\\"balanceQuestion\\\": [Balance game options]}" + ); + } + + private static Message user(String interests) { + return new Message( + "user", + interests + ); + } + } + } +} diff --git a/src/main/java/net/teumteum/user/infra/WebClientConfigurer.java b/src/main/java/net/teumteum/user/infra/WebClientConfigurer.java new file mode 100644 index 00000000..7396a809 --- /dev/null +++ b/src/main/java/net/teumteum/user/infra/WebClientConfigurer.java @@ -0,0 +1,17 @@ +package net.teumteum.user.infra; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +@Profile("prod") +public class WebClientConfigurer { + + @Bean + public WebClient gpt4WebClient() { + return WebClient.create("https://api.openai.com"); + } + +} diff --git a/src/main/java/net/teumteum/user/service/UserService.java b/src/main/java/net/teumteum/user/service/UserService.java index e6a349c7..99e0447a 100644 --- a/src/main/java/net/teumteum/user/service/UserService.java +++ b/src/main/java/net/teumteum/user/service/UserService.java @@ -2,10 +2,12 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import net.teumteum.user.domain.InterestQuestion; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserRepository; import net.teumteum.user.domain.request.UserUpdateRequest; import net.teumteum.user.domain.response.FriendsResponse; +import net.teumteum.user.domain.response.InterestQuestionResponse; import net.teumteum.user.domain.response.UserGetResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import org.springframework.stereotype.Service; @@ -18,6 +20,7 @@ public class UserService { private final UserRepository userRepository; + private final InterestQuestion interestQuestion; public UserGetResponse getUserById(Long userId) { var existUser = getUser(userId); @@ -64,4 +67,15 @@ private User getUser(Long userId) { return userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("userId에 해당하는 user를 찾을 수 없습니다. \"" + userId + "\"")); } + + public InterestQuestionResponse getInterestQuestionByUserIds(List userIds) { + var users = userRepository.findAllById(userIds); + Assert.isTrue(users.size() >= 2, + () -> { + throw new IllegalArgumentException("userIds는 2개 이상 주어져야 합니다."); + } + ); + + return interestQuestion.getQuestion(users); + } } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index aa5b7d62..df56f85c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -31,6 +31,10 @@ management: exposure: include: prometheus +### GPT ### +gpt: + token: + ## LOGGING logging: level: diff --git a/src/test/java/net/teumteum/integration/Api.java b/src/test/java/net/teumteum/integration/Api.java index 9baa7e0d..75b0e8e8 100644 --- a/src/test/java/net/teumteum/integration/Api.java +++ b/src/test/java/net/teumteum/integration/Api.java @@ -1,5 +1,6 @@ package net.teumteum.integration; +import java.util.List; import net.teumteum.meeting.config.PageableHandlerMethodArgumentResolver; import net.teumteum.meeting.domain.Topic; import net.teumteum.user.domain.request.UserUpdateRequest; @@ -22,40 +23,40 @@ class Api { public Api(ApplicationContext applicationContext) { var controllers = applicationContext.getBeansWithAnnotation(Controller.class).values(); webTestClient = WebTestClient.bindToController(controllers.toArray()) - .argumentResolvers(resolvers -> resolvers.addCustomResolver(new PageableHandlerMethodArgumentResolver())) - .build(); + .argumentResolvers(resolvers -> resolvers.addCustomResolver(new PageableHandlerMethodArgumentResolver())) + .build(); } ResponseSpec getUser(String token, Long userId) { return webTestClient - .get() - .uri("/users/" + userId) - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + .get() + .uri("/users/" + userId) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } ResponseSpec getUsersById(String token, String userIds) { return webTestClient.get() - .uri("/users?id=" + userIds) - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + .uri("/users?id=" + userIds) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } ResponseSpec updateUser(String token, UserUpdateRequest userUpdateRequest) { return webTestClient - .put() - .uri("/users") - .header(HttpHeaders.AUTHORIZATION, token) - .bodyValue(userUpdateRequest) - .exchange(); + .put() + .uri("/users") + .header(HttpHeaders.AUTHORIZATION, token) + .bodyValue(userUpdateRequest) + .exchange(); } ResponseSpec addFriends(String token, Long friendId) { return webTestClient.post() - .uri("/users/" + friendId + "/friends") - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + .uri("/users/" + friendId + "/friends") + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } ResponseSpec getFriendsByUserId(String token, Long userId) { @@ -67,32 +68,32 @@ ResponseSpec getFriendsByUserId(String token, Long userId) { ResponseSpec getOpenMeetings(String token, Long cursorId, int size) { return webTestClient.get() - .uri("/meetings" + - "?cursorId=" + cursorId + - "&size=" + size) - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + .uri("/meetings" + + "?cursorId=" + cursorId + + "&size=" + size) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } ResponseSpec getMeetingById(String token, Long meetingId) { return webTestClient.get() - .uri("/meetings/" + meetingId) - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + .uri("/meetings/" + meetingId) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } ResponseSpec getMeetingsByTopic(String token, Pageable pageable, boolean isOpen, Topic topic) { String sort = pageable.getSort().toString().replace(": ", ","); String uri = "/meetings?sort=" + sort + - "&page=" + pageable.getOffset() + - "&size=" + pageable.getPageSize() + - "&isOpen=" + isOpen + - "&topic=" + topic; + "&page=" + pageable.getOffset() + + "&size=" + pageable.getPageSize() + + "&isOpen=" + isOpen + + "&topic=" + topic; return webTestClient.get() - .uri(uri) - .header(HttpHeaders.AUTHORIZATION, token) - .exchange(); + .uri(uri) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); } ResponseSpec joinMeeting(String token, Long meetingId) { @@ -108,4 +109,15 @@ ResponseSpec cancelMeeting(String token, Long meetingId) { .header(HttpHeaders.AUTHORIZATION, token) .exchange(); } + + ResponseSpec getCommonInterests(String token, List userIds) { + var param = new StringBuilder(); + for (Long userId : userIds) { + param.append(userId).append(","); + } + return webTestClient.get() + .uri("/users/interests?user-id=" + param.substring(0, param.length() - 1)) + .header(HttpHeaders.AUTHORIZATION, token) + .exchange(); + } } diff --git a/src/test/java/net/teumteum/integration/IntegrationTest.java b/src/test/java/net/teumteum/integration/IntegrationTest.java index cfa7d63a..39bf0967 100644 --- a/src/test/java/net/teumteum/integration/IntegrationTest.java +++ b/src/test/java/net/teumteum/integration/IntegrationTest.java @@ -1,6 +1,7 @@ package net.teumteum.integration; import net.teumteum.Application; +import net.teumteum.user.infra.GptTestServer; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; @@ -11,7 +12,13 @@ @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @AutoConfigureWebTestClient(timeout = "10000") -@ContextConfiguration(classes = {Application.class, Api.class, Repository.class, SecurityContextSetting.class, TestLoginContext.class}) +@ContextConfiguration(classes = { + Api.class, + Repository.class, + Application.class, + GptTestServer.class, + TestLoginContext.class, + SecurityContextSetting.class}) abstract public class IntegrationTest { @Autowired diff --git a/src/test/java/net/teumteum/integration/UserIntegrationTest.java b/src/test/java/net/teumteum/integration/UserIntegrationTest.java index f19f93fb..b332f452 100644 --- a/src/test/java/net/teumteum/integration/UserIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/UserIntegrationTest.java @@ -1,5 +1,6 @@ package net.teumteum.integration; +import java.util.List; import net.teumteum.core.error.ErrorResponse; import net.teumteum.user.domain.User; import net.teumteum.user.domain.response.FriendsResponse; @@ -10,15 +11,12 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import java.util.List; - @DisplayName("유저 통합테스트의") class UserIntegrationTest extends IntegrationTest { private static final String VALID_TOKEN = "VALID_TOKEN"; private static final String INVALID_TOKEN = "IN_VALID_TOKEN"; - @Nested @DisplayName("유저 조회 API는") class Find_user_api { @@ -35,11 +33,11 @@ void Return_user_info_if_exist_user_id_received() { // then Assertions.assertThat( - result.expectStatus().isOk() - .expectBody(UserGetResponse.class) - .returnResult().getResponseBody()) - .usingRecursiveComparison() - .isEqualTo(expected); + result.expectStatus().isOk() + .expectBody(UserGetResponse.class) + .returnResult().getResponseBody()) + .usingRecursiveComparison() + .isEqualTo(expected); } @Test @@ -53,7 +51,7 @@ void Return_400_bad_request_if_not_exists_user_id_received() { // then result.expectStatus().isBadRequest() - .expectBody(ErrorResponse.class); + .expectBody(ErrorResponse.class); } } @@ -75,9 +73,9 @@ void Return_user_info_if_exist_user_ids_received() { // then Assertions.assertThat(result.expectStatus().isOk() - .expectBody(UsersGetByIdResponse.class) - .returnResult() - .getResponseBody() + .expectBody(UsersGetByIdResponse.class) + .returnResult() + .getResponseBody() ).usingRecursiveComparison().isEqualTo(expected); } diff --git a/src/test/java/net/teumteum/user/infra/GptInterestQuestionTest.java b/src/test/java/net/teumteum/user/infra/GptInterestQuestionTest.java new file mode 100644 index 00000000..e9e169ed --- /dev/null +++ b/src/test/java/net/teumteum/user/infra/GptInterestQuestionTest.java @@ -0,0 +1,93 @@ +package net.teumteum.user.infra; + +import java.util.List; +import net.teumteum.user.domain.InterestQuestion; +import net.teumteum.user.domain.UserFixture; +import net.teumteum.user.domain.response.InterestQuestionResponse; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +@ExtendWith(SpringExtension.class) +@DisplayName("GptInterestQuestion 클래스의") +@ContextConfiguration(classes = {GptInterestQuestion.class, GptTestServer.class}) +class GptInterestQuestionTest { + + @Autowired + private GptTestServer gptTestServer; + + @Autowired + private InterestQuestion interestQuestion; + + + @Nested + @DisplayName("getQuestion 메소드는") + class GetQuestion_method { + + @Test + @DisplayName("user 목록을 받아서, 관심 질문을 반환한다.") + void Return_balance_game_when_receive_user_list() { + // given + var users = List.of(UserFixture.getDefaultUser(), UserFixture.getDefaultUser()); + var expected = new InterestQuestionResponse( + "프로그래머", + List.of("프론트엔드", "백엔드") + ); + + gptTestServer.enqueue(expected); + gptTestServer.enqueue(expected); + + // when + var result = interestQuestion.getQuestion(users); + + // then + Assertions.assertThat(expected).isEqualTo(result); + } + + @Test + @DisplayName("Gpt 서버에서 관심목록 응답을 실패해도 5번까지 retry한다.") + void Do_retry_when_gpt_server_cannot_receive_interests_lists() { + // given + var users = List.of(UserFixture.getDefaultUser(), UserFixture.getDefaultUser()); + var expected = new InterestQuestionResponse( + "프로그래머", + List.of("프론트엔드", "백엔드") + ); + + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue(expected); + + // when + var result = interestQuestion.getQuestion(users); + + // then + Assertions.assertThat(expected).isEqualTo(result); + } + + @Test + @DisplayName("Gpt서버에서 관심목록 조회를 5번 초과로 실패하면 IllegalStateException 을 던진다.") + void Throw_illegal_state_exception_exceed_5_time_to_get_common_interests() { + // given + var users = List.of(UserFixture.getDefaultUser(), UserFixture.getDefaultUser()); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + + // when + var result = Assertions.catchException(() -> interestQuestion.getQuestion(users)); + + // then + Assertions.assertThat(result.getClass()).isEqualTo(IllegalStateException.class); + } + } +} diff --git a/src/test/java/net/teumteum/user/infra/GptTestServer.java b/src/test/java/net/teumteum/user/infra/GptTestServer.java new file mode 100644 index 00000000..40428d6e --- /dev/null +++ b/src/test/java/net/teumteum/user/infra/GptTestServer.java @@ -0,0 +1,61 @@ +package net.teumteum.user.infra; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.module.paramnames.ParameterNamesModule; +import java.io.IOException; +import java.nio.charset.Charset; +import net.teumteum.user.domain.response.InterestQuestionResponse; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okio.Buffer; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.function.client.WebClient; + +public class GptTestServer { + + private final MockWebServer mockWebServer = new MockWebServer(); + private final ObjectMapper objectMapper = objectMapper(); + + { + try { + mockWebServer.start(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void enqueue(InterestQuestionResponse interestQuestionResponse) { + mockWebServer.enqueue( + new MockResponse().setBody(toBuffer(interestQuestionResponse)) + .setResponseCode(200) + .addHeader(HttpHeaders.CONTENT_TYPE, "application/json") + ); + } + + private Buffer toBuffer(InterestQuestionResponse interestQuestionResponse) { + try (var buffer = new Buffer()) { + return buffer.writeString(objectMapper.writeValueAsString(interestQuestionResponse), + Charset.defaultCharset()); + } catch (Exception exception) { + throw new IllegalArgumentException(exception); + } + } + + public void enqueue400() { + mockWebServer.enqueue( + new MockResponse().setResponseCode(400) + ); + } + + @Bean + private WebClient testGptWebClient(GptTestServer gptTestServer) { + return WebClient.create(gptTestServer.mockWebServer.url("").toString()); + } + + @Bean + private ObjectMapper objectMapper() { + var objectMapper = new ObjectMapper(); + return objectMapper.registerModule(new ParameterNamesModule()); + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 7702ec21..3cf3f181 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -5,6 +5,9 @@ spring.jpa.hibernated.ddl-auto=validate spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect spring.datasource.hikari.maximum-pool-size=4 spring.datasource.hikari.pool-name=H2_TEST_POOL + +gpt.token=12345678910 + ### FOR DEBUGGING ### logging.level.org.hibernate.SQL=debug logging.level.org.hibernate.type.descriptor.sql=trace From ad452459c16e353e034ca645c68fd8dff47ffd9f Mon Sep 17 00:00:00 2001 From: xb205 <62425964+devxb@users.noreply.github.com> Date: Wed, 10 Jan 2024 23:10:59 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20Sentry=20=EC=97=B0=EB=8F=99=20(#3?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + gradle.properties | 2 ++ gradle/apm.gradle | 10 ++++++++++ settings.gradle | 1 + .../core/advice/GlobalExceptionHandler.java | 19 +++++++++++++++++++ .../meeting/controller/MeetingController.java | 2 ++ .../user/controller/UserController.java | 2 ++ src/main/resources/application-prod.yml | 7 ++++++- 8 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 gradle/apm.gradle create mode 100644 src/main/java/net/teumteum/core/advice/GlobalExceptionHandler.java diff --git a/build.gradle b/build.gradle index c06112a7..d70acd82 100644 --- a/build.gradle +++ b/build.gradle @@ -3,6 +3,7 @@ plugins { id 'org.springframework.boot' apply false id 'io.spring.dependency-management' id 'org.sonarqube' + id 'io.sentry.jvm.gradle' } apply from: "gradle/spring.gradle" diff --git a/gradle.properties b/gradle.properties index 7f7f907b..89b70de0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,3 +15,5 @@ sonarVersion=4.4.1.3373 mysqlConnectorVersion=8.0.33 ### MOCK_WEB_SERVER ### mockWebServerVersion=4.12.0 +### SENTRY ### +sentryVersion=4.1.1 diff --git a/gradle/apm.gradle b/gradle/apm.gradle new file mode 100644 index 00000000..35e01a54 --- /dev/null +++ b/gradle/apm.gradle @@ -0,0 +1,10 @@ +sentry { + // Generates a JVM (Java, Kotlin, etc.) source bundle and uploads your source code to Sentry. + // This enables source context, allowing you to see your source + // code as part of your stack traces in Sentry. + includeSourceContext = true + + org = "teum-teum" + projectName = "java-spring-boot" + authToken = System.getenv("SENTRY_AUTH_TOKEN") +} diff --git a/settings.gradle b/settings.gradle index 6c757e43..7133f3ca 100644 --- a/settings.gradle +++ b/settings.gradle @@ -3,6 +3,7 @@ pluginManagement { id 'org.springframework.boot' version "${springBootVersion}" id 'io.spring.dependency-management' version "${springDependencyManagementVersion}" id 'org.sonarqube' version "${sonarVersion}" + id 'io.sentry.jvm.gradle' version "${sentryVersion}" } } diff --git a/src/main/java/net/teumteum/core/advice/GlobalExceptionHandler.java b/src/main/java/net/teumteum/core/advice/GlobalExceptionHandler.java new file mode 100644 index 00000000..3c8ce420 --- /dev/null +++ b/src/main/java/net/teumteum/core/advice/GlobalExceptionHandler.java @@ -0,0 +1,19 @@ +package net.teumteum.core.advice; + +import io.sentry.Sentry; +import net.teumteum.core.error.ErrorResponse; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ErrorResponse handleException(Exception exception) { + Sentry.captureException(exception); + return ErrorResponse.of(exception); + } +} diff --git a/src/main/java/net/teumteum/meeting/controller/MeetingController.java b/src/main/java/net/teumteum/meeting/controller/MeetingController.java index a66a9ea1..ed921742 100644 --- a/src/main/java/net/teumteum/meeting/controller/MeetingController.java +++ b/src/main/java/net/teumteum/meeting/controller/MeetingController.java @@ -1,5 +1,6 @@ package net.teumteum.meeting.controller; +import io.sentry.Sentry; import lombok.RequiredArgsConstructor; import net.teumteum.core.error.ErrorResponse; import net.teumteum.core.security.service.SecurityService; @@ -56,6 +57,7 @@ public void deleteParticipant(@PathVariable("meetingId") Long meetingId) { @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResponse handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { + Sentry.captureException(illegalArgumentException); return ErrorResponse.of(illegalArgumentException); } } diff --git a/src/main/java/net/teumteum/user/controller/UserController.java b/src/main/java/net/teumteum/user/controller/UserController.java index 1ab3f38d..bc500a99 100644 --- a/src/main/java/net/teumteum/user/controller/UserController.java +++ b/src/main/java/net/teumteum/user/controller/UserController.java @@ -1,5 +1,6 @@ package net.teumteum.user.controller; +import io.sentry.Sentry; import java.util.Arrays; import java.util.List; import lombok.RequiredArgsConstructor; @@ -75,6 +76,7 @@ public InterestQuestionResponse getInterestQuestion(@RequestParam("user-id") Lis @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResponse handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { + Sentry.captureException(illegalArgumentException); return ErrorResponse.of(illegalArgumentException); } diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index df56f85c..049241ab 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -33,7 +33,12 @@ management: ### GPT ### gpt: - token: + token: + +### SENTRY ### +sentry: + dsn: https://59e89fa57d11ed7a7887bcf404179150@o4506545306271744.ingest.sentry.io/4506545307320320 + traces-sample-rate: 1.0 ## LOGGING logging: From b9dd2409de8ab56dfffaed2e05325c18160e8057 Mon Sep 17 00:00:00 2001 From: ChoiDongKuen Date: Thu, 11 Jan 2024 15:56:10 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EA=B5=AC=ED=98=84,=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4,=EB=84=A4=EC=9D=B4=EB=B2=84=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B4=80=EB=A0=A8=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: SecurityConfig 불필요한 변수 삭제 (#31) * refactor: jwtAuthenticationFilter 리팩토링 (#31) * feat: application-auth.yml 소셜 로그인 관련 설정 추가 (#31) * feat: application-datasource.yml 에 flyway 관련 설정 (#31) * refactor: 전반적인 코드 리팩토링 (#31) * fix: sonarCloud 코드 에러 수정 (#31) * chore: application-dev.yml ddl-auto -> validate 로 변경 (#31) * chore: jwt 라이브러리 gradle 변경 (#31) * chore: kakao,naver 사용자 정보 조회 api url 변경 (#31) * chore: naver 관련 설정 삭제 (#31) --- gradle/devtool.gradle | 1 + gradle/spring.gradle | 1 - .../teumteum/core/security/Authenticated.java | 5 +-- .../core/security/SecurityConfig.java | 5 +-- .../core/security/UserAuthentication.java | 10 ++++++ .../filter/JwtAuthenticationFilter.java | 19 +++++++--- src/main/resources/application-auth.yml | 36 +++++++++++++++++-- src/main/resources/application-datasource.yml | 6 ++++ src/main/resources/application-dev.yml | 6 ++-- .../db/migration/V4__update_users.sql | 9 +++-- 10 files changed, 78 insertions(+), 20 deletions(-) diff --git a/gradle/devtool.gradle b/gradle/devtool.gradle index 511a4428..06937c24 100644 --- a/gradle/devtool.gradle +++ b/gradle/devtool.gradle @@ -2,6 +2,7 @@ allprojects { dependencies { compileOnly "org.projectlombok:lombok:${lombokVersion}" annotationProcessor "org.projectlombok:lombok" + implementation 'io.jsonwebtoken:jjwt:0.9.1' testCompileOnly "org.projectlombok:lombok:${lombokVersion}" testAnnotationProcessor "org.projectlombok:lombok" diff --git a/gradle/spring.gradle b/gradle/spring.gradle index 81823276..41e855c5 100644 --- a/gradle/spring.gradle +++ b/gradle/spring.gradle @@ -25,7 +25,6 @@ allprojects { implementation "org.springframework.boot:spring-boot-starter-security" implementation "org.springframework.boot:spring-boot-starter-oauth2-client" - implementation 'io.jsonwebtoken:jjwt:0.9.1' runtimeOnly 'io.micrometer:micrometer-registry-prometheus' diff --git a/src/main/java/net/teumteum/core/security/Authenticated.java b/src/main/java/net/teumteum/core/security/Authenticated.java index 26ba3f84..74ce6f13 100644 --- a/src/main/java/net/teumteum/core/security/Authenticated.java +++ b/src/main/java/net/teumteum/core/security/Authenticated.java @@ -1,6 +1,7 @@ package net.teumteum.core.security; -/* 소셜 OAuth 로그인 타입 */ + public enum Authenticated { - 카카오,네이버; + 카카오, + 네이버, } diff --git a/src/main/java/net/teumteum/core/security/SecurityConfig.java b/src/main/java/net/teumteum/core/security/SecurityConfig.java index 2c1369c7..8b119f8c 100644 --- a/src/main/java/net/teumteum/core/security/SecurityConfig.java +++ b/src/main/java/net/teumteum/core/security/SecurityConfig.java @@ -12,11 +12,9 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.web.servlet.handler.HandlerMappingIntrospector; @Configuration @EnableWebSecurity @@ -26,8 +24,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean - SecurityFilterChain securityFilterChain(HttpSecurity http, HandlerMappingIntrospector introspector) throws Exception { - MvcRequestMatcher.Builder mvcMatcherBuilder = new MvcRequestMatcher.Builder(introspector); + SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(request diff --git a/src/main/java/net/teumteum/core/security/UserAuthentication.java b/src/main/java/net/teumteum/core/security/UserAuthentication.java index 6568f3f2..c6af46af 100644 --- a/src/main/java/net/teumteum/core/security/UserAuthentication.java +++ b/src/main/java/net/teumteum/core/security/UserAuthentication.java @@ -42,6 +42,16 @@ public boolean isAuthenticated() { return true; } + @Override + public boolean equals(Object obj) { + return super.equals(obj); + } + + @Override + public int hashCode() { + return super.hashCode(); + } + public void setUserId(Long userId) { id = userId; } diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java index 068303e4..06443ac1 100644 --- a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java @@ -13,6 +13,7 @@ import net.teumteum.user.domain.User; import org.springframework.security.authentication.InsufficientAuthenticationException; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Component; import org.springframework.util.ObjectUtils; import org.springframework.util.StringUtils; @@ -28,11 +29,15 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final AuthService authService; private final JwtProperty jwtProperty; + private static void saveUserAuthentication(User user) { + UserAuthentication authentication = new UserAuthentication(user); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - /* Cors Preflight Request */ if (request.getMethod().equals("OPTIONS")) { return; } @@ -40,16 +45,20 @@ protected void doFilterInternal(HttpServletRequest request, try { String token = this.resolveTokenFromRequest(request); if (checkTokenExistenceAndValidation(token)) { - User user = this.authService.findUserByToken(token).get(); - UserAuthentication authentication = new UserAuthentication(user); - SecurityContextHolder.getContext().setAuthentication(authentication); + User user = getUser(token); + saveUserAuthentication(user); } } catch (InsufficientAuthenticationException e) { - log.info("JwtAuthentication UnauthorizedUserException!"); + log.error("JwtAuthentication UnauthorizedUserException!"); } filterChain.doFilter(request, response); } + private User getUser(String token) { + return this.authService.findUserByToken(token) + .orElseThrow(() -> new UsernameNotFoundException("일치하는 회원 정보가 존재하지 않습니다.")); + } + private boolean checkTokenExistenceAndValidation(String token) { return StringUtils.hasText(token) && this.jwtService.validateToken(token); } diff --git a/src/main/resources/application-auth.yml b/src/main/resources/application-auth.yml index 755a4f90..da4b5d77 100644 --- a/src/main/resources/application-auth.yml +++ b/src/main/resources/application-auth.yml @@ -4,7 +4,39 @@ spring: activate: on-profile: "auth" -## JWT + security: + oauth2: + client: + registration: + kakao: + client-id: ${KAKAO_CLIENT_ID} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: ${KAKAO_REDIRECT_URI} + client-authentication-method: POST + authorization-grant-type: authorization_code + scope: + + naver: + client-id: ${NAVER_CLIENT_ID} + client-secret: ${NAVER_CLIENT_ID} + redirect-uri: ${NAVER_REDIRECT_URI} + authorization-grant-type: authorization_code + scope: + + + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + + naver: + authorization-uri: https://nid.naver.com/oauth2.0/authorize + token-uri: https://nid.naver.com/oauth2.0/token + user-info-uri: https://openapi.naver.com/v1/nid/me + user-name-attribute: response + jwt: bearer: Bearer secret: ${JWT_SECRET_KEY} @@ -13,4 +45,4 @@ jwt: header: Authorization refresh: expiration: ${JWT_REFRESH_EXPIRATION:1209600000} - header: Authorization-refresh \ No newline at end of file + header: Authorization-refresh diff --git a/src/main/resources/application-datasource.yml b/src/main/resources/application-datasource.yml index 8b90b347..1d2e3ce6 100644 --- a/src/main/resources/application-datasource.yml +++ b/src/main/resources/application-datasource.yml @@ -12,3 +12,9 @@ spring: hikari: connection-timeout: 3000 maximum-pool-size: 80 + + flyway: + url: jdbc:mysql://${DATABASE_HOST:localhost}:${DATABASE_PORT}/${DATABASE_NAME} + user: ${DATABASE_USERNAME} + password: ${DATABASE_PASSWORD} + baseline-on-migrate: true diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 64f158e6..c73a4d75 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -8,10 +8,10 @@ spring: pathmatch: matching-strategy: ant_path_matcher -### JPA ### + ## JPA jpa: hibernate: - ddl-auto: update + ddl-auto: validate properties: hibernate: format_sql: true @@ -22,4 +22,4 @@ spring: logging: level: org.hibernate.SQL: debug - org.hibernate.type: trace \ No newline at end of file + org.hibernate.type: trace diff --git a/src/main/resources/db/migration/V4__update_users.sql b/src/main/resources/db/migration/V4__update_users.sql index f9314f2a..57f6ff41 100644 --- a/src/main/resources/db/migration/V4__update_users.sql +++ b/src/main/resources/db/migration/V4__update_users.sql @@ -1,3 +1,6 @@ -alter table users drop column oauth_authenticate_info; -alter table users add column oauth_id varchar(255) not null unique; -alter table users add column role_type varchar(255); +alter table users + drop column oauth_authenticate_info; +alter table users + add column oauth_id varchar(255) not null unique; +alter table users + add column role_type varchar(255); From 91f8a5c9397469b0895673bce974ae6f6b66e76f Mon Sep 17 00:00:00 2001 From: ChoiDongKuen Date: Thu, 11 Jan 2024 21:30:43 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4,?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EB=B2=84=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: jwtService 불필요한 주석 제거 (#32) * feat: TokenResponse 레코드 형식으로 구현 (#32) * feat: OAuth 소셜 로그인 응답 객체 OAuthUserInfo 및 상속체 구현 (#32) * feat: OAuth 소셜 로그인 최종 응답 객체 OAuthUserAttributes 구현 (#32) * feat: OAuth 소셜 로그인 로직 구현 (#32) * feat: OAuth 소셜 로그인 로직 구현 (#32) * fix: sonarcloud 코드 버그 수정 (#32) * fix: sonarcloud 코드 버그 수정 (#32) * fix: sonarcloud 코드 버그 수정 (#32) * refactor: 인가 코드 콜백 URL 컨벤션 맞게 수정 (#32) --- .../auth/controller/OAuthLoginController.java | 26 +++++++ .../teumteum/auth/domain/CustomOAuthUser.java | 43 ++++++++++ .../auth/domain/KakaoOAuthUserInfo.java | 14 ++++ .../auth/domain/NaverOAuthUserInfo.java | 22 ++++++ .../net/teumteum/auth/domain/OAuthToken.java | 31 ++++++++ .../auth/domain/OAuthUserAttributes.java | 45 +++++++++++ .../teumteum/auth/domain/OAuthUserInfo.java | 14 ++++ .../domain/response}/TokenResponse.java | 14 +++- .../teumteum/auth/service/AuthService.java | 78 +++++++++++++++++++ .../teumteum/auth/service/OAuthService.java | 59 ++++++++++++++ .../filter/JwtAuthenticationFilter.java | 2 +- .../core/security/service/AuthService.java | 19 ----- .../core/security/service/JwtService.java | 22 +----- .../teumteum/user/domain/UserConnector.java | 5 ++ .../teumteum/user/domain/UserRepository.java | 11 +++ .../user/service/UserConnectorImpl.java | 6 ++ .../meeting/domain/MeetingRepositoryTest.java | 3 + 17 files changed, 372 insertions(+), 42 deletions(-) create mode 100644 src/main/java/net/teumteum/auth/controller/OAuthLoginController.java create mode 100644 src/main/java/net/teumteum/auth/domain/CustomOAuthUser.java create mode 100644 src/main/java/net/teumteum/auth/domain/KakaoOAuthUserInfo.java create mode 100644 src/main/java/net/teumteum/auth/domain/NaverOAuthUserInfo.java create mode 100644 src/main/java/net/teumteum/auth/domain/OAuthToken.java create mode 100644 src/main/java/net/teumteum/auth/domain/OAuthUserAttributes.java create mode 100644 src/main/java/net/teumteum/auth/domain/OAuthUserInfo.java rename src/main/java/net/teumteum/{core/security/dto => auth/domain/response}/TokenResponse.java (52%) create mode 100644 src/main/java/net/teumteum/auth/service/AuthService.java create mode 100644 src/main/java/net/teumteum/auth/service/OAuthService.java delete mode 100644 src/main/java/net/teumteum/core/security/service/AuthService.java diff --git a/src/main/java/net/teumteum/auth/controller/OAuthLoginController.java b/src/main/java/net/teumteum/auth/controller/OAuthLoginController.java new file mode 100644 index 00000000..ab95e38e --- /dev/null +++ b/src/main/java/net/teumteum/auth/controller/OAuthLoginController.java @@ -0,0 +1,26 @@ +package net.teumteum.auth.controller; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.teumteum.auth.domain.response.TokenResponse; +import net.teumteum.auth.service.AuthService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RequestMapping +@RestController +@RequiredArgsConstructor +public class OAuthLoginController { + + private final AuthService authService; + + @PostMapping("/logins/callbacks/{provider}") + @ResponseStatus(HttpStatus.OK) + public TokenResponse oAuthLogin( + @PathVariable String provider, + @RequestParam String code + ) { + return authService.oAuthLogin(provider, code); + } +} diff --git a/src/main/java/net/teumteum/auth/domain/CustomOAuthUser.java b/src/main/java/net/teumteum/auth/domain/CustomOAuthUser.java new file mode 100644 index 00000000..e1f426f2 --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/CustomOAuthUser.java @@ -0,0 +1,43 @@ +package net.teumteum.auth.domain; + +import lombok.Getter; +import net.teumteum.user.domain.User; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Map; + +@Getter +public class CustomOAuthUser implements OAuth2User { + + private final User user; + private final Map attributes; + + private final Collection authorities; + + public CustomOAuthUser(User user, OAuth2User oAuth2User) { + this.user = user; + this.attributes = oAuth2User.getAttributes(); + this.authorities = oAuth2User.getAuthorities(); + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return authorities; + } + + @Override + public String getName() { + return user.getName(); + } + + public Long getUserId() { + return user.getId(); + } +} diff --git a/src/main/java/net/teumteum/auth/domain/KakaoOAuthUserInfo.java b/src/main/java/net/teumteum/auth/domain/KakaoOAuthUserInfo.java new file mode 100644 index 00000000..1b52ecd0 --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/KakaoOAuthUserInfo.java @@ -0,0 +1,14 @@ +package net.teumteum.auth.domain; + +import java.util.Map; + +public class KakaoOAuthUserInfo extends OAuthUserInfo { + public KakaoOAuthUserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getOAuthId() { + return (String) attributes.get("id"); + } +} diff --git a/src/main/java/net/teumteum/auth/domain/NaverOAuthUserInfo.java b/src/main/java/net/teumteum/auth/domain/NaverOAuthUserInfo.java new file mode 100644 index 00000000..4dbf6e9c --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/NaverOAuthUserInfo.java @@ -0,0 +1,22 @@ +package net.teumteum.auth.domain; + +import java.util.Map; + +public class NaverOAuthUserInfo extends OAuthUserInfo { + public NaverOAuthUserInfo(Map attributes) { + super(attributes); + } + + @Override + public String getOAuthId() { + Map response = getResponse(); + if (response == null) { + return null; + } + return (String) response.get("id"); + } + + private Map getResponse() { + return (Map) attributes.get("response"); + } +} diff --git a/src/main/java/net/teumteum/auth/domain/OAuthToken.java b/src/main/java/net/teumteum/auth/domain/OAuthToken.java new file mode 100644 index 00000000..a5ec978b --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/OAuthToken.java @@ -0,0 +1,31 @@ +package net.teumteum.auth.domain; + + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record OAuthToken( + @JsonProperty("token_type") + String tokenType, + @JsonProperty("access_token") + String accessToken, + String scope, + + @JsonProperty("expires_in") + Integer expiresIn +) { + public String getTokenType() { + return this.tokenType; + } + + public String getAccessToken() { + return this.accessToken; + } + + public String getScope() { + return this.scope; + } + + public Integer getExpiresIn() { + return this.expiresIn; + } +} diff --git a/src/main/java/net/teumteum/auth/domain/OAuthUserAttributes.java b/src/main/java/net/teumteum/auth/domain/OAuthUserAttributes.java new file mode 100644 index 00000000..ef081316 --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/OAuthUserAttributes.java @@ -0,0 +1,45 @@ +package net.teumteum.auth.domain; + +import lombok.Builder; +import lombok.Getter; +import net.teumteum.core.security.Authenticated; + +import java.util.Map; + +import static net.teumteum.core.security.Authenticated.네이버; + +@Getter +public class OAuthUserAttributes { + + private final String nameAttributeKey; + private final OAuthUserInfo oAuthUserInfo; + + @Builder + private OAuthUserAttributes(String nameAttributeKey, OAuthUserInfo oAuthUserInfo) { + this.nameAttributeKey = nameAttributeKey; + this.oAuthUserInfo = oAuthUserInfo; + } + + public static OAuthUserAttributes of(Authenticated authenticated, + String userNameAttributeName, Map attributes) { + if (authenticated == 네이버) { + return ofNaver(userNameAttributeName, attributes); + } + return ofKakao(userNameAttributeName, attributes); + } + + + private static OAuthUserAttributes ofNaver(String userNameAttributeName, Map attributes) { + return OAuthUserAttributes.builder() + .nameAttributeKey(userNameAttributeName) + .oAuthUserInfo(new NaverOAuthUserInfo(attributes)) + .build(); + } + + private static OAuthUserAttributes ofKakao(String userNameAttributeName, Map attributes) { + return OAuthUserAttributes.builder() + .nameAttributeKey(userNameAttributeName) + .oAuthUserInfo(new KakaoOAuthUserInfo(attributes)) + .build(); + } +} diff --git a/src/main/java/net/teumteum/auth/domain/OAuthUserInfo.java b/src/main/java/net/teumteum/auth/domain/OAuthUserInfo.java new file mode 100644 index 00000000..7183630d --- /dev/null +++ b/src/main/java/net/teumteum/auth/domain/OAuthUserInfo.java @@ -0,0 +1,14 @@ +package net.teumteum.auth.domain; + +import lombok.AllArgsConstructor; + +import java.util.Map; + +@AllArgsConstructor +public abstract class OAuthUserInfo { + + protected Map attributes; + + public abstract String getOAuthId(); + +} diff --git a/src/main/java/net/teumteum/core/security/dto/TokenResponse.java b/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java similarity index 52% rename from src/main/java/net/teumteum/core/security/dto/TokenResponse.java rename to src/main/java/net/teumteum/auth/domain/response/TokenResponse.java index df88e24c..12c3aee8 100644 --- a/src/main/java/net/teumteum/core/security/dto/TokenResponse.java +++ b/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java @@ -1,18 +1,24 @@ -package net.teumteum.core.security.dto; +package net.teumteum.auth.domain.response; -import lombok.Builder; +import com.fasterxml.jackson.annotation.JsonInclude; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) public class TokenResponse { private String accessToken; private String refreshToken; + private String oauthId; - @Builder - public TokenResponse(String accessToken, String refreshToken) { + public TokenResponse(String accessToken, String refreshToken, String oauthId) { this.accessToken = accessToken; this.refreshToken = refreshToken; + this.oauthId = oauthId; + } + + public TokenResponse(String oauthId) { + this.oauthId = oauthId; } } diff --git a/src/main/java/net/teumteum/auth/service/AuthService.java b/src/main/java/net/teumteum/auth/service/AuthService.java new file mode 100644 index 00000000..14e17c35 --- /dev/null +++ b/src/main/java/net/teumteum/auth/service/AuthService.java @@ -0,0 +1,78 @@ +package net.teumteum.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.teumteum.auth.domain.CustomOAuthUser; +import net.teumteum.auth.domain.OAuthToken; +import net.teumteum.auth.domain.response.TokenResponse; +import net.teumteum.core.security.service.JwtService; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.UserConnector; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AccessToken; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Instant; +import java.util.Collections; +import java.util.Objects; +import java.util.Optional; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + private final JwtService jwtService; + private final OAuthService oAuthService; + private final UserConnector userConnector; + + public Optional findUserByToken(String accessToken) { + Long id = Long.parseLong(jwtService.getUserIdFromToken(accessToken)); + return userConnector.findUserById(id); + } + + public TokenResponse oAuthLogin(String provider, String code) { + log.info("provider is {}", provider); + OAuthToken oAuthToken = getOAuthToken(null, code); + CustomOAuthUser oAuth2User = getCustomOAuthUser(oAuthToken.getAccessToken(), oAuthToken, null); + return jwtService.createServiceToken(oAuth2User.getUser()); + } + + private CustomOAuthUser getCustomOAuthUser(String accessToken, OAuthToken oAuthToken, ClientRegistration clientRegistration) { + OAuth2AccessToken oAuth2AccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, accessToken, Instant.now(), + Instant.ofEpochMilli(oAuthToken.getExpiresIn())); + + OAuth2UserRequest oAuth2UserRequest = new OAuth2UserRequest(clientRegistration, oAuth2AccessToken); + return (CustomOAuthUser) oAuthService.loadUser(oAuth2UserRequest); + } + + private OAuthToken getOAuthToken(ClientRegistration clientRegistration, String code) { + return WebClient.create() + .post() + .uri(clientRegistration.getProviderDetails().getTokenUri()) + .headers(header -> { + header.setContentType(APPLICATION_FORM_URLENCODED); + header.setAcceptCharset(Collections.singletonList(UTF_8)); + }) + .bodyValue(Objects.requireNonNull(getOAuthTokenRequest(clientRegistration, code))) + .retrieve() + .bodyToMono(OAuthToken.class) + .block(); + } + + private MultiValueMap getOAuthTokenRequest(ClientRegistration clientRegistration, String code) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("code", code); + formData.add("grant_type", "authorization_code"); + formData.add("redirect_uri", clientRegistration.getRedirectUri()); + formData.add("client_secret", clientRegistration.getClientSecret()); + formData.add("client_id", clientRegistration.getClientId()); + return formData; + } +} diff --git a/src/main/java/net/teumteum/auth/service/OAuthService.java b/src/main/java/net/teumteum/auth/service/OAuthService.java new file mode 100644 index 00000000..caae63d8 --- /dev/null +++ b/src/main/java/net/teumteum/auth/service/OAuthService.java @@ -0,0 +1,59 @@ +package net.teumteum.auth.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.teumteum.auth.domain.CustomOAuthUser; +import net.teumteum.auth.domain.OAuthUserAttributes; +import net.teumteum.core.security.Authenticated; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.UserConnector; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import static net.teumteum.core.security.Authenticated.네이버; +import static net.teumteum.core.security.Authenticated.카카오; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuthService implements OAuth2UserService { + private static final String NAVER = "naver"; + private static final String KAKAO = "kakao"; + + private final UserConnector userConnector; + + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oAuth2User = delegate.loadUser(userRequest); + + String providerId = userRequest.getClientRegistration().getRegistrationId(); + Authenticated authenticated = getAuthenticated(providerId); + String userNameAttributeName + = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); + + OAuthUserAttributes oAuthUserAttributes + = OAuthUserAttributes.of(authenticated, userNameAttributeName, oAuth2User.getAttributes()); + + User user = getUser(oAuthUserAttributes, authenticated); + + return new CustomOAuthUser(user, oAuth2User); + } + + private Authenticated getAuthenticated(String providerId) { + if (NAVER.equals(providerId)) { + return 네이버; + } + return 카카오; + } + + private User getUser(OAuthUserAttributes oAuthUserAttributes, Authenticated authenticated) { + return userConnector.findByAuthenticatedAndOAuthId(authenticated, + oAuthUserAttributes.getOAuthUserInfo().getOAuthId()).orElse(null); + } +} diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java index 06443ac1..77a739c1 100644 --- a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java @@ -6,9 +6,9 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.teumteum.auth.service.AuthService; import net.teumteum.core.property.JwtProperty; import net.teumteum.core.security.UserAuthentication; -import net.teumteum.core.security.service.AuthService; import net.teumteum.core.security.service.JwtService; import net.teumteum.user.domain.User; import org.springframework.security.authentication.InsufficientAuthenticationException; diff --git a/src/main/java/net/teumteum/core/security/service/AuthService.java b/src/main/java/net/teumteum/core/security/service/AuthService.java deleted file mode 100644 index df2b9a4a..00000000 --- a/src/main/java/net/teumteum/core/security/service/AuthService.java +++ /dev/null @@ -1,19 +0,0 @@ -package net.teumteum.core.security.service; - -import lombok.RequiredArgsConstructor; -import net.teumteum.user.domain.User; -import net.teumteum.user.domain.UserConnector; -import org.springframework.stereotype.Service; - -import java.util.Optional; - -@Service -@RequiredArgsConstructor -public class AuthService { - private final JwtService jwtService; - private final UserConnector userConnector; - public Optional findUserByToken(String accessToken) { - Long id = Long.parseLong(jwtService.getUserIdFromToken(accessToken)); - return userConnector.findUserById(id); - } -} diff --git a/src/main/java/net/teumteum/core/security/service/JwtService.java b/src/main/java/net/teumteum/core/security/service/JwtService.java index 2ef8bd18..08dab7e0 100644 --- a/src/main/java/net/teumteum/core/security/service/JwtService.java +++ b/src/main/java/net/teumteum/core/security/service/JwtService.java @@ -4,8 +4,8 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.teumteum.auth.domain.response.TokenResponse; import net.teumteum.core.property.JwtProperty; -import net.teumteum.core.security.dto.TokenResponse; import net.teumteum.user.domain.User; import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.stereotype.Service; @@ -23,7 +23,6 @@ public class JwtService { private final JwtProperty jwtProperty; private final RedisService redisService; - // HttpServletRequest 부터 Access Token 추출 public Optional extractAccessToken(HttpServletRequest request) { return Optional.ofNullable(request.getHeader(this.jwtProperty.getAccess().getHeader())) .filter(StringUtils::hasText) @@ -31,25 +30,21 @@ public Optional extractAccessToken(HttpServletRequest request) { .map(accessToken -> accessToken.replace(jwtProperty.getBearer(), "")); } - // HttpServletRequest 부터 Refresh Token 추출 public String extractRefreshToken(HttpServletRequest request) { return request.getHeader(this.jwtProperty.getRefresh().getHeader()); } - // access token 생성 public String createAccessToken(String payload) { return this.createToken(payload, this.jwtProperty.getAccess().getExpiration()); } - // refresh token 생성 public String createRefreshToken() { return this.createToken(UUID.randomUUID().toString(), this.jwtProperty.getRefresh().getExpiration()); } - // access token 으로부터 회원 아이디 추출 public String getUserIdFromToken(String token) { try { return Jwts.parser() @@ -62,25 +57,17 @@ public String getUserIdFromToken(String token) { } } - // kakao oauth 로그인 & 일반 로그인 시 jwt 응답 생성 + redis refresh 저장 public TokenResponse createServiceToken(User users) { String accessToken = this.createAccessToken(String.valueOf(users.getId())); String refreshToken = this.createRefreshToken(); - /* 서비스 토큰 생성 */ - TokenResponse userServiceTokenResponseDto = TokenResponse.builder() - .accessToken(this.jwtProperty.getBearer() + " " + accessToken) - .refreshToken(refreshToken) - .build(); - - /* redis refresh token 저장 */ this.redisService.setDataExpire(String.valueOf(users.getId()), - userServiceTokenResponseDto.getRefreshToken(), this.jwtProperty.getRefresh().getExpiration()); + refreshToken, this.jwtProperty.getRefresh().getExpiration()); + + return new TokenResponse(this.jwtProperty.getBearer() + " " + accessToken, refreshToken, null); - return userServiceTokenResponseDto; } - // token 유효성 검증 public boolean validateToken(String token) { try { Jws claimsJws = Jwts.parser().setSigningKey(this.jwtProperty.getSecret()).parseClaimsJws(token); @@ -95,7 +82,6 @@ public boolean validateToken(String token) { return false; } - // 실제 token 생성 로직 private String createToken(String payload, Long tokenExpiration) { Claims claims = Jwts.claims().setSubject(payload); Date tokenExpiresIn = new Date(new Date().getTime() + tokenExpiration); diff --git a/src/main/java/net/teumteum/user/domain/UserConnector.java b/src/main/java/net/teumteum/user/domain/UserConnector.java index f3ad2620..5519cd73 100644 --- a/src/main/java/net/teumteum/user/domain/UserConnector.java +++ b/src/main/java/net/teumteum/user/domain/UserConnector.java @@ -1,5 +1,7 @@ package net.teumteum.user.domain; +import net.teumteum.core.security.Authenticated; + import java.util.List; import java.util.Optional; @@ -8,4 +10,7 @@ public interface UserConnector { Optional findUserById(Long id); List findAllUser(); + + Optional findByAuthenticatedAndOAuthId(Authenticated authenticated, String oAuthId); + } diff --git a/src/main/java/net/teumteum/user/domain/UserRepository.java b/src/main/java/net/teumteum/user/domain/UserRepository.java index 33e882c5..6257b9a4 100644 --- a/src/main/java/net/teumteum/user/domain/UserRepository.java +++ b/src/main/java/net/teumteum/user/domain/UserRepository.java @@ -1,7 +1,18 @@ package net.teumteum.user.domain; +import net.teumteum.core.security.Authenticated; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; public interface UserRepository extends JpaRepository { + @Query("select u from users u " + + "where u.oauth.authenticated = :authenticated and u.oauth.oauthId = :oAuthId") + Optional findByAuthenticatedAndOAuthId(@Param("authenticated") Authenticated authenticated, + @Param("oAuthId") String oAuthId); + + } diff --git a/src/main/java/net/teumteum/user/service/UserConnectorImpl.java b/src/main/java/net/teumteum/user/service/UserConnectorImpl.java index e0704157..ba8d4e40 100644 --- a/src/main/java/net/teumteum/user/service/UserConnectorImpl.java +++ b/src/main/java/net/teumteum/user/service/UserConnectorImpl.java @@ -1,6 +1,7 @@ package net.teumteum.user.service; import lombok.RequiredArgsConstructor; +import net.teumteum.core.security.Authenticated; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserConnector; import net.teumteum.user.domain.UserRepository; @@ -26,4 +27,9 @@ public Optional findUserById(Long id) { public List findAllUser() { return userRepository.findAll(); } + + @Override + public Optional findByAuthenticatedAndOAuthId(Authenticated authenticated, String oAuthId) { + return userRepository.findByAuthenticatedAndOAuthId(authenticated, oAuthId); + } } diff --git a/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java b/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java index 000f497f..8a8a774d 100644 --- a/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java +++ b/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java @@ -1,12 +1,14 @@ package net.teumteum.meeting.domain; import jakarta.persistence.EntityManager; +import net.teumteum.core.config.AppConfig; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; @@ -16,6 +18,7 @@ import java.util.stream.Stream; @DataJpaTest +@Import(AppConfig.class) @DisplayName("MeetingRepository 클래스의") class MeetingRepositoryTest { From b5c3329abfc1f62adf2850d27e0ef976e318f4fc Mon Sep 17 00:00:00 2001 From: ChoiDongKuen Date: Sat, 13 Jan 2024 02:38:32 +0900 Subject: [PATCH 09/17] =?UTF-8?q?refactor:=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B4=80=EB=A0=A8=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20exception=20handle?= =?UTF-8?q?r=20=EC=B6=94=EA=B0=80=20(#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 브랜치 푸쉬 (#43) * refactor: yml 관련 리팩토링 (#43) * feat: CORS 관련 CorsConfig 구성 (#43) * feat: jwt 관련 exception handler 구성 (#43) * feat: oAuth 로그인 webClient 방식으로 변경 (#43) * refactor: 코트 컨벤션 반영 및 코드 포맷팅 * feat: test 하위 application.properties OAuth2.0 관련 임의 설정 값 추가 --- .../auth/controller/OAuthLoginController.java | 19 ++-- .../teumteum/auth/domain/CustomOAuthUser.java | 6 +- .../auth/domain/KakaoOAuthUserInfo.java | 1 + .../auth/domain/NaverOAuthUserInfo.java | 1 + .../net/teumteum/auth/domain/OAuthToken.java | 17 +-- .../auth/domain/OAuthUserAttributes.java | 21 ++-- .../teumteum/auth/domain/OAuthUserInfo.java | 3 +- .../auth/domain/response/TokenResponse.java | 10 +- .../teumteum/auth/service/AuthService.java | 59 +--------- .../teumteum/auth/service/OAuthService.java | 107 +++++++++++++----- .../core/security/SecurityConfig.java | 54 ++++----- .../core/security/config/CorsConfig.java | 19 ++++ .../filter/JwtAccessDeniedHandler.java | 31 +++++ .../filter/JwtAuthenticationEntryPoint.java | 30 +++++ .../filter/JwtAuthenticationFilter.java | 10 +- .../core/security/service/JwtService.java | 75 +++++------- .../core/security/service/RedisService.java | 5 - .../security/service/SecurityService.java | 1 - .../user/infra/WebClientConfigurer.java | 2 +- src/main/resources/application-dev.yml | 3 + src/main/resources/application-prod.yml | 2 +- src/main/resources/application.yml | 2 +- src/test/resources/application.properties | 15 ++- 23 files changed, 278 insertions(+), 215 deletions(-) create mode 100644 src/main/java/net/teumteum/core/security/config/CorsConfig.java create mode 100644 src/main/java/net/teumteum/core/security/filter/JwtAccessDeniedHandler.java create mode 100644 src/main/java/net/teumteum/core/security/filter/JwtAuthenticationEntryPoint.java diff --git a/src/main/java/net/teumteum/auth/controller/OAuthLoginController.java b/src/main/java/net/teumteum/auth/controller/OAuthLoginController.java index ab95e38e..8a605eda 100644 --- a/src/main/java/net/teumteum/auth/controller/OAuthLoginController.java +++ b/src/main/java/net/teumteum/auth/controller/OAuthLoginController.java @@ -3,24 +3,25 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.teumteum.auth.domain.response.TokenResponse; -import net.teumteum.auth.service.AuthService; import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; @Slf4j @RequestMapping -@RestController @RequiredArgsConstructor public class OAuthLoginController { - private final AuthService authService; + private final net.teumteum.auth.service.OAuthService oAuthService; - @PostMapping("/logins/callbacks/{provider}") + @GetMapping("/logins/callbacks/{provider}") @ResponseStatus(HttpStatus.OK) public TokenResponse oAuthLogin( - @PathVariable String provider, - @RequestParam String code - ) { - return authService.oAuthLogin(provider, code); + @PathVariable String provider, + @RequestParam String code) { + return oAuthService.oAuthLogin(provider, code); } } diff --git a/src/main/java/net/teumteum/auth/domain/CustomOAuthUser.java b/src/main/java/net/teumteum/auth/domain/CustomOAuthUser.java index e1f426f2..b01fcd01 100644 --- a/src/main/java/net/teumteum/auth/domain/CustomOAuthUser.java +++ b/src/main/java/net/teumteum/auth/domain/CustomOAuthUser.java @@ -1,19 +1,17 @@ package net.teumteum.auth.domain; +import java.util.Collection; +import java.util.Map; import lombok.Getter; import net.teumteum.user.domain.User; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.oauth2.core.user.OAuth2User; -import java.util.Collection; -import java.util.Map; - @Getter public class CustomOAuthUser implements OAuth2User { private final User user; private final Map attributes; - private final Collection authorities; public CustomOAuthUser(User user, OAuth2User oAuth2User) { diff --git a/src/main/java/net/teumteum/auth/domain/KakaoOAuthUserInfo.java b/src/main/java/net/teumteum/auth/domain/KakaoOAuthUserInfo.java index 1b52ecd0..497e1712 100644 --- a/src/main/java/net/teumteum/auth/domain/KakaoOAuthUserInfo.java +++ b/src/main/java/net/teumteum/auth/domain/KakaoOAuthUserInfo.java @@ -3,6 +3,7 @@ import java.util.Map; public class KakaoOAuthUserInfo extends OAuthUserInfo { + public KakaoOAuthUserInfo(Map attributes) { super(attributes); } diff --git a/src/main/java/net/teumteum/auth/domain/NaverOAuthUserInfo.java b/src/main/java/net/teumteum/auth/domain/NaverOAuthUserInfo.java index 4dbf6e9c..85161985 100644 --- a/src/main/java/net/teumteum/auth/domain/NaverOAuthUserInfo.java +++ b/src/main/java/net/teumteum/auth/domain/NaverOAuthUserInfo.java @@ -3,6 +3,7 @@ import java.util.Map; public class NaverOAuthUserInfo extends OAuthUserInfo { + public NaverOAuthUserInfo(Map attributes) { super(attributes); } diff --git a/src/main/java/net/teumteum/auth/domain/OAuthToken.java b/src/main/java/net/teumteum/auth/domain/OAuthToken.java index a5ec978b..fc856490 100644 --- a/src/main/java/net/teumteum/auth/domain/OAuthToken.java +++ b/src/main/java/net/teumteum/auth/domain/OAuthToken.java @@ -4,15 +4,16 @@ import com.fasterxml.jackson.annotation.JsonProperty; public record OAuthToken( - @JsonProperty("token_type") - String tokenType, - @JsonProperty("access_token") - String accessToken, - String scope, - - @JsonProperty("expires_in") - Integer expiresIn + @JsonProperty("token_type") + String tokenType, + @JsonProperty("access_token") + String accessToken, + String scope, + + @JsonProperty("expires_in") + Integer expiresIn ) { + public String getTokenType() { return this.tokenType; } diff --git a/src/main/java/net/teumteum/auth/domain/OAuthUserAttributes.java b/src/main/java/net/teumteum/auth/domain/OAuthUserAttributes.java index ef081316..305773c2 100644 --- a/src/main/java/net/teumteum/auth/domain/OAuthUserAttributes.java +++ b/src/main/java/net/teumteum/auth/domain/OAuthUserAttributes.java @@ -1,13 +1,12 @@ package net.teumteum.auth.domain; +import static net.teumteum.core.security.Authenticated.네이버; + +import java.util.Map; import lombok.Builder; import lombok.Getter; import net.teumteum.core.security.Authenticated; -import java.util.Map; - -import static net.teumteum.core.security.Authenticated.네이버; - @Getter public class OAuthUserAttributes { @@ -21,7 +20,7 @@ private OAuthUserAttributes(String nameAttributeKey, OAuthUserInfo oAuthUserInfo } public static OAuthUserAttributes of(Authenticated authenticated, - String userNameAttributeName, Map attributes) { + String userNameAttributeName, Map attributes) { if (authenticated == 네이버) { return ofNaver(userNameAttributeName, attributes); } @@ -31,15 +30,15 @@ public static OAuthUserAttributes of(Authenticated authenticated, private static OAuthUserAttributes ofNaver(String userNameAttributeName, Map attributes) { return OAuthUserAttributes.builder() - .nameAttributeKey(userNameAttributeName) - .oAuthUserInfo(new NaverOAuthUserInfo(attributes)) - .build(); + .nameAttributeKey(userNameAttributeName) + .oAuthUserInfo(new NaverOAuthUserInfo(attributes)) + .build(); } private static OAuthUserAttributes ofKakao(String userNameAttributeName, Map attributes) { return OAuthUserAttributes.builder() - .nameAttributeKey(userNameAttributeName) - .oAuthUserInfo(new KakaoOAuthUserInfo(attributes)) - .build(); + .nameAttributeKey(userNameAttributeName) + .oAuthUserInfo(new KakaoOAuthUserInfo(attributes)) + .build(); } } diff --git a/src/main/java/net/teumteum/auth/domain/OAuthUserInfo.java b/src/main/java/net/teumteum/auth/domain/OAuthUserInfo.java index 7183630d..55c690fe 100644 --- a/src/main/java/net/teumteum/auth/domain/OAuthUserInfo.java +++ b/src/main/java/net/teumteum/auth/domain/OAuthUserInfo.java @@ -1,8 +1,7 @@ package net.teumteum.auth.domain; -import lombok.AllArgsConstructor; - import java.util.Map; +import lombok.AllArgsConstructor; @AllArgsConstructor public abstract class OAuthUserInfo { diff --git a/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java b/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java index 12c3aee8..f4898a9b 100644 --- a/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java +++ b/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java @@ -7,18 +7,20 @@ @Getter @NoArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) -public class TokenResponse { +public class TokenResponse{ + private String accessToken; private String refreshToken; private String oauthId; - public TokenResponse(String accessToken, String refreshToken, String oauthId) { + @lombok.Builder + public TokenResponse(String accessToken, String refreshToken){ this.accessToken = accessToken; this.refreshToken = refreshToken; - this.oauthId = oauthId; } - public TokenResponse(String oauthId) { + public TokenResponse(String oauthId){ this.oauthId = oauthId; } + } diff --git a/src/main/java/net/teumteum/auth/service/AuthService.java b/src/main/java/net/teumteum/auth/service/AuthService.java index 14e17c35..b64bbc1b 100644 --- a/src/main/java/net/teumteum/auth/service/AuthService.java +++ b/src/main/java/net/teumteum/auth/service/AuthService.java @@ -1,78 +1,23 @@ package net.teumteum.auth.service; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import net.teumteum.auth.domain.CustomOAuthUser; -import net.teumteum.auth.domain.OAuthToken; -import net.teumteum.auth.domain.response.TokenResponse; import net.teumteum.core.security.service.JwtService; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserConnector; -import org.springframework.security.oauth2.client.registration.ClientRegistration; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.core.OAuth2AccessToken; import org.springframework.stereotype.Service; -import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; -import org.springframework.web.reactive.function.client.WebClient; - -import java.time.Instant; -import java.util.Collections; -import java.util.Objects; -import java.util.Optional; - -import static java.nio.charset.StandardCharsets.UTF_8; -import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; @Slf4j @Service @RequiredArgsConstructor public class AuthService { + private final JwtService jwtService; - private final OAuthService oAuthService; private final UserConnector userConnector; public Optional findUserByToken(String accessToken) { Long id = Long.parseLong(jwtService.getUserIdFromToken(accessToken)); return userConnector.findUserById(id); } - - public TokenResponse oAuthLogin(String provider, String code) { - log.info("provider is {}", provider); - OAuthToken oAuthToken = getOAuthToken(null, code); - CustomOAuthUser oAuth2User = getCustomOAuthUser(oAuthToken.getAccessToken(), oAuthToken, null); - return jwtService.createServiceToken(oAuth2User.getUser()); - } - - private CustomOAuthUser getCustomOAuthUser(String accessToken, OAuthToken oAuthToken, ClientRegistration clientRegistration) { - OAuth2AccessToken oAuth2AccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, accessToken, Instant.now(), - Instant.ofEpochMilli(oAuthToken.getExpiresIn())); - - OAuth2UserRequest oAuth2UserRequest = new OAuth2UserRequest(clientRegistration, oAuth2AccessToken); - return (CustomOAuthUser) oAuthService.loadUser(oAuth2UserRequest); - } - - private OAuthToken getOAuthToken(ClientRegistration clientRegistration, String code) { - return WebClient.create() - .post() - .uri(clientRegistration.getProviderDetails().getTokenUri()) - .headers(header -> { - header.setContentType(APPLICATION_FORM_URLENCODED); - header.setAcceptCharset(Collections.singletonList(UTF_8)); - }) - .bodyValue(Objects.requireNonNull(getOAuthTokenRequest(clientRegistration, code))) - .retrieve() - .bodyToMono(OAuthToken.class) - .block(); - } - - private MultiValueMap getOAuthTokenRequest(ClientRegistration clientRegistration, String code) { - MultiValueMap formData = new LinkedMultiValueMap<>(); - formData.add("code", code); - formData.add("grant_type", "authorization_code"); - formData.add("redirect_uri", clientRegistration.getRedirectUri()); - formData.add("client_secret", clientRegistration.getClientSecret()); - formData.add("client_id", clientRegistration.getClientId()); - return formData; - } } diff --git a/src/main/java/net/teumteum/auth/service/OAuthService.java b/src/main/java/net/teumteum/auth/service/OAuthService.java index caae63d8..537b6c34 100644 --- a/src/main/java/net/teumteum/auth/service/OAuthService.java +++ b/src/main/java/net/teumteum/auth/service/OAuthService.java @@ -1,59 +1,104 @@ package net.teumteum.auth.service; +import static java.nio.charset.StandardCharsets.UTF_8; +import static net.teumteum.core.security.Authenticated.네이버; +import static net.teumteum.core.security.Authenticated.카카오; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import net.teumteum.auth.domain.CustomOAuthUser; -import net.teumteum.auth.domain.OAuthUserAttributes; +import net.teumteum.auth.domain.KakaoOAuthUserInfo; +import net.teumteum.auth.domain.NaverOAuthUserInfo; +import net.teumteum.auth.domain.OAuthToken; +import net.teumteum.auth.domain.OAuthUserInfo; +import net.teumteum.auth.domain.response.TokenResponse; import net.teumteum.core.security.Authenticated; +import net.teumteum.core.security.service.JwtService; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserConnector; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; import org.springframework.stereotype.Service; - -import static net.teumteum.core.security.Authenticated.네이버; -import static net.teumteum.core.security.Authenticated.카카오; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.client.WebClient; @Slf4j @Service @RequiredArgsConstructor -public class OAuthService implements OAuth2UserService { +public class OAuthService { + private static final String NAVER = "naver"; private static final String KAKAO = "kakao"; + private final InMemoryClientRegistrationRepository inMemoryClientRegistrationRepository; + private final JwtService jwtService; + private final UserConnector userConnector; - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - OAuth2UserService delegate = new DefaultOAuth2UserService(); - OAuth2User oAuth2User = delegate.loadUser(userRequest); + public TokenResponse oAuthLogin(String registrationId, String code) { + ClientRegistration clientRegistration = inMemoryClientRegistrationRepository.findByRegistrationId( + registrationId); + Authenticated authenticated = getAuthenticated(clientRegistration.getRegistrationId()); + OAuthUserInfo oAuthUserInfo = getOAuthUserInfo(clientRegistration, authenticated, code); + return checkUserAndMakeResponse(oAuthUserInfo, authenticated); + } + + private Authenticated getAuthenticated(String registrationId) { + if (registrationId.equals(NAVER)) { + return 네이버; + } + return 카카오; + } - String providerId = userRequest.getClientRegistration().getRegistrationId(); - Authenticated authenticated = getAuthenticated(providerId); - String userNameAttributeName - = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); + private OAuthUserInfo getOAuthUserInfo(ClientRegistration clientRegistration, Authenticated authenticated, + String code) { + Map oAuthAttribute = getOAuthAttribute(clientRegistration, getToken(clientRegistration, code)); + if (authenticated == 네이버) { + return new NaverOAuthUserInfo(oAuthAttribute); + } + return new KakaoOAuthUserInfo(oAuthAttribute); + } - OAuthUserAttributes oAuthUserAttributes - = OAuthUserAttributes.of(authenticated, userNameAttributeName, oAuth2User.getAttributes()); + private TokenResponse checkUserAndMakeResponse(OAuthUserInfo oAuthUserInfo, Authenticated authenticated) { + String oauthId = oAuthUserInfo.getOAuthId(); + java.util.Optional user = getUser(oauthId, authenticated); + if (user.isEmpty()) { + return new net.teumteum.auth.domain.response.TokenResponse(oAuthUserInfo.getOAuthId()); + } + return jwtService.createServiceToken(user.get()); + } - User user = getUser(oAuthUserAttributes, authenticated); + private Map getOAuthAttribute(ClientRegistration clientRegistration, OAuthToken oAuthToken) { + return WebClient.create().get().uri(clientRegistration.getProviderDetails().getUserInfoEndpoint().getUri()) + .headers(header -> header.setBearerAuth(oAuthToken.getAccessToken())).retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }).block(); + } - return new CustomOAuthUser(user, oAuth2User); + private OAuthToken getToken(ClientRegistration clientRegistration, String code) { + return WebClient.create().post().uri(clientRegistration.getProviderDetails().getTokenUri()).headers(header -> { + header.setContentType(APPLICATION_FORM_URLENCODED); + header.setAcceptCharset(Collections.singletonList(UTF_8)); + }).bodyValue(tokenRequest(clientRegistration, code)).retrieve().bodyToMono(OAuthToken.class).block(); } - private Authenticated getAuthenticated(String providerId) { - if (NAVER.equals(providerId)) { - return 네이버; - } - return 카카오; + private Optional getUser(String oauthId, Authenticated authenticated) { + return this.userConnector.findByAuthenticatedAndOAuthId(authenticated, oauthId); } - private User getUser(OAuthUserAttributes oAuthUserAttributes, Authenticated authenticated) { - return userConnector.findByAuthenticatedAndOAuthId(authenticated, - oAuthUserAttributes.getOAuthUserInfo().getOAuthId()).orElse(null); + private MultiValueMap tokenRequest(ClientRegistration clientRegistration, String code) { + MultiValueMap formData = new LinkedMultiValueMap<>(); + formData.add("code", code); + formData.add("grant_type", "authorization_code"); + formData.add("redirect_uri", clientRegistration.getRedirectUri()); + formData.add("client_secret", clientRegistration.getClientSecret()); + formData.add("client_id", clientRegistration.getClientId()); + return formData; } } diff --git a/src/main/java/net/teumteum/core/security/SecurityConfig.java b/src/main/java/net/teumteum/core/security/SecurityConfig.java index 8b119f8c..eedd5574 100644 --- a/src/main/java/net/teumteum/core/security/SecurityConfig.java +++ b/src/main/java/net/teumteum/core/security/SecurityConfig.java @@ -2,56 +2,52 @@ import lombok.RequiredArgsConstructor; +import net.teumteum.core.security.filter.JwtAccessDeniedHandler; +import net.teumteum.core.security.filter.JwtAuthenticationEntryPoint; import net.teumteum.core.security.filter.JwtAuthenticationFilter; -import org.springframework.boot.autoconfigure.security.servlet.PathRequest; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +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.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 static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler accessDeniedHandler; private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + return web -> web.ignoring().requestMatchers("/h2-console/**"); + } + @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(request - -> request.requestMatchers("/users").permitAll() - .requestMatchers(PathRequest.toH2Console()).permitAll() - .anyRequest().authenticated()) + http.csrf(AbstractHttpConfigurer::disable) + .cors(Customizer.withDefaults()) + .authorizeHttpRequests( + request -> request.requestMatchers("/**").permitAll() + .anyRequest().authenticated()) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .sessionManagement(sessionManagement - -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(STATELESS)) + .exceptionHandling( + exceptionHandling -> + exceptionHandling + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) ) - .cors(cors -> cors.configurationSource(this.corsConfigurationSource())); - + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } - - @Bean - public CorsConfigurationSource corsConfigurationSource() { - CorsConfiguration configuration = new CorsConfiguration(); - configuration.addAllowedOrigin("*"); - configuration.addAllowedMethod("*"); - configuration.addAllowedHeader("*"); - configuration.setMaxAge(3600L); - - UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - source.registerCorsConfiguration("/**", configuration); - return source; - } } diff --git a/src/main/java/net/teumteum/core/security/config/CorsConfig.java b/src/main/java/net/teumteum/core/security/config/CorsConfig.java new file mode 100644 index 00000000..b95d52aa --- /dev/null +++ b/src/main/java/net/teumteum/core/security/config/CorsConfig.java @@ -0,0 +1,19 @@ +package net.teumteum.core.security.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class CorsConfig implements WebMvcConfigurer { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedMethods("*") + .allowedHeaders("*") + .allowedOriginPatterns("*") + .exposedHeaders("*") + .allowCredentials(true); + WebMvcConfigurer.super.addCorsMappings(registry); + } +} diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAccessDeniedHandler.java b/src/main/java/net/teumteum/core/security/filter/JwtAccessDeniedHandler.java new file mode 100644 index 00000000..3a1f71e9 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/filter/JwtAccessDeniedHandler.java @@ -0,0 +1,31 @@ +package net.teumteum.core.security.filter; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +import static jakarta.servlet.http.HttpServletResponse.SC_FORBIDDEN; + +@Slf4j +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException + ) throws IOException, ServletException { + this.sendUnAuthorizedError(response, accessDeniedException); + } + + private void sendUnAuthorizedError(HttpServletResponse response, + Exception exception) throws IOException { + log.error("Responding with unauthorized error. Message - {}", exception.getMessage()); + response.sendError(SC_FORBIDDEN, exception.getMessage()); + } +} diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationEntryPoint.java b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationEntryPoint.java new file mode 100644 index 00000000..20519ae8 --- /dev/null +++ b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationEntryPoint.java @@ -0,0 +1,30 @@ +package net.teumteum.core.security.filter; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +import static jakarta.servlet.http.HttpServletResponse.SC_UNAUTHORIZED; + +@Slf4j +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authenticationException + ) throws IOException { + this.sendUnAuthenticatedError(response, authenticationException); + } + + private void sendUnAuthenticatedError(HttpServletResponse response, + Exception exception) throws IOException { + log.error("Responding with unauthenticated error. Message - {}", exception.getMessage()); + response.sendError(SC_UNAUTHORIZED, exception.getMessage()); + } +} diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java index 77a739c1..a10bd068 100644 --- a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java @@ -29,11 +29,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final AuthService authService; private final JwtProperty jwtProperty; - private static void saveUserAuthentication(User user) { - UserAuthentication authentication = new UserAuthentication(user); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @@ -63,6 +58,11 @@ private boolean checkTokenExistenceAndValidation(String token) { return StringUtils.hasText(token) && this.jwtService.validateToken(token); } + private void saveUserAuthentication(User user) { + UserAuthentication authentication = new UserAuthentication(user); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + private String resolveTokenFromRequest(HttpServletRequest request) { String token = request.getHeader(jwtProperty.getAccess().getHeader()); if (!ObjectUtils.isEmpty(token) && token.toLowerCase().startsWith(jwtProperty.getBearer().toLowerCase())) { diff --git a/src/main/java/net/teumteum/core/security/service/JwtService.java b/src/main/java/net/teumteum/core/security/service/JwtService.java index 08dab7e0..fd6c15cc 100644 --- a/src/main/java/net/teumteum/core/security/service/JwtService.java +++ b/src/main/java/net/teumteum/core/security/service/JwtService.java @@ -19,78 +19,65 @@ @Service @RequiredArgsConstructor @Slf4j -public class JwtService { +public class JwtService{ + private final JwtProperty jwtProperty; private final RedisService redisService; - public Optional extractAccessToken(HttpServletRequest request) { - return Optional.ofNullable(request.getHeader(this.jwtProperty.getAccess().getHeader())) - .filter(StringUtils::hasText) - .filter(accessToken -> accessToken.startsWith(jwtProperty.getBearer())) - .map(accessToken -> accessToken.replace(jwtProperty.getBearer(), "")); + public Optional extractAccessToken(HttpServletRequest request){ + return Optional.ofNullable(request.getHeader(this.jwtProperty.getAccess().getHeader())).filter(StringUtils::hasText).filter(accessToken -> accessToken.startsWith(jwtProperty.getBearer())).map(accessToken -> accessToken.replace(jwtProperty.getBearer(), "")); } - public String extractRefreshToken(HttpServletRequest request) { + public String extractRefreshToken(HttpServletRequest request){ return request.getHeader(this.jwtProperty.getRefresh().getHeader()); } - public String createAccessToken(String payload) { - return this.createToken(payload, this.jwtProperty.getAccess().getExpiration()); + public String getUserIdFromToken(String token){ + try{ + return Jwts.parser().setSigningKey(this.jwtProperty.getSecret()).parseClaimsJws(token).getBody().getSubject(); + }catch(Exception exception){ + throw new JwtException("Access Token is not valid"); + } } + public TokenResponse createServiceToken(User users){ + String accessToken = this.createAccessToken(String.valueOf(users.getId())); + String refreshToken = this.createRefreshToken(); - public String createRefreshToken() { - return this.createToken(UUID.randomUUID().toString(), this.jwtProperty.getRefresh().getExpiration()); + this.redisService.setDataExpire(String.valueOf(users.getId()), refreshToken, this.jwtProperty.getRefresh().getExpiration()); - } + return new TokenResponse(this.jwtProperty.getBearer() + " " + accessToken, refreshToken); + } - public String getUserIdFromToken(String token) { - try { - return Jwts.parser() - .setSigningKey(this.jwtProperty.getSecret()) - .parseClaimsJws(token) - .getBody() - .getSubject(); - } catch (Exception exception) { - throw new JwtException("Access Token is not valid"); - } + public String createAccessToken(String payload){ + return this.createToken(payload, this.jwtProperty.getAccess().getExpiration()); } - public TokenResponse createServiceToken(User users) { - String accessToken = this.createAccessToken(String.valueOf(users.getId())); - String refreshToken = this.createRefreshToken(); + public String createRefreshToken(){ + return this.createToken(UUID.randomUUID().toString(), this.jwtProperty.getRefresh().getExpiration()); - this.redisService.setDataExpire(String.valueOf(users.getId()), - refreshToken, this.jwtProperty.getRefresh().getExpiration()); + } - return new TokenResponse(this.jwtProperty.getBearer() + " " + accessToken, refreshToken, null); + private String createToken(String payload, Long tokenExpiration){ + Claims claims = Jwts.claims().setSubject(payload); + Date tokenExpiresIn = new Date(new Date().getTime() + tokenExpiration); + return Jwts.builder().setClaims(claims).setIssuedAt(new Date()).setExpiration(tokenExpiresIn).signWith(SignatureAlgorithm.HS512, this.jwtProperty.getSecret()).compact(); } - public boolean validateToken(String token) { - try { + public boolean validateToken(String token){ + try{ Jws claimsJws = Jwts.parser().setSigningKey(this.jwtProperty.getSecret()).parseClaimsJws(token); return !claimsJws.getBody().getExpiration().before(new Date()); - } catch (ExpiredJwtException exception) { + }catch(ExpiredJwtException exception){ log.warn("만료된 jwt 입니다."); - } catch (UnsupportedJwtException exception) { + }catch(UnsupportedJwtException exception){ log.warn("지원되지 않는 jwt 입니다."); - } catch (IllegalArgumentException exception) { + }catch(IllegalArgumentException exception){ log.warn("jwt 에 오류가 존재합니다."); } return false; } - private String createToken(String payload, Long tokenExpiration) { - Claims claims = Jwts.claims().setSubject(payload); - Date tokenExpiresIn = new Date(new Date().getTime() + tokenExpiration); - - return Jwts.builder() - .setClaims(claims) - .setIssuedAt(new Date()) - .setExpiration(tokenExpiresIn) - .signWith(SignatureAlgorithm.HS512, this.jwtProperty.getSecret()) - .compact(); - } } diff --git a/src/main/java/net/teumteum/core/security/service/RedisService.java b/src/main/java/net/teumteum/core/security/service/RedisService.java index 3b2e5183..e252ff7a 100644 --- a/src/main/java/net/teumteum/core/security/service/RedisService.java +++ b/src/main/java/net/teumteum/core/security/service/RedisService.java @@ -7,30 +7,25 @@ import java.time.Duration; -/* Redis 관련 작업을 위한 서비스 */ @Service @RequiredArgsConstructor public class RedisService { private final StringRedisTemplate stringRedisTemplate; - /* key 에 해당하는 데이터 얻어오는 메소드 */ public String getData(String key) { ValueOperations valueOperations = getStringStringValueOperations(); return valueOperations.get(key); } - /* key - value 데이터 설정하는 메소드 */ public void setData(String key, String value) { ValueOperations valueOperations = getStringStringValueOperations(); valueOperations.set(key, value); } - /* key 에 해당하는 데이터 삭제하는 메소드 */ public void deleteData(String key) { this.stringRedisTemplate.delete(key); } - /* key 에 해당하는 데이터 만료기간 설정 메소드 */ public void setDataExpire(String key, String value, Long duration) { ValueOperations valueOperations = getStringStringValueOperations(); Duration expireDuration = Duration.ofSeconds(duration); diff --git a/src/main/java/net/teumteum/core/security/service/SecurityService.java b/src/main/java/net/teumteum/core/security/service/SecurityService.java index 3cf572bd..c4014664 100644 --- a/src/main/java/net/teumteum/core/security/service/SecurityService.java +++ b/src/main/java/net/teumteum/core/security/service/SecurityService.java @@ -9,7 +9,6 @@ @Service @RequiredArgsConstructor public class SecurityService { - private final UserConnector userConnector; public static void clearSecurityContext() { diff --git a/src/main/java/net/teumteum/user/infra/WebClientConfigurer.java b/src/main/java/net/teumteum/user/infra/WebClientConfigurer.java index 7396a809..035fbe16 100644 --- a/src/main/java/net/teumteum/user/infra/WebClientConfigurer.java +++ b/src/main/java/net/teumteum/user/infra/WebClientConfigurer.java @@ -6,7 +6,7 @@ import org.springframework.web.reactive.function.client.WebClient; @Configuration -@Profile("prod") +@Profile({"prod", "dev"}) public class WebClientConfigurer { @Bean diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c73a4d75..a8575e4e 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -23,3 +23,6 @@ logging: level: org.hibernate.SQL: debug org.hibernate.type: trace + +gpt: + token: 1234 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 049241ab..71741f0e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -33,7 +33,7 @@ management: ### GPT ### gpt: - token: + token: 1234 ### SENTRY ### sentry: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 64a6d1ad..fa4ae492 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -9,4 +9,4 @@ spring: group: "dev": "dev, auth, datasource, redis" "prod": "prod, auth, datasource, redis" -# "test": + diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 3cf3f181..0d2a2040 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -5,13 +5,24 @@ spring.jpa.hibernated.ddl-auto=validate spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect spring.datasource.hikari.maximum-pool-size=4 spring.datasource.hikari.pool-name=H2_TEST_POOL - gpt.token=12345678910 - ### FOR DEBUGGING ### logging.level.org.hibernate.SQL=debug logging.level.org.hibernate.type.descriptor.sql=trace spring.jpa.properties.hibernate.format_sql=true spring.jpa.properties.hibernate.highlight_sql=true spring.jpa.properties.hibernate.use_sql_comments=true +### OAuth 2.0 Login ### +spring.security.oauth2.client.registration.kakao.client-id=client_id +spring.security.oauth2.client.registration.kakao.client-secret=client_id +spring.security.oauth2.client.registration.kakao.provider=kakao +spring.security.oauth2.client.registration.kakao.redirect-uri=redirect_uri +spring.security.oauth2.client.registration.kakao.client-authentication-method=POST +spring.security.oauth2.client.registration.kakao.authorization-grant-type=authorization_code +spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kakao.com/oauth/authorize +spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/authorize +spring.security.oauth2.client.provider.kakao.user-info-uri=https://kauth.kakao.com/oauth/authorize +spring.security.oauth2.client.provider.kakao.user-name-attribute=https://kauth.kakao.com/oauth/authorize + + From b2c20fcde3a875a64f1b8bf96becc61017735d9e Mon Sep 17 00:00:00 2001 From: xb205 <62425964+devxb@users.noreply.github.com> Date: Sat, 13 Jan 2024 11:40:11 +0900 Subject: [PATCH 10/17] =?UTF-8?q?refactor:=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EA=B4=80=EC=8B=AC=20=EC=A3=BC=EC=A0=9C=20=EA=B2=8C=EC=9E=84=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=9C=BC=EB=A1=9C=20=EA=B4=80=EC=8B=AC=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=EC=9D=84=20=EB=B0=9B=EC=95=84=EB=B3=BC=20?= =?UTF-8?q?=EC=88=98=20=EC=9E=88=EB=8F=84=EB=A1=9D=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build: sentry.gradle을 build.gradle에 포함시킨다 * refactor: GPT응답에서 관심주제도 받아볼 수 있도록 수정한다 * refactor: balance game type을 enum으로 변경한다 --- build.gradle | 1 + gradle/{apm.gradle => sentry.gradle} | 0 .../user/controller/UserController.java | 6 +- .../teumteum/user/domain/BalanceGameType.java | 34 +++++++++++ .../user/domain/InterestQuestion.java | 8 ++- .../response/BalanceQuestionResponse.java | 10 +++ .../response/InterestQuestionResponse.java | 7 +-- .../response/StoryQuestionResponse.java | 8 +++ .../user/infra/GptInterestQuestion.java | 61 +++++++++++-------- .../teumteum/user/service/UserService.java | 5 +- .../user/infra/GptInterestQuestionTest.java | 46 +++++++++++--- 11 files changed, 139 insertions(+), 47 deletions(-) rename gradle/{apm.gradle => sentry.gradle} (100%) create mode 100644 src/main/java/net/teumteum/user/domain/BalanceGameType.java create mode 100644 src/main/java/net/teumteum/user/domain/response/BalanceQuestionResponse.java create mode 100644 src/main/java/net/teumteum/user/domain/response/StoryQuestionResponse.java diff --git a/build.gradle b/build.gradle index d70acd82..be1e284c 100644 --- a/build.gradle +++ b/build.gradle @@ -11,6 +11,7 @@ apply from: "gradle/devtool.gradle" apply from: "gradle/test.gradle" apply from: "gradle/sonar.gradle" apply from: "gradle/db.gradle" +apply from: "gradle/sentry.gradle" allprojects { diff --git a/gradle/apm.gradle b/gradle/sentry.gradle similarity index 100% rename from gradle/apm.gradle rename to gradle/sentry.gradle diff --git a/src/main/java/net/teumteum/user/controller/UserController.java b/src/main/java/net/teumteum/user/controller/UserController.java index bc500a99..d81af3de 100644 --- a/src/main/java/net/teumteum/user/controller/UserController.java +++ b/src/main/java/net/teumteum/user/controller/UserController.java @@ -69,8 +69,10 @@ public FriendsResponse findFriends(@PathVariable("userId") Long userId) { } @GetMapping("/interests") - public InterestQuestionResponse getInterestQuestion(@RequestParam("user-id") List userIds) { - return userService.getInterestQuestionByUserIds(userIds); + @ResponseStatus(HttpStatus.OK) + public InterestQuestionResponse getInterestQuestion(@RequestParam("user-id") List userIds, + @RequestParam("type") String balance) { + return userService.getInterestQuestionByUserIds(userIds, balance); } @ResponseStatus(HttpStatus.BAD_REQUEST) diff --git a/src/main/java/net/teumteum/user/domain/BalanceGameType.java b/src/main/java/net/teumteum/user/domain/BalanceGameType.java new file mode 100644 index 00000000..6c3e66fd --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/BalanceGameType.java @@ -0,0 +1,34 @@ +package net.teumteum.user.domain; + +import java.util.Arrays; +import java.util.List; +import java.util.function.BiFunction; +import net.teumteum.user.domain.response.InterestQuestionResponse; + +public enum BalanceGameType { + + BALANCE("balance", (users, interestQuestion) -> interestQuestion.getBalanceGame(users)), + STORY("story", (users, interestQuestion) -> interestQuestion.getStoryGame(users)), + ; + + private final String value; + private final BiFunction, InterestQuestion, InterestQuestionResponse> behavior; + + BalanceGameType(String value, BiFunction, InterestQuestion, InterestQuestionResponse> behavior) { + this.value = value; + this.behavior = behavior; + } + + public static BalanceGameType of(String value) { + return Arrays.stream(BalanceGameType.values()) + .filter(type -> type.value.equals(value)) + .findAny() + .orElseThrow( + () -> new IllegalArgumentException("\"" + value + "\" 에 해당하는 enum값을 찾을 수 없습니다.") + ); + } + + public InterestQuestionResponse getInterestQuestionResponse(List users, InterestQuestion interestQuestion) { + return behavior.apply(users, interestQuestion); + } +} diff --git a/src/main/java/net/teumteum/user/domain/InterestQuestion.java b/src/main/java/net/teumteum/user/domain/InterestQuestion.java index 9450c239..66b97e9f 100644 --- a/src/main/java/net/teumteum/user/domain/InterestQuestion.java +++ b/src/main/java/net/teumteum/user/domain/InterestQuestion.java @@ -1,11 +1,13 @@ package net.teumteum.user.domain; import java.util.List; -import net.teumteum.user.domain.response.InterestQuestionResponse; +import net.teumteum.user.domain.response.BalanceQuestionResponse; +import net.teumteum.user.domain.response.StoryQuestionResponse; -@FunctionalInterface public interface InterestQuestion { - InterestQuestionResponse getQuestion(List users); + BalanceQuestionResponse getBalanceGame(List users); + + StoryQuestionResponse getStoryGame(List users); } diff --git a/src/main/java/net/teumteum/user/domain/response/BalanceQuestionResponse.java b/src/main/java/net/teumteum/user/domain/response/BalanceQuestionResponse.java new file mode 100644 index 00000000..c9a6c0cf --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/response/BalanceQuestionResponse.java @@ -0,0 +1,10 @@ +package net.teumteum.user.domain.response; + +import java.util.List; + +public record BalanceQuestionResponse( + String topic, + List balanceQuestion +) implements InterestQuestionResponse { + +} diff --git a/src/main/java/net/teumteum/user/domain/response/InterestQuestionResponse.java b/src/main/java/net/teumteum/user/domain/response/InterestQuestionResponse.java index 22921f57..46516ad1 100644 --- a/src/main/java/net/teumteum/user/domain/response/InterestQuestionResponse.java +++ b/src/main/java/net/teumteum/user/domain/response/InterestQuestionResponse.java @@ -1,10 +1,5 @@ package net.teumteum.user.domain.response; -import java.util.List; - -public record InterestQuestionResponse( - String topic, - List balanceQuestion -) { +public interface InterestQuestionResponse { } diff --git a/src/main/java/net/teumteum/user/domain/response/StoryQuestionResponse.java b/src/main/java/net/teumteum/user/domain/response/StoryQuestionResponse.java new file mode 100644 index 00000000..507b8861 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/response/StoryQuestionResponse.java @@ -0,0 +1,8 @@ +package net.teumteum.user.domain.response; + +public record StoryQuestionResponse( + String topic, + String story +) implements InterestQuestionResponse { + +} diff --git a/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java b/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java index 6834b064..8ce36994 100644 --- a/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java +++ b/src/main/java/net/teumteum/user/infra/GptInterestQuestion.java @@ -10,7 +10,8 @@ import lombok.RequiredArgsConstructor; import net.teumteum.user.domain.InterestQuestion; import net.teumteum.user.domain.User; -import net.teumteum.user.domain.response.InterestQuestionResponse; +import net.teumteum.user.domain.response.BalanceQuestionResponse; +import net.teumteum.user.domain.response.StoryQuestionResponse; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; @@ -32,16 +33,33 @@ public class GptInterestQuestion implements InterestQuestion { @Override - public InterestQuestionResponse getQuestion(List users) { + public BalanceQuestionResponse getBalanceGame(List users) { var interests = parseInterests(users); - var request = GptQuestionRequest.of(interests); + var request = GptQuestionRequest.balanceGame(interests); return webClient.post() .bodyValue(request) .header(HttpHeaders.AUTHORIZATION, gptToken) .exchangeToMono(response -> { if (response.statusCode().is2xxSuccessful()) { - return response.bodyToMono(InterestQuestionResponse.class); + return response.bodyToMono(BalanceQuestionResponse.class); + } + return response.createError(); + }) + .retry(MAX_RETRY_COUNT) + .subscribeOn(Schedulers.fromExecutor(executorService)) + .block(Duration.ofSeconds(5)); + } + + @Override + public StoryQuestionResponse getStoryGame(List users) { + var interests = parseInterests(users); + var request = GptQuestionRequest.story(interests); + + return webClient.post().bodyValue(request).header(HttpHeaders.AUTHORIZATION, gptToken) + .exchangeToMono(response -> { + if (response.statusCode().is2xxSuccessful()) { + return response.bodyToMono(StoryQuestionResponse.class); } return response.createError(); }) @@ -72,29 +90,24 @@ private record GptQuestionRequest( private static final String LANGUAGE_MODEL = "gpt-3.5-turbo-1106"; - private static GptQuestionRequest of(String interests) { - return new GptQuestionRequest( - LANGUAGE_MODEL, - List.of(Message.system(), Message.user(interests)) - ); + private static GptQuestionRequest balanceGame(String interests) { + return new GptQuestionRequest(LANGUAGE_MODEL, List.of(Message.balanceGame(), Message.user(interests))); } - private record Message( - String role, - String content - ) { + private static GptQuestionRequest story(String interests) { + return new GptQuestionRequest(LANGUAGE_MODEL, List.of(Message.story(), Message.user(interests))); + } - private static Message system() { - return new Message( - "system", - "You are a chatbot that receives the user's interests and creates common topics of interest" - + " and balance games corresponding to the topics of interest in the form of sentences based on" - + " the interests. At this time, only two choices for the balance game must be given, and the" - + " choices must be separated by a comma The query results must be returned in JSON format" - + " according to the form below and other The JSON value must be answered in Korean without" - + " words. " - + "{\\\"topic\\\": Topic of common interest, \\\"balanceQuestion\\\": [Balance game options]}" - ); + private record Message(String role, String content) { + + private static Message balanceGame() { + return new Message("system", + "당신은 사용자의 관심사들을 입력받아 관심사 게임을 응답하는 챗봇입니다.관심사 게임은 \"공통 관심 주제\"와 \"밸런스 게임의 질문 선택지\" 로 이루어져 있습니다. \"밸런스 게임의 질문 선택지\"는 문장형태로 이루어지며 상반된 각각 하나의 질문으로 무조건 2개 응답되어야 합니다. 이때, \"밸런스 게임의 질문 선택지\"는 각각 36자 이하로 생성되어야 합니다. 응답은 다음 JSON 형태로 응답해주세요. {\"topic\": 공통 관심 주제, \"balanceQuestion\": [밸런스 게임의 질문 선택지 2개]} 이때, 부가적인 설명없이 JSON만 응답해야하며, JSON의 VALUE는 모두 한국어로 응답해주세요."); + } + + private static Message story() { + return new Message("system", + "당신은 사용자의 관심사들을 입력받아 관심사 게임을 응답하는 챗봇입니다. 관심사 게임은 \"공통 관심 주제\"와 \"관심 주제와 연관되는 질문\" 로 이루어져 있습니다.이때 \"관심 주제와 연관되는 질문\" 은 최대 76자로 제한합니다. 응답은 다음 JSON 형태로 형태로 응답해주세요. {\"topic\": 공통 관심 주제, \"story\": 관심 주제와 연관되는 질문} 이때, 부가적인 설명없이 JSON만 응답해야하며, JSON의 VALUE는 모두 한국어로 응답해주세요."); } private static Message user(String interests) { diff --git a/src/main/java/net/teumteum/user/service/UserService.java b/src/main/java/net/teumteum/user/service/UserService.java index 99e0447a..c01f8888 100644 --- a/src/main/java/net/teumteum/user/service/UserService.java +++ b/src/main/java/net/teumteum/user/service/UserService.java @@ -2,6 +2,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import net.teumteum.user.domain.BalanceGameType; import net.teumteum.user.domain.InterestQuestion; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserRepository; @@ -68,7 +69,7 @@ private User getUser(Long userId) { .orElseThrow(() -> new IllegalArgumentException("userId에 해당하는 user를 찾을 수 없습니다. \"" + userId + "\"")); } - public InterestQuestionResponse getInterestQuestionByUserIds(List userIds) { + public InterestQuestionResponse getInterestQuestionByUserIds(List userIds, String type) { var users = userRepository.findAllById(userIds); Assert.isTrue(users.size() >= 2, () -> { @@ -76,6 +77,6 @@ public InterestQuestionResponse getInterestQuestionByUserIds(List userIds) } ); - return interestQuestion.getQuestion(users); + return BalanceGameType.of(type).getInterestQuestionResponse(users, interestQuestion); } } diff --git a/src/test/java/net/teumteum/user/infra/GptInterestQuestionTest.java b/src/test/java/net/teumteum/user/infra/GptInterestQuestionTest.java index e9e169ed..b049a97a 100644 --- a/src/test/java/net/teumteum/user/infra/GptInterestQuestionTest.java +++ b/src/test/java/net/teumteum/user/infra/GptInterestQuestionTest.java @@ -3,7 +3,8 @@ import java.util.List; import net.teumteum.user.domain.InterestQuestion; import net.teumteum.user.domain.UserFixture; -import net.teumteum.user.domain.response.InterestQuestionResponse; +import net.teumteum.user.domain.response.BalanceQuestionResponse; +import net.teumteum.user.domain.response.StoryQuestionResponse; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -24,17 +25,16 @@ class GptInterestQuestionTest { @Autowired private InterestQuestion interestQuestion; - @Nested - @DisplayName("getQuestion 메소드는") - class GetQuestion_method { + @DisplayName("getBalanceGame 메소드는") + class GetBalanceGame_method { @Test - @DisplayName("user 목록을 받아서, 관심 질문을 반환한다.") + @DisplayName("user 목록을 받아서, 밸런스 게임을 반환한다.") void Return_balance_game_when_receive_user_list() { // given var users = List.of(UserFixture.getDefaultUser(), UserFixture.getDefaultUser()); - var expected = new InterestQuestionResponse( + var expected = new BalanceQuestionResponse( "프로그래머", List.of("프론트엔드", "백엔드") ); @@ -43,7 +43,7 @@ void Return_balance_game_when_receive_user_list() { gptTestServer.enqueue(expected); // when - var result = interestQuestion.getQuestion(users); + var result = interestQuestion.getBalanceGame(users); // then Assertions.assertThat(expected).isEqualTo(result); @@ -54,7 +54,7 @@ void Return_balance_game_when_receive_user_list() { void Do_retry_when_gpt_server_cannot_receive_interests_lists() { // given var users = List.of(UserFixture.getDefaultUser(), UserFixture.getDefaultUser()); - var expected = new InterestQuestionResponse( + var expected = new BalanceQuestionResponse( "프로그래머", List.of("프론트엔드", "백엔드") ); @@ -66,7 +66,7 @@ void Do_retry_when_gpt_server_cannot_receive_interests_lists() { gptTestServer.enqueue(expected); // when - var result = interestQuestion.getQuestion(users); + var result = interestQuestion.getBalanceGame(users); // then Assertions.assertThat(expected).isEqualTo(result); @@ -84,10 +84,36 @@ void Throw_illegal_state_exception_exceed_5_time_to_get_common_interests() { gptTestServer.enqueue400(); // when - var result = Assertions.catchException(() -> interestQuestion.getQuestion(users)); + var result = Assertions.catchException(() -> interestQuestion.getBalanceGame(users)); // then Assertions.assertThat(result.getClass()).isEqualTo(IllegalStateException.class); } } + + @Nested + @DisplayName("getStoryGame 메소드는") + class GetStoryGame_method { + + @Test + @DisplayName("user 목록을 받아서, 밸런스 게임을 반환한다.") + void Return_story_game_when_receive_user_list() { + // given + var users = List.of(UserFixture.getDefaultUser(), UserFixture.getDefaultUser()); + var expected = new StoryQuestionResponse( + "프로그래머", + "어떤 프로그래머가 좋은 프로그래머 일까요?" + ); + + gptTestServer.enqueue400(); + gptTestServer.enqueue400(); + gptTestServer.enqueue(expected); + + // when + var result = interestQuestion.getStoryGame(users); + + // then + Assertions.assertThat(result).isEqualTo(expected); + } + } } From 8492c46ad5ea61b25247e21241c16f1a5d15d5b9 Mon Sep 17 00:00:00 2001 From: ChoiDongKuen Date: Sat, 13 Jan 2024 18:01:01 +0900 Subject: [PATCH 11/17] =?UTF-8?q?feat:=20AccessToken,=20RefreshToken=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4.=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: jwtService 코드 리팩토링(#40) * feat: 토큰 재발행 관련 AuthController,AuthService 구현 (#40) * refactor: jwtService 코드 재정렬 (#40) * refactor: AuthService 코드 리팩토링 (#40) * refactor: 코드 형식 재정렬 (#40) * test: Jwt 재발급 통합 테스트 진행 (#40) * test: Jwt 재발급 컨트롤러 단위 테스트 진행 (#40) * test: Jwt 재발급 서비스 단위 테스트 진행 (#40) * refactor: 코드 재정렬 (#40) * fix: sonarcloud 에러 수정 (#40) * feat: 회원 탈퇴를 구현한다. (#50) * refactor: JwtService 코드 재정렬 (#40) * refactor: 불필요한 import 노출 제거 (#42) * refactor: 코드 재정렬 (#42) * feat: 회원탈퇴 API 구현 (#42) * test: 회원탈퇴 통합 테스트 구현 (#42) * refactor: SecurityConfig 코드 재정렬 (#42) * refactor: 기타 코드 리팩토링 (#42) * fix: CI 에러 수정 (#42) * feat: Redis 관련 설정 클래스를 구현하고 적용한다. (#51) * refactor: 기존 코드 리팩토링 (#49) * feat: RedisConfig 구현 (#49) * fix: CI 에러 수정 (#49) * fix: 테스트명 변경 (#49) * fix: 컨트롤러 url 컨벤션 적용 (#40) * fix: CI 임시 해결 ( test 코드 추후 다시 작성 예정 )(#40) * fix: CI 임시 해결 (#40) * fix: CI 해결 (#40) * fix: CI 해결 (#40) * refactor: 회원 탈퇴 uri 컨벤션으로 수정 (#40) --- .../auth/controller/AuthController.java | 25 ++++ .../auth/domain/response/TokenResponse.java | 10 +- .../teumteum/auth/service/AuthService.java | 40 ++++++- .../teumteum/auth/service/OAuthService.java | 4 +- .../net/teumteum/core/config/RedisConfig.java | 32 +++++ .../core/security/SecurityConfig.java | 48 +++++--- .../core/security/config/CorsConfig.java | 19 --- .../filter/JwtAuthenticationFilter.java | 12 +- .../core/security/service/JwtService.java | 88 ++++++++------ .../core/security/service/RedisService.java | 14 +-- .../security/service/SecurityService.java | 4 +- .../net/teumteum/meeting/domain/Meeting.java | 32 +++-- .../user/controller/UserController.java | 8 ++ .../java/net/teumteum/user/domain/User.java | 27 ++++- .../teumteum/user/service/UserService.java | 13 ++ .../java/net/teumteum/integration/Api.java | 17 ++- .../integration/UserIntegrationTest.java | 20 ++++ .../unit/auth/service/AuthServiceTest.java | 111 ++++++++++++++++++ src/test/resources/application.properties | 3 + 19 files changed, 414 insertions(+), 113 deletions(-) create mode 100644 src/main/java/net/teumteum/auth/controller/AuthController.java create mode 100644 src/main/java/net/teumteum/core/config/RedisConfig.java delete mode 100644 src/main/java/net/teumteum/core/security/config/CorsConfig.java create mode 100644 src/test/java/net/teumteum/unit/auth/service/AuthServiceTest.java diff --git a/src/main/java/net/teumteum/auth/controller/AuthController.java b/src/main/java/net/teumteum/auth/controller/AuthController.java new file mode 100644 index 00000000..db982e05 --- /dev/null +++ b/src/main/java/net/teumteum/auth/controller/AuthController.java @@ -0,0 +1,25 @@ +package net.teumteum.auth.controller; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import net.teumteum.auth.domain.response.TokenResponse; +import net.teumteum.auth.service.AuthService; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth") +public class AuthController { + + private final AuthService authService; + + @PostMapping("/reissues") + @ResponseStatus(HttpStatus.OK) + public TokenResponse reissue(HttpServletRequest request) { + return authService.reissue(request); + } +} diff --git a/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java b/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java index f4898a9b..b1f2ff3f 100644 --- a/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java +++ b/src/main/java/net/teumteum/auth/domain/response/TokenResponse.java @@ -1,25 +1,27 @@ package net.teumteum.auth.domain.response; import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @NoArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) -public class TokenResponse{ +public class TokenResponse { private String accessToken; private String refreshToken; private String oauthId; - @lombok.Builder - public TokenResponse(String accessToken, String refreshToken){ + + @Builder + public TokenResponse(String accessToken, String refreshToken) { this.accessToken = accessToken; this.refreshToken = refreshToken; } - public TokenResponse(String oauthId){ + public TokenResponse(String oauthId) { this.oauthId = oauthId; } diff --git a/src/main/java/net/teumteum/auth/service/AuthService.java b/src/main/java/net/teumteum/auth/service/AuthService.java index b64bbc1b..13326e5b 100644 --- a/src/main/java/net/teumteum/auth/service/AuthService.java +++ b/src/main/java/net/teumteum/auth/service/AuthService.java @@ -1,9 +1,12 @@ package net.teumteum.auth.service; +import jakarta.servlet.http.HttpServletRequest; import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import net.teumteum.auth.domain.response.TokenResponse; import net.teumteum.core.security.service.JwtService; +import net.teumteum.core.security.service.RedisService; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserConnector; import org.springframework.stereotype.Service; @@ -14,10 +17,41 @@ public class AuthService { private final JwtService jwtService; + private final RedisService redisService; private final UserConnector userConnector; - public Optional findUserByToken(String accessToken) { - Long id = Long.parseLong(jwtService.getUserIdFromToken(accessToken)); - return userConnector.findUserById(id); + public TokenResponse reissue(HttpServletRequest request) { + String refreshToken = jwtService.extractRefreshToken(request); + String accessToken = jwtService.extractAccessToken(request); + + checkRefreshTokenValidation(refreshToken); + + User user = findUserByAccessToken(accessToken).orElseThrow( + () -> new IllegalArgumentException("access token 에 해당하는 user를 찾을 수 없습니다.")); + + checkRefreshTokenMatch(user, refreshToken); + return issueNewToken(user); + } + + public Optional findUserByAccessToken(String accessToken) { + return userConnector.findUserById(Long.parseLong(jwtService.getUserIdFromToken(accessToken))); + } + + private void checkRefreshTokenValidation(String refreshToken) { + if (!jwtService.validateToken(refreshToken)) { + throw new IllegalArgumentException("refresh token 이 유효하지 않습니다."); + } + } + + private void checkRefreshTokenMatch(User user, String refreshToken) { + if (!redisService.getData(String.valueOf(user.getId())).equals(refreshToken)) { + throw new IllegalArgumentException("refresh token 이 일치하지 않습니다."); + } + } + + + private TokenResponse issueNewToken(User user) { + return new TokenResponse(jwtService.createAccessToken(user.getOauth().getOauthId()), + jwtService.createRefreshToken()); } } diff --git a/src/main/java/net/teumteum/auth/service/OAuthService.java b/src/main/java/net/teumteum/auth/service/OAuthService.java index 537b6c34..43dd8308 100644 --- a/src/main/java/net/teumteum/auth/service/OAuthService.java +++ b/src/main/java/net/teumteum/auth/service/OAuthService.java @@ -67,9 +67,9 @@ private OAuthUserInfo getOAuthUserInfo(ClientRegistration clientRegistration, Au private TokenResponse checkUserAndMakeResponse(OAuthUserInfo oAuthUserInfo, Authenticated authenticated) { String oauthId = oAuthUserInfo.getOAuthId(); - java.util.Optional user = getUser(oauthId, authenticated); + Optional user = getUser(oauthId, authenticated); if (user.isEmpty()) { - return new net.teumteum.auth.domain.response.TokenResponse(oAuthUserInfo.getOAuthId()); + return new TokenResponse(oAuthUserInfo.getOAuthId()); } return jwtService.createServiceToken(user.get()); } diff --git a/src/main/java/net/teumteum/core/config/RedisConfig.java b/src/main/java/net/teumteum/core/config/RedisConfig.java new file mode 100644 index 00000000..010af599 --- /dev/null +++ b/src/main/java/net/teumteum/core/config/RedisConfig.java @@ -0,0 +1,32 @@ +package net.teumteum.core.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +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.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@RequiredArgsConstructor +public class RedisConfig { + + private final RedisProperties redisProperty; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisProperty.getHost(), redisProperty.getPort()); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + return redisTemplate; + } +} diff --git a/src/main/java/net/teumteum/core/security/SecurityConfig.java b/src/main/java/net/teumteum/core/security/SecurityConfig.java index eedd5574..a4d8dc1d 100644 --- a/src/main/java/net/teumteum/core/security/SecurityConfig.java +++ b/src/main/java/net/teumteum/core/security/SecurityConfig.java @@ -1,31 +1,49 @@ package net.teumteum.core.security; +import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; + +import java.util.Collections; import lombok.RequiredArgsConstructor; import net.teumteum.core.security.filter.JwtAccessDeniedHandler; import net.teumteum.core.security.filter.JwtAuthenticationEntryPoint; import net.teumteum.core.security.filter.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -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.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { + private static final String[] PATTERNS = {"/", "/css/**", "/images/**", "/js/**", "/favicon.ico", "/h2-console/**", + "/docs/index.html", "/common/*.html", "/jwt-test", "/auth/**"}; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler accessDeniedHandler; private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean + CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedHeaders(Collections.singletonList("*")); + config.setAllowedMethods(Collections.singletonList("*")); + config.setAllowedOriginPatterns(Collections.singletonList("/**")); // 허용할 origin + config.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + @Bean public WebSecurityCustomizer webSecurityCustomizer() { return web -> web.ignoring().requestMatchers("/h2-console/**"); @@ -33,21 +51,15 @@ public WebSecurityCustomizer webSecurityCustomizer() { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.csrf(AbstractHttpConfigurer::disable) - .cors(Customizer.withDefaults()) - .authorizeHttpRequests( - request -> request.requestMatchers("/**").permitAll() - .anyRequest().authenticated()) - .httpBasic(AbstractHttpConfigurer::disable) - .formLogin(AbstractHttpConfigurer::disable) - .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(STATELESS)) - .exceptionHandling( - exceptionHandling -> - exceptionHandling - .authenticationEntryPoint(jwtAuthenticationEntryPoint) - .accessDeniedHandler(accessDeniedHandler) - ) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + http.csrf(AbstractHttpConfigurer::disable).cors(AbstractHttpConfigurer::disable).authorizeHttpRequests( + request -> request.requestMatchers("/**").permitAll() + .requestMatchers(PATTERNS).permitAll().anyRequest() + .authenticated()).httpBasic(AbstractHttpConfigurer::disable).formLogin(AbstractHttpConfigurer::disable) + .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(STATELESS)) + .exceptionHandling( + exceptionHandling -> exceptionHandling.authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } } diff --git a/src/main/java/net/teumteum/core/security/config/CorsConfig.java b/src/main/java/net/teumteum/core/security/config/CorsConfig.java deleted file mode 100644 index b95d52aa..00000000 --- a/src/main/java/net/teumteum/core/security/config/CorsConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package net.teumteum.core.security.config; - -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class CorsConfig implements WebMvcConfigurer { - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") - .allowedMethods("*") - .allowedHeaders("*") - .allowedOriginPatterns("*") - .exposedHeaders("*") - .allowCredentials(true); - WebMvcConfigurer.super.addCorsMappings(registry); - } -} diff --git a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java index a10bd068..98181cf4 100644 --- a/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/net/teumteum/core/security/filter/JwtAuthenticationFilter.java @@ -4,6 +4,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.teumteum.auth.service.AuthService; @@ -19,20 +20,19 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; -import java.io.IOException; - @Slf4j @Component @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtService jwtService; private final AuthService authService; private final JwtProperty jwtProperty; @Override protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { if (request.getMethod().equals("OPTIONS")) { return; } @@ -50,8 +50,8 @@ protected void doFilterInternal(HttpServletRequest request, } private User getUser(String token) { - return this.authService.findUserByToken(token) - .orElseThrow(() -> new UsernameNotFoundException("일치하는 회원 정보가 존재하지 않습니다.")); + return this.authService.findUserByAccessToken(token) + .orElseThrow(() -> new UsernameNotFoundException("일치하는 회원 정보가 존재하지 않습니다.")); } private boolean checkTokenExistenceAndValidation(String token) { diff --git a/src/main/java/net/teumteum/core/security/service/JwtService.java b/src/main/java/net/teumteum/core/security/service/JwtService.java index fd6c15cc..76981daa 100644 --- a/src/main/java/net/teumteum/core/security/service/JwtService.java +++ b/src/main/java/net/teumteum/core/security/service/JwtService.java @@ -1,7 +1,14 @@ package net.teumteum.core.security.service; -import io.jsonwebtoken.*; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.UnsupportedJwtException; import jakarta.servlet.http.HttpServletRequest; +import java.util.Date; +import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import net.teumteum.auth.domain.response.TokenResponse; @@ -9,75 +16,84 @@ import net.teumteum.user.domain.User; import org.springframework.security.oauth2.jwt.JwtException; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; - -import java.util.Date; -import java.util.Optional; -import java.util.UUID; +import org.springframework.util.ObjectUtils; /* JWT 관련 모든 작업을 위한 Service */ +@Slf4j @Service @RequiredArgsConstructor -@Slf4j -public class JwtService{ +public class JwtService { private final JwtProperty jwtProperty; private final RedisService redisService; - public Optional extractAccessToken(HttpServletRequest request){ - return Optional.ofNullable(request.getHeader(this.jwtProperty.getAccess().getHeader())).filter(StringUtils::hasText).filter(accessToken -> accessToken.startsWith(jwtProperty.getBearer())).map(accessToken -> accessToken.replace(jwtProperty.getBearer(), "")); + public String extractAccessToken(HttpServletRequest request) { + String accessToken = request.getHeader(jwtProperty.getAccess().getHeader()); + if (!ObjectUtils.isEmpty(accessToken) + && accessToken.toLowerCase().startsWith(jwtProperty.getBearer().toLowerCase())) { + return accessToken.substring(jwtProperty.getBearer().length()).trim(); + } + return null; } - public String extractRefreshToken(HttpServletRequest request){ - return request.getHeader(this.jwtProperty.getRefresh().getHeader()); + public String extractRefreshToken(HttpServletRequest request) { + String refreshToken = request.getHeader(jwtProperty.getRefresh().getHeader()); + if (!ObjectUtils.isEmpty(refreshToken)) { + return refreshToken; + } + return null; } - public String getUserIdFromToken(String token){ - try{ - return Jwts.parser().setSigningKey(this.jwtProperty.getSecret()).parseClaimsJws(token).getBody().getSubject(); - }catch(Exception exception){ + public String getUserIdFromToken(String token) { + try { + return Jwts.parser().setSigningKey(jwtProperty.getSecret()) + .parseClaimsJws(token).getBody().getSubject(); + } catch (Exception exception) { throw new JwtException("Access Token is not valid"); } } - public TokenResponse createServiceToken(User users){ - String accessToken = this.createAccessToken(String.valueOf(users.getId())); - String refreshToken = this.createRefreshToken(); + public TokenResponse createServiceToken(User users) { + String accessToken = createAccessToken(String.valueOf(users.getId())); + String refreshToken = createRefreshToken(); - this.redisService.setDataExpire(String.valueOf(users.getId()), refreshToken, this.jwtProperty.getRefresh().getExpiration()); - - return new TokenResponse(this.jwtProperty.getBearer() + " " + accessToken, refreshToken); + this.redisService.setDataWithExpiration(String.valueOf(users.getId()), refreshToken, + this.jwtProperty.getRefresh().getExpiration()); + return new TokenResponse(jwtProperty.getBearer() + " " + accessToken, refreshToken); } - public String createAccessToken(String payload){ - return this.createToken(payload, this.jwtProperty.getAccess().getExpiration()); + public String createAccessToken(String payload) { + return this.createToken(payload, jwtProperty.getAccess().getExpiration()); } - public String createRefreshToken(){ - return this.createToken(UUID.randomUUID().toString(), this.jwtProperty.getRefresh().getExpiration()); - + public String createRefreshToken() { + return this.createToken(UUID.randomUUID().toString(), jwtProperty.getRefresh().getExpiration()); } - private String createToken(String payload, Long tokenExpiration){ + private String createToken(String payload, Long tokenExpiration) { Claims claims = Jwts.claims().setSubject(payload); Date tokenExpiresIn = new Date(new Date().getTime() + tokenExpiration); - return Jwts.builder().setClaims(claims).setIssuedAt(new Date()).setExpiration(tokenExpiresIn).signWith(SignatureAlgorithm.HS512, this.jwtProperty.getSecret()).compact(); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date()) + .setExpiration(tokenExpiresIn) + .signWith(SignatureAlgorithm.HS512, jwtProperty.getSecret()) + .compact(); } - public boolean validateToken(String token){ - try{ - Jws claimsJws = Jwts.parser().setSigningKey(this.jwtProperty.getSecret()).parseClaimsJws(token); + public boolean validateToken(String token) { + try { + Jws claimsJws = Jwts.parser().setSigningKey(jwtProperty.getSecret()).parseClaimsJws(token); return !claimsJws.getBody().getExpiration().before(new Date()); - }catch(ExpiredJwtException exception){ + } catch (ExpiredJwtException exception) { log.warn("만료된 jwt 입니다."); - }catch(UnsupportedJwtException exception){ + } catch (UnsupportedJwtException exception) { log.warn("지원되지 않는 jwt 입니다."); - }catch(IllegalArgumentException exception){ + } catch (IllegalArgumentException exception) { log.warn("jwt 에 오류가 존재합니다."); } return false; } - } diff --git a/src/main/java/net/teumteum/core/security/service/RedisService.java b/src/main/java/net/teumteum/core/security/service/RedisService.java index e252ff7a..a2e4cfcb 100644 --- a/src/main/java/net/teumteum/core/security/service/RedisService.java +++ b/src/main/java/net/teumteum/core/security/service/RedisService.java @@ -1,15 +1,15 @@ package net.teumteum.core.security.service; +import java.time.Duration; import lombok.RequiredArgsConstructor; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.stereotype.Service; -import java.time.Duration; - @Service @RequiredArgsConstructor public class RedisService { + private final StringRedisTemplate stringRedisTemplate; public String getData(String key) { @@ -22,16 +22,16 @@ public void setData(String key, String value) { valueOperations.set(key, value); } - public void deleteData(String key) { - this.stringRedisTemplate.delete(key); - } - - public void setDataExpire(String key, String value, Long duration) { + public void setDataWithExpiration(String key, String value, Long duration) { ValueOperations valueOperations = getStringStringValueOperations(); Duration expireDuration = Duration.ofSeconds(duration); valueOperations.set(key, value, expireDuration); } + public void deleteData(String key) { + this.stringRedisTemplate.delete(key); + } + private ValueOperations getStringStringValueOperations() { return this.stringRedisTemplate.opsForValue(); } diff --git a/src/main/java/net/teumteum/core/security/service/SecurityService.java b/src/main/java/net/teumteum/core/security/service/SecurityService.java index c4014664..a8375c6d 100644 --- a/src/main/java/net/teumteum/core/security/service/SecurityService.java +++ b/src/main/java/net/teumteum/core/security/service/SecurityService.java @@ -9,6 +9,7 @@ @Service @RequiredArgsConstructor public class SecurityService { + private final UserConnector userConnector; public static void clearSecurityContext() { @@ -21,7 +22,8 @@ private UserAuthentication getUserAuthentication() { public Long getCurrentUserId() { - return getUserAuthentication() == null ? userConnector.findAllUser().get(0).getId() : getUserAuthentication().getId(); + return getUserAuthentication() == null ? userConnector.findAllUser().get(0).getId() + : getUserAuthentication().getId(); } diff --git a/src/main/java/net/teumteum/meeting/domain/Meeting.java b/src/main/java/net/teumteum/meeting/domain/Meeting.java index 7c1a2c02..bb08d50d 100644 --- a/src/main/java/net/teumteum/meeting/domain/Meeting.java +++ b/src/main/java/net/teumteum/meeting/domain/Meeting.java @@ -1,16 +1,25 @@ package net.teumteum.meeting.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import net.teumteum.core.entity.TimeBaseEntity; import org.springframework.util.Assert; -import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.Set; - @Entity @Getter @NoArgsConstructor @@ -75,18 +84,23 @@ private void assertField() { } private void assertIntroduction() { - Assert.isTrue(introduction.length() >= 10 && introduction.length() <= 200, "모임 소개는 10자 ~ 200자 사이가 되어야 합니다. [현재 입력된 모임 소개] : " + introduction); + Assert.isTrue(introduction.length() >= 10 && introduction.length() <= 200, + "모임 소개는 10자 ~ 200자 사이가 되어야 합니다. [현재 입력된 모임 소개] : " + introduction); } private void assertNumberOfRecruits() { - Assert.isTrue(numberOfRecruits >= 2 && numberOfRecruits <= 6, "참여자 수는 2명 ~ 6명 사이가 되어야 합니다. [현재 입력된 참여자 수] : " + numberOfRecruits); + Assert.isTrue(numberOfRecruits >= 2 && numberOfRecruits <= 6, + "참여자 수는 2명 ~ 6명 사이가 되어야 합니다. [현재 입력된 참여자 수] : " + numberOfRecruits); } private void assertTitle() { - Assert.isTrue(title.length() >= 2 && title.length() <= 32, "모임 제목은 2자 ~ 32자 사이가 되어야 합니다. [현재 입력된 모임 제목] : " + title); + Assert.isTrue(title.length() >= 2 && title.length() <= 32, + "모임 제목은 2자 ~ 32자 사이가 되어야 합니다. [현재 입력된 모임 제목] : " + title); } private void assertParticipantUserIds() { - Assert.isTrue(participantUserIds.size() + 1 <= numberOfRecruits, "최대 참여자 수에 도달한 모임에 참여할 수 없습니다." + "[최대 참여자 수] : " + numberOfRecruits + "[현재 참여자 수] : " + participantUserIds.size()); + Assert.isTrue(participantUserIds.size() + 1 <= numberOfRecruits, + "최대 참여자 수에 도달한 모임에 참여할 수 없습니다." + "[최대 참여자 수] : " + numberOfRecruits + "[현재 참여자 수] : " + + participantUserIds.size()); } } diff --git a/src/main/java/net/teumteum/user/controller/UserController.java b/src/main/java/net/teumteum/user/controller/UserController.java index d81af3de..ac2c0c14 100644 --- a/src/main/java/net/teumteum/user/controller/UserController.java +++ b/src/main/java/net/teumteum/user/controller/UserController.java @@ -14,6 +14,7 @@ import net.teumteum.user.service.UserService; import org.springframework.context.ApplicationContext; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -75,6 +76,13 @@ public InterestQuestionResponse getInterestQuestion(@RequestParam("user-id") Lis return userService.getInterestQuestionByUserIds(userIds, balance); } + @DeleteMapping("/withdraws") + @ResponseStatus(HttpStatus.OK) + public void withdraw() { + userService.withdraw(getCurrentUserId()); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) public ErrorResponse handleIllegalArgumentException(IllegalArgumentException illegalArgumentException) { diff --git a/src/main/java/net/teumteum/user/domain/User.java b/src/main/java/net/teumteum/user/domain/User.java index ed220211..a022e769 100644 --- a/src/main/java/net/teumteum/user/domain/User.java +++ b/src/main/java/net/teumteum/user/domain/User.java @@ -1,18 +1,28 @@ package net.teumteum.user.domain; -import jakarta.persistence.*; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; import net.teumteum.core.entity.TimeBaseEntity; +import net.teumteum.core.security.Authenticated; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.util.Assert; -import java.util.ArrayList; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - @Getter @Entity(name = "users") @NoArgsConstructor @@ -69,6 +79,11 @@ public class User extends TimeBaseEntity { @ElementCollection(fetch = FetchType.LAZY) private Set friends = new HashSet<>(); + public User(Long id, String oauthId, Authenticated authenticated) { + this.id = id; + this.oauth = new OAuth(oauthId, authenticated); + } + @PrePersist private void assertField() { assertName(); diff --git a/src/main/java/net/teumteum/user/service/UserService.java b/src/main/java/net/teumteum/user/service/UserService.java index c01f8888..bccfe594 100644 --- a/src/main/java/net/teumteum/user/service/UserService.java +++ b/src/main/java/net/teumteum/user/service/UserService.java @@ -2,6 +2,7 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import net.teumteum.core.security.service.RedisService; import net.teumteum.user.domain.BalanceGameType; import net.teumteum.user.domain.InterestQuestion; import net.teumteum.user.domain.User; @@ -22,6 +23,7 @@ public class UserService { private final UserRepository userRepository; private final InterestQuestion interestQuestion; + private final RedisService redisService; public UserGetResponse getUserById(Long userId) { var existUser = getUser(userId); @@ -57,6 +59,13 @@ public void addFriends(Long myId, Long friendId) { me.addFriend(friend); } + @Transactional + public void withdraw(Long userId) { + var existUser = getUser(userId); + deleteUser(existUser); + redisService.deleteData(String.valueOf(userId)); + } + public FriendsResponse findFriendsByUserId(Long userId) { var user = getUser(userId); var friends = userRepository.findAllById(user.getFriends()); @@ -79,4 +88,8 @@ public InterestQuestionResponse getInterestQuestionByUserIds(List userIds, return BalanceGameType.of(type).getInterestQuestionResponse(users, interestQuestion); } + + private void deleteUser(User user) { + this.userRepository.delete(user); + } } diff --git a/src/test/java/net/teumteum/integration/Api.java b/src/test/java/net/teumteum/integration/Api.java index 75b0e8e8..c5439aa7 100644 --- a/src/test/java/net/teumteum/integration/Api.java +++ b/src/test/java/net/teumteum/integration/Api.java @@ -8,12 +8,10 @@ import org.springframework.context.ApplicationContext; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpHeaders; -import org.springframework.security.test.context.support.WithMockUser; import org.springframework.stereotype.Controller; import org.springframework.test.web.reactive.server.WebTestClient; import org.springframework.test.web.reactive.server.WebTestClient.ResponseSpec; -@WithMockUser(username = "user", roles = {"USER"}) @TestComponent class Api { @@ -120,4 +118,19 @@ ResponseSpec getCommonInterests(String token, List userIds) { .header(HttpHeaders.AUTHORIZATION, token) .exchange(); } + + ResponseSpec reissueJwt(String accessToken, String refreshToken) { + return webTestClient.post() + .uri("/auth/reissue") + .header(HttpHeaders.AUTHORIZATION, accessToken) + .header("Authorization-refresh", refreshToken) + .exchange(); + } + + ResponseSpec withdrawUser(String accessToken) { + return webTestClient.delete() + .uri("/users/withdraw") + .header(HttpHeaders.AUTHORIZATION, accessToken) + .exchange(); + } } diff --git a/src/test/java/net/teumteum/integration/UserIntegrationTest.java b/src/test/java/net/teumteum/integration/UserIntegrationTest.java index b332f452..1e948cfc 100644 --- a/src/test/java/net/teumteum/integration/UserIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/UserIntegrationTest.java @@ -194,4 +194,24 @@ void Return_empty_friends_when_received_empty_friends_user_id() { .usingRecursiveComparison().isEqualTo(expected); } } + +// @Nested +// @DisplayName("회원 탈퇴 API는") +// class Withdraw_user { +// +// @Test +// @DisplayName("현재 로그인한 회원의 회원 탈퇴를 정상적으로 진행한다.") +// void Return_200_ok_when_withdraw_current_user() { +// // given +// var me = repository.saveAndGetUser(); +// +// loginContext.setUserId(me.getId()); +// +// // when +// var result = api.withdrawUser(VALID_TOKEN); +// +// // then +// Assertions.assertThat(result.expectStatus().isOk()); +// } +// } } diff --git a/src/test/java/net/teumteum/unit/auth/service/AuthServiceTest.java b/src/test/java/net/teumteum/unit/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..4312b9f3 --- /dev/null +++ b/src/test/java/net/teumteum/unit/auth/service/AuthServiceTest.java @@ -0,0 +1,111 @@ +package net.teumteum.unit.auth.service; + +import static net.teumteum.core.security.Authenticated.네이버; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; +import net.teumteum.auth.domain.response.TokenResponse; +import net.teumteum.auth.service.AuthService; +import net.teumteum.core.security.service.JwtService; +import net.teumteum.core.security.service.RedisService; +import net.teumteum.user.domain.User; +import net.teumteum.user.domain.UserConnector; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@DisplayName("인증 서비스 단위 테스트의") +public class AuthServiceTest { + + @InjectMocks + AuthService authService; + + @Mock + JwtService jwtService; + + @Mock + RedisService redisService; + + @Mock + UserConnector userConnector; + + @Nested + @DisplayName("토큰 재발급 API는") + class Reissue_jwt_api_unit { + + @Test + @DisplayName("유효하지 않은 access token 과 유효한 refresh token 이 주어지면, 새로운 토큰을 발급한다.") + void Return_new_jwt_if_access_and_refresh_is_exist() { + // given + Optional user = Optional.of(new User(1L, "oauthId", 네이버)); + + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + + given(jwtService.extractAccessToken(any(HttpServletRequest.class))).willReturn("access token"); + + given(jwtService.extractRefreshToken(any(HttpServletRequest.class))).willReturn("refresh token"); + + given(jwtService.getUserIdFromToken(anyString())).willReturn("1"); + + given(jwtService.createAccessToken(anyString())).willReturn("new access token"); + + given(jwtService.createRefreshToken()).willReturn("new refresh token"); + + given(redisService.getData(anyString())).willReturn("refresh token"); + + given(userConnector.findUserById(anyLong())).willReturn(user); + + given(jwtService.validateToken(anyString())).willReturn(true); + + // when + TokenResponse response = authService.reissue(httpServletRequest); + + // then + assertThat(response).isNotNull(); + assertThat(response.getAccessToken()).isEqualTo("new access token"); + assertThat(response.getRefreshToken()).isEqualTo("new refresh token"); + verify(userConnector, times(1)).findUserById(anyLong()); + verify(jwtService, times(1)).validateToken(any()); + } + + @Test + @DisplayName("유효하지 않은 access token 과 유효하지 않은 refresh token 이 주어지면, 500 server 에러로 응답한다. ") + void Return_500_bad_request_if_refresh_token_is_not_valid() { + Optional user = Optional.of(new User(1L, "oauthId", 네이버)); + + HttpServletRequest httpServletRequest = mock(HttpServletRequest.class); + + given(jwtService.extractAccessToken(any(HttpServletRequest.class))).willReturn("access token"); + + given(jwtService.extractRefreshToken(any(HttpServletRequest.class))).willReturn("refresh token"); + + given(jwtService.validateToken(anyString())).willReturn(true); + + given(jwtService.getUserIdFromToken(anyString())).willReturn("1"); + + given(userConnector.findUserById(anyLong())).willReturn(user); + + given(redisService.getData(anyString())).willThrow( + new IllegalArgumentException("refresh token 이 일치하지 않습니다.")); + + // when + assertThatThrownBy(() -> authService.reissue(httpServletRequest)).isInstanceOf( + IllegalArgumentException.class).hasMessage("refresh token 이 일치하지 않습니다."); + + } + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 0d2a2040..8d270fe7 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -23,6 +23,9 @@ spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kak spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/authorize spring.security.oauth2.client.provider.kakao.user-info-uri=https://kauth.kakao.com/oauth/authorize spring.security.oauth2.client.provider.kakao.user-name-attribute=https://kauth.kakao.com/oauth/authorize +### Redis ### +spring.data.redis.host=localhost +spring.data.redis.port=6379 From 1d67efe8edbd4bf12a927db406cadbfd71a8a801 Mon Sep 17 00:00:00 2001 From: ddingmin Date: Sun, 14 Jan 2024 17:10:17 +0900 Subject: [PATCH 12/17] =?UTF-8?q?feat:=20=EB=AA=A8=EC=9E=84=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4.=20(#?= =?UTF-8?q?54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + gradle.properties | 2 + gradle/aws.gradle | 5 ++ gradle/spring.gradle | 2 + .../meeting/controller/MeetingController.java | 29 ++++++-- .../teumteum/meeting/domain/ImageUpload.java | 12 ++++ .../net/teumteum/meeting/domain/Meeting.java | 37 +++++----- .../teumteum/meeting/domain/MeetingArea.java | 18 +++-- .../meeting/domain/MeetingSpecification.java | 6 +- .../domain/request/CreateMeetingRequest.java | 35 ++++++++++ .../domain/response/ImageUploadResponse.java | 12 ++++ .../domain/response/MeetingResponse.java | 12 ++-- .../domain/response/MeetingsResponse.java | 12 ++-- .../meeting/infra/ImageUploadService.java | 52 ++++++++++++++ .../meeting/service/MeetingService.java | 68 ++++++++++++++----- src/main/resources/application-aws.yml | 11 +++ src/main/resources/application-dev.yml | 5 ++ src/main/resources/application-prod.yml | 5 ++ src/main/resources/application.yml | 4 +- .../db/migration/V2__create_meeting.sql | 6 +- .../meeting/domain/MeetingFixture.java | 10 +-- .../meeting/domain/MeetingRepositoryTest.java | 4 +- src/test/resources/application.properties | 8 ++- src/test/resources/schema.sql | 53 ++++++++------- 24 files changed, 304 insertions(+), 105 deletions(-) create mode 100644 gradle/aws.gradle create mode 100644 src/main/java/net/teumteum/meeting/domain/ImageUpload.java create mode 100644 src/main/java/net/teumteum/meeting/domain/request/CreateMeetingRequest.java create mode 100644 src/main/java/net/teumteum/meeting/domain/response/ImageUploadResponse.java create mode 100644 src/main/java/net/teumteum/meeting/infra/ImageUploadService.java create mode 100644 src/main/resources/application-aws.yml diff --git a/build.gradle b/build.gradle index be1e284c..9798bcd9 100644 --- a/build.gradle +++ b/build.gradle @@ -11,6 +11,7 @@ apply from: "gradle/devtool.gradle" apply from: "gradle/test.gradle" apply from: "gradle/sonar.gradle" apply from: "gradle/db.gradle" +apply from: "gradle/aws.gradle" apply from: "gradle/sentry.gradle" allprojects { diff --git a/gradle.properties b/gradle.properties index 89b70de0..a31bbd59 100644 --- a/gradle.properties +++ b/gradle.properties @@ -17,3 +17,5 @@ mysqlConnectorVersion=8.0.33 mockWebServerVersion=4.12.0 ### SENTRY ### sentryVersion=4.1.1 +### AWS-CLOUD ### +springCloudAwsVersion=3.1.0 diff --git a/gradle/aws.gradle b/gradle/aws.gradle new file mode 100644 index 00000000..b7e33418 --- /dev/null +++ b/gradle/aws.gradle @@ -0,0 +1,5 @@ +dependencies { + implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:${springCloudAwsVersion}") + implementation "io.awspring.cloud:spring-cloud-aws-starter-s3" + +} diff --git a/gradle/spring.gradle b/gradle/spring.gradle index 41e855c5..bf762d61 100644 --- a/gradle/spring.gradle +++ b/gradle/spring.gradle @@ -21,6 +21,8 @@ allprojects { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation "org.springframework.boot:spring-boot-starter-actuator" implementation "org.springframework.boot:spring-boot-starter-security" diff --git a/src/main/java/net/teumteum/meeting/controller/MeetingController.java b/src/main/java/net/teumteum/meeting/controller/MeetingController.java index ed921742..34141d3b 100644 --- a/src/main/java/net/teumteum/meeting/controller/MeetingController.java +++ b/src/main/java/net/teumteum/meeting/controller/MeetingController.java @@ -1,10 +1,12 @@ package net.teumteum.meeting.controller; import io.sentry.Sentry; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import net.teumteum.core.error.ErrorResponse; import net.teumteum.core.security.service.SecurityService; import net.teumteum.meeting.domain.Topic; +import net.teumteum.meeting.domain.request.CreateMeetingRequest; import net.teumteum.meeting.domain.response.MeetingResponse; import net.teumteum.meeting.domain.response.MeetingsResponse; import net.teumteum.meeting.model.PageDto; @@ -12,6 +14,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; @RestController @RequiredArgsConstructor @@ -22,6 +27,15 @@ public class MeetingController { private final SecurityService securityService; + @PostMapping() + @ResponseStatus(HttpStatus.CREATED) + public MeetingResponse createMeeting( + @RequestPart @Valid CreateMeetingRequest meetingRequest, + @RequestPart List images) { + Long userId = securityService.getCurrentUserId(); + return meetingService.createMeeting(images, meetingRequest, userId); + } + @GetMapping("/{meetingId}") @ResponseStatus(HttpStatus.OK) public MeetingResponse getMeetingById(@PathVariable("meetingId") Long meetingId) { @@ -30,12 +44,13 @@ public MeetingResponse getMeetingById(@PathVariable("meetingId") Long meetingId) @GetMapping @ResponseStatus(HttpStatus.OK) - public PageDto getMeetingsOrderByDate(Pageable pageable, - @RequestParam(value = "isOpen") boolean isOpen, - @RequestParam(value = "topic", required = false) Topic topic, - @RequestParam(value = "meetingAreaStreet", required = false) String meetingAreaStreet, - @RequestParam(value = "participantUserId", required = false) Long participantUserId, - @RequestParam(value = "searchWord", required = false) String searchWord) { + public PageDto getMeetingsOrderByDate( + Pageable pageable, + @RequestParam(value = "isOpen") boolean isOpen, + @RequestParam(value = "topic", required = false) Topic topic, + @RequestParam(value = "meetingAreaStreet", required = false) String meetingAreaStreet, + @RequestParam(value = "participantUserId", required = false) Long participantUserId, + @RequestParam(value = "searchWord", required = false) String searchWord) { return meetingService.getMeetingsBySpecification(pageable, topic, meetingAreaStreet, participantUserId, searchWord, isOpen); } @@ -49,7 +64,7 @@ public MeetingResponse addParticipant(@PathVariable("meetingId") Long meetingId) @DeleteMapping("/{meetingId}/participants") @ResponseStatus(HttpStatus.OK) -public void deleteParticipant(@PathVariable("meetingId") Long meetingId) { + public void deleteParticipant(@PathVariable("meetingId") Long meetingId) { Long userId = securityService.getCurrentUserId(); meetingService.cancelParticipant(meetingId, userId); } diff --git a/src/main/java/net/teumteum/meeting/domain/ImageUpload.java b/src/main/java/net/teumteum/meeting/domain/ImageUpload.java new file mode 100644 index 00000000..d64d65b1 --- /dev/null +++ b/src/main/java/net/teumteum/meeting/domain/ImageUpload.java @@ -0,0 +1,12 @@ +package net.teumteum.meeting.domain; + +import net.teumteum.meeting.domain.response.ImageUploadResponse; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +public interface ImageUpload { + + ImageUploadResponse upload(MultipartFile file, String path); + +} diff --git a/src/main/java/net/teumteum/meeting/domain/Meeting.java b/src/main/java/net/teumteum/meeting/domain/Meeting.java index bb08d50d..7efcb9ea 100644 --- a/src/main/java/net/teumteum/meeting/domain/Meeting.java +++ b/src/main/java/net/teumteum/meeting/domain/Meeting.java @@ -1,27 +1,21 @@ package net.teumteum.meeting.domain; -import jakarta.persistence.Column; -import jakarta.persistence.ElementCollection; -import jakarta.persistence.Embedded; -import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; -import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.Set; +import jakarta.persistence.*; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; import net.teumteum.core.entity.TimeBaseEntity; import org.springframework.util.Assert; +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.Set; + @Entity @Getter +@Builder @NoArgsConstructor @AllArgsConstructor public class Meeting extends TimeBaseEntity { @@ -37,6 +31,7 @@ public class Meeting extends TimeBaseEntity { @Column(name = "host_user_id") private Long hostUserId; + @Builder.Default @ElementCollection(fetch = FetchType.EAGER) private Set participantUserIds = new HashSet<>(); @@ -56,8 +51,9 @@ public class Meeting extends TimeBaseEntity { @Column(name = "promise_date_time") private LocalDateTime promiseDateTime; + @Builder.Default @ElementCollection(fetch = FetchType.EAGER) - private Set imageUrls = new HashSet<>(); + private Set imageUrls = new LinkedHashSet<>(); public void addParticipant(Long userId) { assertParticipantUserIds(); @@ -85,22 +81,21 @@ private void assertField() { private void assertIntroduction() { Assert.isTrue(introduction.length() >= 10 && introduction.length() <= 200, - "모임 소개는 10자 ~ 200자 사이가 되어야 합니다. [현재 입력된 모임 소개] : " + introduction); + "모임 소개는 10자 ~ 200자 사이가 되어야 합니다. [현재 입력된 모임 소개] : " + introduction); } private void assertNumberOfRecruits() { - Assert.isTrue(numberOfRecruits >= 2 && numberOfRecruits <= 6, - "참여자 수는 2명 ~ 6명 사이가 되어야 합니다. [현재 입력된 참여자 수] : " + numberOfRecruits); + Assert.isTrue(numberOfRecruits >= 3 && numberOfRecruits <= 6, "참여자 수는 3명 ~ 6명 사이가 되어야 합니다. [현재 입력된 참여자 수] : " + numberOfRecruits); } private void assertTitle() { Assert.isTrue(title.length() >= 2 && title.length() <= 32, - "모임 제목은 2자 ~ 32자 사이가 되어야 합니다. [현재 입력된 모임 제목] : " + title); + "모임 제목은 2자 ~ 32자 사이가 되어야 합니다. [현재 입력된 모임 제목] : " + title); } private void assertParticipantUserIds() { Assert.isTrue(participantUserIds.size() + 1 <= numberOfRecruits, - "최대 참여자 수에 도달한 모임에 참여할 수 없습니다." + "[최대 참여자 수] : " + numberOfRecruits + "[현재 참여자 수] : " - + participantUserIds.size()); + "최대 참여자 수에 도달한 모임에 참여할 수 없습니다." + "[최대 참여자 수] : " + numberOfRecruits + "[현재 참여자 수] : " + + participantUserIds.size()); } } diff --git a/src/main/java/net/teumteum/meeting/domain/MeetingArea.java b/src/main/java/net/teumteum/meeting/domain/MeetingArea.java index a9e5aafb..2cc8f4cd 100644 --- a/src/main/java/net/teumteum/meeting/domain/MeetingArea.java +++ b/src/main/java/net/teumteum/meeting/domain/MeetingArea.java @@ -3,22 +3,28 @@ import jakarta.persistence.Column; import jakarta.persistence.Embeddable; import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @Getter +@Builder @Embeddable @NoArgsConstructor @AllArgsConstructor public class MeetingArea { - @Column(name = "city") - private String city; + @Column(name = "main_street") + private String mainStreet; - @Column(name = "street") - private String street; + @Column(name = "address") + private String address; - @Column(name = "zip_code") - private String zipCode; + @Column(name = "address_detail") + private String addressDetail; + + public static MeetingArea of(String roadName, String addressDetail) { + return new MeetingArea(roadName.split(" ")[1], roadName, addressDetail); + } } diff --git a/src/main/java/net/teumteum/meeting/domain/MeetingSpecification.java b/src/main/java/net/teumteum/meeting/domain/MeetingSpecification.java index 651a8014..2d74be02 100644 --- a/src/main/java/net/teumteum/meeting/domain/MeetingSpecification.java +++ b/src/main/java/net/teumteum/meeting/domain/MeetingSpecification.java @@ -9,7 +9,9 @@ public class MeetingSpecification { public static Specification withIsOpen(boolean isOpen) { - if (isOpen) return (root, query, criteriaBuilder) -> criteriaBuilder.greaterThan(root.get("promiseDateTime"), LocalDateTime.now()); + if (isOpen) { + return (root, query, criteriaBuilder) -> criteriaBuilder.greaterThan(root.get("promiseDateTime"), LocalDateTime.now()); + } return (root, query, criteriaBuilder) -> criteriaBuilder.lessThanOrEqualTo(root.get("promiseDateTime"), LocalDateTime.now()); } @@ -18,7 +20,7 @@ public static Specification withTopic(Topic topic) { } public static Specification withAreaStreet(String meetingAreaStreet) { - return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("meetingArea").get("street"), meetingAreaStreet); + return (root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("meetingArea").get("mainStreet"), meetingAreaStreet); } public static Specification withSearchWordInTitle(String searchWord) { diff --git a/src/main/java/net/teumteum/meeting/domain/request/CreateMeetingRequest.java b/src/main/java/net/teumteum/meeting/domain/request/CreateMeetingRequest.java new file mode 100644 index 00000000..7635fb0a --- /dev/null +++ b/src/main/java/net/teumteum/meeting/domain/request/CreateMeetingRequest.java @@ -0,0 +1,35 @@ +package net.teumteum.meeting.domain.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Future; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import net.teumteum.meeting.domain.Topic; + +import java.time.LocalDateTime; + +public record CreateMeetingRequest( + @NotNull(message = "모임 주제를 입력해주세요.") + Topic topic, + @NotNull(message = "모임 제목을 입력해주세요.") + @Size(min = 2, max = 32, message = "모임 제목은 2자 이상 32자 이하로 입력해주세요.") + String title, + @NotNull(message = "모임 소개를 입력해주세요.") + @Size(min = 10, max = 200, message = "모임 소개는 10자 이상 200자 이하로 입력해주세요.") + String introduction, + @NotNull(message = "약속 시간을 입력해주세요.") + @Future(message = "약속 시간은 현재 시간보다 미래여야 합니다.") + LocalDateTime promiseDateTime, + @NotNull(message = "모집 인원을 입력해주세요.") + int numberOfRecruits, + @Valid + MeetingArea meetingArea +) { + public record MeetingArea( + @NotNull(message = "주소를 입력해주세요.") + String address, + @NotNull(message = "상세 주소를 입력해주세요.") + String addressDetail + ) { + } +} diff --git a/src/main/java/net/teumteum/meeting/domain/response/ImageUploadResponse.java b/src/main/java/net/teumteum/meeting/domain/response/ImageUploadResponse.java new file mode 100644 index 00000000..a3f1d3b2 --- /dev/null +++ b/src/main/java/net/teumteum/meeting/domain/response/ImageUploadResponse.java @@ -0,0 +1,12 @@ +package net.teumteum.meeting.domain.response; + +import lombok.Builder; + +@Builder +public record ImageUploadResponse( + String fileName, + String originalFileName, + String contentType, + String filePath +) { +} diff --git a/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java b/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java index f1bd40e6..56386670 100644 --- a/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java +++ b/src/main/java/net/teumteum/meeting/domain/response/MeetingResponse.java @@ -37,17 +37,17 @@ public static MeetingResponse of( } public record MeetingArea( - String city, - String street, - String zipCode + String mainStreet, + String address, + String addressDetail ) { public static MeetingArea of( Meeting meeting ) { return new MeetingArea( - meeting.getMeetingArea().getCity(), - meeting.getMeetingArea().getStreet(), - meeting.getMeetingArea().getZipCode() + meeting.getMeetingArea().getMainStreet(), + meeting.getMeetingArea().getAddress(), + meeting.getMeetingArea().getAddressDetail() ); } } diff --git a/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java b/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java index 4f815245..d20bbe9d 100644 --- a/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java +++ b/src/main/java/net/teumteum/meeting/domain/response/MeetingsResponse.java @@ -48,17 +48,17 @@ public static MeetingResponse of( } public record MeetingArea( - String city, - String street, - String zipCode + String mainStreet, + String address, + String addressDetail ) { public static MeetingArea of( Meeting meeting ) { return new MeetingArea( - meeting.getMeetingArea().getCity(), - meeting.getMeetingArea().getStreet(), - meeting.getMeetingArea().getZipCode() + meeting.getMeetingArea().getMainStreet(), + meeting.getMeetingArea().getAddress(), + meeting.getMeetingArea().getAddressDetail() ); } } diff --git a/src/main/java/net/teumteum/meeting/infra/ImageUploadService.java b/src/main/java/net/teumteum/meeting/infra/ImageUploadService.java new file mode 100644 index 00000000..25f5fca8 --- /dev/null +++ b/src/main/java/net/teumteum/meeting/infra/ImageUploadService.java @@ -0,0 +1,52 @@ +package net.teumteum.meeting.infra; + +import lombok.RequiredArgsConstructor; +import net.teumteum.meeting.domain.ImageUpload; +import net.teumteum.meeting.domain.response.ImageUploadResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ImageUploadService implements ImageUpload { + + private final S3Client s3Client; + @Value("${spring.cloud.aws.s3.bucket}") + private String bucketName; + + @Override + public ImageUploadResponse upload(MultipartFile file, String path) { + String originalFilename = Optional.ofNullable(file.getOriginalFilename()) + .orElseThrow(() -> new IllegalArgumentException("파일 이름이 없습니다.")); + String fileExtension = originalFilename.substring(originalFilename.lastIndexOf(".")); + String fileName = UUID.randomUUID().toString(); + String destination = path + "/" + fileName + fileExtension; + + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucketName) + .key(destination) + .build(); + + try (var inputStream = file.getInputStream()) { + s3Client.putObject(request, RequestBody.fromInputStream(inputStream, file.getSize())); + } catch (Exception e) { + throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "파일 업로드에 실패했습니다."); + } + + return ImageUploadResponse.builder() + .fileName(fileName) + .originalFileName(originalFilename) + .contentType(file.getContentType()) + .filePath(destination) + .build(); + } +} diff --git a/src/main/java/net/teumteum/meeting/service/MeetingService.java b/src/main/java/net/teumteum/meeting/service/MeetingService.java index b124fe27..11866147 100644 --- a/src/main/java/net/teumteum/meeting/service/MeetingService.java +++ b/src/main/java/net/teumteum/meeting/service/MeetingService.java @@ -1,10 +1,8 @@ package net.teumteum.meeting.service; import lombok.RequiredArgsConstructor; -import net.teumteum.meeting.domain.Meeting; -import net.teumteum.meeting.domain.MeetingRepository; -import net.teumteum.meeting.domain.MeetingSpecification; -import net.teumteum.meeting.domain.Topic; +import net.teumteum.meeting.domain.*; +import net.teumteum.meeting.domain.request.CreateMeetingRequest; import net.teumteum.meeting.domain.response.MeetingResponse; import net.teumteum.meeting.domain.response.MeetingsResponse; import net.teumteum.meeting.model.PageDto; @@ -12,17 +10,55 @@ import org.springframework.data.jpa.domain.Specification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.Assert; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.Set; @Service @RequiredArgsConstructor public class MeetingService { private final MeetingRepository meetingRepository; + private final ImageUpload imageUpload; + + @Transactional + public MeetingResponse createMeeting(List images, CreateMeetingRequest meetingRequest, Long userId) { + Assert.isTrue(!images.isEmpty() && images.size() <= 5, "이미지는 1개 이상 5개 이하로 업로드해야 합니다."); + + Meeting meeting = meetingRepository.save( + Meeting.builder() + .hostUserId(userId) + .title(meetingRequest.title()) + .topic(meetingRequest.topic()) + .introduction(meetingRequest.introduction()) + .meetingArea(MeetingArea.of( + meetingRequest.meetingArea().address(), + meetingRequest.meetingArea().addressDetail()) + ) + .numberOfRecruits(meetingRequest.numberOfRecruits()) + .promiseDateTime(meetingRequest.promiseDateTime()) + .participantUserIds(Set.of(userId)) + .build() + ); + + uploadMeetingImages(images, meeting); + + return MeetingResponse.of(meeting); + } + + private void uploadMeetingImages(List images, Meeting meeting) { + images.forEach( + image -> meeting.getImageUrls().add( + imageUpload.upload(image, meeting.getId().toString()).filePath() + ) + ); + } @Transactional(readOnly = true) public MeetingResponse getMeetingById(Long meetingId) { - var existMeeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new IllegalArgumentException("meetingId에 해당하는 모임을 찾을 수 없습니다. \"" + meetingId + "\"")); + var existMeeting = getMeeting(meetingId); return MeetingResponse.of(existMeeting); } @@ -35,14 +71,11 @@ public PageDto getMeetingsBySpecification(Pageable pageable, T if (topic != null) { spec = spec.and(MeetingSpecification.withTopic(topic)); - } - else if (meetingAreaStreet != null) { + } else if (meetingAreaStreet != null) { spec.and(MeetingSpecification.withAreaStreet(meetingAreaStreet)); - } - else if (participantUserId != null) { + } else if (participantUserId != null) { spec = spec.and(MeetingSpecification.withParticipantUserId(participantUserId)); - } - else if (searchWord != null) { + } else if (searchWord != null) { spec = MeetingSpecification.withSearchWordInTitle(searchWord).or(MeetingSpecification.withSearchWordInIntroduction(searchWord)) .and(MeetingSpecification.withIsOpen(isOpen)); } @@ -54,8 +87,7 @@ else if (searchWord != null) { @Transactional public MeetingResponse addParticipant(Long meetingId, Long userId) { - var existMeeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new IllegalArgumentException("meetingId에 해당하는 모임을 찾을 수 없습니다. \"" + meetingId + "\"")); + var existMeeting = getMeeting(meetingId); if (existMeeting.alreadyParticipant(userId)) { throw new IllegalArgumentException("이미 참여한 모임입니다."); @@ -71,8 +103,7 @@ public MeetingResponse addParticipant(Long meetingId, Long userId) { @Transactional public void cancelParticipant(Long meetingId, Long userId) { - var existMeeting = meetingRepository.findById(meetingId) - .orElseThrow(() -> new IllegalArgumentException("meetingId에 해당하는 모임을 찾을 수 없습니다. \"" + meetingId + "\"")); + var existMeeting = getMeeting(meetingId); if (!existMeeting.isOpen()) { throw new IllegalArgumentException("종료된 모임에서 참여를 취소할 수 없습니다."); @@ -84,4 +115,9 @@ public void cancelParticipant(Long meetingId, Long userId) { existMeeting.cancelParticipant(userId); } + + private Meeting getMeeting(Long meetingId) { + return meetingRepository.findById(meetingId) + .orElseThrow(() -> new IllegalArgumentException("meetingId에 해당하는 모임을 찾을 수 없습니다. \"" + meetingId + "\"")); + } } diff --git a/src/main/resources/application-aws.yml b/src/main/resources/application-aws.yml new file mode 100644 index 00000000..c6f54eff --- /dev/null +++ b/src/main/resources/application-aws.yml @@ -0,0 +1,11 @@ +spring: + cloud: + aws: + credentials: + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + region: + auto: false + static: ${AWS_REGION} + s3: + bucket: ${AWS_S3_BUCKET} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index a8575e4e..bf798954 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -8,6 +8,11 @@ spring: pathmatch: matching-strategy: ant_path_matcher + servlet: + multipart: + max-file-size: 10MB + max-request-size: 50MB + ## JPA jpa: hibernate: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 71741f0e..9a032c17 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -8,6 +8,11 @@ spring: pathmatch: matching-strategy: ant_path_matcher + servlet: + multipart: + max-file-size: 10MB + max-request-size: 50MB + ### JPA ### jpa: hibernate: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fa4ae492..932a8333 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,6 +7,6 @@ spring: profiles: group: - "dev": "dev, auth, datasource, redis" - "prod": "prod, auth, datasource, redis" + "dev": "dev, auth, datasource, redis, aws" + "prod": "prod, auth, datasource, redis, aws" diff --git a/src/main/resources/db/migration/V2__create_meeting.sql b/src/main/resources/db/migration/V2__create_meeting.sql index d04111c2..2a1125e0 100644 --- a/src/main/resources/db/migration/V2__create_meeting.sql +++ b/src/main/resources/db/migration/V2__create_meeting.sql @@ -9,9 +9,9 @@ create table if not exists meeting updated_at timestamp(6) not null, title varchar(32) null, introduction varchar(200) null, - city varchar(255) null, - street varchar(255) null, - zip_code varchar(255) null, + address varchar(255) null, + main_street varchar(255) null, + address_detail varchar(255) null, topic enum ('고민_나누기', '모여서_작업', '스터디', '사이드_프로젝트') null ); diff --git a/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java b/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java index 9b134f62..b8a03ad4 100644 --- a/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java +++ b/src/test/java/net/teumteum/meeting/domain/MeetingFixture.java @@ -30,8 +30,8 @@ public static Meeting getCloseMeeting() { public static Meeting getOpenFullMeeting() { return newMeetingByBuilder(MeetingBuilder.builder() .promiseDateTime(LocalDateTime.of(4000, 1, 1, 0, 0)) - .numberOfRecruits(2) - .participantUserIds(new HashSet<>(List.of(0L, 1L))) + .numberOfRecruits(3) + .participantUserIds(new HashSet<>(List.of(0L, 1L, 2L))) .build() ); } @@ -52,10 +52,10 @@ public static Meeting getCloseMeetingWithTopic(Topic topic) { ); } - public static Meeting getOpenMeetingWithStreet(String street) { + public static Meeting getOpenMeetingWithMainStreet(String mainStreet) { return newMeetingByBuilder(MeetingBuilder.builder() .promiseDateTime(LocalDateTime.of(4000, 1, 1, 0, 0)) - .meetingArea(new MeetingArea("서울특별시", street, "강남대로 390")) + .meetingArea(new MeetingArea(mainStreet, "서울특별시", "강남대로 390")) .build() ); } @@ -144,7 +144,7 @@ public static class MeetingBuilder { private String introduction = "모임에 대한 간단한 설명입니다."; @Builder.Default - private MeetingArea meetingArea = new MeetingArea("서울특별시", "강남구", "강남대로 390"); + private MeetingArea meetingArea = new MeetingArea("강남구", "서울특별시 강남대로 390", "강남역 11번 출구"); @Builder.Default private int numberOfRecruits = 3; diff --git a/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java b/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java index 8a8a774d..cac3e321 100644 --- a/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java +++ b/src/test/java/net/teumteum/meeting/domain/MeetingRepositoryTest.java @@ -140,11 +140,11 @@ void Find_success_if_exists_meetings_meeting_street_and_page_nation_input() { // given var createSize = 3; - var expectedMeetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithStreet("강남")) + var expectedMeetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithMainStreet("강남")) .limit(createSize) .toList(); - var existsWrongMeetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithStreet("판교")) + var existsWrongMeetings = Stream.generate(() -> MeetingFixture.getOpenMeetingWithMainStreet("판교")) .limit(createSize) .toList(); diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index 8d270fe7..b9db218a 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -23,9 +23,11 @@ spring.security.oauth2.client.provider.kakao.authorization-uri=https://kauth.kak spring.security.oauth2.client.provider.kakao.token-uri=https://kauth.kakao.com/oauth/authorize spring.security.oauth2.client.provider.kakao.user-info-uri=https://kauth.kakao.com/oauth/authorize spring.security.oauth2.client.provider.kakao.user-name-attribute=https://kauth.kakao.com/oauth/authorize +### AWS S3 ### +spring.cloud.aws.credentials.access-key=12345678910 +spring.cloud.aws.credentials.secret-key=12345678910 +spring.cloud.aws.region.static=ap-northeast-2 +spring.cloud.aws.s3.bucket: test-bucket ### Redis ### spring.data.redis.host=localhost spring.data.redis.port=6379 - - - diff --git a/src/test/resources/schema.sql b/src/test/resources/schema.sql index bb71abc2..b3162557 100644 --- a/src/test/resources/schema.sql +++ b/src/test/resources/schema.sql @@ -1,25 +1,25 @@ create table if not exists users ( - id bigint not null auto_increment, - certificated boolean, - manner_temperature integer, - mbti varchar(4), - character_id bigint, - birth varchar(10), - name varchar(10), - goal varchar(50), + id bigint not null auto_increment, + certificated boolean, + manner_temperature integer, + mbti varchar(4), + character_id bigint, + birth varchar(10), + name varchar(10), + goal varchar(50), oauth_id varchar(255) not null unique, - authenticated varchar(255) not null, - role_type varchar(255), - city varchar(255), - detail_job_class varchar(255), - job_class varchar(255), - job_name varchar(255), - status enum ('직장인','학생','취업준비생'), - terms_of_service boolean not null, - privacy_policy boolean not null, - created_at timestamp(6) not null, - updated_at timestamp(6) not null, + authenticated varchar(255) not null, + role_type varchar(255), + city varchar(255), + detail_job_class varchar(255), + job_class varchar(255), + job_name varchar(255), + status enum ('직장인','학생','취업준비생'), + terms_of_service boolean not null, + privacy_policy boolean not null, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, primary key (id) ); @@ -48,9 +48,9 @@ create table if not exists meeting updated_at timestamp(6) not null, title varchar(32) null, introduction varchar(200) null, - city varchar(255) null, - street varchar(255) null, - zip_code varchar(255) null, + address varchar(255) null, + main_street varchar(255) null, + address_detail varchar(255) null, topic enum ('고민_나누기', '모여서_작업', '스터디', '사이드_프로젝트') null ); @@ -68,8 +68,9 @@ create table if not exists meeting_participant_user_ids foreign key (meeting_id) references meeting (id) ); -create table if not exists users_friends( - users_id bigint not null, - friends bigint not null, - foreign key (users_id) references users(id) +create table if not exists users_friends +( + users_id bigint not null, + friends bigint not null, + foreign key (users_id) references users (id) ); From 8c71c618acb3ec74bd798cc47155c32bce9f2adf Mon Sep 17 00:00:00 2001 From: ChoiDongKuen Date: Sun, 14 Jan 2024 18:52:17 +0900 Subject: [PATCH 13/17] =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=EB=93=B1=EB=A1=9D=20API=20=EC=9D=84=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=95=9C=EB=8B=A4.=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: jwtService 코드 리팩토링(#40) * feat: 토큰 재발행 관련 AuthController,AuthService 구현 (#40) * refactor: jwtService 코드 재정렬 (#40) * refactor: AuthService 코드 리팩토링 (#40) * refactor: 코드 형식 재정렬 (#40) * test: Jwt 재발급 통합 테스트 진행 (#40) * test: Jwt 재발급 컨트롤러 단위 테스트 진행 (#40) * test: Jwt 재발급 서비스 단위 테스트 진행 (#40) * refactor: JwtService 코드 재정렬 (#40) * refactor: 불필요한 import 노출 제거 (#42) * refactor: 코드 재정렬 (#42) * feat: 회원탈퇴 API 구현 (#42) * test: 회원탈퇴 통합 테스트 구현 (#42) * refactor: SecurityConfig 코드 재정렬 (#42) * refactor: 기타 코드 리팩토링 (#42) * fix: CI 에러 수정 (#42) * refactor: 기존 코드 리팩토링 (#49) * feat: RedisConfig 구현 (#49) * fix: CI 에러 수정 (#49) * feat: 회원 카드 등록 API 구현 (#47) * feat: 회원 카드 등록 API 통합 테스트 (#47) * fix: CI 에러 수정 (#47) * refactor: deleteUser 메소드 중복 제거 (#47) * feat: test 을 위한 application.p jwt 관련 설정값 추가 (#47) * test: 테스트 코드 관련 리팩토링 (#47) * test: 레디스 관련 설정 변경 (#47) * test: 회원 카드 등록 테스트 수정 및 추가 구현 (#47) * test: Property Test 구현 (#47) * fix: CI 에러 수정 (#47) * fix: SonarCloud 에러 수정 (#47) * fix: SonarCloud 에러 수정 (#47) * fix: SonarCloud 에러 수정 (#47) * fix: SonarCloud 에러 수정 (#47) --- .../net/teumteum/core/config/RedisConfig.java | 5 +- .../teumteum/core/property/RedisProperty.java | 3 +- .../core/security/SecurityConfig.java | 8 +- .../user/controller/UserController.java | 8 ++ .../domain/request/UserRegisterRequest.java | 83 +++++++++++++++++ .../domain/request/UserUpdateRequest.java | 14 ++- .../domain/response/UserRegisterResponse.java | 7 ++ .../teumteum/user/service/UserService.java | 21 ++++- .../teumteum/core/property/PropertyTest.java | 65 +++++++++++++ .../java/net/teumteum/integration/Api.java | 14 ++- .../teumteum/integration/RequestFixture.java | 56 ++++++----- .../integration/UserIntegrationTest.java | 61 ++++++++---- .../auth/controller/AuthControllerTest.java | 92 +++++++++++++++++++ src/test/resources/application.properties | 5 +- 14 files changed, 386 insertions(+), 56 deletions(-) create mode 100644 src/main/java/net/teumteum/user/domain/request/UserRegisterRequest.java create mode 100644 src/main/java/net/teumteum/user/domain/response/UserRegisterResponse.java create mode 100644 src/test/java/net/teumteum/core/property/PropertyTest.java create mode 100644 src/test/java/net/teumteum/unit/auth/controller/AuthControllerTest.java diff --git a/src/main/java/net/teumteum/core/config/RedisConfig.java b/src/main/java/net/teumteum/core/config/RedisConfig.java index 010af599..11c903c4 100644 --- a/src/main/java/net/teumteum/core/config/RedisConfig.java +++ b/src/main/java/net/teumteum/core/config/RedisConfig.java @@ -1,7 +1,7 @@ package net.teumteum.core.config; import lombok.RequiredArgsConstructor; -import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import net.teumteum.core.property.RedisProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; @@ -14,11 +14,12 @@ @RequiredArgsConstructor public class RedisConfig { - private final RedisProperties redisProperty; + private final RedisProperty redisProperty; @Bean public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(redisProperty.getHost(), redisProperty.getPort()); + } @Bean diff --git a/src/main/java/net/teumteum/core/property/RedisProperty.java b/src/main/java/net/teumteum/core/property/RedisProperty.java index fbc645fb..08d9780c 100644 --- a/src/main/java/net/teumteum/core/property/RedisProperty.java +++ b/src/main/java/net/teumteum/core/property/RedisProperty.java @@ -6,8 +6,9 @@ @Getter @Setter -@ConfigurationProperties(prefix = "data.redis") +@ConfigurationProperties(prefix = "spring.data.redis") public class RedisProperty { + private String host; private int port; } diff --git a/src/main/java/net/teumteum/core/security/SecurityConfig.java b/src/main/java/net/teumteum/core/security/SecurityConfig.java index a4d8dc1d..a2a74af0 100644 --- a/src/main/java/net/teumteum/core/security/SecurityConfig.java +++ b/src/main/java/net/teumteum/core/security/SecurityConfig.java @@ -3,6 +3,7 @@ import static org.springframework.security.config.http.SessionCreationPolicy.STATELESS; + import java.util.Collections; import lombok.RequiredArgsConstructor; import net.teumteum.core.security.filter.JwtAccessDeniedHandler; @@ -20,6 +21,7 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + @Configuration @EnableWebSecurity @RequiredArgsConstructor @@ -51,8 +53,10 @@ public WebSecurityCustomizer webSecurityCustomizer() { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.csrf(AbstractHttpConfigurer::disable).cors(AbstractHttpConfigurer::disable).authorizeHttpRequests( - request -> request.requestMatchers("/**").permitAll() + http.csrf(AbstractHttpConfigurer::disable) + .cors(AbstractHttpConfigurer::disable) + .authorizeHttpRequests( + request -> request.requestMatchers("/**").permitAll() .requestMatchers(PATTERNS).permitAll().anyRequest() .authenticated()).httpBasic(AbstractHttpConfigurer::disable).formLogin(AbstractHttpConfigurer::disable) .sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(STATELESS)) diff --git a/src/main/java/net/teumteum/user/controller/UserController.java b/src/main/java/net/teumteum/user/controller/UserController.java index ac2c0c14..909b9b45 100644 --- a/src/main/java/net/teumteum/user/controller/UserController.java +++ b/src/main/java/net/teumteum/user/controller/UserController.java @@ -6,10 +6,12 @@ import lombok.RequiredArgsConstructor; import net.teumteum.core.error.ErrorResponse; import net.teumteum.core.security.service.SecurityService; +import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserUpdateRequest; import net.teumteum.user.domain.response.FriendsResponse; import net.teumteum.user.domain.response.InterestQuestionResponse; import net.teumteum.user.domain.response.UserGetResponse; +import net.teumteum.user.domain.response.UserRegisterResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import net.teumteum.user.service.UserService; import org.springframework.context.ApplicationContext; @@ -82,6 +84,12 @@ public void withdraw() { userService.withdraw(getCurrentUserId()); } + @PostMapping("/registers") + @ResponseStatus(HttpStatus.CREATED) + public UserRegisterResponse register(@RequestBody UserRegisterRequest request) { + return userService.register(request); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(IllegalArgumentException.class) diff --git a/src/main/java/net/teumteum/user/domain/request/UserRegisterRequest.java b/src/main/java/net/teumteum/user/domain/request/UserRegisterRequest.java new file mode 100644 index 00000000..4ef7e66b --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/request/UserRegisterRequest.java @@ -0,0 +1,83 @@ +package net.teumteum.user.domain.request; + +import static net.teumteum.user.domain.RoleType.ROLE_USER; + +import com.fasterxml.jackson.annotation.JsonInclude; +import java.util.List; +import net.teumteum.core.security.Authenticated; +import net.teumteum.user.domain.JobStatus; +import net.teumteum.user.domain.OAuth; +import net.teumteum.user.domain.User; + +public record UserRegisterRequest( + String id, + Terms terms, + String name, + String birth, + Long characterId, + Authenticated authenticated, + ActivityArea activityArea, + String mbti, + String status, + Job job, + List interests, + String goal +) { + + public User toUser() { + return new User( + null, + name, + birth, + characterId, + 0, + new OAuth( + id, + authenticated + ), + ROLE_USER, + new net.teumteum.user.domain.ActivityArea( + activityArea.city, + activityArea.street + ), + mbti, + JobStatus.valueOf(status), + goal, + new net.teumteum.user.domain.Job( + job.name, + false, + job.jobClass, + job.detailClass + ), + interests, + new net.teumteum.user.domain.Terms( + terms.service, + terms.privatePolicy + ), + null + ); + } + + public record Terms( + boolean service, + boolean privatePolicy + ) { + + } + + public record ActivityArea( + String city, + List street + ) { + + } + + public record Job( + @JsonInclude(JsonInclude.Include.NON_NULL) + String name, + String jobClass, + String detailClass + ) { + + } +} diff --git a/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java b/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java index e029d3ab..05ba8ec8 100644 --- a/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java +++ b/src/main/java/net/teumteum/user/domain/request/UserUpdateRequest.java @@ -1,12 +1,16 @@ package net.teumteum.user.domain.request; +import static net.teumteum.user.domain.RoleType.ROLE_USER; + import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; import java.util.Set; - -import net.teumteum.user.domain.*; - -import static net.teumteum.user.domain.RoleType.ROLE_USER; +import net.teumteum.user.domain.ActivityArea; +import net.teumteum.user.domain.Job; +import net.teumteum.user.domain.JobStatus; +import net.teumteum.user.domain.OAuth; +import net.teumteum.user.domain.Terms; +import net.teumteum.user.domain.User; public record UserUpdateRequest( Long id, @@ -35,7 +39,7 @@ public User toUser() { newBirth, newCharacterId, IGNORE_MANNER_TEMPERATURE, - IGNORE_O_AUTH, + IGNORE_O_AUTH, ROLE_USER, new ActivityArea( newActivityArea.city, diff --git a/src/main/java/net/teumteum/user/domain/response/UserRegisterResponse.java b/src/main/java/net/teumteum/user/domain/response/UserRegisterResponse.java new file mode 100644 index 00000000..7fc204b0 --- /dev/null +++ b/src/main/java/net/teumteum/user/domain/response/UserRegisterResponse.java @@ -0,0 +1,7 @@ +package net.teumteum.user.domain.response; + +public record UserRegisterResponse( + Long id +) { + +} diff --git a/src/main/java/net/teumteum/user/service/UserService.java b/src/main/java/net/teumteum/user/service/UserService.java index bccfe594..7a9ecf99 100644 --- a/src/main/java/net/teumteum/user/service/UserService.java +++ b/src/main/java/net/teumteum/user/service/UserService.java @@ -2,15 +2,18 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import net.teumteum.core.security.Authenticated; import net.teumteum.core.security.service.RedisService; import net.teumteum.user.domain.BalanceGameType; import net.teumteum.user.domain.InterestQuestion; import net.teumteum.user.domain.User; import net.teumteum.user.domain.UserRepository; +import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserUpdateRequest; import net.teumteum.user.domain.response.FriendsResponse; import net.teumteum.user.domain.response.InterestQuestionResponse; import net.teumteum.user.domain.response.UserGetResponse; +import net.teumteum.user.domain.response.UserRegisterResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -62,10 +65,19 @@ public void addFriends(Long myId, Long friendId) { @Transactional public void withdraw(Long userId) { var existUser = getUser(userId); - deleteUser(existUser); + + userRepository.delete(existUser); redisService.deleteData(String.valueOf(userId)); } + @Transactional + public UserRegisterResponse register(UserRegisterRequest request) { + checkUserExistence(request.authenticated(), request.id()); + + return new UserRegisterResponse(userRepository.save(request.toUser()).getId()); + } + + public FriendsResponse findFriendsByUserId(Long userId) { var user = getUser(userId); var friends = userRepository.findAllById(user.getFriends()); @@ -89,7 +101,10 @@ public InterestQuestionResponse getInterestQuestionByUserIds(List userIds, return BalanceGameType.of(type).getInterestQuestionResponse(users, interestQuestion); } - private void deleteUser(User user) { - this.userRepository.delete(user); + private void checkUserExistence(Authenticated authenticated, String oauthId) { + userRepository.findByAuthenticatedAndOAuthId(authenticated, oauthId) + .ifPresent(user -> { + throw new IllegalArgumentException("일치하는 user 가 이미 존재합니다."); + }); } } diff --git a/src/test/java/net/teumteum/core/property/PropertyTest.java b/src/test/java/net/teumteum/core/property/PropertyTest.java new file mode 100644 index 00000000..b86415ba --- /dev/null +++ b/src/test/java/net/teumteum/core/property/PropertyTest.java @@ -0,0 +1,65 @@ +package net.teumteum.core.property; + +import net.teumteum.Application; +import net.teumteum.integration.SecurityContextSetting; +import net.teumteum.integration.TestLoginContext; +import net.teumteum.user.infra.GptTestServer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; + + +@SpringBootTest +@ContextConfiguration(classes = { + Application.class, + GptTestServer.class, + TestLoginContext.class, + SecurityContextSetting.class}) +@DisplayName("Property 설정 클래스의") +class PropertyTest { + + @Autowired + RedisProperty redisProperty; + + @Autowired + JwtProperty jwtProperty; + + + @Nested + @DisplayName("RedisProperty 클래스는") + class Read_redis_value_from_application_yml { + + @Test + @DisplayName("RedisProperty 클래스가 application.yml 에서 설정 값을 정상적으로 읽어온다.") + void Make_redis_property_from_application_yml() { + // given + String expectedHost = "localhost"; + int expectedPort = 6378; + + // when & then + Assertions.assertEquals(expectedHost, redisProperty.getHost()); + Assertions.assertEquals(expectedPort, redisProperty.getPort()); + } + } + + @Nested + @DisplayName("JwtProperty 클래스는") + class Read_jwt_value_from_application_yml { + + @Test + @DisplayName("JwtProperty 클래스가 application.yml 에서 설정 값을 정상적으로 읽어온다.") + void Make_jwt_property_from_application_yml() { + // given + String expectedBearer = "Bearer"; + String expectedSecret = "secret"; + + // when & then + Assertions.assertEquals(expectedBearer, jwtProperty.getBearer()); + Assertions.assertEquals(expectedSecret, jwtProperty.getSecret()); + } + } +} diff --git a/src/test/java/net/teumteum/integration/Api.java b/src/test/java/net/teumteum/integration/Api.java index c5439aa7..1d4ea32d 100644 --- a/src/test/java/net/teumteum/integration/Api.java +++ b/src/test/java/net/teumteum/integration/Api.java @@ -3,6 +3,7 @@ import java.util.List; import net.teumteum.meeting.config.PageableHandlerMethodArgumentResolver; import net.teumteum.meeting.domain.Topic; +import net.teumteum.user.domain.request.UserRegisterRequest; import net.teumteum.user.domain.request.UserUpdateRequest; import org.springframework.boot.test.context.TestComponent; import org.springframework.context.ApplicationContext; @@ -121,7 +122,7 @@ ResponseSpec getCommonInterests(String token, List userIds) { ResponseSpec reissueJwt(String accessToken, String refreshToken) { return webTestClient.post() - .uri("/auth/reissue") + .uri("/auth/reissues") .header(HttpHeaders.AUTHORIZATION, accessToken) .header("Authorization-refresh", refreshToken) .exchange(); @@ -129,8 +130,17 @@ ResponseSpec reissueJwt(String accessToken, String refreshToken) { ResponseSpec withdrawUser(String accessToken) { return webTestClient.delete() - .uri("/users/withdraw") + .uri("/users/withdraws") .header(HttpHeaders.AUTHORIZATION, accessToken) .exchange(); } + + ResponseSpec registerUserCard(String accessToken, UserRegisterRequest userRegisterRequest) { + return webTestClient + .post() + .uri("/users/registers") + .header(HttpHeaders.AUTHORIZATION, accessToken) + .bodyValue(userRegisterRequest) + .exchange(); + } } diff --git a/src/test/java/net/teumteum/integration/RequestFixture.java b/src/test/java/net/teumteum/integration/RequestFixture.java index 68e41096..3f288ccd 100644 --- a/src/test/java/net/teumteum/integration/RequestFixture.java +++ b/src/test/java/net/teumteum/integration/RequestFixture.java @@ -1,6 +1,12 @@ package net.teumteum.integration; +import java.util.UUID; +import net.teumteum.core.security.Authenticated; +import net.teumteum.user.domain.Job; import net.teumteum.user.domain.User; +import net.teumteum.user.domain.request.UserRegisterRequest; +import net.teumteum.user.domain.request.UserRegisterRequest.ActivityArea; +import net.teumteum.user.domain.request.UserRegisterRequest.Terms; import net.teumteum.user.domain.request.UserUpdateRequest; import net.teumteum.user.domain.request.UserUpdateRequest.NewActivityArea; import net.teumteum.user.domain.request.UserUpdateRequest.NewJob; @@ -8,33 +14,41 @@ public class RequestFixture { public static UserUpdateRequest userUpdateRequest(User user) { - return new UserUpdateRequest( - user.getId(), - "new_name", - user.getBirth(), - user.getCharacterId(), - newActivityArea(user), - user.getMbti(), - user.getStatus().name(), - user.getGoal(), - newJob(user), - user.getInterests() - ); + return new UserUpdateRequest(user.getId(), "new_name", user.getBirth(), user.getCharacterId(), + newActivityArea(user), user.getMbti(), user.getStatus().name(), user.getGoal(), newJob(user), + user.getInterests()); } private static NewActivityArea newActivityArea(User user) { - return new NewActivityArea( - user.getActivityArea().getCity(), - user.getActivityArea().getStreet() - ); + return new NewActivityArea(user.getActivityArea().getCity(), user.getActivityArea().getStreet()); } private static NewJob newJob(User user) { - return new NewJob( - user.getJob().getName(), - user.getJob().getJobClass(), - user.getJob().getDetailJobClass() - ); + return new NewJob(user.getJob().getName(), user.getJob().getJobClass(), user.getJob().getDetailJobClass()); + } + + public static UserRegisterRequest userRegisterRequest(User user) { + return new UserRegisterRequest(UUID.randomUUID().toString(), + new Terms(user.getTerms().getService(), user.getTerms().getPrivacyPolicy()), user.getName(), + user.getBirth(), user.getCharacterId(), Authenticated.카카오, activityArea(user), + user.getMbti(), user.getStatus().name(), new UserRegisterRequest.Job("직장인", "디자인", "BX 디자이너"), + user.getInterests(), user.getGoal()); + } + + public static UserRegisterRequest userRegisterRequestWithFail(User user) { + return new UserRegisterRequest(user.getOauth().getOauthId(), + new Terms(user.getTerms().getService(), user.getTerms().getPrivacyPolicy()), user.getName(), + user.getBirth(), user.getCharacterId(), user.getOauth().getAuthenticated(), activityArea(user), + user.getMbti(), user.getStatus().name(), new UserRegisterRequest.Job("직장인", "디자인", "BX 디자이너"), + user.getInterests(), user.getGoal()); + } + + private static ActivityArea activityArea(User user) { + return new ActivityArea(user.getActivityArea().getCity(), user.getActivityArea().getStreet()); + } + + private static Job job(User user) { + return new Job(user.getJob().getName(), false, user.getJob().getJobClass(), user.getJob().getDetailJobClass()); } } diff --git a/src/test/java/net/teumteum/integration/UserIntegrationTest.java b/src/test/java/net/teumteum/integration/UserIntegrationTest.java index 1e948cfc..a73c483b 100644 --- a/src/test/java/net/teumteum/integration/UserIntegrationTest.java +++ b/src/test/java/net/teumteum/integration/UserIntegrationTest.java @@ -5,6 +5,7 @@ import net.teumteum.user.domain.User; import net.teumteum.user.domain.response.FriendsResponse; import net.teumteum.user.domain.response.UserGetResponse; +import net.teumteum.user.domain.response.UserRegisterResponse; import net.teumteum.user.domain.response.UsersGetByIdResponse; import org.assertj.core.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -195,23 +196,45 @@ void Return_empty_friends_when_received_empty_friends_user_id() { } } -// @Nested -// @DisplayName("회원 탈퇴 API는") -// class Withdraw_user { -// -// @Test -// @DisplayName("현재 로그인한 회원의 회원 탈퇴를 정상적으로 진행한다.") -// void Return_200_ok_when_withdraw_current_user() { -// // given -// var me = repository.saveAndGetUser(); -// -// loginContext.setUserId(me.getId()); -// -// // when -// var result = api.withdrawUser(VALID_TOKEN); -// -// // then -// Assertions.assertThat(result.expectStatus().isOk()); -// } -// } + @Nested + @DisplayName("회원 카드 등록 API는") + class Register_user_card { + + @Test + @DisplayName("등록할 회원의 정보가 주어지면, 회원 정보를 저장한다.") + void Register_user_info() { + // given + var additionalUser = repository.saveAndGetUser(); + + var UserRegister = RequestFixture.userRegisterRequest(additionalUser); + // when + var result = api.registerUserCard(VALID_TOKEN, UserRegister); + + // then + Assertions.assertThat(result.expectStatus().isCreated() + .expectBody(UserRegisterResponse.class) + .returnResult() + .getResponseBody()) + .usingRecursiveComparison().isNotNull(); + } + + @Test + @DisplayName("이미 존재하는 회원인 경우, 400 Bad Request 을 반환한다 ") + void Return_400_badRequest_register_user_card() { + // given + var existUser = repository.saveAndGetUser(); + + var userRegister = RequestFixture.userRegisterRequestWithFail(existUser); + // when + var result = api.registerUserCard(VALID_TOKEN, userRegister); + + // then + var responseBody = result.expectStatus().isBadRequest() + .expectBody(ErrorResponse.class) + .returnResult().getResponseBody(); + + Assertions.assertThat(responseBody) + .isNotNull(); + } + } } diff --git a/src/test/java/net/teumteum/unit/auth/controller/AuthControllerTest.java b/src/test/java/net/teumteum/unit/auth/controller/AuthControllerTest.java new file mode 100644 index 00000000..d3ecda68 --- /dev/null +++ b/src/test/java/net/teumteum/unit/auth/controller/AuthControllerTest.java @@ -0,0 +1,92 @@ +package net.teumteum.unit.auth.controller; + + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import jakarta.servlet.http.HttpServletRequest; +import net.teumteum.auth.controller.AuthController; +import net.teumteum.auth.domain.response.TokenResponse; +import net.teumteum.auth.service.AuthService; +import net.teumteum.core.security.SecurityConfig; +import net.teumteum.core.security.filter.JwtAuthenticationFilter; +import net.teumteum.core.security.service.JwtService; +import net.teumteum.core.security.service.RedisService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(value = AuthController.class, + excludeFilters = {@ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = SecurityConfig.class), + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = JwtAuthenticationFilter.class), + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = RedisService.class), + @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = JwtService.class)} +) +@WithMockUser +@DisplayName("인증 컨트롤러 단위 테스트의") +public class AuthControllerTest { + + private static final String VALID_ACCESS_TOKEN = "VALID_ACCESS_TOKEN"; + private static final String INVALID_ACCESS_TOKEN = "INVALID_ACCESS_TOKEN"; + private static final String VALID_REFRESH_TOKEN = "VALID_REFRESH_TOKEN"; + private static final String INVALID_REFRESH_TOKEN = "INVALID_REFRESH_TOKEN"; + + @Autowired + private MockMvc mockMvc; + + @MockBean + private AuthService authService; + + @Nested + @DisplayName("토큰 재발급 API는") + class Reissue_jwt_api_unit { + + @Test + @DisplayName("유효하지 않은 access token 과 유효한 refresh token 이 주어지면, 새로운 토큰을 발급한다.") + void Return_new_jwt_if_access_and_refresh_is_exist() throws Exception { + // given + TokenResponse tokenResponse = new TokenResponse(INVALID_ACCESS_TOKEN, VALID_REFRESH_TOKEN); + + given(authService.reissue(any(HttpServletRequest.class))).willReturn(tokenResponse); + // when & then + mockMvc.perform(post("/auth/reissues") + .with(csrf()) + .header(AUTHORIZATION, INVALID_ACCESS_TOKEN) + .header("Authorization-refresh", VALID_REFRESH_TOKEN)) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.accessToken").isNotEmpty()) + .andExpect(jsonPath("$.refreshToken").isNotEmpty()); + } + + @Test + @DisplayName("유효하지 않은 access token 과 유효하지 않은 refresh token 이 주어지면, 500 Server Error를 응답한다.") + void Return_500_bad_request_if_refresh_token_is_not_valid() throws Exception { + // given + given(authService.reissue(any(HttpServletRequest.class))).willThrow( + new IllegalArgumentException("refresh token 이 유효하지 않습니다.")); + + // when & then + mockMvc.perform(post("/auth/reissues") + .with(csrf()) + .header(AUTHORIZATION, INVALID_ACCESS_TOKEN) + .header("Authorization-refresh", INVALID_REFRESH_TOKEN)) + .andDo(print()) + .andExpect(status().is5xxServerError()) + .andExpect(jsonPath("$.message").value("refresh token 이 유효하지 않습니다.")); + } + } +} diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index b9db218a..d9752b2a 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -30,4 +30,7 @@ spring.cloud.aws.region.static=ap-northeast-2 spring.cloud.aws.s3.bucket: test-bucket ### Redis ### spring.data.redis.host=localhost -spring.data.redis.port=6379 +spring.data.redis.port=6378 +### JWT ### +jwt.bearer=Bearer +jwt.secret=secret From f40853c7a5c6f28c4bb9844dfef2cbbaf2b2351d Mon Sep 17 00:00:00 2001 From: xb205 <62425964+devxb@users.noreply.github.com> Date: Sun, 14 Jan 2024 19:26:05 +0900 Subject: [PATCH 14/17] =?UTF-8?q?ci:=20cd=20=EA=B5=AC=EC=84=B1=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci: cd를 구성한다 * fix: SENTRY_AUTH_TOKEN을 env가 아닌, property로 가져오도록 수정한다 * fix: ghcr 레포지토리 명을 수정한다 * ci: 깃헙 패키지 권한을 확인한다 * build: plain.jar가 생성되지 않도록 한다 * ci: cd를 완성한다 --- .github/workflows/deployer.yml | 105 ++++++++++++++++++ Dockerfile | 67 +++++++++++ build.gradle | 4 + gradle/sentry.gradle | 5 +- src/main/resources/application-datasource.yml | 4 +- src/main/resources/application-prod.yml | 7 -- src/main/resources/application.yml | 2 +- 7 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/deployer.yml create mode 100644 Dockerfile diff --git a/.github/workflows/deployer.yml b/.github/workflows/deployer.yml new file mode 100644 index 00000000..b476f52e --- /dev/null +++ b/.github/workflows/deployer.yml @@ -0,0 +1,105 @@ +name: 🎇 Deployer + +on: + push: + branches: + - 'main' + +jobs: + build: + name: build and set image + runs-on: ubuntu-latest + strategy: + matrix: + java-version: [ 21 ] + steps: + - name: checkout code + uses: actions/checkout@v3 + with: + submodules: true + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + distribution: 'zulu' + java-version: ${{ matrix.java-version }} + kotlin-version: ${{ matrix.kotlin-version }} + + - name: Cache Gradle packages + uses: actions/cache@v3 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: build server + run: ./gradlew build -x test -DSENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }} + + - name: docker arm64 build set up - qemu + uses: docker/setup-qemu-action@v2 + + - name: docker arm64 build set up - buildx + uses: docker/setup-buildx-action@v2 + + - name: login github container registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: extract version + run: echo "##[set-output name=version;]$(echo '${{ github.event.head_commit.message }}' | egrep -o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}')" + id: extract_version_name + + - name: push + uses: docker/build-push-action@v4 + with: + context: . + platforms: linux/arm64/v8 + push: true + tags: | + ghcr.io/depromeet/teum-teum-server/api:${{ steps.extract_version_name.outputs.version }} + build-args: | + "KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }}" + "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" + "KAKAO_REDIRECT_URI=${{ secrets.KAKAO_REDIRECT_URI }}" + "NAVER_CLIENT_ID=${{ secrets.NAVER_CLIENT_ID }}" + "NAVER_CLIENT_SECRET=${{ secrets.NAVER_CLIENT_SECRET }}" + "NAVER_REDIRECT_URI=${{ secrets.NAVER_REDIRECT_URI }}" + "JWT_SECRET_KEY=${{ secrets.NAVER_REDIRECT_URI }}" + "DB_URL=${{ secrets.DB_URL }}" + "DB_USERNAME=${{ secrets.DB_USERNAME }}" + "DB_PASSWORD=${{ secrets.DB_PASSWORD }}" + "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" + "GPT_TOKEN=${{ secrets.GPT_TOKEN }}" + "REDIS_HOST=${{ secrets.REDIS_HOST }}" + "REDIS_PORT=${{ secrets.REDIS_PORT }}" + "AWS_ACCESS_KEY=${{ secrets.AWS_ACCESS_KEY }}" + "AWS_SECRET_KEY=${{ secrets.AWS_SECRET_KEY }}" + "AWS_REGION=${{ secrets.AWS_REGION }}" + "AWS_S3_BUCKET=${{ secrets.AWS_S3_BUCKET }}" + + - name: create release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.extract_version_name.outputs.version }} + release_name: ${{ steps.extract_version_name.outputs.version }} + + deploy: + needs: build + name: deploy + runs-on: self-hosted + steps: + - name: extract version + run: echo "##[set-output name=version;]$(echo '${{ github.event.head_commit.message }}' | egrep -o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}')" + id: extract_version_name + + - name: run server + run: | + sudo docker pull ghcr.io/depromeet/teum-teum-server/api:${{ steps.extract_version_name.outputs.version }} + sudo docker ps -q --filter "expose=8080" | xargs sudo docker stop | xargs sudo docker rm + sudo docker run -d -p 8080:8080 ghcr.io/depromeet/teum-teum-server/api:${{ steps.extract_version_name.outputs.version }} + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..c358ce3f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +FROM openjdk:11.0.11-jre-slim + +ARG KAKAO_CLIENT_ID +ARG KAKAO_CLIENT_SECRET +ARG KAKAO_REDIRECT_URI +ARG NAVER_CLIENT_ID +ARG NAVER_CLIENT_SECRET +ARG NAVER_REDIRECT_URI +ARG JWT_SECRET_KEY +ARG DB_URL +ARG DB_USERNAME +ARG DB_PASSWORD +ARG SENTRY_AUTH_TOKEN +ARG GPT_TOKEN +ARG REDIS_HOST +ARG REDIS_PORT +ARG AWS_ACCESS_KEY +ARG AWS_SECRET_KEY +ARG AWS_REGION +ARG AWS_S3_BUCKET + +ARG JAR_FILE=./build/libs/*.jar + +COPY ${JAR_FILE} teum.jar + +ENV kakao_client_id=${KAKAO_CLIENT_ID} \ + kakao_client_secret=${KAKAO_CLIENT_SECRET} \ + kakao_redirect_uri=${KAKAO_REDIRECT_URI} \ + naver_client_id=${NAVER_CLIENT_ID} \ + naver_client_secret=${NAVER_CLIENT_SECRET} \ + naver_redirect_uri=${NAVER_REDIRECT_URI} \ + jwt_secret_key=${JWT_SECRET_KEY} \ + db_url=${DB_URL} \ + db_user=${DB_USERNAME} \ + db_password=${DB_PASSWORD} \ + sentry_auth_token=${SENTRY_AUTH_TOKEN} \ + gpt_token=${GPT_TOKEN} \ + redis_host=${REDIS_HOST} \ + redis_port=${REDIS_PORT} \ + aws_access_key=${AWS_ACCESS_KEY} \ + aws_secret_key=${AWS_SECRET_KEY} \ + aws_region=${AWS_REGION} \ + aws_s3_bucket=${AWS_S3_BUCKET} + + +ENTRYPOINT java -jar teum.jar \ + --spring.datasource.url=${db_url} \ + --spring.security.oauth2.client.registration.kakao.client-id=${kakao_client_id} \ + --spring.security.oauth2.client.registration.kakao.client-secret=${kakao_client_secret} \ + --spring.security.oauth2.client.registration.kakao.redirect-uri=${kakao_redirect_uri} \ + --spring.security.oauth2.client.registration.naver.client-id=${naver_client_id} \ + --spring.security.oauth2.client.registration.naver.client-secret=${naver_client_secret} \ + --spring.security.oauth2.client.registration.naver.redirect-uri=${naver_redirect_uri} \ + --jwt.secret=${jwt_secret_key} \ + --spring.datasource.url=${db_url} \ + --spring.datasource.username=${db_user} \ + --spring.datasource.password=${db_password} \ + --spring.flyway.url=${db_url} \ + --spring.flyway.user=${db_user} \ + --spring.flyway.password=${db_password} \ + --gpt.token=${gpt_token} \ + --spring.data.redis.host=${redis_host} \ + --spring.data.redis.port=${redis_port} \ + --spring.cloud.aws.credentials.access-key=${aws_access_key} \ + --spring.cloud.aws.credentials.secret-key=${aws_secret_key} \ + --spring.cloud.aws.region.static=${aws_region} \ + --spring.cloud.aws.s3.bucket=${aws_s3_bucket} diff --git a/build.gradle b/build.gradle index 9798bcd9..c577d99c 100644 --- a/build.gradle +++ b/build.gradle @@ -16,6 +16,10 @@ apply from: "gradle/sentry.gradle" allprojects { + jar { + enabled = false + } + group = "${projectGroup}" version = "${projectVersion}" diff --git a/gradle/sentry.gradle b/gradle/sentry.gradle index 35e01a54..fdf273fe 100644 --- a/gradle/sentry.gradle +++ b/gradle/sentry.gradle @@ -1,10 +1,7 @@ sentry { - // Generates a JVM (Java, Kotlin, etc.) source bundle and uploads your source code to Sentry. - // This enables source context, allowing you to see your source - // code as part of your stack traces in Sentry. includeSourceContext = true org = "teum-teum" projectName = "java-spring-boot" - authToken = System.getenv("SENTRY_AUTH_TOKEN") + authToken = System.getProperty("SENTRY_AUTH_TOKEN") } diff --git a/src/main/resources/application-datasource.yml b/src/main/resources/application-datasource.yml index 1d2e3ce6..c3305ef8 100644 --- a/src/main/resources/application-datasource.yml +++ b/src/main/resources/application-datasource.yml @@ -6,7 +6,7 @@ spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${DATABASE_HOST:localhost}:${DATABASE_PORT}/${DATABASE_NAME}?&characterEncoding=UTF-8&serverTimezone=Asia/Seoul&autoReconnect=true + url: username: ${DATABASE_USERNAME} password: ${DATABASE_PASSWORD} hikari: @@ -14,7 +14,7 @@ spring: maximum-pool-size: 80 flyway: - url: jdbc:mysql://${DATABASE_HOST:localhost}:${DATABASE_PORT}/${DATABASE_NAME} + url: user: ${DATABASE_USERNAME} password: ${DATABASE_PASSWORD} baseline-on-migrate: true diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 9a032c17..6f86ba8e 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -22,13 +22,6 @@ spring: format_sql: true default_batch_fetch_size: 100 -### FLYWAY ### - flyway: - url: - user: - password: - baseline-on-migrate: true - ### ACTUATOR ### management: endpoints: diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 932a8333..c99a9190 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,5 +1,5 @@ server: - port: ${APPLICATION_PORT:8080} + port: 8080 spring: application: From 29c2b71b9e8de2c5be7706c1ed1990a3538d5e04 Mon Sep 17 00:00:00 2001 From: xb205 <62425964+devxb@users.noreply.github.com> Date: Sun, 14 Jan 2024 19:44:14 +0900 Subject: [PATCH 15/17] =?UTF-8?q?build:=20Dockerfile=20jdk=20version=2021?= =?UTF-8?q?=EB=A1=9C=20=EC=97=85=EA=B7=B8=EB=A0=88=EC=9D=B4=EB=93=9C=20(#5?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c358ce3f..ba0b3ea1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM openjdk:11.0.11-jre-slim +FROM openjdk:21 ARG KAKAO_CLIENT_ID ARG KAKAO_CLIENT_SECRET From e6d4e5abd18d5c6ecfd49d8f383b896d606da199 Mon Sep 17 00:00:00 2001 From: xb205 <62425964+devxb@users.noreply.github.com> Date: Sun, 14 Jan 2024 20:06:00 +0900 Subject: [PATCH 16/17] =?UTF-8?q?build:=20=08=EC=8B=A4=ED=96=89=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=8C=8C=EC=9D=BC=EC=97=90=20prod=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ba0b3ea1..d8695942 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,7 +43,7 @@ ENV kakao_client_id=${KAKAO_CLIENT_ID} \ aws_s3_bucket=${AWS_S3_BUCKET} -ENTRYPOINT java -jar teum.jar \ +ENTRYPOINT java -jar -Dspring.profiles.active=prod teum.jar \ --spring.datasource.url=${db_url} \ --spring.security.oauth2.client.registration.kakao.client-id=${kakao_client_id} \ --spring.security.oauth2.client.registration.kakao.client-secret=${kakao_client_secret} \ From feb8296b0bc2d0d6e539b8b599727dd68bcae875 Mon Sep 17 00:00:00 2001 From: xb205 <62425964+devxb@users.noreply.github.com> Date: Sun, 14 Jan 2024 20:25:42 +0900 Subject: [PATCH 17/17] =?UTF-8?q?build:=20MySQL=20Dialect=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#64)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-aws.yml | 4 ++++ src/main/resources/application-prod.yml | 1 + 2 files changed, 5 insertions(+) diff --git a/src/main/resources/application-aws.yml b/src/main/resources/application-aws.yml index c6f54eff..32c9dc57 100644 --- a/src/main/resources/application-aws.yml +++ b/src/main/resources/application-aws.yml @@ -1,4 +1,8 @@ spring: + config: + activate: + on-profile: "aws" + cloud: aws: credentials: diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 6f86ba8e..7ac4f5dd 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -21,6 +21,7 @@ spring: hibernate: format_sql: true default_batch_fetch_size: 100 + dialect: org.hibernate.dialect.MySQL8Dialect ### ACTUATOR ### management: