Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature/#2] 소셜 로그인 구현 #4

Merged
merged 18 commits into from
Aug 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,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') {
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()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

엇 혹시 애플 로그인이 KAKAO와 동일한 과정으로 유저 정보가 들어오나요?! 아마 토큰을 받아와서 복호화를 거치는 등의 복잡한 과정이 포함되있었던것 같아서 궁금해서 말씀드려요~~!!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

네 그렇네요 .. ㅜㅜ 확인해보니 복호화하는 과정이 있는 것 같습니다 이것도 다시 수정할게요!
참고 : https://hello-gg.tistory.com/m/65

.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));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저희가 IOS 분들이랑 한번 더 회의를 해야 결정이 될 것 같긴 한데, 차후에 공통 응답 스펙으로 한번 더 감싸면 좋을 것 같습니다~~!!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

공통 응답 스펙으로 한번 더 감싼다는게 정확히 어떤 방식인지 사실 이해가 안가서요 .. ! 일단 제 방식대로 코딩했는데 나중에 한번더 얘기해보면 좋을 것 같아요 ~

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Data
@AllArgsConstructor
@NoArgsConstructor
public class ApiResponse<T> {

    private int code;
    private String message;
    private T data;


    public ApiResponse(final int code, final String message) {
        this.code = code;
        this.message = message;
    }

    public static <T> ResponseEntity<ApiResponse> success(final SuccessCode successCode, final T data) {
        return ResponseEntity.status(successCode.getStatus())
                .body(new ApiResponse<>(successCode.getStatus(), successCode.getMessage(), data));
    }

    public static <T> ResponseEntity<ApiResponse> success(final SuccessCode successCode) {
        return ResponseEntity.status(successCode.getStatus())
                .body(new ApiResponse<>(successCode.getStatus(), successCode.getMessage(), null));
    }
}

이런 공통 응답 클래스 만들어서

    // OAuth2를 사용한 소셜 인증
    @PostMapping("/social")
    public ResponseEntity<ApiResponse> socialLogin(@Valid @RequestBody OAuthLoginRequest oAuthLoginRequest) {

        OAuthResponseDto oAuthResponseDto = oAuthService.login(oAuthLoginRequest.toDto());
        return ApiResponse.success(SuccessCode.INSERT_SUCCESS, oAuthResponseDto);
    }

이런 식으로 클라이언트에서 같은 양식의 응답을 받을 수 있도록 하는거에요~

}

}
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
Loading