Skip to content

Commit afba129

Browse files
authored
Merge pull request #151 from SWU-Elixir/feat/136-social-login
feat: 구글/카카오/네이버 소셜 로그인 구현
2 parents db1e21e + 019b498 commit afba129

24 files changed

+701
-44
lines changed

src/main/java/BE_Elixir/Elixir/domain/auth/controller/AuthController.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22

33
import BE_Elixir.Elixir.domain.auth.controller.api.AuthApi;
44
import BE_Elixir.Elixir.domain.auth.dto.AccessTokenDTO;
5+
import BE_Elixir.Elixir.domain.auth.dto.request.SocialLoginRequestDTO;
6+
import BE_Elixir.Elixir.domain.auth.dto.response.SocialLoginResponseDTO;
57
import BE_Elixir.Elixir.domain.auth.service.AuthService;
68
import BE_Elixir.Elixir.domain.auth.dto.response.TokenResponseDTO;
79
import BE_Elixir.Elixir.domain.auth.dto.request.LoginRequestDTO;
810
import BE_Elixir.Elixir.domain.member.entity.MemberDetails;
11+
import BE_Elixir.Elixir.global.enums.LoginType;
912
import BE_Elixir.Elixir.global.redis.RedisAuthService;
1013
import BE_Elixir.Elixir.global.response.CommonResponse;
1114
import BE_Elixir.Elixir.global.security.JwtProvider;
@@ -29,7 +32,9 @@ public class AuthController implements AuthApi {
2932

3033
// 로그인
3134
@PostMapping("/login")
32-
public ResponseEntity<CommonResponse<TokenResponseDTO>> login(@RequestBody LoginRequestDTO request) {
35+
public ResponseEntity<CommonResponse<TokenResponseDTO>> login(
36+
@RequestBody LoginRequestDTO request
37+
) {
3338
log.info("로그인 요청 - email: {}", request.getEmail());
3439

3540
TokenResponseDTO token = authService.signIn(request);
@@ -71,4 +76,19 @@ public ResponseEntity<CommonResponse<AccessTokenDTO>> refresh (
7176
return ResponseEntity.ok(CommonResponse.success(HttpStatus.OK.value(), HttpStatus.OK.toString(),
7277
"Access Token 재발급 성공", token));
7378
}
79+
80+
// sns 소셜 로그인
81+
@PostMapping(value="/oauth/{loginType}")
82+
public ResponseEntity<CommonResponse<SocialLoginResponseDTO>> socialLogin(
83+
@PathVariable(name="loginType") LoginType loginType,
84+
@RequestBody SocialLoginRequestDTO request
85+
) {
86+
log.info("소셜 로그인 요청 - type: {}", loginType);
87+
SocialLoginResponseDTO response = authService.handleSocialLogin(loginType, request.getAccessToken());
88+
89+
log.info("소셜 로그인 성공");
90+
return ResponseEntity.ok(CommonResponse.success(HttpStatus.OK.value(), HttpStatus.OK.toString(),
91+
"소셜 로그인 성공", response));
92+
}
93+
7494
}

src/main/java/BE_Elixir/Elixir/domain/auth/controller/api/AuthApi.java

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package BE_Elixir.Elixir.domain.auth.controller.api;
22

33
import BE_Elixir.Elixir.domain.auth.dto.AccessTokenDTO;
4+
import BE_Elixir.Elixir.domain.auth.dto.request.SocialLoginRequestDTO;
5+
import BE_Elixir.Elixir.domain.auth.dto.response.SocialLoginResponseDTO;
46
import BE_Elixir.Elixir.domain.auth.dto.response.TokenResponseDTO;
57
import BE_Elixir.Elixir.domain.auth.dto.request.LoginRequestDTO;
68
import BE_Elixir.Elixir.domain.member.entity.MemberDetails;
9+
import BE_Elixir.Elixir.global.enums.LoginType;
710
import BE_Elixir.Elixir.global.response.CommonResponse;
811
import io.swagger.v3.oas.annotations.Operation;
912
import io.swagger.v3.oas.annotations.media.Content;
@@ -16,29 +19,30 @@
1619
import jakarta.servlet.http.HttpServletRequest;
1720
import org.springframework.http.ResponseEntity;
1821
import org.springframework.security.core.annotation.AuthenticationPrincipal;
22+
import org.springframework.web.bind.annotation.PathVariable;
1923
import org.springframework.web.bind.annotation.RequestBody;
2024

2125

2226
@Tag(name = "Auth API", description = "회원 인증 관련 API")
2327
public interface AuthApi {
2428

25-
@Operation(summary = "로그인", description = "이메일과 비밀번호로 로그인합니다..")
29+
@Operation(summary = "일반 로그인", description = "이메일과 비밀번호로 로그인합니다.")
2630
@ApiResponses({
27-
@ApiResponse(responseCode = "200", description = "로그인 성공",
31+
@ApiResponse(responseCode = "200", description = "일반 로그인 성공",
2832
content = @Content(schema = @Schema(implementation = CommonResponse.class),
2933
examples = @ExampleObject(value = """
3034
{
3135
"status": 200,
3236
"code": "200 OK",
33-
"message": "로그인 성공",
37+
"message": "일반 로그인 성공",
3438
"data": {
3539
"grantType": "bearer",
3640
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
3741
"refreshToken": "dGhpc2lzYXJlZnJlc2h0b2tlbg=="
3842
}
3943
}
4044
"""))),
41-
@ApiResponse(responseCode = "401", description = "로그인 실패",
45+
@ApiResponse(responseCode = "401", description = "일반 로그인 실패",
4246
content = @Content(schema = @Schema(implementation = CommonResponse.class),
4347
examples = @ExampleObject(value = """
4448
{
@@ -112,4 +116,36 @@ ResponseEntity<CommonResponse<AccessTokenDTO>> refresh (
112116
@AuthenticationPrincipal MemberDetails memberDetails,
113117
HttpServletRequest request
114118
);
119+
120+
@Operation(summary = "소셜 로그인", description = "구글, 네이버, 카카오를 이용해 소셜 로그인을 진행합니다.")
121+
@ApiResponses({
122+
@ApiResponse(responseCode = "200", description = "소셜 로그인 성공",
123+
content = @Content(schema = @Schema(implementation = CommonResponse.class),
124+
examples = @ExampleObject(value = """
125+
{
126+
"status": 200,
127+
"code": "200 OK",
128+
"message": "소셜 로그인 성공",
129+
"data": {
130+
"grantType": "bearer",
131+
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
132+
"refreshToken": "dGhpc2lzYXJlZnJlc2h0b2tlbg=="
133+
}
134+
}
135+
"""))),
136+
@ApiResponse(responseCode = "401", description = "소셜 로그인 실패",
137+
content = @Content(schema = @Schema(implementation = CommonResponse.class),
138+
examples = @ExampleObject(value = """
139+
{
140+
"status": 409,
141+
"code": "409 CONFLICT",
142+
"message": "이미 가입된 이메일입니다. 일반 로그인을 사용해주세요.",
143+
"data": null
144+
}
145+
""")))
146+
})
147+
ResponseEntity<CommonResponse<SocialLoginResponseDTO>> socialLogin(
148+
@PathVariable(name="loginType") LoginType loginType,
149+
@RequestBody SocialLoginRequestDTO request
150+
);
115151
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package BE_Elixir.Elixir.domain.auth.dto.request;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Data;
6+
7+
@Builder
8+
@Data
9+
@AllArgsConstructor
10+
public class SocialLoginRequestDTO {
11+
private String accessToken;
12+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package BE_Elixir.Elixir.domain.auth.dto.response;
2+
3+
import BE_Elixir.Elixir.domain.auth.dto.social.SocialUserInfo;
4+
import BE_Elixir.Elixir.global.enums.LoginType;
5+
import lombok.AllArgsConstructor;
6+
import lombok.Builder;
7+
import lombok.Data;
8+
9+
@Builder
10+
@Data
11+
@AllArgsConstructor
12+
public class SocialLoginResponseDTO {
13+
private boolean isRegistered;
14+
15+
private String accessToken;
16+
private String refreshToken;
17+
18+
private LoginType loginType;
19+
private SocialUserInfo socialUserInfo;
20+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package BE_Elixir.Elixir.domain.auth.dto.social;
2+
3+
import lombok.Getter;
4+
import lombok.NoArgsConstructor;
5+
6+
@Getter
7+
@NoArgsConstructor
8+
public class GoogleUserInfoResponse {
9+
private String id;
10+
private String email;
11+
private String verified_email;
12+
private String name;
13+
private String given_name;
14+
private String family_name;
15+
private String picture;
16+
private String locale;
17+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package BE_Elixir.Elixir.domain.auth.dto.social;
2+
3+
import lombok.Getter;
4+
import lombok.NoArgsConstructor;
5+
6+
@Getter
7+
@NoArgsConstructor
8+
public class KakaoUserInfoResponse {
9+
private Long id;
10+
private KakaoAccount kakao_account;
11+
12+
@Getter
13+
@NoArgsConstructor
14+
public static class KakaoAccount {
15+
private String email;
16+
private Profile profile;
17+
18+
@Getter
19+
@NoArgsConstructor
20+
public static class Profile {
21+
private String nickname;
22+
private String profile_image_url;
23+
}
24+
}
25+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package BE_Elixir.Elixir.domain.auth.dto.social;
2+
3+
import lombok.Getter;
4+
import lombok.NoArgsConstructor;
5+
6+
@Getter
7+
@NoArgsConstructor
8+
public class NaverUserInfoResponse {
9+
private NaverResponse response;
10+
11+
@Getter
12+
@NoArgsConstructor
13+
public static class NaverResponse {
14+
private String id;
15+
private String email;
16+
private String name;
17+
private String nickname;
18+
private String profile_image;
19+
private String gender;
20+
private String birthyear;
21+
}
22+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package BE_Elixir.Elixir.domain.auth.dto.social;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Data;
6+
7+
@Builder
8+
@Data
9+
@AllArgsConstructor
10+
public class SocialUserInfo {
11+
12+
private String email;
13+
private String nickname;
14+
private String gender;
15+
private Integer birthYear;
16+
17+
private String profileImage; // ??
18+
19+
}

src/main/java/BE_Elixir/Elixir/domain/auth/service/AuthService.java

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import BE_Elixir.Elixir.domain.achievement.service.MemberStatsService;
44
import BE_Elixir.Elixir.domain.auth.dto.AccessTokenDTO;
5+
import BE_Elixir.Elixir.domain.auth.dto.social.SocialUserInfo;
6+
import BE_Elixir.Elixir.domain.auth.dto.response.SocialLoginResponseDTO;
57
import BE_Elixir.Elixir.domain.auth.dto.response.TokenResponseDTO;
68
import BE_Elixir.Elixir.domain.auth.dto.request.LoginRequestDTO;
79
import BE_Elixir.Elixir.domain.challenge.event.events.LoginSuccessEvent;
@@ -10,6 +12,7 @@
1012
import BE_Elixir.Elixir.domain.member.repository.MemberRepository;
1113
import BE_Elixir.Elixir.domain.member.service.MemberDetailsService;
1214
import BE_Elixir.Elixir.global.enums.AchievementType;
15+
import BE_Elixir.Elixir.global.enums.LoginType;
1316
import BE_Elixir.Elixir.global.exception.CustomException;
1417
import BE_Elixir.Elixir.global.exception.ErrorCode;
1518
import BE_Elixir.Elixir.global.redis.RedisAuthService;
@@ -24,6 +27,8 @@
2427
import org.springframework.security.core.Authentication;
2528
import org.springframework.stereotype.Service;
2629

30+
import java.util.Optional;
31+
2732

2833
@Service
2934
@RequiredArgsConstructor
@@ -35,13 +40,21 @@ public class AuthService {
3540
private final JwtProvider jwtProvider;
3641
private final RedisAuthService redisAuthService;
3742
private final MemberDetailsService memberDetailsService;
43+
private final OauthClientFactory oauthClientFactory;
44+
private final MemberRepository memberRepository;
3845
private final ApplicationEventPublisher eventPublisher;
3946
private final MemberStatsService memberStatsService;
40-
private final MemberRepository memberRepository;
4147

4248
// 로그인 (jwt 발급 및 Redis 저장)
4349
public TokenResponseDTO signIn(LoginRequestDTO request) {
4450
try {
51+
// 일반 회원 검증
52+
LoginType loginType = memberRepository.findLoginTypeByEmail(request.getEmail())
53+
.orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND));
54+
if (loginType != LoginType.LOCAL) {
55+
throw new CustomException(ErrorCode.EMAIL_REGISTERED_WITH_SOCIAL);
56+
}
57+
4558
// email + password 기반 authentication 객체 생성
4659
UsernamePasswordAuthenticationToken authenticationToken =
4760
new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword());
@@ -109,4 +122,55 @@ public AccessTokenDTO refreshAccessToken(String email, String refreshToken) {
109122

110123
return jwtProvider.generateAccessToken(authentication);
111124
}
125+
126+
// 소셜 로그인
127+
public SocialLoginResponseDTO handleSocialLogin(LoginType loginType, String accessToken) {
128+
OauthClient client = oauthClientFactory.getClient(loginType);
129+
SocialUserInfo userInfo = client.getUserInfo(accessToken);
130+
131+
Optional<Member> existing = memberRepository.findByEmail(userInfo.getEmail());
132+
133+
if (existing.isPresent()) {
134+
// 이미 회원가입된 경우
135+
Member member = existing.get();
136+
137+
// 로그인 타입 확인
138+
// 이미 일반 로그인 계정이 존재하는 경우
139+
if (member.getLoginType() == LoginType.LOCAL) {
140+
throw new CustomException(ErrorCode.EMAIL_REGISTERED_WITH_LOCAL);
141+
}
142+
// 가입된 타입과 로그인한 타입이 동일하지 않은 경우
143+
if (member.getLoginType() != client.getType()) {
144+
throw new CustomException(ErrorCode.EMAIL_REGISTERED_WITH_ANOTHER_SOCIAL);
145+
}
146+
147+
// Spring Security Authentication 객체 생성
148+
MemberDetails memberDetails = memberDetailsService.loadUserByUsername(member.getEmail());
149+
Authentication authentication = new UsernamePasswordAuthenticationToken(
150+
memberDetails, "", memberDetails.getAuthorities()
151+
);
152+
153+
// JWT 생성
154+
TokenResponseDTO tokenResponse = jwtProvider.generateToken(authentication);
155+
String refreshToken = tokenResponse.getRefreshToken();
156+
157+
// Redis에 Refresh Token 저장
158+
redisAuthService.saveRefreshToken(member.getEmail(), refreshToken);
159+
log.info("[소셜 로그인] Refresh Token Redis에 저장: email={}, token={}", member.getEmail(), refreshToken);
160+
161+
return SocialLoginResponseDTO.builder()
162+
.isRegistered(true)
163+
.accessToken(tokenResponse.getAccessToken())
164+
.refreshToken(refreshToken)
165+
.build();
166+
167+
} else {
168+
return SocialLoginResponseDTO.builder()
169+
.isRegistered(false)
170+
.loginType(loginType)
171+
.socialUserInfo(userInfo)
172+
.build();
173+
}
174+
}
175+
112176
}

0 commit comments

Comments
 (0)