Skip to content

Commit 0f4f4f9

Browse files
Merge pull request #101 from prgrms-web-devcourse-final-project/feat/#88
[Auth] 카카오 소셜 로그인 구현
2 parents 26bfec5 + 18b72e7 commit 0f4f4f9

15 files changed

Lines changed: 281 additions & 7 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ jobs:
3434
TMAP_API_KEY: ${{ secrets.TMAP_API_KEY }}
3535
KAKAOMAP_API_KEY: ${{ secrets.KAKAOMAP_API_KEY }}
3636

37-
37+
KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }}
38+
KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }}
39+
KAKAO_REDIRECT_URI: ${{ secrets.KAKAO_REDIRECT_URI }}
3840

3941
steps:
4042
- name: Checkout

src/main/java/com/back/web7_9_codecrete_be/domain/auth/controller/AuthController.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,18 @@ public RsData<?> refresh() {
9090
String newAccessToken = tokenService.reissueAccessToken();
9191
return RsData.success("토큰 재발급 완료", newAccessToken);
9292
}
93+
94+
@Operation(summary = "카카오 소셜 로그인", description = "카카오 OAuth 인가 코드를 이용해 로그인/회원가입을 진행합니다.")
95+
@GetMapping("/login/kakao")
96+
public RsData<?> kakaoLogin(@RequestParam String code) {
97+
LoginResponse response = authService.kakaoLogin(code);
98+
return RsData.success("카카오 로그인 성공", response);
99+
}
100+
101+
@Operation(summary = "구글 소셜 로그인", description = "구글 OAuth 인가 코드를 이용해 로그인/회원가입을 진행합니다.")
102+
@GetMapping("/login/google")
103+
public RsData<?> googleLogin(@RequestParam String code) {
104+
LoginResponse response = authService.googleLogin(code);
105+
return RsData.success("구글 로그인 성공", response);
106+
}
93107
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.back.web7_9_codecrete_be.domain.auth.dto.kakao;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import lombok.Getter;
5+
6+
@Getter
7+
public class KakaoTokenResponse {
8+
9+
@JsonProperty("access_token")
10+
private String accessToken;
11+
12+
@JsonProperty("expires_in")
13+
private int expiresIn;
14+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package com.back.web7_9_codecrete_be.domain.auth.dto.kakao;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Getter;
5+
6+
@Getter
7+
@AllArgsConstructor
8+
public class KakaoUserInfo {
9+
private String socialId;
10+
private String email;
11+
private String nickname;
12+
private String profileImageUrl;
13+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.back.web7_9_codecrete_be.domain.auth.dto.kakao;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import lombok.Getter;
5+
6+
@Getter
7+
public class KakaoUserResponse {
8+
9+
private Long id;
10+
11+
@JsonProperty("kakao_account")
12+
private KakaoAccount kakaoAccount;
13+
14+
public KakaoUserInfo toUserInfo() {
15+
return new KakaoUserInfo(
16+
String.valueOf(id),
17+
kakaoAccount.getEmail(),
18+
kakaoAccount.getProfile().getNickname(),
19+
kakaoAccount.getProfile().getProfileImageUrl()
20+
);
21+
}
22+
23+
@Getter
24+
public static class KakaoAccount {
25+
private String email;
26+
private Profile profile;
27+
}
28+
29+
@Getter
30+
public static class Profile {
31+
private String nickname;
32+
33+
@JsonProperty("profile_image_url")
34+
private String profileImageUrl;
35+
}
36+
}

src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package com.back.web7_9_codecrete_be.domain.auth.service;
22

3+
import com.back.web7_9_codecrete_be.domain.auth.dto.kakao.KakaoUserInfo;
34
import com.back.web7_9_codecrete_be.domain.auth.dto.request.LoginRequest;
45
import com.back.web7_9_codecrete_be.domain.auth.dto.request.SignupRequest;
56
import com.back.web7_9_codecrete_be.domain.auth.dto.response.LoginResponse;
67
import com.back.web7_9_codecrete_be.domain.email.service.EmailService;
8+
import com.back.web7_9_codecrete_be.domain.users.entity.SocialType;
79
import com.back.web7_9_codecrete_be.domain.users.entity.User;
810
import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository;
911
import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode;
@@ -25,6 +27,7 @@ public class AuthService {
2527
private final PasswordEncoder passwordEncoder;
2628
private final EmailService emailService;
2729
private final TokenService tokenService;
30+
private final KakaoOAuthService kakaoOAuthService;
2831

2932
// 회원가입
3033
public void signUp(SignupRequest req) {
@@ -50,6 +53,8 @@ public void signUp(SignupRequest req) {
5053
.password(passwordEncoder.encode(req.getPassword()))
5154
.birth(LocalDate.parse(req.getBirth()))
5255
.profileImage(req.getProfileImage())
56+
.socialType(SocialType.LOCAL)
57+
.socialId(null)
5358
.build();
5459

5560
userRepository.save(user);
@@ -66,6 +71,10 @@ public LoginResponse login(LoginRequest req) {
6671
throw new BusinessException(UserErrorCode.USER_DELETED);
6772
}
6873

74+
if (user.getSocialType() != SocialType.LOCAL) {
75+
throw new BusinessException(AuthErrorCode.SOCIAL_USER_CANNOT_LOGIN);
76+
}
77+
6978
if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) {
7079
throw new BusinessException(AuthErrorCode.INVALID_PASSWORD);
7180
}
@@ -119,4 +128,61 @@ private String generateTempPassword() {
119128
}
120129
return sb.toString();
121130
}
131+
132+
@Transactional
133+
public LoginResponse kakaoLogin(String code) {
134+
135+
// 1. 인가 코드 → 카카오 Access Token
136+
String kakaoAccessToken = kakaoOAuthService.getAccessToken(code);
137+
138+
// 2. Access Token → 사용자 정보
139+
KakaoUserInfo kakaoUserInfo = kakaoOAuthService.getUserInfo(kakaoAccessToken);
140+
141+
if (kakaoUserInfo.getEmail() == null) {
142+
throw new BusinessException(AuthErrorCode.SOCIAL_EMAIL_NOT_PROVIDED);
143+
}
144+
145+
// 3. 소셜 ID 기준 사용자 조회
146+
User user = userRepository
147+
.findBySocialTypeAndSocialId(
148+
SocialType.KAKAO,
149+
kakaoUserInfo.getSocialId()
150+
)
151+
.orElseGet(() -> registerKakaoUser(kakaoUserInfo));
152+
153+
// 4. 탈퇴 사용자 체크
154+
if (user.getIsDeleted()) {
155+
throw new BusinessException(UserErrorCode.USER_DELETED);
156+
}
157+
158+
// 5. 토큰 발급
159+
tokenService.issueTokens(user);
160+
161+
return new LoginResponse(user.getId(), user.getNickname());
162+
}
163+
164+
private User registerKakaoUser(KakaoUserInfo info) {
165+
166+
String nickname = info.getNickname();
167+
if (userRepository.existsByNickname(nickname)) {
168+
nickname = nickname + "_" + System.currentTimeMillis();
169+
}
170+
171+
User user = User.builder()
172+
.email(info.getEmail())
173+
.nickname(nickname)
174+
.password(null)
175+
.birth(null)
176+
.profileImage(info.getProfileImageUrl())
177+
.socialType(SocialType.KAKAO)
178+
.socialId(info.getSocialId())
179+
.build();
180+
181+
return userRepository.save(user);
182+
}
183+
184+
public LoginResponse googleLogin(String code) {
185+
// TODO: 구글 인가 코드 → 사용자 정보 → 로그인 처리
186+
throw new UnsupportedOperationException("구글 로그인 미구현");
187+
}
122188
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.back.web7_9_codecrete_be.domain.auth.service;
2+
3+
import com.back.web7_9_codecrete_be.domain.auth.dto.kakao.KakaoTokenResponse;
4+
import com.back.web7_9_codecrete_be.domain.auth.dto.kakao.KakaoUserInfo;
5+
import com.back.web7_9_codecrete_be.domain.auth.dto.kakao.KakaoUserResponse;
6+
import lombok.RequiredArgsConstructor;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.http.*;
9+
import org.springframework.stereotype.Service;
10+
import org.springframework.util.LinkedMultiValueMap;
11+
import org.springframework.util.MultiValueMap;
12+
import org.springframework.web.client.RestTemplate;
13+
14+
@Service
15+
@RequiredArgsConstructor
16+
public class KakaoOAuthService {
17+
18+
@Value("${oauth.kakao.client-id}")
19+
private String clientId;
20+
21+
@Value("${oauth.kakao.redirect-uri}")
22+
private String redirectUri;
23+
24+
@Value("${oauth.kakao.client-secret}")
25+
private String clientSecret;
26+
27+
private static final String TOKEN_URL = "https://kauth.kakao.com/oauth/token";
28+
private static final String USER_INFO_URL = "https://kapi.kakao.com/v2/user/me";
29+
30+
private final RestTemplate restTemplate = new RestTemplate();
31+
32+
// 인가 코드 → 액세스 토큰
33+
public String getAccessToken(String code) {
34+
HttpHeaders headers = new HttpHeaders();
35+
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
36+
37+
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
38+
params.add("grant_type", "authorization_code");
39+
params.add("client_id", clientId);
40+
params.add("client_secret", clientSecret);
41+
params.add("redirect_uri", redirectUri);
42+
params.add("code", code);
43+
44+
HttpEntity<MultiValueMap<String, String>> request =
45+
new HttpEntity<>(params, headers);
46+
47+
ResponseEntity<KakaoTokenResponse> response =
48+
restTemplate.postForEntity(
49+
TOKEN_URL,
50+
request,
51+
KakaoTokenResponse.class
52+
);
53+
54+
return response.getBody().getAccessToken();
55+
}
56+
57+
// 인가 코드 → 카카오 사용자 정보
58+
public KakaoUserInfo getUserInfo(String accessToken) {
59+
HttpHeaders headers = new HttpHeaders();
60+
headers.setBearerAuth(accessToken);
61+
62+
HttpEntity<Void> request = new HttpEntity<>(headers);
63+
64+
ResponseEntity<KakaoUserResponse> response =
65+
restTemplate.exchange(
66+
USER_INFO_URL,
67+
HttpMethod.GET,
68+
request,
69+
KakaoUserResponse.class
70+
);
71+
72+
return response.getBody().toUserInfo();
73+
}
74+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.back.web7_9_codecrete_be.domain.users.entity;
2+
3+
public enum SocialType {
4+
LOCAL, // 일반 회원
5+
KAKAO, // 카카오
6+
GOOGLE // 구글
7+
}

src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@ public class User {
2828
@Column(nullable = false, unique = true, length = 20)
2929
private String nickname;
3030

31-
@Column(nullable = false, length = 100)
31+
// 소셜 로그인 사용자는 password가 없을 수 있으므로 nullable = true
32+
@Column(length = 100)
3233
private String password;
3334

34-
@Column(nullable = false)
35+
// 소셜 로그인 시 생년월일을 불러오지 못하므로 nullable = true
36+
@Column(nullable = true)
3537
private LocalDate birth;
3638

3739
@Column(name = "profile_image", length = 255)
@@ -59,18 +61,30 @@ public class User {
5961
@Column(name = "is_deleted", nullable = false)
6062
private Boolean isDeleted;
6163

64+
// 소셜 로그인용 컬럼 추가
65+
@Enumerated(EnumType.STRING)
66+
@Column(name = "social_type", nullable = false, length = 20)
67+
private SocialType socialType;
68+
69+
@Column(name = "social_id", length = 100)
70+
private String socialId;
71+
6272
@Builder
6373
public User(String email,
6474
String nickname,
6575
String password,
6676
LocalDate birth,
67-
String profileImage) {
77+
String profileImage,
78+
SocialType socialType,
79+
String socialId) {
6880

6981
this.email = email;
7082
this.nickname = nickname;
7183
this.password = password;
7284
this.birth = birth;
7385
this.profileImage = profileImage;
86+
this.socialType = socialType;
87+
this.socialId = socialId;
7488

7589
// 기본값 세팅
7690
this.role = Role.USER;

src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRepository.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.back.web7_9_codecrete_be.domain.users.repository;
22

3+
import com.back.web7_9_codecrete_be.domain.users.entity.SocialType;
34
import com.back.web7_9_codecrete_be.domain.users.entity.User;
45
import com.back.web7_9_codecrete_be.domain.users.entity.UserStatus;
56
import org.springframework.data.jpa.repository.JpaRepository;
@@ -19,4 +20,18 @@ List<User> findByIsDeletedTrueAndStatusAndDeletedDateBefore(
1920
LocalDateTime time
2021
);
2122
Optional<User> findByEmailAndIsDeletedTrue(String email);
23+
24+
// 소셜 로그인 관련 추가 메서드
25+
26+
// 소셜 로그인용: 소셜 타입 + 소셜 ID 조회
27+
Optional<User> findBySocialTypeAndSocialId(
28+
SocialType socialType,
29+
String socialId
30+
);
31+
32+
// 소셜 회원가입용: 이메일 + 소셜 타입 조회
33+
Optional<User> findByEmailAndSocialType(
34+
String email,
35+
SocialType socialType
36+
);
2237
}

0 commit comments

Comments
 (0)