diff --git a/backend/build.gradle b/backend/build.gradle index e5a4520f..1b77cc8b 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -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' @@ -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' diff --git a/backend/src/main/java/io/f1/backend/domain/stat/entity/Stat.java b/backend/src/main/java/io/f1/backend/domain/stat/entity/Stat.java index 5bcb8b3a..c022f16c 100644 --- a/backend/src/main/java/io/f1/backend/domain/stat/entity/Stat.java +++ b/backend/src/main/java/io/f1/backend/domain/stat/entity/Stat.java @@ -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 @@ -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; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/user/api/SignupController.java b/backend/src/main/java/io/f1/backend/domain/user/api/SignupController.java new file mode 100644 index 00000000..d0cb45ac --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/api/SignupController.java @@ -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 completeSignup( + @RequestBody SignupRequestDto signupRequest, HttpSession httpSession) { + SignupResponseDto response = userService.signup(httpSession, signupRequest); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/CustomOAuthUserService.java b/backend/src/main/java/io/f1/backend/domain/user/app/CustomOAuthUserService.java new file mode 100644 index 00000000..61fd8bd6 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/app/CustomOAuthUserService.java @@ -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); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java b/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java new file mode 100644 index 00000000..57e27746 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/app/UserService.java @@ -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); + + 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("닉네임 중복"); + } + } + + 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)); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/handler/CustomAuthenticationEntryPoint.java b/backend/src/main/java/io/f1/backend/domain/user/app/handler/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..ebc9ffb1 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/app/handler/CustomAuthenticationEntryPoint.java @@ -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\"}"); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthLogoutSuccessHandler.java b/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthLogoutSuccessHandler.java new file mode 100644 index 00000000..8f0528b6 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthLogoutSuccessHandler.java @@ -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 + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthSuccessHandler.java b/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthSuccessHandler.java new file mode 100644 index 00000000..a465c885 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/app/handler/OAuthSuccessHandler.java @@ -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", "로그인 성공")); + } + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java b/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java index 71267cd3..d7420e59 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java @@ -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 {} +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByProviderAndProviderId(String provider, String providerId); + + Boolean existsUserByNickname(String nickname); +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/SessionUser.java b/backend/src/main/java/io/f1/backend/domain/user/dto/SessionUser.java new file mode 100644 index 00000000..80aeb4ed --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/dto/SessionUser.java @@ -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(); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequestDto.java b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequestDto.java new file mode 100644 index 00000000..efb34bc4 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupRequestDto.java @@ -0,0 +1,5 @@ +package io.f1.backend.domain.user.dto; + +import jakarta.validation.constraints.NotBlank; + +public record SignupRequestDto(@NotBlank(message = "닉네임을 입력하세요") String nickname) {} diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponseDto.java b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponseDto.java new file mode 100644 index 00000000..f0593a3a --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/dto/SignupResponseDto.java @@ -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(); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/dto/UserPrincipal.java b/backend/src/main/java/io/f1/backend/domain/user/dto/UserPrincipal.java new file mode 100644 index 00000000..ef82fcf8 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/dto/UserPrincipal.java @@ -0,0 +1,79 @@ +package io.f1.backend.domain.user.dto; + +import io.f1.backend.domain.user.entity.User; + +import lombok.Getter; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.Collections; +import java.util.Map; + +@Getter +public class UserPrincipal implements UserDetails, OAuth2User { + + public static final String ROLE_USER = "ROLE_USER"; + private final User user; + private final Map attributes; + + public UserPrincipal(User user, Map attributes) { + this.user = user; + this.attributes = attributes; + } + + @Override + public Map getAttributes() { + return attributes; + } + + public Long getUserId() { + return user.getId(); + } + + public String getUserNickname() { + return user.getNickname(); + } + + @Override + public String getName() { + return user.getProviderId(); + } + + @Override + public Collection getAuthorities() { + return Collections.singleton(() -> ROLE_USER); + } + + @Override + public String getPassword() { + return null; // 소셜 로그인이라 비밀번호 없음 + } + + @Override + public String getUsername() { + return user.getProviderId(); + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/entity/User.java b/backend/src/main/java/io/f1/backend/domain/user/entity/User.java index e989134c..4402e4e0 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/entity/User.java +++ b/backend/src/main/java/io/f1/backend/domain/user/entity/User.java @@ -12,7 +12,9 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import java.time.LocalDateTime; @@ -21,6 +23,7 @@ @Setter // quizService의 퀴즈 조회 메서드 구현 시까지 임시 사용 @Entity @Table(name = "`user`") +@NoArgsConstructor public class User extends BaseEntity { @Id @@ -30,7 +33,7 @@ public class User extends BaseEntity { @OneToOne(mappedBy = "user", cascade = CascadeType.ALL) private Stat stat; - @Column(unique = true, nullable = false) + @Column(unique = true) private String nickname; @Column(nullable = false) @@ -41,4 +44,23 @@ public class User extends BaseEntity { @Column(nullable = false) private LocalDateTime lastLogin; + + @Builder + public User(String provider, String providerId, LocalDateTime lastLogin) { + this.provider = provider; + this.providerId = providerId; + this.lastLogin = lastLogin; + } + + public void updateNickname(String nickname) { + this.nickname = nickname; + } + + public void updateLastLogin(LocalDateTime lastLogin) { + this.lastLogin = lastLogin; + } + + public void initStat(Stat stat) { + this.stat = stat; + } } diff --git a/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java new file mode 100644 index 00000000..57c98ac4 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java @@ -0,0 +1,64 @@ +package io.f1.backend.global.config; + +import io.f1.backend.domain.user.app.CustomOAuthUserService; +import io.f1.backend.domain.user.app.handler.CustomAuthenticationEntryPoint; +import io.f1.backend.domain.user.app.handler.OAuthLogoutSuccessHandler; +import io.f1.backend.domain.user.app.handler.OAuthSuccessHandler; + +import lombok.RequiredArgsConstructor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + private final CustomOAuthUserService customOAuthUserService; + private final OAuthSuccessHandler oAuthSuccessHandler; + private final OAuthLogoutSuccessHandler oAuthLogoutSuccessHandler; + + @Bean + public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { + http.csrf(AbstractHttpConfigurer::disable) + .exceptionHandling( + exception -> + exception.authenticationEntryPoint(customAuthenticationEntryPoint)) + .authorizeHttpRequests( + auth -> + auth.requestMatchers( + "/", + "/login", + "/oauth2/**", + "/signup", + "/css/**", + "/js/**") + .permitAll() + .requestMatchers("/ws/**") + .authenticated() + .anyRequest() + .authenticated()) + .formLogin(AbstractHttpConfigurer::disable) + .oauth2Login( + oauth2 -> + oauth2.userInfoEndpoint( + userInfo -> + userInfo.userService( + customOAuthUserService)) + .successHandler(oAuthSuccessHandler)) + .logout( + logout -> + logout.logoutUrl("/logout") + .logoutSuccessHandler(oAuthLogoutSuccessHandler) + .clearAuthentication(true) + .invalidateHttpSession(true) + .permitAll()); + return http.build(); + } +} diff --git a/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java new file mode 100644 index 00000000..cd00c053 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java @@ -0,0 +1,22 @@ +package io.f1.backend.global.util; + +import io.f1.backend.domain.user.dto.UserPrincipal; +import io.f1.backend.domain.user.entity.User; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Collections; + +public class SecurityUtils { + + private SecurityUtils() {} + + public static void setAuthentication(User user) { + UserPrincipal userPrincipal = new UserPrincipal(user, Collections.emptyMap()); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userPrincipal, null, userPrincipal.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(authentication); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 7a6d0fbd..bed10bcd 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -8,7 +8,7 @@ spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url : ${DB_URL} + url: ${DB_URL} username: ${DB_USERNAME} password: ${DB_PASSWORD} @@ -26,6 +26,25 @@ spring: hibernate: show_sql: true format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + + security: + oauth2: + client: + registration: + kakao: + client-name: Kakao + client-id: ${KAKAO_CLIENT} + client-secret: ${KAKAO_SECRET} + redirect-uri: "{baseUrl}/{action}/oauth2/code/{registrationId}" + authorization-grant-type: authorization_code + client-authentication-method: client_secret_post + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id file: thumbnail-path : images/thumbnail/ # 이후 배포 환경에서는 바꾸면 될 듯