diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1344ec1..2b97b8e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,7 @@ on: # when the workflows should be triggered ? permissions: contents: read + checks: write jobs: # defining jobs, executed in this workflows build: @@ -23,7 +24,7 @@ jobs: # defining jobs, executed in this workflows path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | ${{ runner.os }}-gradle- @@ -31,6 +32,7 @@ jobs: # defining jobs, executed in this workflows uses: actions/setup-java@v3 # set up the required java version with: java-version: '17' + distribution: 'temurin' - name: Gradle Authorization run: chmod +x gradlew @@ -43,7 +45,7 @@ jobs: # defining jobs, executed in this workflows - name: Test with Gradle run: ./gradlew --info test # Publish Unit Test Results - - nane: Publish Unit Test Results + - name: Publish Unit Test Results uses: EnricoMi/publish-unit-test-result-action@v1 if: ${{ always() }} with: diff --git a/.gitignore b/.gitignore index c2065bc..0db581c 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ out/ ### VS Code ### .vscode/ +src/main/resources/application.yml diff --git a/build.gradle b/build.gradle index 8ad06b0..c0b457e 100644 --- a/build.gradle +++ b/build.gradle @@ -16,10 +16,18 @@ repositories { } dependencies { + implementation 'io.jsonwebtoken:jjwt:0.9.1' + implementation 'javax.xml.bind:jaxb-api:2.3.1' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.projectlombok:lombok:1.18.28' implementation 'org.springframework.boot:spring-boot-starter' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + implementation 'org.mariadb.jdbc:mariadb-java-client:3.1.4' testImplementation 'org.springframework.boot:spring-boot-starter-test' + annotationProcessor 'org.projectlombok:lombok:1.18.28' + } tasks.named('test') { diff --git a/src/main/java/com/backend/BackendApplication.java b/src/main/java/com/backend/BackendApplication.java index 2bd17d8..0494104 100644 --- a/src/main/java/com/backend/BackendApplication.java +++ b/src/main/java/com/backend/BackendApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication +@EnableJpaAuditing public class BackendApplication { public static void main(String[] args) { diff --git a/src/main/java/com/backend/auth/application/OAuthService.java b/src/main/java/com/backend/auth/application/OAuthService.java new file mode 100644 index 0000000..0a745c4 --- /dev/null +++ b/src/main/java/com/backend/auth/application/OAuthService.java @@ -0,0 +1,34 @@ +package com.backend.auth.application; + +import com.backend.auth.application.client.OAuthHandler; +import com.backend.auth.application.dto.response.OAuthUserInfo; +import com.backend.auth.presentation.dto.request.LoginRequest; +import com.backend.auth.presentation.dto.response.LoginResponse; +import com.backend.global.util.JwtUtil; +import com.backend.user.application.UserService; +import com.backend.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class OAuthService { + + @Value("${jwt.secret}") + private String key; + + private Long expireTime = 1000 * 60 * 60L; + + private final UserService userService; + private final OAuthHandler oAuthHandler; + + public LoginResponse login(LoginRequest loginRequest) throws Exception { + OAuthUserInfo userInfo = oAuthHandler.getUserInfo(loginRequest.accessToken(), loginRequest.provider()); + + User uncheckedUser = User.from(userInfo, loginRequest.provider()); + User user = userService.findUserOrRegister(uncheckedUser); + + return new LoginResponse(JwtUtil.generateToken(user, key, expireTime), user.getNickname()); + } +} diff --git a/src/main/java/com/backend/auth/application/client/AppleClient.java b/src/main/java/com/backend/auth/application/client/AppleClient.java new file mode 100644 index 0000000..b83bbd0 --- /dev/null +++ b/src/main/java/com/backend/auth/application/client/AppleClient.java @@ -0,0 +1,43 @@ +package com.backend.auth.application.client; + +import com.backend.auth.application.dto.response.OAuthUserInfo; +import com.backend.user.domain.SocialType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +public class AppleClient implements OAuthClient { + + private static final String APPLE_API_URL = "https://appleid.apple.com/auth/token"; + private final WebClient appleOauthLoginClient; + + public AppleClient (final WebClient webClient){ + this.appleOauthLoginClient = appleOauthLoginClient(webClient); + } + + @Override + public boolean supports(SocialType provider) { + return provider.isSameAs(SocialType.APPLE); + } + + @Override + public OAuthUserInfo getUserInfo(String accessToken) { + return appleOauthLoginClient.get() + .uri(APPLE_API_URL) + .headers(h -> h.setBearerAuth(accessToken)) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, response -> Mono.error(new Exception("Apple Login: 잘못된 토큰 정보입니다."))) + .onStatus(HttpStatusCode::is5xxServerError, response -> Mono.error(new Exception("Apple Login: 내부 서버 오류"))) + .bodyToMono(OAuthUserInfo.class) + .block(); + } + + private WebClient appleOauthLoginClient(WebClient webClient) { + return webClient.mutate() + .baseUrl(APPLE_API_URL) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} diff --git a/src/main/java/com/backend/auth/application/client/KakaoClient.java b/src/main/java/com/backend/auth/application/client/KakaoClient.java new file mode 100644 index 0000000..24b46c2 --- /dev/null +++ b/src/main/java/com/backend/auth/application/client/KakaoClient.java @@ -0,0 +1,43 @@ +package com.backend.auth.application.client; + +import com.backend.auth.application.dto.response.OAuthUserInfo; +import com.backend.user.domain.SocialType; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +public class KakaoClient implements OAuthClient { + private static final String KAKAO_BASE_URL = "https://kapi.kakao.com"; + private static final String KAKAO_URI = "/v2/user/me"; + private final WebClient kakaoOauthLoginClient; + + public KakaoClient(WebClient webClient){ + this.kakaoOauthLoginClient = kakaoOauthLoginClient(webClient); + } + + @Override + public boolean supports(SocialType provider) { + return provider.isSameAs(SocialType.KAKAO); + } + + @Override + public OAuthUserInfo getUserInfo(String accessToken) { + return kakaoOauthLoginClient.get() + .uri(KAKAO_URI) + .headers(h -> h.setBearerAuth(accessToken)) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, response -> Mono.error(new Exception("Kakao Login: 잘못된 토큰 정보입니다."))) + .onStatus(HttpStatusCode::is5xxServerError, response -> Mono.error(new Exception("Kakao Login: 내부 서버 오류"))) + .bodyToMono(OAuthUserInfo.class) + .block(); + } + + private WebClient kakaoOauthLoginClient(WebClient webClient) { + return webClient.mutate() + .baseUrl(KAKAO_BASE_URL) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .build(); + } +} diff --git a/src/main/java/com/backend/auth/application/client/OAuthClient.java b/src/main/java/com/backend/auth/application/client/OAuthClient.java new file mode 100644 index 0000000..1cd79c8 --- /dev/null +++ b/src/main/java/com/backend/auth/application/client/OAuthClient.java @@ -0,0 +1,9 @@ +package com.backend.auth.application.client; + +import com.backend.auth.application.dto.response.OAuthUserInfo; +import com.backend.user.domain.SocialType; + +public interface OAuthClient { + boolean supports(SocialType provider); + OAuthUserInfo getUserInfo(String accessToken); +} diff --git a/src/main/java/com/backend/auth/application/client/OAuthHandler.java b/src/main/java/com/backend/auth/application/client/OAuthHandler.java new file mode 100644 index 0000000..7fab850 --- /dev/null +++ b/src/main/java/com/backend/auth/application/client/OAuthHandler.java @@ -0,0 +1,31 @@ +package com.backend.auth.application.client; + +import com.backend.auth.application.dto.response.OAuthUserInfo; +import com.backend.user.domain.SocialType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +public class OAuthHandler { + private final List oAuthClientList; + + public OAuthHandler(List oAuthClientsList){ + this.oAuthClientList = oAuthClientsList; + } + + public OAuthUserInfo getUserInfo(String accessToken, String provider) throws Exception { + OAuthClient oAuthClient = getClient(provider); + return oAuthClient.getUserInfo(accessToken); + } + + private OAuthClient getClient(String provider) throws Exception { + SocialType socialType = SocialType.valueOf(provider); + return oAuthClientList.stream() + .filter(c -> c.supports(socialType)) + .findFirst() + .orElseThrow(Exception::new); // 커스텀 예외처리 "UnsupportedProviderException" 추가 + } +} diff --git a/src/main/java/com/backend/auth/application/config/WebClientConfig.java b/src/main/java/com/backend/auth/application/config/WebClientConfig.java new file mode 100644 index 0000000..6684ecb --- /dev/null +++ b/src/main/java/com/backend/auth/application/config/WebClientConfig.java @@ -0,0 +1,26 @@ +package com.backend.auth.application.config; + +import com.backend.auth.application.client.AppleClient; +import com.backend.auth.application.client.KakaoClient; +import com.backend.auth.application.client.OAuthClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + @Bean + public WebClient webClient(){ + return WebClient.create(); + } + + @Bean + public OAuthClient kakaoClient(WebClient webClient){ + return new KakaoClient(webClient); + } + + @Bean + public OAuthClient appleClient(WebClient webClient){ + return new AppleClient(webClient); + } +} diff --git a/src/main/java/com/backend/auth/application/dto/response/OAuthUserInfo.java b/src/main/java/com/backend/auth/application/dto/response/OAuthUserInfo.java new file mode 100644 index 0000000..38034a3 --- /dev/null +++ b/src/main/java/com/backend/auth/application/dto/response/OAuthUserInfo.java @@ -0,0 +1,6 @@ +package com.backend.auth.application.dto.response; + +public record OAuthUserInfo( + String id, + String nickname +){ } diff --git a/src/main/java/com/backend/auth/presentation/OAuthController.java b/src/main/java/com/backend/auth/presentation/OAuthController.java new file mode 100644 index 0000000..213ad59 --- /dev/null +++ b/src/main/java/com/backend/auth/presentation/OAuthController.java @@ -0,0 +1,32 @@ +package com.backend.auth.presentation; + +import com.backend.auth.application.OAuthService; +import com.backend.auth.presentation.dto.request.LoginRequest; +import com.backend.auth.presentation.dto.response.LoginResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "회원 인증", description = "소셜 로그인 API입니다.") +@RequiredArgsConstructor +@RestController +public class OAuthController { + + private final OAuthService oauthService; + + @Operation(summary = "소셜 로그인", description = "소셜 로그인 후 사용자 토큰 발급") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "소셜 로그인 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청으로 인한 실패"), + @ApiResponse(responseCode = "401", description = "접근 권한 없음") + }) + @PostMapping("/auth") + public ResponseEntity generateAccessToken(@RequestBody LoginRequest loginRequest) throws Exception { + return ResponseEntity.ok(oauthService.login(loginRequest)); + } + +} diff --git a/src/main/java/com/backend/auth/presentation/dto/request/LoginRequest.java b/src/main/java/com/backend/auth/presentation/dto/request/LoginRequest.java new file mode 100644 index 0000000..3c8631a --- /dev/null +++ b/src/main/java/com/backend/auth/presentation/dto/request/LoginRequest.java @@ -0,0 +1,6 @@ +package com.backend.auth.presentation.dto.request; + +public record LoginRequest( + String accessToken, + String provider +) { } diff --git a/src/main/java/com/backend/auth/presentation/dto/response/LoginResponse.java b/src/main/java/com/backend/auth/presentation/dto/response/LoginResponse.java new file mode 100644 index 0000000..f3b39e9 --- /dev/null +++ b/src/main/java/com/backend/auth/presentation/dto/response/LoginResponse.java @@ -0,0 +1,7 @@ +package com.backend.auth.presentation.dto.response; + +public record LoginResponse ( + String accessToken, + String nickname +){} + diff --git a/src/main/java/com/backend/api/LoggingController.java b/src/main/java/com/backend/global/api/LoggingController.java similarity index 94% rename from src/main/java/com/backend/api/LoggingController.java rename to src/main/java/com/backend/global/api/LoggingController.java index 6d6191e..8ae6b43 100644 --- a/src/main/java/com/backend/api/LoggingController.java +++ b/src/main/java/com/backend/global/api/LoggingController.java @@ -1,4 +1,4 @@ -package com.backend.api; +package com.backend.global.api; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.GetMapping; diff --git a/src/main/java/com/backend/config/SwaggerConfig.java b/src/main/java/com/backend/global/config/SwaggerConfig.java similarity index 95% rename from src/main/java/com/backend/config/SwaggerConfig.java rename to src/main/java/com/backend/global/config/SwaggerConfig.java index d5cd2ee..c5a04ed 100644 --- a/src/main/java/com/backend/config/SwaggerConfig.java +++ b/src/main/java/com/backend/global/config/SwaggerConfig.java @@ -1,4 +1,4 @@ -package com.backend.config; +package com.backend.global.config; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; diff --git a/src/main/java/com/backend/global/entity/BaseEntity.java b/src/main/java/com/backend/global/entity/BaseEntity.java new file mode 100644 index 0000000..04a91dd --- /dev/null +++ b/src/main/java/com/backend/global/entity/BaseEntity.java @@ -0,0 +1,23 @@ +package com.backend.global.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import org.mariadb.jdbc.plugin.codec.LocalDateTimeCodec; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + @CreatedDate + @Column(name="created_at", nullable=false, updatable=false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name="updated_at", nullable=false) + private LocalDateTime updatedAt ; +} diff --git a/src/main/java/com/backend/global/util/JwtUtil.java b/src/main/java/com/backend/global/util/JwtUtil.java new file mode 100644 index 0000000..ec0abe3 --- /dev/null +++ b/src/main/java/com/backend/global/util/JwtUtil.java @@ -0,0 +1,24 @@ +package com.backend.global.util; + +import com.backend.user.domain.User; +import io.jsonwebtoken.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtUtil { + + public static String generateToken(User user, String key, Long expireTime) { + Claims claims = Jwts.claims(); + claims.put("userId", user.getId()); + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(new Date(System.currentTimeMillis())) + .setExpiration(new Date(System.currentTimeMillis() + expireTime)) + .signWith(SignatureAlgorithm.HS256, key) + .compact(); + } +} diff --git a/src/main/java/com/backend/user/application/UserService.java b/src/main/java/com/backend/user/application/UserService.java new file mode 100644 index 0000000..0e0154c --- /dev/null +++ b/src/main/java/com/backend/user/application/UserService.java @@ -0,0 +1,20 @@ +package com.backend.user.application; + +import com.backend.user.domain.User; +import com.backend.user.domain.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.Optional; + +@RequiredArgsConstructor +@Service +public class UserService { + + private final UserRepository userRepository; + + public User findUserOrRegister(User uncheckedUser) { + Optional user = userRepository.findBySocialId(uncheckedUser.getSocialId()); + return user.orElseGet(() -> userRepository.save(uncheckedUser)); + } +} diff --git a/src/main/java/com/backend/user/domain/SocialType.java b/src/main/java/com/backend/user/domain/SocialType.java new file mode 100644 index 0000000..30b5b9e --- /dev/null +++ b/src/main/java/com/backend/user/domain/SocialType.java @@ -0,0 +1,10 @@ +package com.backend.user.domain; + +public enum SocialType { + KAKAO, + APPLE; + + public boolean isSameAs(SocialType socialType){ + return this.equals(socialType); + } +} \ No newline at end of file diff --git a/src/main/java/com/backend/user/domain/User.java b/src/main/java/com/backend/user/domain/User.java new file mode 100644 index 0000000..86d2b3a --- /dev/null +++ b/src/main/java/com/backend/user/domain/User.java @@ -0,0 +1,68 @@ +package com.backend.user.domain; + +import com.backend.auth.application.dto.response.OAuthUserInfo; +import com.backend.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.Locale; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +@Entity +public class User extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "user_id") + private Long id; + + @Column(nullable = false, length = 15) + private String nickname; + + @Column(nullable = false) + private Boolean enabledPush; + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false, length = 15) + private SocialType socialType; + + @Column(nullable = false) + private String socialId; + + @Enumerated(value = EnumType.STRING) + @Column(nullable = false) + private UserStatus userStatus; + + @Builder + private User ( + final String nickname, + final Boolean enabledPush, + final SocialType socialType, + final String socialId, + final UserStatus userStatus + ) { + this.nickname = nickname; + this.enabledPush = enabledPush; + this.socialType = socialType; + this.socialId = socialId; + this.userStatus = userStatus; + } + + public static User from(OAuthUserInfo userInfo, String provider) { + return User.builder() + .nickname(userInfo.nickname()) + .socialType(SocialType.valueOf(provider.toUpperCase(Locale.ROOT))) + .socialId(userInfo.id()) + .userStatus(UserStatus.ACTIVE) + .build(); + } + + @PrePersist + private void setting(){ + this.enabledPush = false; + this.userStatus = UserStatus.ACTIVE; + } +} diff --git a/src/main/java/com/backend/user/domain/UserRepository.java b/src/main/java/com/backend/user/domain/UserRepository.java new file mode 100644 index 0000000..5d84bf5 --- /dev/null +++ b/src/main/java/com/backend/user/domain/UserRepository.java @@ -0,0 +1,9 @@ +package com.backend.user.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findBySocialId(String SocialId); +} diff --git a/src/main/java/com/backend/user/domain/UserStatus.java b/src/main/java/com/backend/user/domain/UserStatus.java new file mode 100644 index 0000000..f3e09e5 --- /dev/null +++ b/src/main/java/com/backend/user/domain/UserStatus.java @@ -0,0 +1,8 @@ +package com.backend.user.domain; + +public enum UserStatus { + ACTIVE, + INACTIVE, + BLOCK, + DELETE +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 2a0fb96..0000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,14 +0,0 @@ -logging-module: - version: 0.0.1 - -springdoc: - swagger-ui: - groups-order: DESC - disable-swagger-default-url: true - display-request-duration: true - operations-sorter: method - api-docs: - path: /api-docs - show-actuator: true - default-consumes-media-type: application/json;charset=UTF-8 - default-produces-media-type: application/json;charset=UTF-8 \ No newline at end of file diff --git a/src/test/java/com/backend/auth/application/OAuthServiceTest.java b/src/test/java/com/backend/auth/application/OAuthServiceTest.java new file mode 100644 index 0000000..760581e --- /dev/null +++ b/src/test/java/com/backend/auth/application/OAuthServiceTest.java @@ -0,0 +1,45 @@ +package com.backend.auth.application; + +import com.backend.auth.application.client.OAuthHandler; +import com.backend.auth.application.dto.response.OAuthUserInfo; +import com.backend.auth.presentation.dto.request.LoginRequest; +import com.backend.auth.presentation.dto.response.LoginResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.BDDMockito.given; + +@SpringBootTest +@ExtendWith(SpringExtension.class) +public class OAuthServiceTest { + + @Autowired + private OAuthService oAuthService; + + @MockBean + private OAuthHandler oAuthHandler; + + @DisplayName("Access Token을 이용해 OAuth 인증 후 JWT를 발급한다.") + @Test + public void LoginSuccess() throws Exception { + // given + String mockToken = "mock_access_token_for_kakao"; + LoginRequest loginRequest = new LoginRequest(mockToken, "KAKAO"); + + OAuthUserInfo mockUserInfo = new OAuthUserInfo("id", "nickname"); + given(oAuthHandler.getUserInfo(anyString(), anyString())).willReturn(mockUserInfo); + + // when + LoginResponse loginResponse = oAuthService.login(loginRequest); + + // then + assertThat(loginResponse.accessToken()).isNotNull(); + } +} \ No newline at end of file