Skip to content

Commit 628b62b

Browse files
authored
[Feat] 구글 / 카카오 OAuth 구현 (#73)
1 parent 5980d6e commit 628b62b

14 files changed

Lines changed: 283 additions & 20 deletions

File tree

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ dependencies {
6161

6262
// s3
6363
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
64+
65+
// oauth2
66+
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
6467
}
6568

6669
tasks.named('test') {

src/main/java/umc/codeplay/apiPayLoad/code/status/ErrorStatus.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ public enum ErrorStatus implements BaseErrorCode {
2424
NOT_AUTHORIZED(HttpStatus.BAD_REQUEST, "AUTH400", "인증되지 않은 요청입니다."),
2525
ID_OR_PASSWORD_WRONG(HttpStatus.BAD_REQUEST, "AUTH401", "아이디 혹은 비밀번호가 잘못되었습니다."),
2626
INVALID_REFRESH_TOKEN(HttpStatus.BAD_REQUEST, "AUTH402", "유효하지 않은 리프레시 토큰입니다."),
27+
OAUTH_TOKEN_REQUEST_FAILED(HttpStatus.BAD_REQUEST, "AUTH403", "외부인증 토큰 요청에 실패했습니다."),
28+
OAUTH_USERINFO_REQUEST_FAILED(HttpStatus.BAD_REQUEST, "AUTH404", "외부인증 유저 정보 요청에 실패했습니다."),
29+
AUTHORIZATION_METHOD_ERROR(HttpStatus.BAD_REQUEST, "AUTH405", "인증 방식이 잘못되었습니다."),
30+
INVALID_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH406", "유효하지 않은 OAuth 제공자입니다."),
2731

2832
AWS_SERVICE_UNAVAILABLE(HttpStatus.BAD_REQUEST, "AWS400", "AWS S3에 파일을 업로드할 수 없습니다.");
2933

src/main/java/umc/codeplay/config/SecurityConfig.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
6060
auth
6161
// 로그인, 회원가입 등 토큰 없이 접근해야 하는 API 허용
6262
.requestMatchers(
63+
"/oauth/**",
6364
"/health",
6465
"/health/s3",
65-
"/auth/refresh",
66-
"/auth/signup",
67-
"/auth/login",
66+
"/auth/**",
6867
"/v2/api-docs",
6968
"/v3/api-docs",
7069
"/v3/api-docs/**",
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package umc.codeplay.config.properties;
2+
3+
import lombok.Data;
4+
5+
@Data
6+
public class BaseOAuthProperties {
7+
8+
private String clientId;
9+
private String clientSecret;
10+
private String redirectUri;
11+
private String scope;
12+
private String authorizationUri;
13+
private String tokenUri;
14+
private String userInfoUri;
15+
private String additionalParameters;
16+
17+
public String getUrl() {
18+
return authorizationUri
19+
+ "?client_id="
20+
+ clientId
21+
+ "&redirect_uri="
22+
+ redirectUri
23+
+ "&response_type=code"
24+
+ "&scope="
25+
+ scope
26+
+ additionalParameters;
27+
}
28+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package umc.codeplay.config.properties;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
import org.springframework.stereotype.Component;
5+
6+
@Component
7+
@ConfigurationProperties(prefix = "google.oauth2")
8+
public class GoogleOAuthProperties extends BaseOAuthProperties {
9+
// BaseOAuthProperties 의 필드를 그대로 상속받아 사용.
10+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package umc.codeplay.config.properties;
2+
3+
import org.springframework.boot.context.properties.ConfigurationProperties;
4+
import org.springframework.stereotype.Component;
5+
6+
@Component
7+
@ConfigurationProperties(prefix = "kakao.oauth2")
8+
public class KakaoOAuthProperties extends BaseOAuthProperties {
9+
// BaseOAuthProperties 의 필드를 그대로 상속받아 사용.
10+
}

src/main/java/umc/codeplay/controller/AuthController.java

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@
1313

1414
import lombok.RequiredArgsConstructor;
1515

16-
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
1716
import umc.codeplay.apiPayLoad.ApiResponse;
1817
import umc.codeplay.apiPayLoad.code.status.ErrorStatus;
1918
import umc.codeplay.apiPayLoad.exception.handler.GeneralHandler;
2019
import umc.codeplay.converter.MemberConverter;
2120
import umc.codeplay.domain.Member;
21+
import umc.codeplay.domain.enums.SocialStatus;
2222
import umc.codeplay.dto.MemberRequestDTO;
2323
import umc.codeplay.dto.MemberResponseDTO;
2424
import umc.codeplay.jwt.JwtUtil;
@@ -37,6 +37,10 @@ public class AuthController {
3737
@PostMapping("/login")
3838
public ApiResponse<MemberResponseDTO.LoginResultDTO> login(
3939
@RequestBody MemberRequestDTO.LoginDto request) {
40+
if (memberService.getSocialStatus(request.getEmail()) != SocialStatus.NONE) {
41+
throw new GeneralHandler(ErrorStatus.AUTHORIZATION_METHOD_ERROR);
42+
}
43+
4044
// 아이디/비밀번호를 사용해 AuthenticationToken 생성
4145
UsernamePasswordAuthenticationToken authToken =
4246
new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword());
@@ -98,10 +102,4 @@ public ApiResponse<MemberResponseDTO.LoginResultDTO> refresh(
98102
throw new GeneralHandler(ErrorStatus.INVALID_REFRESH_TOKEN);
99103
}
100104
}
101-
102-
@SecurityRequirement(name = "JWT TOKEN")
103-
@GetMapping("/test")
104-
public ApiResponse<String> test() {
105-
return ApiResponse.onSuccess("test");
106-
}
107105
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package umc.codeplay.controller;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
6+
import org.springframework.http.*;
7+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
8+
import org.springframework.util.LinkedMultiValueMap;
9+
import org.springframework.util.MultiValueMap;
10+
import org.springframework.web.bind.annotation.*;
11+
import org.springframework.web.client.RestTemplate;
12+
import org.springframework.web.servlet.view.RedirectView;
13+
14+
import lombok.RequiredArgsConstructor;
15+
16+
import umc.codeplay.apiPayLoad.ApiResponse;
17+
import umc.codeplay.apiPayLoad.code.status.ErrorStatus;
18+
import umc.codeplay.apiPayLoad.exception.handler.GeneralHandler;
19+
import umc.codeplay.config.properties.BaseOAuthProperties;
20+
import umc.codeplay.config.properties.GoogleOAuthProperties;
21+
import umc.codeplay.config.properties.KakaoOAuthProperties;
22+
import umc.codeplay.domain.Member;
23+
import umc.codeplay.domain.enums.SocialStatus;
24+
import umc.codeplay.dto.MemberResponseDTO;
25+
import umc.codeplay.jwt.JwtUtil;
26+
import umc.codeplay.service.MemberService;
27+
28+
@RestController
29+
@RequestMapping("/oauth")
30+
@RequiredArgsConstructor
31+
public class OAuthController {
32+
33+
private final JwtUtil jwtUtil;
34+
private final RestTemplate restTemplate = new RestTemplate();
35+
private final GoogleOAuthProperties googleOAuthProperties;
36+
private final KakaoOAuthProperties kakaoOAuthProperties;
37+
private final MemberService memberService;
38+
39+
@GetMapping("/authorize/{provider}")
40+
public RedirectView redirectToOAuth(@PathVariable("provider") String provider) {
41+
// CSRF 방어용 state, PKCE(code_challenge)..는 굳이
42+
BaseOAuthProperties properties =
43+
switch (provider) {
44+
case "google" -> googleOAuthProperties;
45+
case "kakao" -> kakaoOAuthProperties;
46+
default -> throw new GeneralHandler(ErrorStatus.INVALID_OAUTH_PROVIDER);
47+
};
48+
49+
String url = properties.getUrl();
50+
51+
RedirectView redirectView = new RedirectView();
52+
redirectView.setUrl(url);
53+
return redirectView;
54+
}
55+
56+
@GetMapping("/callback/{provider}")
57+
public ApiResponse<MemberResponseDTO.LoginResultDTO> OAuthCallback(
58+
@RequestParam("code") String code, @PathVariable("provider") String provider) {
59+
BaseOAuthProperties properties =
60+
switch (provider) {
61+
case "google" -> googleOAuthProperties;
62+
case "kakao" -> kakaoOAuthProperties;
63+
default -> throw new GeneralHandler(ErrorStatus.INVALID_OAUTH_PROVIDER);
64+
};
65+
// (1) 받은 code 로 구글 토큰 엔드포인트에 Access/ID Token 교환
66+
Map<String, Object> tokenResponse = requestOAuthToken(code, properties);
67+
68+
// (2) 받아온 Access Token(or ID Token)을 통해 사용자 정보 가져오기
69+
// String idToken = (String) tokenResponse.get("id_token"); // OIDC
70+
String accessToken = (String) tokenResponse.get("access_token");
71+
Map<String, Object> userInfo = requestOAuthUserInfo(accessToken, properties);
72+
String email = null;
73+
String name = null;
74+
switch (provider) {
75+
case "google" -> {
76+
// (3-a) 구글 UserInfo Endpoint 로 이메일, 프로필 등 조회
77+
email = (String) userInfo.get("email");
78+
name = (String) userInfo.get("name");
79+
}
80+
case "kakao" -> {
81+
// (3-b) 카카오 UserInfo Endpoint 로 이메일, 프로필 등 조회
82+
Map<String, Object> kakaoAccount =
83+
(Map<String, Object>) userInfo.get("kakao_account");
84+
Map<String, Object> kakaoProperties =
85+
(Map<String, Object>) userInfo.get("properties");
86+
email = (String) kakaoAccount.get("email");
87+
name = (String) kakaoProperties.get("nickname");
88+
}
89+
}
90+
91+
// (4) 우리 DB에서 회원 조회 or 생성
92+
Member member =
93+
memberService.findOrCreateOAuthMember(
94+
email, name, SocialStatus.valueOf(provider.toUpperCase()));
95+
96+
// (5) JWTUtil 이용해서 Access/Refresh 토큰 발급
97+
var authorities = List.of(new SimpleGrantedAuthority("ROLE_" + member.getRole().name()));
98+
99+
String serviceAccessToken = jwtUtil.generateToken(email, authorities);
100+
String serviceRefreshToken = jwtUtil.generateRefreshToken(email, authorities);
101+
102+
// (6) 최종적으로 JWT(액세스/리프레시)를 프론트에 응답
103+
return ApiResponse.onSuccess(
104+
MemberResponseDTO.LoginResultDTO.builder()
105+
.email(email)
106+
.token(serviceAccessToken)
107+
.refreshToken(serviceRefreshToken)
108+
.build());
109+
}
110+
111+
private Map<String, Object> requestOAuthToken(String code, BaseOAuthProperties properties) {
112+
HttpHeaders headers = new HttpHeaders();
113+
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
114+
115+
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
116+
params.add("grant_type", "authorization_code");
117+
params.add("client_id", properties.getClientId());
118+
params.add("client_secret", properties.getClientSecret());
119+
params.add("redirect_uri", properties.getRedirectUri());
120+
params.add("code", code);
121+
122+
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
123+
124+
ResponseEntity<Map> response =
125+
restTemplate.postForEntity(properties.getTokenUri(), request, Map.class);
126+
127+
if (response.getStatusCode() == HttpStatus.OK) {
128+
return response.getBody();
129+
}
130+
throw new GeneralHandler(ErrorStatus.OAUTH_TOKEN_REQUEST_FAILED);
131+
}
132+
133+
private Map<String, Object> requestOAuthUserInfo(
134+
String accessToken, BaseOAuthProperties properties) {
135+
HttpHeaders headers = new HttpHeaders();
136+
headers.set("Authorization", "Bearer " + accessToken);
137+
138+
HttpEntity<Void> request = new HttpEntity<>(headers);
139+
140+
ResponseEntity<Map> response =
141+
restTemplate.exchange(
142+
properties.getUserInfoUri(), HttpMethod.GET, request, Map.class);
143+
144+
if (response.getStatusCode() == HttpStatus.OK) {
145+
return response.getBody();
146+
}
147+
throw new GeneralHandler(ErrorStatus.OAUTH_USERINFO_REQUEST_FAILED);
148+
}
149+
}

src/main/java/umc/codeplay/converter/MemberConverter.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package umc.codeplay.converter;
22

33
import umc.codeplay.domain.Member;
4+
import umc.codeplay.domain.enums.Role;
5+
import umc.codeplay.domain.enums.SocialStatus;
46
import umc.codeplay.dto.MemberRequestDTO;
57
import umc.codeplay.dto.MemberResponseDTO;
68

@@ -12,7 +14,8 @@ public static Member toMember(MemberRequestDTO.JoinDto request) {
1214
.name(request.getName())
1315
.email(request.getEmail())
1416
.password(request.getPassword())
15-
.role(request.getRole())
17+
.role(Role.USER)
18+
.socialStatus(SocialStatus.NONE)
1619
.build();
1720
}
1821

src/main/java/umc/codeplay/domain/Member.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,14 @@
22

33
import jakarta.persistence.*;
44

5-
import lombok.AccessLevel;
6-
import lombok.AllArgsConstructor;
7-
import lombok.Builder;
8-
import lombok.Getter;
9-
import lombok.NoArgsConstructor;
5+
import lombok.*;
106

117
import umc.codeplay.domain.enums.Role;
8+
import umc.codeplay.domain.enums.SocialStatus;
129

1310
@Entity
1411
@Getter
12+
@Setter
1513
@Builder
1614
@NoArgsConstructor(access = AccessLevel.PROTECTED)
1715
@AllArgsConstructor
@@ -27,8 +25,12 @@ public class Member {
2725

2826
private String email;
2927

28+
@Enumerated(EnumType.STRING)
3029
private Role role;
3130

31+
@Enumerated(EnumType.STRING)
32+
private SocialStatus socialStatus;
33+
3234
public void encodePassword(String password) {
3335
this.password = password;
3436
}

0 commit comments

Comments
 (0)