-
Notifications
You must be signed in to change notification settings - Fork 2
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
Changes from all commits
fde7784
06c234b
73d3e7f
96cf857
fe524c7
a5b2c0d
b269a29
f2e9c2b
e2cb482
3856b6f
2ed31b0
d03c52a
b7aedf4
1942876
65b86e6
1c04716
c7b187b
31fa951
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -35,3 +35,4 @@ out/ | |
|
||
### VS Code ### | ||
.vscode/ | ||
src/main/resources/application.yml |
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()); | ||
} | ||
} |
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(); | ||
} | ||
} |
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 | ||
){ } |
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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 저희가 IOS 분들이랑 한번 더 회의를 해야 결정이 될 것 같긴 한데, 차후에 공통 응답 스펙으로 한번 더 감싸면 좋을 것 같습니다~~!! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 공통 응답 스펙으로 한번 더 감싼다는게 정확히 어떤 방식인지 사실 이해가 안가서요 .. ! 일단 제 방식대로 코딩했는데 나중에 한번더 얘기해보면 좋을 것 같아요 ~ There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
이런 공통 응답 클래스 만들어서
이런 식으로 클라이언트에서 같은 양식의 응답을 받을 수 있도록 하는거에요~ |
||
} | ||
|
||
} |
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 |
---|---|---|
@@ -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 ; | ||
} |
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(); | ||
} | ||
} |
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)); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
엇 혹시 애플 로그인이 KAKAO와 동일한 과정으로 유저 정보가 들어오나요?! 아마 토큰을 받아와서 복호화를 거치는 등의 복잡한 과정이 포함되있었던것 같아서 궁금해서 말씀드려요~~!!
There was a problem hiding this comment.
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