Skip to content

Commit 22398c1

Browse files
authored
Merge pull request #27 from Nexters/feature/6-user-kakao-social-login-api
feat: 카카오 소셜 로그인 및 사용자 온보딩 시스템 구현
2 parents 26cd552 + 2ba7bb1 commit 22398c1

40 files changed

+1302
-180
lines changed

.env.example

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,18 @@ POSTGRES_USER=root
88
POSTGRES_PASSWORD=password
99

1010
# Docker Hub configuration (for deployment)
11-
DOCKER_USERNAME=your-dockerhub-username
11+
DOCKER_USERNAME=your-dockerhub-username
12+
13+
# Kakao Social Login
14+
KAKAO_CLIENT_ID=your-kakao-app-key
15+
KAKAO_CLIENT_SECRET=your-kakao-client-secret
16+
17+
# 로컬 환경
18+
OAUTH_REDIRECT_URI=http://localhost:9090/oauth2/redirect
19+
# 테스트 환경
20+
#OAUTH_REDIRECT_URI=https://dev.holdy.kr/oauth2/redirect
21+
# 운영 환경
22+
#OAUTH_REDIRECT_URI=https://holdy.kr/oauth2/redirect
23+
24+
# JWT
25+
JWT_SECRET_KEY=your-secret-key

build.gradle

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ dependencies {
3434
implementation 'org.flywaydb:flyway-core'
3535
runtimeOnly 'org.flywaydb:flyway-database-postgresql'
3636
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
37+
implementation 'org.springframework.boot:spring-boot-starter-security'
38+
39+
// spring security
40+
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
41+
42+
// jwt
43+
implementation("io.jsonwebtoken:jjwt-api:0.11.5")
44+
runtimeOnly("io.jsonwebtoken:jjwt-impl:0.11.5")
45+
runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.11.5")
3746
}
3847

3948
tasks.named('test') {

docker-compose.dev.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ services:
2121
- DB_URL=jdbc:postgresql://db:5432/${POSTGRES_DB:-db}
2222
- DB_USERNAME=${POSTGRES_USER:-root}
2323
- DB_PASSWORD=${POSTGRES_PASSWORD:-password}
24+
- KAKAO_CLIENT_ID=${KAKAO_CLIENT_ID}
25+
- KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET}
26+
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
27+
- APP_OAUTH2_AUTHORIZED_REDIRECT_URI=${OAUTH_REDIRECT_URI}
2428
ports:
2529
- 9090:8080
2630
depends_on:

docker-compose.prod.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ services:
2121
- DB_URL=jdbc:postgresql://db:5432/${POSTGRES_DB:-db}
2222
- DB_USERNAME=${POSTGRES_USER:-root}
2323
- DB_PASSWORD=${POSTGRES_PASSWORD:-password}
24+
- KAKAO_CLIENT_ID=${KAKAO_CLIENT_ID}
25+
- KAKAO_CLIENT_SECRET=${KAKAO_CLIENT_SECRET}
26+
- JWT_SECRET_KEY=${JWT_SECRET_KEY}
27+
- APP_OAUTH2_AUTHORIZED_REDIRECT_URI=${OAUTH_REDIRECT_URI}
2428
ports:
2529
- 9090:8080
2630
depends_on:
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
package com.climbup.climbup.auth.controller;
2+
3+
import com.climbup.climbup.auth.dto.OnboardingDto;
4+
import com.climbup.climbup.auth.service.OnboardingService;
5+
import com.climbup.climbup.auth.util.JwtUtil;
6+
import com.climbup.climbup.user.docs.UserApiDocs;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.Parameter;
9+
import io.swagger.v3.oas.annotations.responses.ApiResponse;
10+
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
11+
import io.swagger.v3.oas.annotations.tags.Tag;
12+
import lombok.RequiredArgsConstructor;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.web.bind.annotation.*;
15+
16+
@Tag(name = "Onboarding", description = "사용자 온보딩 API")
17+
@RestController
18+
@RequestMapping("/api/onboarding")
19+
@RequiredArgsConstructor
20+
public class OnboardingController {
21+
22+
private final OnboardingService onboardingService;
23+
private final JwtUtil jwtUtil;
24+
25+
@Operation(
26+
summary = "온보딩 완료",
27+
description = "암장과 레벨을 동시에 설정하여 온보딩을 완료합니다.",
28+
security = @SecurityRequirement(name = "bearerAuth")
29+
)
30+
@ApiResponse(responseCode = "200", description = "온보딩 완료 성공")
31+
@PostMapping("/complete")
32+
public ResponseEntity<OnboardingDto.Response> completeOnboarding(
33+
@Parameter(
34+
description = UserApiDocs.AUTHORIZATION_DESCRIPTION,
35+
required = true,
36+
example = UserApiDocs.AUTHORIZATION_EXAMPLE
37+
)
38+
@RequestHeader("Authorization") String authorization,
39+
@RequestBody OnboardingDto.CompleteRequest request) {
40+
41+
String token = authorization.replace("Bearer ", "");
42+
Long userId = jwtUtil.getUserId(token);
43+
44+
onboardingService.completeOnboarding(userId, request.getGymId(), request.getLevelId());
45+
46+
return ResponseEntity.ok(new OnboardingDto.Response("온보딩이 완료되었습니다."));
47+
}
48+
49+
@Operation(
50+
summary = "암장 선택",
51+
description = "사용자의 암장을 설정합니다.",
52+
security = @SecurityRequirement(name = "bearerAuth")
53+
)
54+
@ApiResponse(responseCode = "200", description = "암장 설정 성공")
55+
@PostMapping("/gym")
56+
public ResponseEntity<OnboardingDto.Response> setGym(
57+
@Parameter(
58+
description = UserApiDocs.AUTHORIZATION_DESCRIPTION,
59+
required = true,
60+
example = UserApiDocs.AUTHORIZATION_EXAMPLE
61+
)
62+
@RequestHeader("Authorization") String authorization,
63+
@RequestBody OnboardingDto.GymRequest request) {
64+
65+
String token = authorization.replace("Bearer ", "");
66+
Long userId = jwtUtil.getUserId(token);
67+
68+
onboardingService.setUserGym(userId, request.getGymId());
69+
70+
return ResponseEntity.ok(new OnboardingDto.Response("암장이 설정되었습니다."));
71+
}
72+
73+
@Operation(
74+
summary = "레벨 선택",
75+
description = "사용자의 레벨을 설정합니다.",
76+
security = @SecurityRequirement(name = "bearerAuth")
77+
)
78+
@ApiResponse(responseCode = "200", description = "레벨 설정 성공")
79+
@PostMapping("/level")
80+
public ResponseEntity<OnboardingDto.Response> setLevel(
81+
@Parameter(
82+
description = UserApiDocs.AUTHORIZATION_DESCRIPTION,
83+
required = true,
84+
example = UserApiDocs.AUTHORIZATION_EXAMPLE
85+
)
86+
@RequestHeader("Authorization") String authorization,
87+
@RequestBody OnboardingDto.LevelRequest request) {
88+
89+
String token = authorization.replace("Bearer ", "");
90+
Long userId = jwtUtil.getUserId(token);
91+
92+
onboardingService.setUserLevel(userId, request.getLevelId());
93+
94+
return ResponseEntity.ok(new OnboardingDto.Response("레벨이 설정되었습니다."));
95+
}
96+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.climbup.climbup.auth.dto;
2+
3+
import com.climbup.climbup.user.entity.User;
4+
import lombok.Getter;
5+
import org.springframework.security.core.GrantedAuthority;
6+
import org.springframework.security.core.authority.SimpleGrantedAuthority;
7+
import org.springframework.security.oauth2.core.user.OAuth2User;
8+
9+
import java.util.Collection;
10+
import java.util.Collections;
11+
import java.util.Map;
12+
13+
@Getter
14+
public class CustomOAuth2User implements OAuth2User {
15+
private final User user;
16+
private final Map<String, Object> attributes;
17+
18+
public CustomOAuth2User(User user, Map<String, Object> attributes) {
19+
this.user = user;
20+
this.attributes = attributes;
21+
}
22+
23+
@Override
24+
public Map<String, Object> getAttributes() {
25+
return attributes;
26+
}
27+
28+
@Override
29+
public Collection<? extends GrantedAuthority> getAuthorities() {
30+
return Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"));
31+
}
32+
33+
@Override
34+
public String getName() {
35+
return user.getKakaoId();
36+
}
37+
38+
public Long getUserId() {
39+
return user.getId();
40+
}
41+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package com.climbup.climbup.auth.dto;
2+
3+
import java.util.Map;
4+
5+
public class KakaoOAuth2UserInfo {
6+
private final Map<String, Object> attributes;
7+
private final Map<String, Object> properties;
8+
9+
public KakaoOAuth2UserInfo(Map<String, Object> attributes) {
10+
this.attributes = attributes;
11+
this.properties = (Map<String, Object>) attributes.get("properties");
12+
}
13+
14+
public String getId() {
15+
return String.valueOf(attributes.get("id"));
16+
}
17+
18+
public String getNickname() {
19+
if (properties == null) {
20+
return "클라이머" + getId().substring(0, 4);
21+
}
22+
String nickname = (String) properties.get("nickname");
23+
return nickname != null ? nickname : "클라이머" + getId().substring(0, 4);
24+
}
25+
26+
public String getProfileImageUrl() {
27+
if (properties == null) {
28+
return getDefaultImageUrl();
29+
}
30+
String imageUrl = (String) properties.get("profile_image");
31+
return imageUrl != null ? imageUrl : getDefaultImageUrl();
32+
}
33+
34+
private String getDefaultImageUrl() {
35+
return "https://via.placeholder.com/150?text=User";
36+
}
37+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package com.climbup.climbup.auth.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.Getter;
5+
import lombok.NoArgsConstructor;
6+
7+
@NoArgsConstructor
8+
public class OnboardingDto {
9+
10+
@Getter
11+
@NoArgsConstructor
12+
@Schema(description = "온보딩 완료 요청")
13+
public static class CompleteRequest {
14+
@Schema(description = "암장 ID", example = "1")
15+
private Long gymId;
16+
17+
@Schema(description = "레벨 ID", example = "1")
18+
private Long levelId;
19+
}
20+
21+
@Getter
22+
@NoArgsConstructor
23+
@Schema(description = "암장 선택 요청")
24+
public static class GymRequest {
25+
@Schema(description = "암장 ID", example = "1")
26+
private Long gymId;
27+
}
28+
29+
@Getter
30+
@NoArgsConstructor
31+
@Schema(description = "레벨 선택 요청")
32+
public static class LevelRequest {
33+
@Schema(description = "레벨 ID", example = "1")
34+
private Long levelId;
35+
}
36+
37+
@Getter
38+
@Schema(description = "온보딩 응답")
39+
public static class Response {
40+
@Schema(description = "결과 메시지", example = "온보딩이 완료되었습니다.")
41+
private final String message;
42+
43+
public Response(String message) {
44+
this.message = message;
45+
}
46+
}
47+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.climbup.climbup.auth.handler;
2+
3+
import com.climbup.climbup.auth.dto.CustomOAuth2User;
4+
import com.climbup.climbup.auth.util.JwtUtil;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.security.core.Authentication;
9+
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
10+
import org.springframework.stereotype.Component;
11+
12+
import jakarta.servlet.http.HttpServletRequest;
13+
import jakarta.servlet.http.HttpServletResponse;
14+
import java.io.IOException;
15+
import java.net.URLEncoder;
16+
import java.nio.charset.StandardCharsets;
17+
18+
@Component
19+
@RequiredArgsConstructor
20+
@Slf4j
21+
public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
22+
23+
private final JwtUtil jwtUtil;
24+
25+
@Value("${app.oauth2.authorized-redirect-uri:http://localhost:3000/auth/callback}")
26+
private String redirectUri;
27+
28+
@Override
29+
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
30+
Authentication authentication) throws IOException {
31+
32+
try {
33+
CustomOAuth2User oauth2User = (CustomOAuth2User) authentication.getPrincipal();
34+
log.info("✅ 로그인 성공: userId={}", oauth2User.getUserId());
35+
36+
// JWT에는 userId만 포함
37+
String token = jwtUtil.createAccessToken(oauth2User.getUserId());
38+
39+
// 프론트엔드 콜백 페이지로 리다이렉트
40+
String targetUrl = redirectUri + "?token=" + URLEncoder.encode(token, StandardCharsets.UTF_8);
41+
42+
log.info("프론트엔드로 리다이렉트: {}", targetUrl);
43+
response.sendRedirect(targetUrl);
44+
45+
} catch (Exception e) {
46+
log.error("❌ OAuth2 성공 핸들러 오류", e);
47+
String errorUrl = redirectUri.replace("/auth/callback", "/login?error=oauth_error");
48+
response.sendRedirect(errorUrl);
49+
}
50+
}
51+
}

0 commit comments

Comments
 (0)