diff --git a/.gitignore b/.gitignore index df8fb3c3..f4a54d77 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,4 @@ gradle-app.setting logs/ application-*.yml src/main/resources/config +!application-test.yml diff --git a/build.gradle b/build.gradle index 8a948e73..bf593fb3 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,9 @@ dependencies { // H2 implementation 'com.h2database:h2' + + // Configuration Binding + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" } tasks.named('test') { diff --git a/src/main/java/com/moabam/MoabamServerApplication.java b/src/main/java/com/moabam/MoabamServerApplication.java index 5390acc3..e2dbce1d 100644 --- a/src/main/java/com/moabam/MoabamServerApplication.java +++ b/src/main/java/com/moabam/MoabamServerApplication.java @@ -2,12 +2,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +@ConfigurationPropertiesScan @SpringBootApplication public class MoabamServerApplication { public static void main(String[] args) { SpringApplication.run(MoabamServerApplication.class, args); } - } diff --git a/src/main/java/com/moabam/api/application/AuthenticationService.java b/src/main/java/com/moabam/api/application/AuthenticationService.java new file mode 100644 index 00000000..bd383e95 --- /dev/null +++ b/src/main/java/com/moabam/api/application/AuthenticationService.java @@ -0,0 +1,57 @@ +package com.moabam.api.application; + +import static com.moabam.global.common.util.OAuthParameterNames.*; + +import java.io.IOException; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +import com.moabam.api.dto.AuthorizationCodeRequest; +import com.moabam.api.mapper.OAuthMapper; +import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.config.OAuthConfig; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AuthenticationService { + + private final OAuthConfig oAuthConfig; + + private String getAuthorizaionCodeUri() { + AuthorizationCodeRequest authorizationCodeRequest = OAuthMapper.toAuthorizationCodeRequest(oAuthConfig); + return generateQueryParamsWith(authorizationCodeRequest); + } + + private String generateQueryParamsWith(AuthorizationCodeRequest authorizationCodeRequest) { + UriComponentsBuilder authorizationCodeUri = UriComponentsBuilder + .fromUriString(oAuthConfig.provider().authorizationUri()) + .queryParam(RESPONSE_TYPE, CODE) + .queryParam(CLIENT_ID, authorizationCodeRequest.clientId()) + .queryParam(REDIRECT_URI, authorizationCodeRequest.redirectUri()); + + if (!authorizationCodeRequest.scope().isEmpty()) { + String scopes = String.join(GlobalConstant.COMMA, authorizationCodeRequest.scope()); + authorizationCodeUri.queryParam(SCOPE, scopes); + } + + return authorizationCodeUri.toUriString(); + } + + public void redirectToLoginPage(HttpServletResponse httpServletResponse) { + String authorizationCodeUri = getAuthorizaionCodeUri(); + + try { + httpServletResponse.setContentType(MediaType.APPLICATION_FORM_URLENCODED + GlobalConstant.CHARSET_UTF_8); + httpServletResponse.sendRedirect(authorizationCodeUri); + } catch (IOException e) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILD); + } + } +} diff --git a/src/main/java/com/moabam/api/domain/Member.java b/src/main/java/com/moabam/api/domain/Member.java new file mode 100644 index 00000000..c2e3701e --- /dev/null +++ b/src/main/java/com/moabam/api/domain/Member.java @@ -0,0 +1,96 @@ +package com.moabam.api.domain; + +import static java.util.Objects.*; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +import com.moabam.global.common.entity.BaseTimeEntity; +import com.moabam.global.common.util.BaseImageUrl; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@Table(name = "member") +@SQLDelete(sql = "UPDATE member SET deleted_at = CURRENT_TIMESTAMP where participant_id = ?") +@Where(clause = "deleted_at IS NOT NULL") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "social_id", nullable = false, unique = true) + private String socialId; + + @Column(name = "nickname", nullable = false, unique = true) + private String nickname; + + @Column(name = "intro", length = 30) + private String intro; + + @Column(name = "profile_image", nullable = false) + private String profileImage; + + @Column(name = "total_certify_count", nullable = false) + @ColumnDefault("0") + private long totalCertifyCount; + + @Column(name = "report_count", nullable = false) + @ColumnDefault("0") + private int reportCount; + + @Column(name = "current_night_count", nullable = false) + @ColumnDefault("0") + private int currentNightCount; + + @Column(name = "current_morning_count", nullable = false) + @ColumnDefault("0") + private int currentMorningCount; + + @Column(name = "morning_bug", nullable = false) + @ColumnDefault("0") + private int morningBug; + + @Column(name = "night_bug", nullable = false) + @ColumnDefault("0") + private int nightBug; + + @Column(name = "golden_bug", nullable = false) + @ColumnDefault("0") + private int goldenBug; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + @ColumnDefault("USER") + private Role role; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + private Member(Long id, String socialId, String nickname, String profileImage) { + this.id = id; + this.socialId = requireNonNull(socialId); + this.nickname = requireNonNull(nickname); + this.profileImage = requireNonNullElse(profileImage, BaseImageUrl.PROFILE_URL); + this.role = Role.USER; + } +} diff --git a/src/main/java/com/moabam/api/domain/Role.java b/src/main/java/com/moabam/api/domain/Role.java new file mode 100644 index 00000000..65cb1a49 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/Role.java @@ -0,0 +1,8 @@ +package com.moabam.api.domain; + +public enum Role { + + USER, + BLACK, + ADMIN +} diff --git a/src/main/java/com/moabam/api/dto/AuthorizationCodeRequest.java b/src/main/java/com/moabam/api/dto/AuthorizationCodeRequest.java new file mode 100644 index 00000000..b6c30db5 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/AuthorizationCodeRequest.java @@ -0,0 +1,26 @@ +package com.moabam.api.dto; + +import static java.util.Objects.*; + +import java.util.List; + +import lombok.Builder; + +public record AuthorizationCodeRequest( + String clientId, + String redirectUri, + String responseType, + List scope, + String state +) { + + @Builder + public AuthorizationCodeRequest(String clientId, String redirectUri, String responseType, List scope, + String state) { + this.clientId = requireNonNull(clientId); + this.redirectUri = requireNonNull(redirectUri); + this.responseType = responseType; + this.scope = scope; + this.state = state; + } +} diff --git a/src/main/java/com/moabam/api/mapper/OAuthMapper.java b/src/main/java/com/moabam/api/mapper/OAuthMapper.java new file mode 100644 index 00000000..7d16b8ef --- /dev/null +++ b/src/main/java/com/moabam/api/mapper/OAuthMapper.java @@ -0,0 +1,19 @@ +package com.moabam.api.mapper; + +import com.moabam.api.dto.AuthorizationCodeRequest; +import com.moabam.global.config.OAuthConfig; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class OAuthMapper { + + public static AuthorizationCodeRequest toAuthorizationCodeRequest(OAuthConfig oAuthConfig) { + return AuthorizationCodeRequest.builder() + .clientId(oAuthConfig.client().clientId()) + .redirectUri(oAuthConfig.provider().redirectUri()) + .scope(oAuthConfig.client().scope()) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java new file mode 100644 index 00000000..fd2e730f --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -0,0 +1,23 @@ +package com.moabam.api.presentation; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.AuthenticationService; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/members") +@RequiredArgsConstructor +public class MemberController { + + private final AuthenticationService authenticationService; + + @GetMapping + public void socialLogin(HttpServletResponse httpServletResponse) { + authenticationService.redirectToLoginPage(httpServletResponse); + } +} diff --git a/src/main/java/com/moabam/global/common/util/BaseImageUrl.java b/src/main/java/com/moabam/global/common/util/BaseImageUrl.java new file mode 100644 index 00000000..d13f36ff --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/BaseImageUrl.java @@ -0,0 +1,10 @@ +package com.moabam.global.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class BaseImageUrl { + + public static final String PROFILE_URL = "/profile/baseUrl"; +} diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java new file mode 100644 index 00000000..5b87cd58 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -0,0 +1,11 @@ +package com.moabam.global.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class GlobalConstant { + + public static final String COMMA = ","; + public static final String CHARSET_UTF_8 = ";charset=UTF-8"; +} diff --git a/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java b/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java new file mode 100644 index 00000000..efc65e59 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java @@ -0,0 +1,14 @@ +package com.moabam.global.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class OAuthParameterNames { + + public static final String RESPONSE_TYPE = "response_type"; + public static final String CODE = "code"; + public static final String CLIENT_ID = "client_id"; + public static final String REDIRECT_URI = "redirect_uri"; + public static final String SCOPE = "scope"; +} diff --git a/src/main/java/com/moabam/global/config/OAuthConfig.java b/src/main/java/com/moabam/global/config/OAuthConfig.java new file mode 100644 index 00000000..cff7f45b --- /dev/null +++ b/src/main/java/com/moabam/global/config/OAuthConfig.java @@ -0,0 +1,28 @@ +package com.moabam.global.config; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth2") +public record OAuthConfig( + Provider provider, + Client client +) { + + public record Client( + String provider, + String clientId, + String authorizationGrantType, + List scope + ) { + + } + + public record Provider( + String authorizationUri, + String redirectUri + ) { + + } +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index dd05b455..82942270 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -11,7 +11,9 @@ public enum ErrorMessage { ROOM_NOT_FOUND("존재하지 않는 방 입니다."), ROOM_MAX_USER_COUNT_MODIFY_FAIL("잘못된 최대 인원수 설정입니다."), ROOM_MODIFY_UNAUTHORIZED_REQUEST("방장이 아닌 사용자는 방을 수정할 수 없습니다."), - PARTICIPANT_NOT_FOUND("방에 대한 참여자의 정보가 없습니다."); + PARTICIPANT_NOT_FOUND("방에 대한 참여자의 정보가 없습니다."), + LOGIN_FAILED("로그인에 실패했습니다."), + REQUEST_FAILD("네트우크 접근 실패입니다."); private final String message; } diff --git a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java new file mode 100644 index 00000000..cfebb663 --- /dev/null +++ b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java @@ -0,0 +1,118 @@ +package com.moabam.api.application; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.util.List; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import com.moabam.api.dto.AuthorizationCodeRequest; +import com.moabam.api.mapper.OAuthMapper; +import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.config.OAuthConfig; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +import jakarta.servlet.http.HttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class AuthenticationServiceTest { + + @InjectMocks + AuthenticationService authenticationService; + OAuthConfig oauthConfig; + AuthenticationService noPropertyService; + OAuthConfig noOAuthConfig; + + @BeforeEach + public void initParams() { + oauthConfig = new OAuthConfig( + new OAuthConfig.Provider("https://authorization/url", "http://redirect/url"), + new OAuthConfig.Client("provider", "testtestetsttest", "authorization_code", + List.of("profile_nickname", "profile_image")) + ); + ReflectionTestUtils.setField(authenticationService, "oAuthConfig", oauthConfig); + + noOAuthConfig = new OAuthConfig( + new OAuthConfig.Provider(null, null), + new OAuthConfig.Client(null, null, null, null) + ); + noPropertyService = new AuthenticationService(noOAuthConfig); + + } + + @DisplayName("인가코드 URI 생성 매퍼 실패") + @Test + void authorization_code_request_mapping_fail() { + // When + Then + Assertions.assertThatThrownBy(() -> OAuthMapper.toAuthorizationCodeRequest(noOAuthConfig)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("인가코드 URI 생성 매퍼 성공") + @Test + void authorization_code_request_mapping_success() { + // Given + AuthorizationCodeRequest authorizationCodeRequest = OAuthMapper.toAuthorizationCodeRequest(oauthConfig); + + // When + Then + assertThat(authorizationCodeRequest).isNotNull(); + assertAll( + () -> assertThat(authorizationCodeRequest.clientId()).isEqualTo(oauthConfig.client().clientId()), + () -> assertThat(authorizationCodeRequest.redirectUri()).isEqualTo(oauthConfig.provider().redirectUri()) + ); + } + + @DisplayName("인가코드 URI 생성 성공") + @Test + void authorization_code_uri_generate_success() throws IOException { + // given + String uri = "https://authorization/url?" + + "response_type=code&" + + "client_id=testtestetsttest&" + + "redirect_uri=http://redirect/url&scope=profile_nickname,profile_image"; + + MockHttpServletResponse mockHttpServletResponse = mockHttpServletResponse = new MockHttpServletResponse(); + + // when + authenticationService.redirectToLoginPage(mockHttpServletResponse); + + // then + assertThat(mockHttpServletResponse.getContentType()) + .isEqualTo(MediaType.APPLICATION_FORM_URLENCODED + GlobalConstant.CHARSET_UTF_8); + assertThat(mockHttpServletResponse.getRedirectedUrl()).isEqualTo(uri); + } + + @DisplayName("redirect 실패 테스트") + @Test + void redirect_fail_test() { + // given + HttpServletResponse mockHttpServletResponse = Mockito.mock(HttpServletResponse.class); + + try { + doThrow(IOException.class).when(mockHttpServletResponse).sendRedirect(any(String.class)); + + assertThatThrownBy(() -> { + // When + Then + authenticationService.redirectToLoginPage(mockHttpServletResponse); + }).isExactlyInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.REQUEST_FAILD.getMessage()); + } catch (Exception ignored) { + + } + } +} diff --git a/src/test/java/com/moabam/api/domain/MemberTest.java b/src/test/java/com/moabam/api/domain/MemberTest.java new file mode 100644 index 00000000..b08dde3f --- /dev/null +++ b/src/test/java/com/moabam/api/domain/MemberTest.java @@ -0,0 +1,70 @@ +package com.moabam.api.domain; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.global.common.util.BaseImageUrl; + +class MemberTest { + + String socialId = "1"; + String nickname = "밥세공기"; + String profileImage = "kakao/profile/url"; + + @DisplayName("회원 생성 성공") + @Test + void create_member_success() { + // When + Then + assertThatNoException().isThrownBy(() -> Member.builder() + .socialId(socialId) + .nickname(nickname) + .profileImage(profileImage) + .build()); + } + + @DisplayName("프로필 이미지 없이 회원 생성 성공") + @Test + void create_member_noImage_success() { + // When + Then + assertThatNoException().isThrownBy(() -> { + Member member = Member.builder() + .socialId(socialId) + .nickname(nickname) + .profileImage(null) + .build(); + + assertAll( + () -> assertThat(member.getProfileImage()).isEqualTo(BaseImageUrl.PROFILE_URL), + () -> assertThat(member.getRole()).isEqualTo(Role.USER), + () -> assertThat(member.getNightBug()).isZero(), + () -> assertThat(member.getGoldenBug()).isZero(), + () -> assertThat(member.getMorningBug()).isZero(), + () -> assertThat(member.getTotalCertifyCount()).isZero(), + () -> assertThat(member.getReportCount()).isZero(), + () -> assertThat(member.getCurrentMorningCount()).isZero(), + () -> assertThat(member.getCurrentNightCount()).isZero() + ); + }); + } + + @DisplayName("소셜ID에 따른 회원 생성 실패") + @Test + void creat_member_failBy_socialId() { + // When + Then + assertThatThrownBy(Member.builder() + .nickname(nickname)::build) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("닉네임에 따른 회원 생성 실패") + @Test + void create_member_failBy_nickname() { + // When + Then + assertThatThrownBy(Member.builder() + .socialId(socialId)::build) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java new file mode 100644 index 00000000..cd4da732 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -0,0 +1,66 @@ +package com.moabam.api.presentation; + +import static com.moabam.global.common.util.OAuthParameterNames.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.web.util.UriComponentsBuilder; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.AuthenticationService; +import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.config.OAuthConfig; + +@SpringBootTest( + properties = { + "oauth2.provider.authorization-uri=https://kauth.kakao.com/oauth/authorize", + "oauth2.provider.redirect-uri=http://localhost:8080/members/login/kakao/oauth", + "oauth2.client.client-id=testtesttesttesttesttesttesttesttest", + "oauth2.client.authorization-grant-type=authorization_code", + "oauth2.client.scope=profile_nickname,profile_image" + } +) +@AutoConfigureMockMvc +class MemberControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + AuthenticationService authenticationService; + + @Autowired + OAuthConfig oAuthConfig; + + @DisplayName("인가 코드 받기 위한 로그인 페이지 요청") + @Test + void authorization_code_request_success() throws Exception { + // given + String uri = UriComponentsBuilder + .fromUriString(oAuthConfig.provider().authorizationUri()) + .queryParam(RESPONSE_TYPE, "code") + .queryParam(CLIENT_ID, oAuthConfig.client().clientId()) + .queryParam(REDIRECT_URI, oAuthConfig.provider().redirectUri()) + .queryParam(SCOPE, String.join(",", oAuthConfig.client().scope())) + .toUriString(); + + // expected + ResultActions result = mockMvc.perform(get("/members")); + + result.andExpect(status().is3xxRedirection()) + .andExpect(header().string("Content-type", + MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8)) + .andExpect(redirectedUrl(uri)); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..b149d76f --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,14 @@ +oauth2: + client: + provider: test + client-id: testtestetsttest + client-secret: testtestetsttest + authorization-grant-type: authorization_code + scope: + - profile_nickname + - profile_image + + provider: + authorization_uri: https://test.com/test/test + redirect_uri: http://test:8080/test + token_uri: https://test.test.com/test/test