Skip to content

Commit

Permalink
[Feature/#2] 소셜 로그인 구현 (#4)
Browse files Browse the repository at this point in the history
* feat: BaseEntity 작성

global/entity 폴더에 created_at, updated_at 필드를 넣는 BaseEntity 클래스를 작성하였습니다.

* feat: User Entity 생성 (#2)

DB에 매핑될 User 객체를 생성하였습니다. Kakao, Apple 두 개의 소셜 타입을 구분할 SocialType 필드를 enum Type으로 추가하였습니다.

* chore: Swagger, Logging 클래스 global 폴더로 이동

* feat: OAuthController 생성 및 콜백 메서드 생성

1. 클라이언트가 AccessToken을 전달할 때 접근하는 'POST /auth' 콜백 주소를 연결할 메서드를 생성하였습니다.
2. 컨트롤러 메서드의 request, response dto를 record 클래스로 정의하였습니다.

* feat: login 로직을 구현한 OAuthService 생성

1. 팩토리 메서드 패턴을 적용하여 OAuthClient 인터페이스를 provider에 따라 구현체를 매핑하도록 설정하였습니다.
2. OAuthClient에서 소셜 사용자의 정보를 불러오고 User 객체로 변환하였습니다.
3. User가 기 가입한 회원이라면 바로 반환, 그렇지 않다면 DB에 등록 후 반환하기 위해 UserService에서 로직을 구현하였습니다.
4. 마지막으로 회원에 대한 JWT 를 발급하여 반환하였습니다.

* build: syntax error 수정

* build: syntax error 수정

* build: Junit Test 관련 권한 추가

* feat: WebClient를 이용해 카카오 인증서버에서 사용자 추출 (#2)

KakaoClient 구현체에서 WebClient 라이브러리를 사용해 kapi.kakao.com 서버에 접근하여 사용자 정보 (KakaoUserInfo)를 추출하는 코드입니다.

* feat: JwtProvider 클래스 생성

1. createToken 메서드
2. validateToken 메서드

* chore: .gitignore 수정

* feat: 애플 로그인 구현

* refactor: OAuthHandler에서 Provider별 클래스 분리 구현

* test: OAuthServiceTest 로그인 성공 테스트 생성(#2)

* feat: KakaoClient BASE_URL과 URI 분리

* style: dto 폴더구조 request, response 나눠서 변경

* refactor: SocialId만 이용해서 사용자 조회하도록 변경

* refactor: BaseEntity에 자동으로 날짜를 주입하도록 설정

---------

Co-authored-by: MingyeomKim <[email protected]>
  • Loading branch information
Mingyum-Kim and Mingyum-Kim authored Aug 12, 2023
1 parent ef5159a commit f1d9af7
Show file tree
Hide file tree
Showing 25 changed files with 461 additions and 18 deletions.
6 changes: 4 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on: # when the workflows should be triggered ?

permissions:
contents: read
checks: write

jobs: # defining jobs, executed in this workflows
build:
Expand All @@ -23,14 +24,15 @@ 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-
- name: Set up JDK 17
uses: actions/setup-java@v3 # set up the required java version
with:
java-version: '17'
distribution: 'temurin'

- name: Gradle Authorization
run: chmod +x gradlew
Expand All @@ -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:
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ out/

### VS Code ###
.vscode/
src/main/resources/application.yml
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@ 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-data-jpa:2.2.1.RELEASE'
implementation 'org.projectlombok:lombok'
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') {
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/com/backend/BackendApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
34 changes: 34 additions & 0 deletions src/main/java/com/backend/auth/application/OAuthService.java
Original file line number Diff line number Diff line change
@@ -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());
}
}
43 changes: 43 additions & 0 deletions src/main/java/com/backend/auth/application/client/AppleClient.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
43 changes: 43 additions & 0 deletions src/main/java/com/backend/auth/application/client/KakaoClient.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<OAuthClient> oAuthClientList;

public OAuthHandler(List<OAuthClient> 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" 추가
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.backend.auth.application.dto.response;

public record OAuthUserInfo(
String id,
String nickname
){ }
32 changes: 32 additions & 0 deletions src/main/java/com/backend/auth/presentation/OAuthController.java
Original file line number Diff line number Diff line change
@@ -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<LoginResponse> generateAccessToken(@RequestBody LoginRequest loginRequest) throws Exception {
return ResponseEntity.ok(oauthService.login(loginRequest));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.backend.auth.presentation.dto.request;

public record LoginRequest(
String accessToken,
String provider
) { }
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.backend.auth.presentation.dto.response;

public record LoginResponse (
String accessToken,
String nickname
){}

Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/com/backend/global/entity/BaseEntity.java
Original file line number Diff line number Diff line change
@@ -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 ;
}
24 changes: 24 additions & 0 deletions src/main/java/com/backend/global/util/JwtUtil.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
20 changes: 20 additions & 0 deletions src/main/java/com/backend/user/application/UserService.java
Original file line number Diff line number Diff line change
@@ -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> user = userRepository.findBySocialId(uncheckedUser.getSocialId());
return user.orElseGet(() -> userRepository.save(uncheckedUser));
}
}
Loading

0 comments on commit f1d9af7

Please sign in to comment.