Skip to content

Commit 5b98aca

Browse files
authored
Merge pull request #51 from prgrms-web-devcourse-final-project/feat/kakaoLogin/main
카카오 로그인 기능
2 parents a95bfe2 + a98eb89 commit 5b98aca

File tree

12 files changed

+287
-11
lines changed

12 files changed

+287
-11
lines changed

src/main/java/org/dfbf/soundlink/SoundLinkJavaApplication.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package org.dfbf.soundlink;
22

3+
import org.dfbf.soundlink.global.config.FeignConfig;
34
import org.springframework.boot.SpringApplication;
45
import org.springframework.boot.autoconfigure.SpringBootApplication;
56
import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration;
67
import org.springframework.cache.annotation.EnableCaching;
8+
import org.springframework.cloud.openfeign.EnableFeignClients;
79

10+
@EnableFeignClients(basePackages = "org.dfbf.soundlink.global.auth.client", defaultConfiguration = FeignConfig.class)
811
@SpringBootApplication(exclude= SecurityAutoConfiguration.class)
912
@EnableCaching
1013
public class SoundLinkJavaApplication {

src/main/java/org/dfbf/soundlink/domain/user/controller/AuthController.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,21 @@
44
import io.swagger.v3.oas.annotations.tags.Tag;
55
import jakarta.servlet.http.HttpServletRequest;
66
import jakarta.servlet.http.HttpServletResponse;
7-
import lombok.AllArgsConstructor;
7+
import lombok.RequiredArgsConstructor;
88
import org.dfbf.soundlink.domain.user.dto.request.LoginReqDto;
9+
import org.dfbf.soundlink.domain.user.service.KakaoAuthService;
910
import org.dfbf.soundlink.domain.user.service.UserService;
1011
import org.dfbf.soundlink.global.exception.ResponseResult;
11-
import org.springframework.web.bind.annotation.PostMapping;
12-
import org.springframework.web.bind.annotation.RequestBody;
13-
import org.springframework.web.bind.annotation.RequestMapping;
14-
import org.springframework.web.bind.annotation.RestController;
12+
import org.springframework.web.bind.annotation.*;
1513

1614
@RestController
17-
@AllArgsConstructor
15+
@RequiredArgsConstructor
1816
@Tag(name = "Auth API", description = "인증 관련 API")
1917
@RequestMapping("/api/auth")
2018
public class AuthController {
2119

2220
private final UserService userService;
21+
private final KakaoAuthService kakaoAuthService;
2322

2423
@PostMapping("/login")
2524
@Operation(summary = "로그인", description = "로그인 API")
@@ -38,4 +37,11 @@ public ResponseResult logout(HttpServletResponse response, HttpServletRequest re
3837
public ResponseResult reissueToken(HttpServletRequest request, HttpServletResponse response){
3938
return userService.reissueToken(request, response);
4039
}
40+
41+
// 카카오 로그인 (인가 코드 받아서 회원가입 또는 로그인 진행)
42+
@Operation(summary = "카카오 로그인", description = "카카오 로그인 후 JWT 발급")
43+
@GetMapping("/login/kakao")
44+
public ResponseResult kakaoCallback(@RequestParam String code, HttpServletResponse response) {
45+
return kakaoAuthService.kakaoLogin(code, response);
46+
}
4147
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package org.dfbf.soundlink.domain.user.dto.response;
2+
3+
public record KakaoTokenResponseDTO(
4+
String token_type,
5+
String access_token,
6+
String refresh_token,
7+
int expires_in,
8+
int refresh_token_expires_in,
9+
String scope
10+
) {
11+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package org.dfbf.soundlink.domain.user.dto.response;
2+
3+
public record KakaoUserDTO(
4+
String id, // id는 Long 타입이지만, 안전성을 위해 String으로 처리
5+
KakaoAccount kakao_account
6+
) {
7+
public record KakaoAccount(
8+
String email,
9+
String nickname
10+
) {
11+
}
12+
}

src/main/java/org/dfbf/soundlink/domain/user/repository/UserRepository.java

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

33
import org.dfbf.soundlink.domain.user.entity.User;
44
import org.dfbf.soundlink.domain.user.repository.dsl.UserRepositoryCustom;
5-
import org.springframework.cache.annotation.CachePut;
65
import org.springframework.data.jpa.repository.JpaRepository;
76

87
import java.util.Optional;
@@ -15,4 +14,6 @@ public interface UserRepository extends JpaRepository<User, Long>, UserRepositor
1514
boolean existsByLoginId(String loginId);
1615

1716
Optional<User> findByLoginId(String loginId);
17+
18+
Optional<User> findBySocialId(Long socialId);
1819
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
package org.dfbf.soundlink.domain.user.service;
2+
3+
import jakarta.servlet.http.HttpServletResponse;
4+
import lombok.RequiredArgsConstructor;
5+
import org.dfbf.soundlink.domain.user.dto.response.KakaoTokenResponseDTO;
6+
import org.dfbf.soundlink.domain.user.dto.response.KakaoUserDTO;
7+
import org.dfbf.soundlink.domain.user.entity.User;
8+
import org.dfbf.soundlink.domain.user.repository.UserRepository;
9+
import org.dfbf.soundlink.global.auth.JwtProvider;
10+
import org.dfbf.soundlink.global.auth.client.KakaoAuthClient;
11+
import org.dfbf.soundlink.global.auth.client.KakaoUserClient;
12+
import org.dfbf.soundlink.global.comm.enums.SocialType;
13+
import org.dfbf.soundlink.global.exception.ResponseResult;
14+
import org.springframework.beans.factory.annotation.Value;
15+
import org.springframework.http.ResponseCookie;
16+
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
17+
import org.springframework.stereotype.Service;
18+
import org.springframework.util.LinkedMultiValueMap;
19+
import org.springframework.util.MultiValueMap;
20+
21+
import java.util.HashMap;
22+
import java.util.Map;
23+
import java.util.Optional;
24+
25+
@Service
26+
@RequiredArgsConstructor
27+
public class KakaoAuthService {
28+
29+
private final KakaoAuthClient kakaoAuthClient;
30+
private final KakaoUserClient kakaoUserClient;
31+
private final UserRepository userRepository;
32+
private final JwtProvider jwtProvider;
33+
private final BCryptPasswordEncoder passwordEncoder;
34+
private static final String domain = "";
35+
36+
@Value("${kakao.client-id}")
37+
private String clientId;
38+
39+
@Value("${kakao.redirect-uri}")
40+
private String redirectUri;
41+
42+
@Value("${REFRESH_TOKEN_EXPIRATION_TIME}")
43+
private int REFRESH_TOKEN_EXPIRATION_TIME;
44+
45+
/**
46+
* 카카오 로그인 및 JWT 발급
47+
*/
48+
public ResponseResult kakaoLogin(String code, HttpServletResponse response) {
49+
// 요청 파라미터 설정 (JSONObject 대신 하나의 Key와 하나 이상의 value로 이루어진 리스트를 쌍으로 받기 위해 LinkedMultiValueMap 사용)
50+
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
51+
params.add("grant_type", "authorization_code");
52+
params.add("client_id", clientId);
53+
params.add("redirect_uri", redirectUri);
54+
params.add("code", code);
55+
56+
// 카카오 토큰 정보 요청
57+
KakaoTokenResponseDTO tokenResponse = kakaoAuthClient.getAccessToken(params);
58+
String accessToken = tokenResponse.access_token();
59+
60+
// 카카오 사용자 정보 요청
61+
KakaoUserDTO kakaoUser = kakaoUserClient.getUserInfo("Bearer " + accessToken);
62+
String kakaoNickname = kakaoUser.kakao_account().nickname(); // 닉네임
63+
64+
// DB에서 사용자 조회
65+
Optional<User> existingUser = userRepository.findBySocialId(Long.valueOf(kakaoUser.id()));
66+
67+
if (existingUser.isPresent()) {
68+
// 기존 사용자가 존재 시, 로그인 처리
69+
return generateTokenResponse(existingUser.get(), response);
70+
} else {
71+
// 신규 가입은 닉네임 중복 확인 필요
72+
if (userRepository.existsByNickname(kakaoNickname)) {
73+
// 기존 닉네임이 중복 시, 새로운 임의의 닉네임 생성
74+
kakaoNickname = generateUniqueNickname(kakaoNickname);
75+
}
76+
// 회원가입
77+
User newUser = registerNewKakaoUser(kakaoUser, kakaoNickname);
78+
return generateTokenResponse(newUser, response);
79+
}
80+
}
81+
82+
/**
83+
* JWT 토큰 생성
84+
*/
85+
private ResponseResult generateTokenResponse(User user, HttpServletResponse response) {
86+
String jwtAccessToken = jwtProvider.createAccessToken(user.getUserId());
87+
String jwtRefreshToken = jwtProvider.createRefreshToken(user.getUserId());
88+
89+
//refreshToken - 쿠키
90+
ResponseCookie refreshCookie = getRefreshToken(jwtRefreshToken);
91+
response.setHeader("Set-Cookie", refreshCookie.toString());
92+
93+
//accessToken - 바디
94+
Map<String, String> responseBody = new HashMap<>();
95+
responseBody.put("accessToken", jwtAccessToken);
96+
97+
return new ResponseResult(responseBody);
98+
}
99+
100+
/**
101+
* 새로운 카카오 사용자 회원가입
102+
*/
103+
private User registerNewKakaoUser(KakaoUserDTO kakaoUser, String nickname) {
104+
User newUser = User.builder()
105+
.nickName(nickname)
106+
.socialId(Long.valueOf(kakaoUser.id()))
107+
.socialType(SocialType.KAKAO)
108+
.loginId("kakao_" + kakaoUser.id()) // 카카오 ID 기반 로그인 ID 생성
109+
.password(passwordEncoder.encode(null))
110+
.email(kakaoUser.kakao_account().email())
111+
.build();
112+
return userRepository.save(newUser);
113+
}
114+
115+
116+
/**
117+
* 닉네임이 중복될 경우 새로운 닉네임 생성
118+
*/
119+
private String generateUniqueNickname(String baseNickname) {
120+
int suffix = 1;
121+
String newNickname = baseNickname;
122+
123+
// 닉네임이 중복되지 않을 때까지 반복
124+
while (userRepository.existsByNickname(newNickname)) {
125+
newNickname = baseNickname + "_" + suffix;
126+
suffix++;
127+
}
128+
return newNickname;
129+
}
130+
131+
// RefreshToken을 쿠키로 설정
132+
private ResponseCookie getRefreshToken(String refreshToken) {
133+
return ResponseCookie
134+
.from("REFRESHTOKEN", refreshToken)
135+
.domain(domain)
136+
.path("/")
137+
.httpOnly(true)
138+
.secure(false)
139+
.maxAge(REFRESH_TOKEN_EXPIRATION_TIME/1000) // 만료시간 설정(밀리초 -> 초로 변경)
140+
.build();
141+
}
142+
}

src/main/java/org/dfbf/soundlink/domain/user/service/UserService.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,9 @@
2020
import org.dfbf.soundlink.domain.user.repository.ProfileMusicRepository;
2121
import org.dfbf.soundlink.domain.user.repository.UserRepository;
2222
import org.dfbf.soundlink.global.auth.JwtProvider;
23-
import org.dfbf.soundlink.global.auth.TokenProperties;
2423
import org.dfbf.soundlink.global.exception.ErrorCode;
2524
import org.dfbf.soundlink.global.exception.ResponseResult;
2625
import org.springframework.beans.factory.annotation.Value;
27-
import org.springframework.cache.annotation.CachePut;
28-
import org.springframework.data.redis.core.RedisTemplate;
2926
import org.springframework.http.ResponseCookie;
3027
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
3128
import org.springframework.stereotype.Service;
@@ -235,7 +232,8 @@ public ResponseResult login(LoginReqDto loginReqDto, HttpServletResponse respons
235232
return new ResponseResult(ErrorCode.FAIL_TO_FIND_USER, "계정을 찾을 수 없습니다.");
236233
}
237234
// 비밀번호 검증(암호화 된 비밀번호 비교)
238-
if(!passwordEncoder.matches(loginReqDto.password(), userRepository.findPasswordByLoginId(loginReqDto.loginId()))){
235+
if( loginReqDto.password() == null || loginReqDto.password().isEmpty() ||
236+
!passwordEncoder.matches(loginReqDto.password(), userRepository.findPasswordByLoginId(loginReqDto.loginId()))){
239237
return new ResponseResult( ErrorCode.NOT_EQUALS_PASSWORD,"잘못된 비밀번호 입니다.");
240238
}
241239

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package org.dfbf.soundlink.global.auth.client;
2+
3+
import org.dfbf.soundlink.domain.user.dto.response.KakaoTokenResponseDTO;
4+
import org.dfbf.soundlink.domain.user.dto.response.KakaoUserDTO;
5+
import org.springframework.cloud.openfeign.FeignClient;
6+
import org.springframework.util.MultiValueMap;
7+
import org.springframework.web.bind.annotation.GetMapping;
8+
import org.springframework.web.bind.annotation.PostMapping;
9+
import org.springframework.web.bind.annotation.RequestBody;
10+
import org.springframework.web.bind.annotation.RequestHeader;
11+
12+
@FeignClient(name = "kakaoAuthClient", url = "https://kauth.kakao.com")
13+
public interface KakaoAuthClient {
14+
15+
// 카카오 Access Token 요청
16+
@PostMapping("/oauth/token")
17+
KakaoTokenResponseDTO getAccessToken(@RequestBody MultiValueMap<String, String> params);
18+
19+
// 카카오 사용자 정보 요청
20+
@GetMapping("/v2/user/me")
21+
KakaoUserDTO getUserInfo(@RequestHeader("Authorization") String accessToken);
22+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package org.dfbf.soundlink.global.auth.client;
2+
3+
import org.dfbf.soundlink.domain.user.dto.response.KakaoUserDTO;
4+
import org.springframework.cloud.openfeign.FeignClient;
5+
import org.springframework.web.bind.annotation.GetMapping;
6+
import org.springframework.web.bind.annotation.RequestHeader;
7+
8+
@FeignClient(name = "kakaoUserClient", url = "https://kapi.kakao.com")
9+
public interface KakaoUserClient {
10+
@GetMapping("/v2/user/me")
11+
KakaoUserDTO getUserInfo(@RequestHeader("Authorization") String accessToken);
12+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package org.dfbf.soundlink.global.config;
2+
3+
import feign.Response;
4+
import feign.codec.ErrorDecoder;
5+
import org.dfbf.soundlink.global.exception.BusinessException;
6+
import org.dfbf.soundlink.global.exception.ErrorCode;
7+
import org.slf4j.Logger;
8+
import org.slf4j.LoggerFactory;
9+
import org.springframework.http.HttpStatus;
10+
11+
public class CustomFeignErrorDecoder implements ErrorDecoder {
12+
private static final Logger log = LoggerFactory.getLogger(CustomFeignErrorDecoder.class);
13+
private final ErrorDecoder defaultErrorDecoder = new Default();
14+
15+
@Override
16+
public Exception decode(String methodKey, Response response) {
17+
HttpStatus status = HttpStatus.valueOf(response.status());
18+
log.error("Feign 요청 실패: methodKey={}, status={}", methodKey, status);
19+
20+
return switch (status) {
21+
case UNAUTHORIZED -> // 401
22+
new BusinessException(ErrorCode.KAKAO_API_UNAUTHORIZED);
23+
case FORBIDDEN -> // 403
24+
new BusinessException(ErrorCode.KAKAO_API_FORBIDDEN);
25+
case BAD_REQUEST -> // 400
26+
new BusinessException(ErrorCode.KAKAO_API_BAD_REQUEST);
27+
case INTERNAL_SERVER_ERROR -> // 500
28+
new BusinessException(ErrorCode.KAKAO_API_SERVER_ERROR);
29+
default -> defaultErrorDecoder.decode(methodKey, response);
30+
};
31+
}
32+
}

0 commit comments

Comments
 (0)