Skip to content

Commit 2eeb552

Browse files
committed
Feat: 카카오 로그인 구현
1 parent b2d5eb4 commit 2eeb552

File tree

13 files changed

+327
-10
lines changed

13 files changed

+327
-10
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ dependencies {
4343

4444
// Security
4545
implementation("org.springframework.boot:spring-boot-starter-security")
46+
implementation("org.springframework.boot:spring-boot-starter-oauth2-client")
4647

4748
// Development Tools
4849
compileOnly("org.projectlombok:lombok")

src/main/java/com/back/domain/user/entity/User.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121

2222
@Entity
2323
@Getter
24+
@Setter
2425
@SuperBuilder
2526
@NoArgsConstructor
2627
@AllArgsConstructor
@@ -39,8 +40,6 @@ public class User extends BaseEntity {
3940

4041
private String providerId;
4142

42-
// 사용자 상태 변경
43-
@Setter
4443
@Enumerated(EnumType.STRING)
4544
private UserStatus userStatus;
4645

@@ -115,6 +114,14 @@ public static User createAdmin(String username, String email, String password) {
115114
return new User(username, email, password, Role.ADMIN, UserStatus.ACTIVE);
116115
}
117116

117+
// OAuth2 사용자 생성
118+
public static User createOAuth2User(String username, String email, String provider, String providerId) {
119+
User user = new User(username, email, "SOCIAL_LOGIN_PASSWORD", Role.USER, UserStatus.ACTIVE);
120+
user.setProvider(provider);
121+
user.setProviderId(providerId);
122+
return user;
123+
}
124+
118125
// -------------------- 연관관계 메서드 --------------------
119126
public void setUserProfile(UserProfile profile) {
120127
this.userProfile = profile;

src/main/java/com/back/domain/user/repository/UserRepository.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ public interface UserRepository extends JpaRepository<User, Long> {
1111
boolean existsByUsername(String username);
1212
boolean existsByEmail(String email);
1313
Optional<User> findByUsername(String username);
14+
Optional<User> findByProviderAndProviderId(String provider, String providerId);
1415
}

src/main/java/com/back/global/exception/ErrorCode.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,11 @@ public enum ErrorCode {
6565
EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_004", "만료된 액세스 토큰입니다."),
6666
EXPIRED_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH_005", "만료된 리프레시 토큰입니다."),
6767
REFRESH_TOKEN_REUSE(HttpStatus.FORBIDDEN, "AUTH_006", "재사용된 리프레시 토큰입니다."),
68-
ACCESS_DENIED(HttpStatus.FORBIDDEN, "AUTH_007", "권한이 없습니다.");
68+
ACCESS_DENIED(HttpStatus.FORBIDDEN, "AUTH_007", "권한이 없습니다."),
69+
UNSUPPORTED_OAUTH_PROVIDER(HttpStatus.BAD_REQUEST, "AUTH_008", "지원하지 않는 소셜 로그인 제공자입니다."),
70+
OAUTH2_ATTRIBUTE_MISSING(HttpStatus.UNAUTHORIZED, "AUTH_009", "소셜 계정에서 필요한 사용자 정보를 가져올 수 없습니다."),
71+
OAUTH2_EMAIL_NOT_FOUND(HttpStatus.UNAUTHORIZED, "AUTH_010", "소셜 계정에서 이메일 정보를 확인할 수 없습니다."),
72+
OAUTH2_AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_011", "소셜 로그인 인증에 실패했습니다.");
6973

7074

7175
private final HttpStatus status;

src/main/java/com/back/global/security/CurrentUser.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.back.global.security;
22

3+
import com.back.domain.user.entity.Role;
34
import com.back.domain.user.entity.User;
45
import com.back.domain.user.repository.UserRepository;
56
import com.back.global.exception.CustomException;
@@ -9,8 +10,6 @@
910
import org.springframework.security.core.context.SecurityContextHolder;
1011
import org.springframework.stereotype.Component;
1112

12-
import java.util.Optional;
13-
1413
/**
1514
* SecurityContext에 저장된 인증 정보를 바탕으로
1615
* 현재 로그인한 사용자 정보를 가져오는 유틸 클래스
@@ -34,7 +33,7 @@ public boolean isAuthenticated() {
3433

3534
public String getUsername() { return getDetails().getUsername(); }
3635

37-
public String getRole() { return getDetails().getRole(); }
36+
public Role getRole() { return getDetails().getRole(); }
3837

3938
public String getEmail() { return getUserFromDb().getEmail(); }
4039

src/main/java/com/back/global/security/CustomUserDetails.java

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package com.back.global.security;
22

3+
import com.back.domain.user.entity.Role;
34
import lombok.AllArgsConstructor;
45
import lombok.Builder;
56
import lombok.Getter;
67
import org.springframework.security.core.authority.SimpleGrantedAuthority;
78
import org.springframework.security.core.userdetails.UserDetails;
9+
import org.springframework.security.oauth2.core.user.OAuth2User;
810

911
import java.util.Collection;
1012
import java.util.List;
13+
import java.util.Map;
1114

1215
/**
1316
* Spring Security에서 사용하는 사용자 인증 정보 클래스
@@ -16,11 +19,19 @@
1619
@Getter
1720
@Builder
1821
@AllArgsConstructor
19-
public class CustomUserDetails implements UserDetails {
22+
public class CustomUserDetails implements UserDetails, OAuth2User{
2023
private Long userId;
2124
private String username;
22-
private String role;
25+
private Role role;
26+
private Map<String, Object> attributes;
2327

28+
public CustomUserDetails(Long userId, String username, Role role) {
29+
this.userId = userId;
30+
this.username = username;
31+
this.role = role;
32+
}
33+
34+
// ========== UserDetails 구현 ==========
2435
@Override
2536
public Collection<SimpleGrantedAuthority> getAuthorities() {
2637
// Spring Security 권한 체크는 "ROLE_" prefix 필요
@@ -57,4 +68,15 @@ public boolean isCredentialsNonExpired() {
5768
public boolean isEnabled() {
5869
return true;
5970
}
71+
72+
// ========== OAuth2User 구현 ==========
73+
@Override
74+
public Map<String, Object> getAttributes() {
75+
return attributes;
76+
}
77+
78+
@Override
79+
public String getName() {
80+
return String.valueOf(username);
81+
}
6082
}

src/main/java/com/back/global/security/JwtTokenProvider.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.back.global.security;
22

3+
import com.back.domain.user.entity.Role;
34
import com.back.global.exception.CustomException;
45
import com.back.global.exception.ErrorCode;
56
import io.jsonwebtoken.Claims;
@@ -98,7 +99,7 @@ public Authentication getAuthentication(String token) {
9899
String role = claims.get("role", String.class);
99100

100101
SimpleGrantedAuthority authority = new SimpleGrantedAuthority("ROLE_" + role);
101-
CustomUserDetails principal = new CustomUserDetails(userId, username, role);
102+
CustomUserDetails principal = new CustomUserDetails(userId, username, Role.valueOf(role));
102103

103104
return new UsernamePasswordAuthenticationToken(principal, token, List.of(authority));
104105
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package com.back.global.security;
2+
3+
import com.back.domain.user.dto.LoginResponse;
4+
import com.back.domain.user.dto.UserResponse;
5+
import com.back.domain.user.entity.User;
6+
import com.back.domain.user.entity.UserToken;
7+
import com.back.domain.user.repository.UserRepository;
8+
import com.back.domain.user.repository.UserTokenRepository;
9+
import com.back.global.common.dto.RsData;
10+
import com.back.global.exception.CustomException;
11+
import com.back.global.exception.ErrorCode;
12+
import com.back.global.util.CookieUtil;
13+
import com.fasterxml.jackson.databind.ObjectMapper;
14+
import jakarta.servlet.http.HttpServletRequest;
15+
import jakarta.servlet.http.HttpServletResponse;
16+
import lombok.RequiredArgsConstructor;
17+
import org.springframework.security.core.Authentication;
18+
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
19+
import org.springframework.stereotype.Component;
20+
21+
import java.io.IOException;
22+
import java.time.LocalDateTime;
23+
24+
/**
25+
* OAuth2 로그인 성공 시 처리 핸들러
26+
*
27+
* 주요 기능:
28+
* 1. 인증된 사용자 정보로 DB 조회
29+
* 2. JWT Access Token 및 Refresh Token 생성
30+
* 3. Refresh Token을 HttpOnly 쿠키에 저장
31+
* 4. Access Token 및 사용자 정보를 JSON 형태로 응답
32+
*/
33+
@Component
34+
@RequiredArgsConstructor
35+
public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler {
36+
private final JwtTokenProvider jwtTokenProvider;
37+
private final UserRepository userRepository;
38+
private final UserTokenRepository userTokenRepository;
39+
private final ObjectMapper objectMapper;
40+
41+
@Override
42+
public void onAuthenticationSuccess(HttpServletRequest request,
43+
HttpServletResponse response,
44+
Authentication authentication) throws IOException {
45+
try {
46+
// 인증 정보에서 사용자 정보 추출 및 DB 조회
47+
String username = authentication.getName();
48+
User user = userRepository.findByUsername(username)
49+
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
50+
51+
// 토큰 생성
52+
String accessToken = jwtTokenProvider.createAccessToken(user.getId(), user.getUsername(), user.getRole().name());
53+
String refreshToken = jwtTokenProvider.createRefreshToken(user.getId());
54+
55+
// DB에 Refresh Token 저장
56+
UserToken userToken = new UserToken(
57+
user,
58+
refreshToken,
59+
LocalDateTime.now().plusSeconds(jwtTokenProvider.getRefreshTokenExpirationInSeconds())
60+
);
61+
userTokenRepository.save(userToken);
62+
63+
// Refresh Token을 HttpOnly 쿠키에 저장
64+
CookieUtil.addCookie(
65+
response,
66+
"refreshToken",
67+
refreshToken,
68+
(int) jwtTokenProvider.getRefreshTokenExpirationInSeconds(),
69+
"/",
70+
true
71+
);
72+
73+
// 응답 데이터 구성
74+
LoginResponse loginResponse = new LoginResponse(
75+
accessToken,
76+
UserResponse.from(user)
77+
);
78+
79+
RsData<LoginResponse> rsData = RsData.success(
80+
"소셜 로그인에 성공했습니다.",
81+
loginResponse
82+
);
83+
84+
// JSON 직렬화 후 응답
85+
response.setStatus(HttpServletResponse.SC_OK);
86+
response.setContentType("application/json;charset=UTF-8");
87+
objectMapper.writeValue(response.getWriter(), rsData);
88+
} catch (CustomException e) {
89+
handleException(response, e);
90+
} catch (Exception e) {
91+
handleException(response, new CustomException(ErrorCode.OAUTH2_AUTHENTICATION_FAILED));
92+
}
93+
}
94+
95+
private void handleException(HttpServletResponse response, CustomException e) throws IOException {
96+
RsData<Object> rsData = RsData.fail(e.getErrorCode());
97+
response.setStatus(e.getErrorCode().getStatus().value());
98+
response.setContentType("application/json;charset=UTF-8");
99+
objectMapper.writeValue(response.getWriter(), rsData);
100+
}
101+
}

src/main/java/com/back/global/security/SecurityConfig.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.back.global.security;
22

3+
import com.back.global.security.oauth.CustomOAuth2UserService;
34
import lombok.RequiredArgsConstructor;
45
import org.springframework.context.annotation.Bean;
56
import org.springframework.context.annotation.Configuration;
@@ -21,14 +22,16 @@ public class SecurityConfig {
2122
private final JwtAuthenticationFilter jwtAuthenticationFilter;
2223
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
2324
private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
25+
private final CustomOAuth2UserService customOAuth2UserService;
26+
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
2427

2528
@Bean
2629
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
2730
http
2831
// 인가 규칙 설정
2932
.authorizeHttpRequests(
3033
auth -> auth
31-
.requestMatchers("/api/auth/**").permitAll()
34+
.requestMatchers("/api/auth/**", "/oauth2/**", "/login/oauth2/**").permitAll()
3235
.requestMatchers("/api/ws/**").permitAll()
3336
.requestMatchers("/api/rooms/*/messages/**").permitAll() //스터디 룸 내에 잡혀있어 있는 채팅 관련 전체 허용
3437
//.requestMatchers("/api/rooms/RoomChatApiControllerTest").permitAll() // 테스트용 임시 허용
@@ -37,6 +40,12 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
3740
.anyRequest().authenticated()
3841
)
3942

43+
// OAuth2 로그인 설정
44+
.oauth2Login(oauth -> oauth
45+
.userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService))
46+
.successHandler(oAuth2LoginSuccessHandler)
47+
)
48+
4049
// 인증/인가 실패 핸들러
4150
.exceptionHandling(exception -> exception
4251
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.back.global.security.oauth;
2+
3+
import com.back.domain.user.entity.User;
4+
import com.back.domain.user.entity.UserProfile;
5+
import com.back.domain.user.repository.UserProfileRepository;
6+
import com.back.domain.user.repository.UserRepository;
7+
import com.back.global.exception.CustomException;
8+
import com.back.global.exception.ErrorCode;
9+
import com.back.global.security.CustomUserDetails;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
12+
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
13+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
14+
import org.springframework.security.oauth2.core.user.OAuth2User;
15+
import org.springframework.stereotype.Service;
16+
17+
import java.util.Map;
18+
19+
/**
20+
* 소셜 로그인(OAuth2) 사용자의 정보를 받아 DB에 매핑하는 서비스 클래스
21+
*
22+
* 주요 기능:
23+
* 1. 소셜 로그인 제공자(카카오 등)에서 사용자 정보를 받아옴
24+
* 2. provider + providerId로 사용자 DB 조회
25+
* 3. 기존 사용자가 없으면 신규 가입 처리
26+
* 4. 최종적으로 SecurityContext에 저장될 CustomUserDetails를 반환
27+
*/
28+
@Service
29+
@RequiredArgsConstructor
30+
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
31+
32+
private final UserRepository userRepository;
33+
private final UserProfileRepository userProfileRepository;
34+
35+
@Override
36+
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
37+
try {
38+
System.out.println("OAuth2 login: provider = " + userRequest.getClientRegistration().getRegistrationId());
39+
40+
// 소셜 제공자에서 사용자 정보 로드
41+
OAuth2User oAuth2User = super.loadUser(userRequest);
42+
String registrationId = userRequest.getClientRegistration().getRegistrationId();
43+
Map<String, Object> attributes = oAuth2User.getAttributes();
44+
45+
// 소셜 제공자별로 사용자 정보 매핑
46+
OAuth2UserInfo userInfo = switch (registrationId) {
47+
case "kakao" -> new KakaoOAuth2UserInfo(attributes);
48+
default -> throw new CustomException(ErrorCode.UNSUPPORTED_OAUTH_PROVIDER);
49+
};
50+
51+
// 필수 정보 검증
52+
if (userInfo.getEmail() == null || userInfo.getEmail().isBlank()) {
53+
throw new CustomException(ErrorCode.OAUTH2_EMAIL_NOT_FOUND);
54+
}
55+
if (userInfo.getProviderId() == null) {
56+
throw new CustomException(ErrorCode.OAUTH2_ATTRIBUTE_MISSING);
57+
}
58+
59+
// DB에서 사용자 조회 또는 신규 가입 처리
60+
User user = userRepository.findByProviderAndProviderId(userInfo.getProvider(), userInfo.getProviderId())
61+
.orElseGet(() -> {
62+
User newUser = User.createOAuth2User(
63+
userInfo.getProvider() + "_" + userInfo.getProviderId(),
64+
userInfo.getEmail(),
65+
userInfo.getProvider(),
66+
userInfo.getProviderId()
67+
);
68+
69+
UserProfile userProfile = new UserProfile(
70+
newUser,
71+
userInfo.getNickname(),
72+
userInfo.getProfileImageUrl(),
73+
null,
74+
null,
75+
0
76+
);
77+
newUser.setUserProfile(userProfile);
78+
79+
return userRepository.save(newUser);
80+
});
81+
82+
// SecurityContext에 저장될 사용자 객체 반환
83+
return new CustomUserDetails(
84+
user.getId(),
85+
user.getUsername(),
86+
user.getRole(),
87+
attributes
88+
);
89+
} catch (CustomException e) {
90+
throw e;
91+
} catch (Exception e) {
92+
throw new CustomException(ErrorCode.OAUTH2_AUTHENTICATION_FAILED);
93+
}
94+
}
95+
}

0 commit comments

Comments
 (0)