Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

/* DATABASE */
runtimeOnly 'com.mysql:mysql-connector-j'
Expand All @@ -39,6 +41,7 @@ dependencies {
testImplementation 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
testRuntimeOnly 'com.h2database:h2'
testImplementation 'org.springframework.security:spring-security-test'

/* ETC */
annotationProcessor 'org.projectlombok:lombok'
Expand Down
13 changes: 13 additions & 0 deletions backend/src/main/java/io/f1/backend/domain/stat/entity/Stat.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.NoArgsConstructor;

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Stat extends BaseEntity {

@Id
Expand All @@ -31,4 +36,12 @@ public class Stat extends BaseEntity {

@Column(nullable = false)
private Long score;

@Builder
public Stat(User user, Long totalGames, Long winningGames, Long score) {
this.user = user;
this.totalGames = totalGames;
this.winningGames = winningGames;
this.score = score;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.f1.backend.domain.user.api;

import io.f1.backend.domain.user.app.UserService;
import io.f1.backend.domain.user.dto.SignupRequestDto;
import io.f1.backend.domain.user.dto.SignupResponseDto;

import jakarta.servlet.http.HttpSession;

import lombok.RequiredArgsConstructor;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class SignupController {

private final UserService userService;

@PostMapping("/signup")
public ResponseEntity<SignupResponseDto> completeSignup(
@RequestBody SignupRequestDto signupRequest, HttpSession httpSession) {
SignupResponseDto response = userService.signup(httpSession, signupRequest);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package io.f1.backend.domain.user.app;

import io.f1.backend.domain.stat.entity.Stat;
import io.f1.backend.domain.user.dao.UserRepository;
import io.f1.backend.domain.user.dto.SessionUser;
import io.f1.backend.domain.user.dto.UserPrincipal;
import io.f1.backend.domain.user.entity.User;

import jakarta.servlet.http.HttpSession;

import lombok.RequiredArgsConstructor;

import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;
import java.util.Objects;

@Service
@RequiredArgsConstructor
public class CustomOAuthUserService extends DefaultOAuth2UserService {

private final UserRepository userRepository;
private final HttpSession httpSession;

@Override
@Transactional
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest);

String provider = userRequest.getClientRegistration().getRegistrationId();
String providerId = Objects.requireNonNull(oAuth2User.getAttribute("id")).toString();

User user =
userRepository
.findByProviderAndProviderId(provider, providerId)
.map(this::updateLastLogin)
.orElseGet(() -> createNewUser(provider, providerId));

httpSession.setAttribute("OAuthUser", new SessionUser(user));
return new UserPrincipal(user, oAuth2User.getAttributes());
}

private User updateLastLogin(User user) {
user.updateLastLogin(LocalDateTime.now());
return userRepository.save(user);
}

private User createNewUser(String provider, String providerId) {
User user =
User.builder()
.provider(provider)
.providerId(providerId)
.lastLogin(LocalDateTime.now())
.build();

Stat stat = Stat.builder().totalGames(0L).winningGames(0L).score(0L).user(user).build();

user.initStat(stat);
return userRepository.save(user);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package io.f1.backend.domain.user.app;

import io.f1.backend.domain.user.dao.UserRepository;
import io.f1.backend.domain.user.dto.SessionUser;
import io.f1.backend.domain.user.dto.SignupRequestDto;
import io.f1.backend.domain.user.dto.SignupResponseDto;
import io.f1.backend.domain.user.entity.User;
import io.f1.backend.global.util.SecurityUtils;

import jakarta.servlet.http.HttpSession;

import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserService {

private final UserRepository userRepository;

@Transactional
public SignupResponseDto signup(HttpSession session, SignupRequestDto signupRequest) {
SessionUser sessionUser = extractSessionUser(session);

String nickname = signupRequest.nickname();
validateNickname(nickname);
validateDuplicateNickname(nickname);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[L5-참고의견]
validateDuplicateNickname 메서드는 validateNickname 내부에 위치시켜도 좋을 것 같습니다.


User user = updateUserNickname(sessionUser.getUserId(), nickname);
updateSessionAfterSignup(session, user);
SecurityUtils.setAuthentication(user);

return SignupResponseDto.toDto(user);
}

private SessionUser extractSessionUser(HttpSession session) {
SessionUser sessionUser = (SessionUser) session.getAttribute("OAuthUser");
if (sessionUser == null) {
throw new RuntimeException("세션에 OAuth 정보 없음");
}
return sessionUser;
}

private void validateNickname(String nickname) {
if (nickname == null || nickname.trim().isEmpty()) {
throw new RuntimeException("E400002: 닉네임은 필수 입력입니다.");
}
if (nickname.length() > 6) {
throw new RuntimeException("E400003: 닉네임은 6글자 이하로 입력해야 합니다.");
}
if (!nickname.matches("^[가-힣a-zA-Z0-9]+$")) {
throw new RuntimeException("E400004: 한글, 영문, 숫자만 입력해주세요.");
}
}

private void validateDuplicateNickname(String nickname) {
if (userRepository.existsUserByNickname(nickname)) {
throw new RuntimeException("닉네임 중복");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[L5-참고의견]
닉네임 중복은 E409001 에러코드가 존재하는 것으로 확인됩니다.

}
}

private User updateUserNickname(Long userId, String nickname) {
User user =
userRepository.findById(userId).orElseThrow(() -> new RuntimeException("사용자 없음"));
user.updateNickname(nickname);

return userRepository.save(user);
}

private void updateSessionAfterSignup(HttpSession session, User user) {
session.removeAttribute("OAuthUser");
session.setAttribute("user", new SessionUser(user));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package io.f1.backend.domain.user.app.handler;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

import java.io.IOException;

@Component
public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint {

@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException authException)
throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // 401
response.getWriter().write("{\"error\": \"Unauthorized\"}");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package io.f1.backend.domain.user.app.handler;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.stereotype.Component;

@Component
public class OAuthLogoutSuccessHandler implements LogoutSuccessHandler {

@Override
public void onLogoutSuccess(
HttpServletRequest request,
HttpServletResponse response,
Authentication authentication) {
response.setStatus(HttpServletResponse.SC_NO_CONTENT); // 204
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package io.f1.backend.domain.user.app.handler;

import com.fasterxml.jackson.databind.ObjectMapper;

import io.f1.backend.domain.user.dto.UserPrincipal;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import lombok.RequiredArgsConstructor;

import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.Map;

@Component
@RequiredArgsConstructor
public class OAuthSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {

private final ObjectMapper objectMapper;

@Override
public void onAuthenticationSuccess(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
UserPrincipal principal = (UserPrincipal) authentication.getPrincipal();
response.setContentType("application/json;charset=UTF-8");

if (principal.getUserNickname() == null) {
// 닉네임 설정 필요 → 202 Accepted
response.setStatus(HttpServletResponse.SC_ACCEPTED);
objectMapper.writeValue(response.getWriter(), Map.of("message", "닉네임을 설정하세요."));
} else {
// 정상 로그인 → 200 OK
response.setStatus(HttpServletResponse.SC_OK);
objectMapper.writeValue(response.getWriter(), Map.of("message", "로그인 성공"));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@
import io.f1.backend.domain.user.entity.User;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

// TODO : 퀴즈 생성을 위한 user 생성을 위해 임의로 만듦.
public interface UserRepository extends JpaRepository<User, Long> {}
import java.util.Optional;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {

Optional<User> findByProviderAndProviderId(String provider, String providerId);

Boolean existsUserByNickname(String nickname);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.f1.backend.domain.user.dto;

import io.f1.backend.domain.user.entity.User;

import lombok.Getter;

import java.io.Serializable;
import java.time.LocalDateTime;

@Getter
public class SessionUser implements Serializable {

private final Long userId;
private final String nickname;
private final String providerId;
private final LocalDateTime lastLogin;

public SessionUser(User user) {
this.userId = user.getId();
this.nickname = user.getNickname();
this.providerId = user.getProviderId();
this.lastLogin = user.getLastLogin();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.f1.backend.domain.user.dto;

import jakarta.validation.constraints.NotBlank;

public record SignupRequestDto(@NotBlank(message = "닉네임을 입력하세요") String nickname) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package io.f1.backend.domain.user.dto;

import io.f1.backend.domain.user.entity.User;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SignupResponseDto {

private Long id;
private String nickname;

public static SignupResponseDto toDto(User user) {
return SignupResponseDto.builder().id(user.getId()).nickname(user.getNickname()).build();
}
}
Loading