Skip to content

Commit 5d6071c

Browse files
authored
[feat] Kakao OAuth 회원가입, 로그인, 로그아웃 기능 (#24)
* 🔧 chore: Kakao OAuth 기능 관련 설정 * ✨ feat: Kakao Oauth 회원가입, 로그인, 로그아웃 구현 * chore: Java 스타일 수정 * chore: Java 스타일 수정 --------- Co-authored-by: github-actions <>
1 parent 9598683 commit 5d6071c

17 files changed

+543
-4
lines changed

backend/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ dependencies {
2929
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
3030
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
3131
implementation 'org.springframework.boot:spring-boot-starter-websocket'
32+
implementation 'org.springframework.boot:spring-boot-starter-security'
33+
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
3234

3335
/* DATABASE */
3436
runtimeOnly 'com.mysql:mysql-connector-j'
@@ -39,6 +41,7 @@ dependencies {
3941
testImplementation 'org.projectlombok:lombok'
4042
testAnnotationProcessor 'org.projectlombok:lombok'
4143
testRuntimeOnly 'com.h2database:h2'
44+
testImplementation 'org.springframework.security:spring-security-test'
4245

4346
/* ETC */
4447
annotationProcessor 'org.projectlombok:lombok'

backend/src/main/java/io/f1/backend/domain/stat/entity/Stat.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
import jakarta.persistence.JoinColumn;
1313
import jakarta.persistence.OneToOne;
1414

15+
import lombok.AccessLevel;
16+
import lombok.Builder;
17+
import lombok.NoArgsConstructor;
18+
1519
@Entity
20+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
1621
public class Stat extends BaseEntity {
1722

1823
@Id
@@ -31,4 +36,12 @@ public class Stat extends BaseEntity {
3136

3237
@Column(nullable = false)
3338
private Long score;
39+
40+
@Builder
41+
public Stat(User user, Long totalGames, Long winningGames, Long score) {
42+
this.user = user;
43+
this.totalGames = totalGames;
44+
this.winningGames = winningGames;
45+
this.score = score;
46+
}
3447
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package io.f1.backend.domain.user.api;
2+
3+
import io.f1.backend.domain.user.app.UserService;
4+
import io.f1.backend.domain.user.dto.SignupRequestDto;
5+
import io.f1.backend.domain.user.dto.SignupResponseDto;
6+
7+
import jakarta.servlet.http.HttpSession;
8+
9+
import lombok.RequiredArgsConstructor;
10+
11+
import org.springframework.http.HttpStatus;
12+
import org.springframework.http.ResponseEntity;
13+
import org.springframework.web.bind.annotation.PostMapping;
14+
import org.springframework.web.bind.annotation.RequestBody;
15+
import org.springframework.web.bind.annotation.RestController;
16+
17+
@RestController
18+
@RequiredArgsConstructor
19+
public class SignupController {
20+
21+
private final UserService userService;
22+
23+
@PostMapping("/signup")
24+
public ResponseEntity<SignupResponseDto> completeSignup(
25+
@RequestBody SignupRequestDto signupRequest, HttpSession httpSession) {
26+
SignupResponseDto response = userService.signup(httpSession, signupRequest);
27+
return ResponseEntity.status(HttpStatus.CREATED).body(response);
28+
}
29+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package io.f1.backend.domain.user.app;
2+
3+
import io.f1.backend.domain.stat.entity.Stat;
4+
import io.f1.backend.domain.user.dao.UserRepository;
5+
import io.f1.backend.domain.user.dto.SessionUser;
6+
import io.f1.backend.domain.user.dto.UserPrincipal;
7+
import io.f1.backend.domain.user.entity.User;
8+
9+
import jakarta.servlet.http.HttpSession;
10+
11+
import lombok.RequiredArgsConstructor;
12+
13+
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
14+
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
15+
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
16+
import org.springframework.security.oauth2.core.user.OAuth2User;
17+
import org.springframework.stereotype.Service;
18+
import org.springframework.transaction.annotation.Transactional;
19+
20+
import java.time.LocalDateTime;
21+
import java.util.Objects;
22+
23+
@Service
24+
@RequiredArgsConstructor
25+
public class CustomOAuthUserService extends DefaultOAuth2UserService {
26+
27+
private final UserRepository userRepository;
28+
private final HttpSession httpSession;
29+
30+
@Override
31+
@Transactional
32+
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
33+
OAuth2User oAuth2User = super.loadUser(userRequest);
34+
35+
String provider = userRequest.getClientRegistration().getRegistrationId();
36+
String providerId = Objects.requireNonNull(oAuth2User.getAttribute("id")).toString();
37+
38+
User user =
39+
userRepository
40+
.findByProviderAndProviderId(provider, providerId)
41+
.map(this::updateLastLogin)
42+
.orElseGet(() -> createNewUser(provider, providerId));
43+
44+
httpSession.setAttribute("OAuthUser", new SessionUser(user));
45+
return new UserPrincipal(user, oAuth2User.getAttributes());
46+
}
47+
48+
private User updateLastLogin(User user) {
49+
user.updateLastLogin(LocalDateTime.now());
50+
return userRepository.save(user);
51+
}
52+
53+
private User createNewUser(String provider, String providerId) {
54+
User user =
55+
User.builder()
56+
.provider(provider)
57+
.providerId(providerId)
58+
.lastLogin(LocalDateTime.now())
59+
.build();
60+
61+
Stat stat = Stat.builder().totalGames(0L).winningGames(0L).score(0L).user(user).build();
62+
63+
user.initStat(stat);
64+
return userRepository.save(user);
65+
}
66+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package io.f1.backend.domain.user.app;
2+
3+
import io.f1.backend.domain.user.dao.UserRepository;
4+
import io.f1.backend.domain.user.dto.SessionUser;
5+
import io.f1.backend.domain.user.dto.SignupRequestDto;
6+
import io.f1.backend.domain.user.dto.SignupResponseDto;
7+
import io.f1.backend.domain.user.entity.User;
8+
import io.f1.backend.global.util.SecurityUtils;
9+
10+
import jakarta.servlet.http.HttpSession;
11+
12+
import lombok.RequiredArgsConstructor;
13+
14+
import org.springframework.stereotype.Service;
15+
import org.springframework.transaction.annotation.Transactional;
16+
17+
@Service
18+
@RequiredArgsConstructor
19+
public class UserService {
20+
21+
private final UserRepository userRepository;
22+
23+
@Transactional
24+
public SignupResponseDto signup(HttpSession session, SignupRequestDto signupRequest) {
25+
SessionUser sessionUser = extractSessionUser(session);
26+
27+
String nickname = signupRequest.nickname();
28+
validateNickname(nickname);
29+
validateDuplicateNickname(nickname);
30+
31+
User user = updateUserNickname(sessionUser.getUserId(), nickname);
32+
updateSessionAfterSignup(session, user);
33+
SecurityUtils.setAuthentication(user);
34+
35+
return SignupResponseDto.toDto(user);
36+
}
37+
38+
private SessionUser extractSessionUser(HttpSession session) {
39+
SessionUser sessionUser = (SessionUser) session.getAttribute("OAuthUser");
40+
if (sessionUser == null) {
41+
throw new RuntimeException("세션에 OAuth 정보 없음");
42+
}
43+
return sessionUser;
44+
}
45+
46+
private void validateNickname(String nickname) {
47+
if (nickname == null || nickname.trim().isEmpty()) {
48+
throw new RuntimeException("E400002: 닉네임은 필수 입력입니다.");
49+
}
50+
if (nickname.length() > 6) {
51+
throw new RuntimeException("E400003: 닉네임은 6글자 이하로 입력해야 합니다.");
52+
}
53+
if (!nickname.matches("^[가-힣a-zA-Z0-9]+$")) {
54+
throw new RuntimeException("E400004: 한글, 영문, 숫자만 입력해주세요.");
55+
}
56+
}
57+
58+
private void validateDuplicateNickname(String nickname) {
59+
if (userRepository.existsUserByNickname(nickname)) {
60+
throw new RuntimeException("닉네임 중복");
61+
}
62+
}
63+
64+
private User updateUserNickname(Long userId, String nickname) {
65+
User user =
66+
userRepository.findById(userId).orElseThrow(() -> new RuntimeException("사용자 없음"));
67+
user.updateNickname(nickname);
68+
69+
return userRepository.save(user);
70+
}
71+
72+
private void updateSessionAfterSignup(HttpSession session, User user) {
73+
session.removeAttribute("OAuthUser");
74+
session.setAttribute("user", new SessionUser(user));
75+
}
76+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package io.f1.backend.domain.user.app.handler;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import jakarta.servlet.http.HttpServletResponse;
5+
6+
import org.springframework.security.core.AuthenticationException;
7+
import org.springframework.security.web.AuthenticationEntryPoint;
8+
import org.springframework.stereotype.Component;
9+
10+
import java.io.IOException;
11+
12+
@Component
13+
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {
14+
15+
@Override
16+
public void commence(
17+
HttpServletRequest request,
18+
HttpServletResponse response,
19+
AuthenticationException authException)
20+
throws IOException {
21+
response.setContentType("application/json;charset=UTF-8");
22+
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
23+
response.getWriter().write("{\"error\": \"Unauthorized\"}");
24+
}
25+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package io.f1.backend.domain.user.app.handler;
2+
3+
import jakarta.servlet.http.HttpServletRequest;
4+
import jakarta.servlet.http.HttpServletResponse;
5+
6+
import org.springframework.security.core.Authentication;
7+
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
8+
import org.springframework.stereotype.Component;
9+
10+
@Component
11+
public class OAuthLogoutSuccessHandler implements LogoutSuccessHandler {
12+
13+
@Override
14+
public void onLogoutSuccess(
15+
HttpServletRequest request,
16+
HttpServletResponse response,
17+
Authentication authentication) {
18+
response.setStatus(HttpServletResponse.SC_NO_CONTENT); // 204
19+
}
20+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package io.f1.backend.domain.user.app.handler;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
5+
import io.f1.backend.domain.user.dto.UserPrincipal;
6+
7+
import jakarta.servlet.http.HttpServletRequest;
8+
import jakarta.servlet.http.HttpServletResponse;
9+
10+
import lombok.RequiredArgsConstructor;
11+
12+
import org.springframework.security.core.Authentication;
13+
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
14+
import org.springframework.stereotype.Component;
15+
16+
import java.io.IOException;
17+
import java.util.Map;
18+
19+
@Component
20+
@RequiredArgsConstructor
21+
public class OAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
22+
23+
private final ObjectMapper objectMapper;
24+
25+
@Override
26+
public void onAuthenticationSuccess(
27+
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
28+
throws IOException {
29+
UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
30+
response.setContentType("application/json;charset=UTF-8");
31+
32+
if (principal.getUserNickname() == null) {
33+
// 닉네임 설정 필요 → 202 Accepted
34+
response.setStatus(HttpServletResponse.SC_ACCEPTED);
35+
objectMapper.writeValue(response.getWriter(), Map.of("message", "닉네임을 설정하세요."));
36+
} else {
37+
// 정상 로그인 → 200 OK
38+
response.setStatus(HttpServletResponse.SC_OK);
39+
objectMapper.writeValue(response.getWriter(), Map.of("message", "로그인 성공"));
40+
}
41+
}
42+
}

backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
import io.f1.backend.domain.user.entity.User;
44

55
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.stereotype.Repository;
67

7-
// TODO : 퀴즈 생성을 위한 user 생성을 위해 임의로 만듦.
8-
public interface UserRepository extends JpaRepository<User, Long> {}
8+
import java.util.Optional;
9+
10+
@Repository
11+
public interface UserRepository extends JpaRepository<User, Long> {
12+
13+
Optional<User> findByProviderAndProviderId(String provider, String providerId);
14+
15+
Boolean existsUserByNickname(String nickname);
16+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package io.f1.backend.domain.user.dto;
2+
3+
import io.f1.backend.domain.user.entity.User;
4+
5+
import lombok.Getter;
6+
7+
import java.io.Serializable;
8+
import java.time.LocalDateTime;
9+
10+
@Getter
11+
public class SessionUser implements Serializable {
12+
13+
private final Long userId;
14+
private final String nickname;
15+
private final String providerId;
16+
private final LocalDateTime lastLogin;
17+
18+
public SessionUser(User user) {
19+
this.userId = user.getId();
20+
this.nickname = user.getNickname();
21+
this.providerId = user.getProviderId();
22+
this.lastLogin = user.getLastLogin();
23+
}
24+
}

0 commit comments

Comments
 (0)