diff --git a/back/src/main/java/com/back/domain/session/controller/SessionController.java b/back/src/main/java/com/back/domain/session/controller/SessionController.java deleted file mode 100644 index bdb5243..0000000 --- a/back/src/main/java/com/back/domain/session/controller/SessionController.java +++ /dev/null @@ -1,69 +0,0 @@ -package com.back.domain.session.controller; - -import com.back.domain.session.service.SessionService; -import com.back.domain.user.service.UserService; -import com.back.global.config.JwtTokenProvider; -import com.back.global.config.TokenInfo; -import com.back.global.dto.LoginRequest; -import com.back.global.dto.SignupRequest; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.bind.annotation.*; - -/** - * 사용자 인증 및 세션 관련 API 요청을 처리하는 컨트롤러. - * 회원가입, 로그인, 게스트 로그인, 로그아웃 기능을 제공합니다. - */ -@RestController -@RequestMapping("/users-auth") -@RequiredArgsConstructor -public class SessionController { - - private final SessionService sessionService; - private final UserService userService; - private final JwtTokenProvider jwtTokenProvider; - private final AuthenticationManager authenticationManager; - - @PostMapping("/signup") - public ResponseEntity signup(@Valid @RequestBody SignupRequest signupRequest) { - // 사용자 회원가입 처리 - userService.signup(signupRequest); - return ResponseEntity.ok("회원가입 성공"); - } - - @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) { - // 세션 기반 로그인 후 JWT 토큰 발급 - Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(loginRequest.getLoginId(), loginRequest.getPassword()) - ); - SecurityContextHolder.getContext().setAuthentication(authentication); - - TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication); - - return ResponseEntity.ok(tokenInfo); - } - - @PostMapping("/guest") - public ResponseEntity guestLogin() { - // 게스트 토큰 발급 - Authentication guestAuthentication = sessionService.authenticateGuest(); - SecurityContextHolder.getContext().setAuthentication(guestAuthentication); - - TokenInfo tokenInfo = jwtTokenProvider.generateToken(guestAuthentication); - return ResponseEntity.ok(tokenInfo); - } - - @PostMapping("/logout") - public ResponseEntity logout(HttpServletRequest request, HttpServletResponse response) { - // 로그아웃 처리 - return ResponseEntity.ok("로그아웃 성공"); - } -} diff --git a/back/src/main/java/com/back/domain/session/entity/Session.java b/back/src/main/java/com/back/domain/session/entity/Session.java deleted file mode 100644 index 36e5ab0..0000000 --- a/back/src/main/java/com/back/domain/session/entity/Session.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.back.domain.session.entity; - -import com.back.domain.user.entity.User; -import com.back.global.baseentity.BaseEntity; -import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; -import org.hibernate.annotations.ColumnDefault; - -import java.time.LocalDateTime; - -/** - * 사용자 세션 정보를 저장하는 엔티티. - * 로그인된 사용자 또는 게스트의 세션 상태를 관리합니다. - */ -@Entity -@Table(name = "sessions") -@Getter -@Setter -@NoArgsConstructor -@AllArgsConstructor -@Builder -public class Session extends BaseEntity { - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id", nullable = false) - private User user; - - @Enumerated(EnumType.STRING) - @Column(nullable = false) - @ColumnDefault("'GUEST'") - private SessionType sessionKind; - - @Column(unique = true, nullable = false, length = 191) - private String jwtId; - - @Column(nullable = false) - private LocalDateTime expiresAt; - - @Column(nullable = false) - @ColumnDefault("false") - private boolean revoked; -} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/session/entity/SessionType.java b/back/src/main/java/com/back/domain/session/entity/SessionType.java deleted file mode 100644 index 3034fc0..0000000 --- a/back/src/main/java/com/back/domain/session/entity/SessionType.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.back.domain.session.entity; - -/** - * 사용자 세션의 타입을 정의하는 Enum. - * USER, GUEST, ADMIN 세 가지 유형이 있습니다. - */ -public enum SessionType { - USER, GUEST, ADMIN -} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/session/repository/SessionRepository.java b/back/src/main/java/com/back/domain/session/repository/SessionRepository.java deleted file mode 100644 index 6007651..0000000 --- a/back/src/main/java/com/back/domain/session/repository/SessionRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.back.domain.session.repository; - -import com.back.domain.session.entity.Session; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; - -/** - * 세션 엔티티에 대한 데이터베이스 접근을 담당하는 JpaRepository. - */ -@Repository -public interface SessionRepository extends JpaRepository { -} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/session/service/SessionService.java b/back/src/main/java/com/back/domain/session/service/SessionService.java deleted file mode 100644 index 27787b9..0000000 --- a/back/src/main/java/com/back/domain/session/service/SessionService.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.back.domain.session.service; - -import com.back.domain.session.repository.SessionRepository; -import com.back.domain.user.entity.Gender; -import com.back.domain.user.entity.Mbti; -import com.back.domain.user.entity.Role; -import com.back.domain.user.entity.User; -import com.back.domain.user.repository.UserRepository; -import com.back.global.config.CustomUserDetails; -import lombok.RequiredArgsConstructor; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; -import java.util.Collections; -import java.util.UUID; - -/** - * 세션 관련 비즈니스 로직을 처리하는 서비스. - * 게스트 사용자 인증 및 세션 관리를 담당합니다. - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class SessionService { - - private final SessionRepository sessionRepository; - private final UserRepository userRepository; - private final PasswordEncoder passwordEncoder; - - public Authentication authenticateGuest() { - // 게스트 유저를 생성하고 Authentication 객체를 반환 - String guestLoginId = "guest_" + UUID.randomUUID().toString().substring(0, 8); - String guestEmail = guestLoginId + "@example.com"; - String guestPassword = UUID.randomUUID().toString(); - - User guestUser = User.builder() - .loginId(guestLoginId) - .email(guestEmail) - .password(guestPassword) - .nickname("게스트_" + UUID.randomUUID().toString().substring(0, 4)) - .birthdayAt(LocalDateTime.now()) - .gender(Gender.N) - .mbti(Mbti.INFP) - .beliefs("자유") - .role(Role.GUEST) - .build(); - - CustomUserDetails guestUserDetails = new CustomUserDetails(guestUser); - return new UsernamePasswordAuthenticationToken( - guestUserDetails, - guestUser.getPassword(), - Collections.singletonList(new SimpleGrantedAuthority("ROLE_" + guestUser.getRole().name())) - ); - } -} \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/user/controller/UserAuthController.java b/back/src/main/java/com/back/domain/user/controller/UserAuthController.java new file mode 100644 index 0000000..e5a0993 --- /dev/null +++ b/back/src/main/java/com/back/domain/user/controller/UserAuthController.java @@ -0,0 +1,88 @@ +package com.back.domain.user.controller; + +import com.back.domain.user.dto.LoginRequest; +import com.back.domain.user.dto.SignupRequest; +import com.back.domain.user.dto.UserResponse; +import com.back.domain.user.entity.User; +import com.back.domain.user.service.GuestService; +import com.back.domain.user.service.UserService; +import com.back.global.common.ApiResponse; +import com.back.global.security.CustomUserDetails; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.web.bind.annotation.*; + +/** + * 사용자 인증/인가 관련 기능을 제공하는 REST 컨트롤러입니다. + * - 회원가입 (/signup): 새로운 사용자를 등록합니다. + * - 로그인 (/login): 이메일과 비밀번호를 이용해 인증 후 세션을 생성합니다. + * - 게스트 로그인 (/guest): 비회원 사용자용 임시 계정을 생성하고 로그인 처리합니다. + * - 현재 사용자 조회 (/me): 현재 인증된 사용자의 정보를 반환합니다. + */ +@RestController +@RequestMapping("/api/v1/users-auth") +@RequiredArgsConstructor +public class UserAuthController { + + private final UserService userService; + private final AuthenticationManager authenticationManager; + private final GuestService guestService; + + @PostMapping("/signup") + public ResponseEntity> signup(@Valid @RequestBody SignupRequest req){ + User saved = userService.signup(req); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(UserResponse.from(saved), "성공적으로 생성되었습니다.")); + } + + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody LoginRequest req, + HttpServletRequest request, + HttpServletResponse response) + { + Authentication auth = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(req.email(), req.password())); + SecurityContextHolder.getContext().setAuthentication(auth); + + request.getSession(true); + new HttpSessionSecurityContextRepository() + .saveContext(SecurityContextHolder.getContext(), request, response); + + CustomUserDetails cud = (CustomUserDetails) auth.getPrincipal(); + return ResponseEntity.ok(ApiResponse.success(UserResponse.from(cud.getUser()), "로그인 성공")); + } + + @PostMapping("/guest") + public ResponseEntity> guestLogin(HttpServletRequest request, HttpServletResponse response){ + User savedGuest = guestService.createAndSaveGuest(); + + CustomUserDetails cud = new CustomUserDetails(savedGuest); + Authentication auth = new UsernamePasswordAuthenticationToken(cud, null, cud.getAuthorities()); + SecurityContextHolder.getContext().setAuthentication(auth); + + request.getSession(true); + new HttpSessionSecurityContextRepository() + .saveContext(SecurityContextHolder.getContext(), request, response); + + return ResponseEntity.ok(ApiResponse.success(UserResponse.from(savedGuest), "게스트 로그인 성공")); + } + + @GetMapping("/me") + public ResponseEntity> me(@AuthenticationPrincipal CustomUserDetails cud) { + if (cud == null) { + return ResponseEntity.ok(ApiResponse.success(null, "anonymous")); + } + return ResponseEntity.ok(ApiResponse.success(UserResponse.from(cud.getUser()), "authenticated")); + } +} diff --git a/back/src/main/java/com/back/domain/user/dto/LoginRequest.java b/back/src/main/java/com/back/domain/user/dto/LoginRequest.java new file mode 100644 index 0000000..c5a38f2 --- /dev/null +++ b/back/src/main/java/com/back/domain/user/dto/LoginRequest.java @@ -0,0 +1,15 @@ +package com.back.domain.user.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +/** + * 사용자 로그인 요청 시 필요한 정보를 담는 DTO 클래스. + * 로그인 ID와 비밀번호를 포함합니다. + */ +public record LoginRequest( + @Email @NotBlank(message = "로그인 아이디는 필수 입력 값입니다.") + String email, + @NotBlank(message = "비밀번호는 필수 입력 값입니다.") + String password +) {} diff --git a/back/src/main/java/com/back/domain/user/dto/SignupRequest.java b/back/src/main/java/com/back/domain/user/dto/SignupRequest.java new file mode 100644 index 0000000..3f2539f --- /dev/null +++ b/back/src/main/java/com/back/domain/user/dto/SignupRequest.java @@ -0,0 +1,33 @@ +package com.back.domain.user.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import jakarta.validation.constraints.*; + +import java.time.LocalDateTime; + +/** + * 사용자 회원가입 요청 시 필요한 정보를 담는 DTO 클래스. + * 로그인 ID, 이메일, 비밀번호, 사용자 이름, 닉네임, 생년월일을 포함합니다. + */ +public record SignupRequest( + + @NotBlank(message = "이메일은 필수 입력 값입니다.") + @Email(message = "이메일 형식에 맞지 않습니다.") + String email, + + @NotBlank(message = "비밀번호는 필수 입력 값입니다.") + @Pattern(regexp="(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}", + message="비밀번호는 영문, 숫자, 특수기호 포함 8~20자여야 합니다.") + String password, + + @NotBlank(message = "이름은 필수 입력 값입니다.") + String username, + + @NotBlank(message = "닉네임은 필수 입력 값입니다.") + String nickname, + + @NotNull(message = "생년월일은 필수 입력 값입니다.") + @Past(message = "생년월일은 과거 날짜여야 합니다.") + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") // JSON 바인딩용(폼 바인딩이면 @DateTimeFormat) + LocalDateTime birthdayAt +) {} diff --git a/back/src/main/java/com/back/domain/user/dto/UserResponse.java b/back/src/main/java/com/back/domain/user/dto/UserResponse.java new file mode 100644 index 0000000..6d50390 --- /dev/null +++ b/back/src/main/java/com/back/domain/user/dto/UserResponse.java @@ -0,0 +1,27 @@ +package com.back.domain.user.dto; + +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; + +import java.time.LocalDateTime; + +/** + * 사용자 정보를 클라이언트에 반환하기 위한 응답 DTO입니다. + */ +public record UserResponse( + Long id, + String email, + String username, + Role role, + String nickname, + LocalDateTime birthdayAt, + String authProvider +) { + public static UserResponse from(User u) { + return new UserResponse( + u.getId(), u.getEmail(), u.getUsername(), + u.getRole(), u.getNickname(), u.getBirthdayAt(), + u.getAuthProvider() == null ? "" : u.getAuthProvider().name() + ); + } +} diff --git a/back/src/main/java/com/back/domain/user/entity/AuthProvider.java b/back/src/main/java/com/back/domain/user/entity/AuthProvider.java index 164e562..a5a40ef 100644 --- a/back/src/main/java/com/back/domain/user/entity/AuthProvider.java +++ b/back/src/main/java/com/back/domain/user/entity/AuthProvider.java @@ -5,5 +5,5 @@ * 일반 로그인, 소셜 로그인(Google, Kakao, Naver, GitHub, Apple), 게스트 로그인 등을 포함합니다. */ public enum AuthProvider { - LOCAL, GOOGLE, KAKAO, NAVER, GITHUB, APPLE, GUEST + LOCAL, GOOGLE, GITHUB, GUEST } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/user/entity/User.java b/back/src/main/java/com/back/domain/user/entity/User.java index 5e679bc..597ae19 100644 --- a/back/src/main/java/com/back/domain/user/entity/User.java +++ b/back/src/main/java/com/back/domain/user/entity/User.java @@ -3,7 +3,6 @@ import com.back.global.baseentity.BaseEntity; import jakarta.persistence.*; import lombok.*; -import org.hibernate.annotations.ColumnDefault; import org.springframework.data.annotation.LastModifiedDate; import java.time.LocalDateTime; @@ -21,35 +20,31 @@ @Builder public class User extends BaseEntity { - @Column(unique = true) - private String loginId; - - @Column(unique = true) + @Column(nullable = false, unique = true) private String email; @Enumerated(EnumType.STRING) @Column(nullable = false) - @ColumnDefault("'GUEST'") private Role role; @Column(nullable = true) private String password; - @Column(length = 80) + @Column(nullable = false, length = 30) + private String username; + + @Column(nullable = false, unique = true, length = 80) private String nickname; @Column(nullable = false) private LocalDateTime birthdayAt; @Enumerated(EnumType.STRING) - @Column(nullable = false) private Gender gender; @Enumerated(EnumType.STRING) - @Column(nullable = false) private Mbti mbti; - @Column(nullable = false) private String beliefs; private String lifeSatis; diff --git a/back/src/main/java/com/back/domain/user/repository/UserRepository.java b/back/src/main/java/com/back/domain/user/repository/UserRepository.java index 617d6d1..7a1f033 100644 --- a/back/src/main/java/com/back/domain/user/repository/UserRepository.java +++ b/back/src/main/java/com/back/domain/user/repository/UserRepository.java @@ -11,6 +11,8 @@ */ @Repository public interface UserRepository extends JpaRepository { - Optional findByLoginId(String loginId); Optional findByEmail(String email); + + boolean existsByEmail(String email); + boolean existsByNickname(String nickname); } \ No newline at end of file diff --git a/back/src/main/java/com/back/domain/user/service/GuestService.java b/back/src/main/java/com/back/domain/user/service/GuestService.java new file mode 100644 index 0000000..59cf79d --- /dev/null +++ b/back/src/main/java/com/back/domain/user/service/GuestService.java @@ -0,0 +1,38 @@ +package com.back.domain.user.service; + +import com.back.domain.user.entity.*; +import com.back.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 게스트 사용자 계정을 생성하고 DB에 저장하는 서비스 클래스입니다. + * 고유한 email을 생성하고 GUEST로 저장합니다. + */ +@Service +@RequiredArgsConstructor +public class GuestService { + private final UserRepository userRepository; + + @Transactional + public User createAndSaveGuest(){ + String guestLoginId = "guest_" + UUID.randomUUID().toString().substring(0, 8); + String guestEmail = guestLoginId + "@example.com"; + + + User guest = User.builder() + .email(guestEmail) + .username(guestLoginId) + .password(null) // 게스트 비밀번호 없음(추후 전환 시 설정) + .nickname("게스트_" + UUID.randomUUID().toString().substring(0, 4)) + .birthdayAt(LocalDateTime.now()) + .role(Role.GUEST) + .authProvider(AuthProvider.GUEST) + .build(); + return userRepository.save(guest); + } +} diff --git a/back/src/main/java/com/back/domain/user/service/UserService.java b/back/src/main/java/com/back/domain/user/service/UserService.java index 9891fea..dd05447 100644 --- a/back/src/main/java/com/back/domain/user/service/UserService.java +++ b/back/src/main/java/com/back/domain/user/service/UserService.java @@ -1,9 +1,8 @@ package com.back.domain.user.service; -import com.back.domain.user.entity.Role; -import com.back.domain.user.entity.User; +import com.back.domain.user.entity.*; import com.back.domain.user.repository.UserRepository; -import com.back.global.dto.SignupRequest; +import com.back.domain.user.dto.SignupRequest; import com.back.global.exception.ApiException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -11,9 +10,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.util.Optional; + /** - * 사용자 관련 비즈니스 로직을 처리하는 서비스. - * 사용자 회원가입, 정보 조회 등의 기능을 제공합니다. + * 사용자 관련 비즈니스 로직을 처리하는 서비스입니다. + * 회원가입(signup), 소셜 로그인 사용자 등록/갱신(upsertOAuthUser) */ @Service @RequiredArgsConstructor @@ -24,33 +26,79 @@ public class UserService { private final PasswordEncoder passwordEncoder; @Transactional - public void signup(SignupRequest signupRequest) { - // 사용자 회원가입 처리 - if (userRepository.findByLoginId(signupRequest.getLoginId()).isPresent()) { - throw new ApiException(ErrorCode.LOGIN_ID_DUPLICATION); - } - if (userRepository.findByEmail(signupRequest.getEmail()).isPresent()) { + public User signup(SignupRequest signupRequest) { + // 사전 검증 + if (userRepository.existsByEmail(signupRequest.email())) { throw new ApiException(ErrorCode.EMAIL_DUPLICATION); } + if (userRepository.existsByNickname(signupRequest.nickname())) { + throw new ApiException(ErrorCode.NICKNAME_DUPLICATION); // 없으면 추가(아래 참고) + } + + User user = User.builder() + .email(signupRequest.email()) + .password(passwordEncoder.encode(signupRequest.password())) + .username(signupRequest.username()) + .nickname(signupRequest.nickname()) + .birthdayAt(signupRequest.birthdayAt()) + .authProvider(AuthProvider.LOCAL) + .role(Role.USER) + .build(); + + return userRepository.save(user); + } + + @Transactional + public User upsertOAuthUser(String email, String nickname, AuthProvider provider){ + Optional found = userRepository.findByEmail(email); + if(found.isPresent()){ + User user = found.get(); + if(user.getAuthProvider()==null) user.setAuthProvider(provider); + if(user.getNickname()==null || user.getNickname().isBlank()){ + user.setNickname(safeUniqueNickname(nickname)); + } + if(user.getUsername()==null || user.getUsername().isBlank()){ + user.setUsername(defaultUsernameFromEmail(email)); + } + return user; + } + // 최초 소셜 로그인 시 필수값 기본 세팅 + String safeNick = safeUniqueNickname(nickname); + String defaultUsername = defaultUsernameFromEmail(email); User user = User.builder() - .loginId(signupRequest.getLoginId()) - .email(signupRequest.getEmail()) - .password(passwordEncoder.encode(signupRequest.getPassword())) - .nickname(signupRequest.getNickname()) - .birthdayAt(signupRequest.getBirthdayAt()) - .gender(signupRequest.getGender()) - .mbti(signupRequest.getMbti()) - .beliefs(signupRequest.getBeliefs()) + .email(email) + .username(defaultUsername) + .password(null) + .nickname(safeNick) + .birthdayAt(LocalDateTime.now()) .role(Role.USER) + .authProvider(provider) .build(); + return userRepository.save(user); + } - userRepository.save(user); + // 유니크 닉네임 생성 + private String safeUniqueNickname(String base) { + if (base == null || base.isBlank()) base = "user"; + String nick = base; + int i = 0; + while (userRepository.existsByNickname(nick)) { + i++; + nick = base + i; + if (nick.length() > 80) { + nick = (base.length() > 75 ? base.substring(0, 75) : base) + i; + } + } + return nick; } - public User findByLoginId(String loginId) { - // 로그인 ID로 사용자 정보 조회 - return userRepository.findByLoginId(loginId) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + // 이메일로부터 username 생성 + private String defaultUsernameFromEmail(String email) { + String local = (email != null && email.contains("@")) ? email.substring(0, email.indexOf('@')) : "user"; + String candidate = local.replaceAll("[^a-zA-Z0-9._-]", ""); + if (candidate.length() < 3) candidate = "user"; + if (candidate.length() > 30) candidate = candidate.substring(0, 30); + return candidate; } } diff --git a/back/src/main/java/com/back/global/config/CorsConfig.java b/back/src/main/java/com/back/global/config/CorsConfig.java new file mode 100644 index 0000000..7fa89dd --- /dev/null +++ b/back/src/main/java/com/back/global/config/CorsConfig.java @@ -0,0 +1,28 @@ +package com.back.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +/** + * 애플리케이션의 CORS(Cross-Origin Resource Sharing) 정책을 설정하는 구성 클래스입니다. + */ +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource(){ + CorsConfiguration conf = new CorsConfiguration(); + conf.setAllowedOriginPatterns(List.of("http://localhost:*")); + conf.setAllowedMethods(List.of("GET","POST","PUT","DELETE","PATCH","OPTIONS")); + conf.setAllowedHeaders(List.of("*")); + conf.setAllowCredentials(true); + conf.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", conf); + return source; + } +} diff --git a/back/src/main/java/com/back/global/config/CustomOAuth2UserService.java b/back/src/main/java/com/back/global/config/CustomOAuth2UserService.java deleted file mode 100644 index 01271a2..0000000 --- a/back/src/main/java/com/back/global/config/CustomOAuth2UserService.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.back.global.config; - -import com.back.domain.user.entity.AuthProvider; -import com.back.domain.user.entity.Role; -import com.back.domain.user.entity.User; -import com.back.domain.user.repository.UserRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; -import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.user.OAuth2User; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.Map; -import java.util.Optional; - -@Service -@RequiredArgsConstructor -public class CustomOAuth2UserService implements OAuth2UserService { - - private final UserRepository userRepository; - - @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - OAuth2UserService delegate = new DefaultOAuth2UserService(); - OAuth2User oAuth2User = delegate.loadUser(userRequest); - - String registrationId = userRequest.getClientRegistration().getRegistrationId(); - String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); - - OAuthAttributes attributes = OAuthAttributes.of(registrationId, userNameAttributeName, oAuth2User.getAttributes()); - - User user = saveOrUpdate(attributes, registrationId); - - return new CustomUserDetails(user, oAuth2User.getAttributes()); - } - - private User saveOrUpdate(OAuthAttributes attributes, String registrationId) { - Optional userOptional = userRepository.findByEmail(attributes.getEmail()); - User user; - - if (userOptional.isPresent()) { - user = userOptional.get(); - // 이미 존재하는 유저라면 정보 업데이트 (예: 닉네임, 프로필 이미지 등) - // user.update(attributes.getName(), attributes.getPicture()); - } else { - // 새로운 유저라면 회원가입 처리 - AuthProvider authProvider = AuthProvider.valueOf(registrationId.toUpperCase()); - user = User.builder() - .loginId(attributes.getEmail()) // OAuth2에서는 이메일을 loginId로 사용 - .email(attributes.getEmail()) - .nickname(attributes.getName()) - .role(Role.USER) - .authProvider(authProvider) // AuthProvider 추가 - .password("oauth2_user") // OAuth2 유저는 비밀번호가 필요 없음 (임시 값) - .birthdayAt(LocalDateTime.now()) // 임시 값 - .gender(null) // 임시 값 - .mbti(null) // 임시 값 - .beliefs("OAuth2 User") // 임시 값 - .build(); - } - return userRepository.save(user); - } -} diff --git a/back/src/main/java/com/back/global/config/JwtAuthenticationFilter.java b/back/src/main/java/com/back/global/config/JwtAuthenticationFilter.java deleted file mode 100644 index b71d81c..0000000 --- a/back/src/main/java/com/back/global/config/JwtAuthenticationFilter.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.back.global.config; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletResponse; -import jakarta.servlet.http.HttpServletRequest; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.util.StringUtils; -import org.springframework.web.filter.GenericFilterBean; - -import java.io.IOException; - -/** - * JWT 토큰을 사용하여 인증을 처리하는 필터. - * 요청 헤더에서 JWT 토큰을 추출하고 유효성을 검사하여 Spring Security 컨텍스트에 인증 정보를 설정합니다. - */ -@RequiredArgsConstructor -public class JwtAuthenticationFilter extends GenericFilterBean { - - private final JwtTokenProvider jwtTokenProvider; - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { - // 요청을 필터링하여 JWT 토큰을 검사하고 인증 정보를 설정 - String token = resolveToken((HttpServletRequest) request); - - if (token != null && jwtTokenProvider.validateToken(token)) { - Authentication authentication = jwtTokenProvider.getAuthentication(token); - SecurityContextHolder.getContext().setAuthentication(authentication); - } - chain.doFilter(request, response); - } - - private String resolveToken(HttpServletRequest request) { - // HttpServletRequest의 Authorization 헤더에서 JWT 토큰을 추출 - String bearerToken = request.getHeader("Authorization"); - if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer")) { - return bearerToken.substring(7); - } - return null; - } -} diff --git a/back/src/main/java/com/back/global/config/JwtTokenProvider.java b/back/src/main/java/com/back/global/config/JwtTokenProvider.java deleted file mode 100644 index d30ed00..0000000 --- a/back/src/main/java/com/back/global/config/JwtTokenProvider.java +++ /dev/null @@ -1,108 +0,0 @@ -package com.back.global.config; - -import io.jsonwebtoken.*; -import io.jsonwebtoken.io.Decoders; -import io.jsonwebtoken.security.Keys; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.stereotype.Component; - -import java.security.Key; -import java.util.Arrays; -import java.util.Collection; -import java.util.Date; -import java.util.stream.Collectors; - -/** - * JWT(JSON Web Token)를 생성하고 검증하는 유틸리티 클래스. - * 사용자 인증 정보를 기반으로 Access Token과 Refresh Token을 발급하며, - * 토큰의 유효성을 검사하고 토큰에서 인증 정보를 추출하는 기능을 제공합니다. - */ -@Slf4j -@Component -public class JwtTokenProvider { - - private final Key key; - private final CustomUserDetailsService customUserDetailsService; - - public JwtTokenProvider(@Value("${jwt.secret}") String secretKey, CustomUserDetailsService customUserDetailsService) { - byte[] keyBytes = Decoders.BASE64.decode(secretKey); - this.key = Keys.hmacShaKeyFor(keyBytes); - this.customUserDetailsService = customUserDetailsService; - } - - public TokenInfo generateToken(Authentication authentication) { - // 유저 정보를 가지고 AccessToken과 RefreshToken을 생성 - String authorities = authentication.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .collect(Collectors.joining(",")); - - long now = (new Date()).getTime(); - Date accessTokenExpiresIn = new Date(now + 86400000); // 24시간 - String accessToken = Jwts.builder() - .setSubject(authentication.getName()) - .claim("auth", authorities) - .setExpiration(accessTokenExpiresIn) - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - - String refreshToken = Jwts.builder() - .setExpiration(new Date(now + 86400000 * 7)) // 7일 - .signWith(key, SignatureAlgorithm.HS256) - .compact(); - - return TokenInfo.builder() - .grantType("Bearer") - .accessToken(accessToken) - .refreshToken(refreshToken) - .build(); - } - - public Authentication getAuthentication(String accessToken) { - // JWT 토큰을 복호화하여 토큰에 들어있는 정보를 꺼내 인증 객체를 생성 - Claims claims = parseClaims(accessToken); - - if (claims.get("auth") == null) { - throw new RuntimeException("권한 정보가 없는 토큰입니다."); - } - - Collection authorities = - Arrays.stream(claims.get("auth").toString().split(",")) - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); - - UserDetails principal = customUserDetailsService.loadUserByUsername(claims.getSubject()); - return new UsernamePasswordAuthenticationToken(principal, "", authorities); - } - - public boolean validateToken(String token) { - // 토큰 정보를 검증 - try { - Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); - return true; - } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { - log.info("Invalid JWT Token", e); - } catch (ExpiredJwtException e) { - log.info("Expired JWT Token", e); - } catch (UnsupportedJwtException e) { - log.info("Unsupported JWT Token", e); - } catch (IllegalArgumentException e) { - log.info("JWT claims string is empty.", e); - } - return false; - } - - private Claims parseClaims(String accessToken) { - // Access Token에서 Claims를 파싱 - try { - return Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(accessToken).getBody(); - } catch (ExpiredJwtException e) { - return e.getClaims(); - } - } -} diff --git a/back/src/main/java/com/back/global/config/OAuth2SuccessHandler.java b/back/src/main/java/com/back/global/config/OAuth2SuccessHandler.java deleted file mode 100644 index 97fa174..0000000 --- a/back/src/main/java/com/back/global/config/OAuth2SuccessHandler.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.back.global.config; - -import com.back.global.config.JwtTokenProvider; -import com.back.global.config.TokenInfo; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; -import org.springframework.stereotype.Component; -import org.springframework.web.util.UriComponentsBuilder; - -import java.io.IOException; - -/** - * OAuth2 로그인 성공 시 호출되는 핸들러. - * 로그인 성공 후 JWT 토큰을 생성하고 클라이언트로 리다이렉트하여 전달합니다. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { - - private final JwtTokenProvider jwtTokenProvider; - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { - // OAuth2 로그인 성공 시 JWT 토큰을 생성하고 클라이언트로 리다이렉트 - log.info("OAuth2 Login Success!"); - - TokenInfo tokenInfo = jwtTokenProvider.generateToken(authentication); - - String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/oauth2/redirect") // 클라이언트 리다이렉트 URL - .queryParam("token", tokenInfo.getAccessToken()) - .build().toUriString(); - - getRedirectStrategy().sendRedirect(request, response, targetUrl); - } -} diff --git a/back/src/main/java/com/back/global/config/OAuthAttributes.java b/back/src/main/java/com/back/global/config/OAuthAttributes.java deleted file mode 100644 index b50cb21..0000000 --- a/back/src/main/java/com/back/global/config/OAuthAttributes.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.back.global.config; - -import lombok.Builder; -import lombok.Getter; - -import java.util.Map; - -/** - * OAuth2 공급자로부터 받은 사용자 정보를 담는 DTO 클래스. - * 각 공급자별로 다른 속성 이름을 통일하여 처리할 수 있도록 돕습니다. - */ -@Getter -@Builder -public class OAuthAttributes { - private Map attributes; - private String nameAttributeKey; - private String name; - private String email; - private String picture; - - public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map attributes) { - // OAuth2 공급자(registrationId)에 따라 적절한 OAuthAttributes 객체를 생성 - if ("github".equals(registrationId)) { - return ofGithub(userNameAttributeName, attributes); - } else if ("google".equals(registrationId)) { - return ofGoogle(userNameAttributeName, attributes); - } - return null; // 또는 예외 처리 - } - - private static OAuthAttributes ofGoogle(String userNameAttributeName, Map attributes) { - // Google OAuth2 사용자 속성을 OAuthAttributes 객체로 변환 - return OAuthAttributes.builder() - .name((String) attributes.get("name")) - .email((String) attributes.get("email")) - .picture((String) attributes.get("picture")) - .attributes(attributes) - .nameAttributeKey(userNameAttributeName) - .build(); - } - - private static OAuthAttributes ofGithub(String userNameAttributeName, Map attributes) { - // GitHub OAuth2 사용자 속성을 OAuthAttributes 객체로 변환 - return OAuthAttributes.builder() - .name((String) attributes.get("login")) // GitHub는 'login' 필드를 이름으로 사용 - .email((String) attributes.get("email")) // GitHub는 이메일이 없을 수 있음 - .picture((String) attributes.get("avatar_url")) - .attributes(attributes) - .nameAttributeKey(userNameAttributeName) - .build(); - } -} diff --git a/back/src/main/java/com/back/global/config/PasswordEncoderConfig.java b/back/src/main/java/com/back/global/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..7066a0b --- /dev/null +++ b/back/src/main/java/com/back/global/config/PasswordEncoderConfig.java @@ -0,0 +1,19 @@ +package com.back.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.factory.PasswordEncoderFactories; +import org.springframework.security.crypto.password.PasswordEncoder; + +/** + * 비밀번호 암호화를 위한 Spring Security 설정 클래스입니다. + * - PasswordEncoder Bean을 등록하여 서비스/시큐리티 전반에서 비밀번호 암호화에 사용합니다. + */ +@Configuration +public class PasswordEncoderConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } +} diff --git a/back/src/main/java/com/back/global/config/SecurityConfig.java b/back/src/main/java/com/back/global/config/SecurityConfig.java deleted file mode 100644 index c062634..0000000 --- a/back/src/main/java/com/back/global/config/SecurityConfig.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.back.global.config; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.authentication.dao.DaoAuthenticationProvider; -import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; -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.configuration.WebSecurityCustomizer; // 추가 -import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.factory.PasswordEncoderFactories; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; - -/** - * Spring Security 설정을 정의하는 구성 클래스. - * 웹 보안, 세션 관리, 인증/인가 규칙, OAuth2 로그인 및 JWT 필터 등을 설정합니다. - */ -@Configuration -@EnableWebSecurity -@RequiredArgsConstructor -public class SecurityConfig { - - private final JwtTokenProvider jwtTokenProvider; - private final CustomUserDetailsService customUserDetailsService; - private final CustomOAuth2UserService customOAuth2UserService; - private final OAuth2SuccessHandler oAuth2SuccessHandler; - private final OAuth2FailureHandler oAuth2FailureHandler; - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - // HTTP 보안 필터 체인을 구성 - http - .httpBasic().disable() - .csrf().disable() - .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) - .and() - .formLogin() - .loginPage("/users-auth/login") - .loginProcessingUrl("/users-auth/login") - .defaultSuccessUrl("/") - .failureUrl("/users-auth/login?error=true") - .permitAll() - .and() - .logout() - .logoutUrl("/users-auth/logout") - .logoutSuccessUrl("/users-auth/login?logout=true") - .invalidateHttpSession(true) - .deleteCookies("JSESSIONID") - .permitAll() - .and() - .oauth2Login() - .userInfoEndpoint() - .userService(customOAuth2UserService) - .and() - .successHandler(oAuth2SuccessHandler) - .failureHandler(oAuth2FailureHandler) - .and() - .authorizeHttpRequests() - .requestMatchers("/users-auth/**").permitAll() // Swagger 경로를 WebSecurityCustomizer로 제외했으므로 여기서 제거 - .requestMatchers("/admin/**").hasRole("ADMIN") - .anyRequest().authenticated() - .and() - .addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class); - return http.build(); - } - - @Bean - public PasswordEncoder passwordEncoder() { - // 비밀번호 인코더를 제공하는 Bean을 등록 - return PasswordEncoderFactories.createDelegatingPasswordEncoder(); - } - - @Bean - public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { - // AuthenticationManager를 Bean으로 등록 - return authenticationConfiguration.getAuthenticationManager(); - } - - @Bean - public DaoAuthenticationProvider authenticationProvider() { - // DaoAuthenticationProvider를 Bean으로 등록 - DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider(); - authProvider.setUserDetailsService(customUserDetailsService); - authProvider.setPasswordEncoder(passwordEncoder()); - return authProvider; - } - - @Bean // WebSecurityCustomizer Bean 추가 - public WebSecurityCustomizer webSecurityCustomizer() { - return (web) -> web.ignoring().requestMatchers( - "/swagger-ui/**", - "/v3/api-docs/**", - "/swagger-resources/**", - "/webjars/**" - ); - } -} \ No newline at end of file diff --git a/back/src/main/java/com/back/global/config/TokenInfo.java b/back/src/main/java/com/back/global/config/TokenInfo.java deleted file mode 100644 index 43233a7..0000000 --- a/back/src/main/java/com/back/global/config/TokenInfo.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.back.global.config; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; - -/** - * JWT 토큰 정보를 담는 DTO(Data Transfer Object) 클래스. - * Access Token과 Refresh Token, 그리고 토큰의 타입을 포함합니다. - */ -@Data -@Builder -@AllArgsConstructor -public class TokenInfo { - private String grantType; - private String accessToken; - private String refreshToken; -} diff --git a/back/src/main/java/com/back/global/dto/LoginRequest.java b/back/src/main/java/com/back/global/dto/LoginRequest.java deleted file mode 100644 index 207de2b..0000000 --- a/back/src/main/java/com/back/global/dto/LoginRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.back.global.dto; - -import lombok.Getter; -import lombok.Setter; - -/** - * 사용자 로그인 요청 시 필요한 정보를 담는 DTO 클래스. - * 로그인 ID와 비밀번호를 포함합니다. - */ -@Getter -@Setter -public class LoginRequest { - private String loginId; - private String password; -} diff --git a/back/src/main/java/com/back/global/dto/SignupRequest.java b/back/src/main/java/com/back/global/dto/SignupRequest.java deleted file mode 100644 index 87044e8..0000000 --- a/back/src/main/java/com/back/global/dto/SignupRequest.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.back.global.dto; - -import com.back.domain.user.entity.Gender; -import com.back.domain.user.entity.Mbti; -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Past; -import jakarta.validation.constraints.Pattern; -import lombok.Getter; -import lombok.Setter; -import org.springframework.format.annotation.DateTimeFormat; - -import java.time.LocalDateTime; - -/** - * 사용자 회원가입 요청 시 필요한 정보를 담는 DTO 클래스. - * 로그인 ID, 이메일, 비밀번호, 닉네임, 생년월일, 성별, MBTI, 가치관 등을 포함합니다. - */ -@Getter -@Setter -public class SignupRequest { - - @NotBlank(message = "로그인 아이디는 필수 입력 값입니다.") - private String loginId; - - @NotBlank(message = "이메일은 필수 입력 값입니다.") - @Email(message = "이메일 형식에 맞지 않습니다.") - private String email; - - @NotBlank(message = "비밀번호는 필수 입력 값입니다.") - @Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*\\W)(?=\\S+$).{8,20}", - message = "비밀번호는 영문 대,소문자와 숫자, 특수기호가 적어도 1개 이상씩 포함된 8자 ~ 20자의 비밀번호여야 합니다.") - private String password; - - @NotBlank(message = "닉네임은 필수 입력 값입니다.") - private String nickname; - - @NotNull(message = "생년월일은 필수 입력 값입니다.") - @Past(message = "생년월일은 과거 날짜여야 합니다.") - @DateTimeFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") - private LocalDateTime birthdayAt; - - @NotNull(message = "성별은 필수 입력 값입니다.") - private Gender gender; - - @NotNull(message = "MBTI는 필수 입력 값입니다.") - private Mbti mbti; - - @NotBlank(message = "가치관은 필수 입력 값입니다.") - private String beliefs; -} diff --git a/back/src/main/java/com/back/global/exception/ErrorCode.java b/back/src/main/java/com/back/global/exception/ErrorCode.java index 9524914..abf658f 100644 --- a/back/src/main/java/com/back/global/exception/ErrorCode.java +++ b/back/src/main/java/com/back/global/exception/ErrorCode.java @@ -26,6 +26,7 @@ public enum ErrorCode { LOGIN_ID_DUPLICATION(HttpStatus.BAD_REQUEST, "U003", "Login ID Duplication"), INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U004", "Invalid Password"), UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "U005", "Unauthorized User"), + NICKNAME_DUPLICATION(HttpStatus.CONFLICT, "U006", "이미 사용 중인 닉네임입니다."), // Post Errors POST_NOT_FOUND(HttpStatus.NOT_FOUND, "P001", "Post Not Found"), diff --git a/back/src/main/java/com/back/global/exception/GlobalExceptionHandler.java b/back/src/main/java/com/back/global/exception/GlobalExceptionHandler.java index 374f6da..e50378f 100644 --- a/back/src/main/java/com/back/global/exception/GlobalExceptionHandler.java +++ b/back/src/main/java/com/back/global/exception/GlobalExceptionHandler.java @@ -14,7 +14,10 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ProblemDetail; import org.springframework.http.converter.HttpMessageNotReadableException; -import org.springframework.validation.BindException; // NOTE: MVC 바인딩 예외 +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.validation.BindException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -107,4 +110,43 @@ public ProblemDetail handleAny(Exception ex, HttpServletRequest req) { if (traceId != null) pd.setProperty("traceId", traceId); return pd; } + + // 아이디 비밀번호 불일치 401 + @ExceptionHandler(BadCredentialsException.class) + public ProblemDetail handleBadCredentials(BadCredentialsException ex, HttpServletRequest req) { + return ProblemDetails.of( + HttpStatus.UNAUTHORIZED, + "BAD_CREDENTIALS", + "로그인 실패: 아이디 또는 비밀번호가 올바르지 않습니다.", + "BAD_CREDENTIALS", + Map.of(), + req + ); + } + + // 기타 인증 오류 401 + @ExceptionHandler(AuthenticationException.class) + public ProblemDetail handleAuthentication(AuthenticationException ex, HttpServletRequest req) { + return ProblemDetails.of( + HttpStatus.UNAUTHORIZED, + "UNAUTHORIZED", + "인증이 필요하거나 인증에 실패했습니다.", + "UNAUTHORIZED", + Map.of("reason", ex.getClass().getSimpleName()), + req + ); + } + + // 권한 부족 403 + @ExceptionHandler(AccessDeniedException.class) + public ProblemDetail handleAccessDenied(AccessDeniedException ex, HttpServletRequest req) { + return ProblemDetails.of( + HttpStatus.FORBIDDEN, + "FORBIDDEN", + "요청하신 리소스에 대한 권한이 없습니다.", + "FORBIDDEN", + Map.of(), + req + ); + } } diff --git a/back/src/main/java/com/back/global/initdata/InitData.java b/back/src/main/java/com/back/global/initdata/InitData.java index cd5522c..688421e 100644 --- a/back/src/main/java/com/back/global/initdata/InitData.java +++ b/back/src/main/java/com/back/global/initdata/InitData.java @@ -26,13 +26,13 @@ public class InitData implements CommandLineRunner { @Override public void run(String... args) throws Exception { // 애플리케이션 시작 시 초기 사용자 데이터 생성 - if (userRepository.findByLoginId("admin").isEmpty()) { + if (userRepository.findByEmail("admin@example.com").isEmpty()) { User admin = User.builder() - .loginId("admin") .email("admin@example.com") .password(passwordEncoder.encode("admin1234!")) .role(Role.ADMIN) - .nickname("관리자") + .username("관리자") + .nickname("관리자닉네임") .birthdayAt(LocalDateTime.of(1990, 1, 1, 0, 0)) .gender(Gender.M) .mbti(Mbti.INTJ) @@ -41,13 +41,13 @@ public void run(String... args) throws Exception { userRepository.save(admin); } - if (userRepository.findByLoginId("user1").isEmpty()) { + if (userRepository.findByEmail("user1@example.com").isEmpty()) { User user1 = User.builder() - .loginId("user1") .email("user1@example.com") .password(passwordEncoder.encode("user1234!")) .role(Role.USER) - .nickname("사용자1") + .username("사용자1") + .nickname("사용자닉네임") .birthdayAt(LocalDateTime.of(1995, 5, 10, 0, 0)) .gender(Gender.F) .mbti(Mbti.ENFP) diff --git a/back/src/main/java/com/back/global/security/CsrfCookieFilter.java b/back/src/main/java/com/back/global/security/CsrfCookieFilter.java new file mode 100644 index 0000000..6abad4e --- /dev/null +++ b/back/src/main/java/com/back/global/security/CsrfCookieFilter.java @@ -0,0 +1,24 @@ +package com.back.global.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.web.csrf.CsrfToken; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * CSRF 토큰을 강제로 한 번 꺼내도록 해서 브라우저 응답에 XSRF-TOKEN 쿠키가 + * 반드시 포함되도록 보장하는 보조 필터입니다. + */ +public class CsrfCookieFilter extends OncePerRequestFilter { + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) + throws ServletException, IOException { + CsrfToken token = (CsrfToken) req.getAttribute(CsrfToken.class.getName()); + if (token != null) token.getToken(); + chain.doFilter(req, res); + } +} diff --git a/back/src/main/java/com/back/global/security/CustomLogoutSuccessHandler.java b/back/src/main/java/com/back/global/security/CustomLogoutSuccessHandler.java new file mode 100644 index 0000000..5805f5d --- /dev/null +++ b/back/src/main/java/com/back/global/security/CustomLogoutSuccessHandler.java @@ -0,0 +1,45 @@ +package com.back.global.security; + +import com.back.global.common.ApiResponse; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.logout.LogoutSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Map; + +/** + * 로그아웃 성공 시 JSON 응답을 내려주는 커스텀 핸들러입니다. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class CustomLogoutSuccessHandler implements LogoutSuccessHandler { + + private final ObjectMapper objectMapper; + + @Override + public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + if (authentication != null) { + log.info("User logged out: {}", authentication.getName()); + } + + response.setStatus(HttpStatus.OK.value()); + response.setContentType("application/json;charset=UTF-8"); + + ApiResponse> apiResponse = ApiResponse.success( + Map.of("message", "로그아웃되었습니다"), + "로그아웃이 완료되었습니다" + ); + + response.getWriter().write(objectMapper.writeValueAsString(apiResponse)); + } +} diff --git a/back/src/main/java/com/back/global/config/CustomUserDetails.java b/back/src/main/java/com/back/global/security/CustomUserDetails.java similarity index 88% rename from back/src/main/java/com/back/global/config/CustomUserDetails.java rename to back/src/main/java/com/back/global/security/CustomUserDetails.java index e91775d..2edeca0 100644 --- a/back/src/main/java/com/back/global/config/CustomUserDetails.java +++ b/back/src/main/java/com/back/global/security/CustomUserDetails.java @@ -1,4 +1,4 @@ -package com.back.global.config; +package com.back.global.security; import com.back.domain.user.entity.User; import lombok.Getter; @@ -42,7 +42,7 @@ public String getPassword() { @Override public String getUsername() { - return user.getLoginId(); + return user.getEmail(); } @Override @@ -70,8 +70,8 @@ public Map getAttributes() { return attributes; } - @Override - public String getName() { - return user.getLoginId(); + @Override public String getName() { + if (user.getNickname()!=null && !user.getNickname().isBlank()) return user.getNickname(); + return user.getEmail(); } } \ No newline at end of file diff --git a/back/src/main/java/com/back/global/config/CustomUserDetailsService.java b/back/src/main/java/com/back/global/security/CustomUserDetailsService.java similarity index 57% rename from back/src/main/java/com/back/global/config/CustomUserDetailsService.java rename to back/src/main/java/com/back/global/security/CustomUserDetailsService.java index 8837ff6..b4b9763 100644 --- a/back/src/main/java/com/back/global/config/CustomUserDetailsService.java +++ b/back/src/main/java/com/back/global/security/CustomUserDetailsService.java @@ -1,9 +1,7 @@ -package com.back.global.config; +package com.back.global.security; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; -import com.back.global.exception.ApiException; -import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -11,7 +9,9 @@ import org.springframework.stereotype.Service; /** - * Spring Security에서 사용자 인증을 위해 사용자 정보를 로드하는 서비스. + * 스프링 시큐리티가 인증 시 사용자 정보를 불러올 때 호출하는 서비스입니다. + * 이 서비스는 AuthenticationManager가 로그인 시도를 처리할 때 + * UserDetailsService.loadUserByUsername()를 자동으로 호출합니다. */ @Service @RequiredArgsConstructor @@ -20,10 +20,10 @@ public class CustomUserDetailsService implements UserDetailsService { private final UserRepository userRepository; @Override - public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException { + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { // 사용자 정보를 데이터베이스에서 조회하여 UserDetails 객체로 반환 - User user = userRepository.findByLoginId(loginId) - .orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND)); + User user = userRepository.findByEmail(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); return new CustomUserDetails(user); } } diff --git a/back/src/main/java/com/back/global/security/SecurityConfig.java b/back/src/main/java/com/back/global/security/SecurityConfig.java new file mode 100644 index 0000000..4e711f0 --- /dev/null +++ b/back/src/main/java/com/back/global/security/SecurityConfig.java @@ -0,0 +1,77 @@ +package com.back.global.security; + +import com.back.global.security.oauth2.CustomOAuth2UserService; +import com.back.global.security.oauth2.OAuth2FailureHandler; +import com.back.global.security.oauth2.OAuth2SuccessHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +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.configuration.WebSecurityCustomizer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; + +/** + * Spring Security 설정을 정의하는 구성 클래스입니다. + * 웹 보안, 세션 관리, 인증/인가 규칙, OAuth2 로그인 등을 설정합니다. + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + private final OAuth2FailureHandler oAuth2FailureHandler; + private final CustomLogoutSuccessHandler customLogoutSuccessHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .cors(Customizer.withDefaults()) + .csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) + .addFilterAfter(new CsrfCookieFilter(), CsrfFilter.class) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/users-auth/**", "/oauth2/**", "/login/oauth2/**").permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + .logout(logout -> logout + .logoutUrl("/api/v1/users-auth/logout") + .logoutSuccessHandler(customLogoutSuccessHandler) + .invalidateHttpSession(true) + .clearAuthentication(true) + .deleteCookies("JSESSIONID") + ) + .oauth2Login(oauth -> oauth + .userInfoEndpoint(ui -> ui.userService(customOAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler) + ) + .formLogin(form -> form.disable()) + .httpBasic(h -> h.disable()); + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + @Bean // WebSecurityCustomizer Bean 추가 + public WebSecurityCustomizer webSecurityCustomizer() { + return (web) -> web.ignoring().requestMatchers( + "/swagger-ui/**", + "/v3/api-docs/**", + "/swagger-resources/**", + "/webjars/**" + ); + } +} \ No newline at end of file diff --git a/back/src/main/java/com/back/global/security/oauth2/CustomOAuth2UserService.java b/back/src/main/java/com/back/global/security/oauth2/CustomOAuth2UserService.java new file mode 100644 index 0000000..9099143 --- /dev/null +++ b/back/src/main/java/com/back/global/security/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,62 @@ +package com.back.global.security.oauth2; + +import com.back.domain.user.entity.AuthProvider; +import com.back.domain.user.entity.User; +import com.back.domain.user.service.UserService; +import com.back.global.security.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +/** + * Spring Security OAuth2 로그인 시 사용자 정보를 가져오고, + * 내부 User 엔티티와 매핑하여 처리하는 커스텀 서비스 클래스입니다. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomOAuth2UserService implements OAuth2UserService { + + private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + private final UserService userService; + + @Override + public OAuth2User loadUser(OAuth2UserRequest req) throws OAuth2AuthenticationException { + try { + OAuth2User raw = delegate.loadUser(req); + + String registrationId = req.getClientRegistration().getRegistrationId(); // google/github + String userNameAttr = req.getClientRegistration() + .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); + + OAuthAttributes attrs = OAuthAttributes.of(registrationId, userNameAttr, raw.getAttributes()); + + String email = attrs.email(); + if (email == null || email.isBlank()) { + throw new OAuth2AuthenticationException( + new OAuth2Error("email_not_found", "OAuth2 제공자로부터 이메일을 받을 수 없습니다.", null) + ); + } + + AuthProvider provider = AuthProvider.valueOf(registrationId.toUpperCase()); + User user = userService.upsertOAuthUser(email, attrs.name(), provider); + + return new CustomUserDetails(user, raw.getAttributes()); + + } catch (OAuth2AuthenticationException ex) { + throw ex; + } catch (Exception ex) { + log.error("OAuth2 사용자 로드 중 오류", ex); + throw new OAuth2AuthenticationException( + new OAuth2Error("server_error", "OAuth2 사용자 정보를 로드할 수 없습니다.", null), + ex + ); + } + } +} diff --git a/back/src/main/java/com/back/global/config/OAuth2FailureHandler.java b/back/src/main/java/com/back/global/security/oauth2/OAuth2FailureHandler.java similarity index 81% rename from back/src/main/java/com/back/global/config/OAuth2FailureHandler.java rename to back/src/main/java/com/back/global/security/oauth2/OAuth2FailureHandler.java index d60501d..580c6e1 100644 --- a/back/src/main/java/com/back/global/config/OAuth2FailureHandler.java +++ b/back/src/main/java/com/back/global/security/oauth2/OAuth2FailureHandler.java @@ -1,4 +1,4 @@ -package com.back.global.config; +package com.back.global.security.oauth2; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -12,7 +12,7 @@ import java.io.IOException; /** - * OAuth2 로그인 실패 시 호출되는 핸들러. + * OAuth2 로그인 과정에서 인증에 실패했을 때 호출되는 핸들러 클래스입니다. * 로그인 실패 정보를 클라이언트로 리다이렉트하여 전달합니다. */ @Slf4j @@ -21,10 +21,9 @@ public class OAuth2FailureHandler extends SimpleUrlAuthenticationFailureHandler @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { - // OAuth2 로그인 실패 시 에러 메시지를 포함하여 클라이언트로 리다이렉트 log.error("OAuth2 Login Failure: {}", exception.getMessage()); - String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/oauth2/redirect") // 클라이언트 리다이렉트 URL + String targetUrl = UriComponentsBuilder.fromUriString("http://localhost:3000/oauth2/redirect") .queryParam("error", exception.getMessage()) .build().toUriString(); diff --git a/back/src/main/java/com/back/global/security/oauth2/OAuth2SuccessHandler.java b/back/src/main/java/com/back/global/security/oauth2/OAuth2SuccessHandler.java new file mode 100644 index 0000000..15649c7 --- /dev/null +++ b/back/src/main/java/com/back/global/security/oauth2/OAuth2SuccessHandler.java @@ -0,0 +1,36 @@ +package com.back.global.security.oauth2; + +import com.back.domain.user.entity.User; +import com.back.global.security.CustomUserDetails; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * OAuth2 로그인 성공 시 호출되는 핸들러 클래스입니다. + * 로그인 성공시 프론트엔드 클라이언트로 리다이렉트합니다. + */ +@Component +@Slf4j +public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + log.info("OAuth2 로그인 성공"); + + CustomUserDetails customUserDetails = (CustomUserDetails) authentication.getPrincipal(); + User user = customUserDetails.getUser(); + + log.info("OAuth2 로그인 완료 - 사용자: {} ({})", user.getEmail(), user.getAuthProvider()); + + response.sendRedirect("http://localhost:3000/oauth2/redirect?success=true"); + } +} \ No newline at end of file diff --git a/back/src/main/java/com/back/global/security/oauth2/OAuthAttributes.java b/back/src/main/java/com/back/global/security/oauth2/OAuthAttributes.java new file mode 100644 index 0000000..c5b863e --- /dev/null +++ b/back/src/main/java/com/back/global/security/oauth2/OAuthAttributes.java @@ -0,0 +1,54 @@ +package com.back.global.security.oauth2; + +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; + +import java.util.Map; + +/** + * OAuth2 공급자로부터 받은 사용자 정보를 표준화하여 담는 DTO record 클래스입니다. + * Google, GitHub 등 OAuth2 제공자마다 다른 사용자 속성 키를 애플리케이션에서 일관되게 다룰 수 있도록 변환합니다. + */ +public record OAuthAttributes( + Map attributes, + String nameAttributeKey, + String name, + String email, + String picture +) { + + public static OAuthAttributes of(String registrationId, String userNameAttributeName, Map attributes) { + return switch (registrationId.toLowerCase()) { + case "github" -> ofGithub(userNameAttributeName, attributes); + case "google" -> ofGoogle(userNameAttributeName, attributes); + default -> throw new OAuth2AuthenticationException( + new OAuth2Error("provider_not_supported", "지원하지 않는 제공자: " + registrationId, null) + ); + }; + } + + private static OAuthAttributes ofGoogle(String userNameAttributeName, Map attributes) { + return new OAuthAttributes( + attributes, + userNameAttributeName, + (String) attributes.get("name"), + (String) attributes.get("email"), + (String) attributes.get("picture") + ); + } + + private static OAuthAttributes ofGithub(String userNameAttributeName, Map attributes) { + String email = (String) attributes.get("email"); + String name = (String) attributes.get("name"); + if (name == null || name.isBlank()) { + name = (String) attributes.get("login"); + } + return new OAuthAttributes( + attributes, + userNameAttributeName, + name, + email, + (String) attributes.get("avatar_url") + ); + } +} \ No newline at end of file diff --git a/back/src/main/resources/application.yml b/back/src/main/resources/application.yml index 49c8258..d5fd910 100644 --- a/back/src/main/resources/application.yml +++ b/back/src/main/resources/application.yml @@ -51,9 +51,6 @@ logging: org.hibernate.orm.jdbc.extract: TRACE org.springframework.transaction.interceptor: TRACE -jwt: - secret: c3ByaW5nLWJvb3Qtc2VjdXJpdHktand0LXR1dG9yaWFsLWJhY2stZW5kLXNlY3JldC1rZXkK # Base64 encoded 256-bit key - springdoc: swagger-ui: path: /swagger-ui.html @@ -62,4 +59,12 @@ springdoc: api-docs: path: /v3/api-docs - +server: + servlet: + session: + timeout: 30m + cookie: + name: JSESSIONID + http-only: true + secure: false # 로컬 false, 운영 true(HTTPS) + same-site: Lax \ No newline at end of file diff --git a/back/src/test/java/com/back/domain/node/controller/BaseLineControllerTest.java b/back/src/test/java/com/back/domain/node/controller/BaseLineControllerTest.java index f44c6ec..494245b 100644 --- a/back/src/test/java/com/back/domain/node/controller/BaseLineControllerTest.java +++ b/back/src/test/java/com/back/domain/node/controller/BaseLineControllerTest.java @@ -56,6 +56,7 @@ void initUser() { .beliefs("NONE") .authProvider(AuthProvider.GUEST) .nickname("tester-" + uid) + .username("name-" + uid) .build(); userId = userRepository.save(user).getId(); } diff --git a/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java b/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java index 92b31a2..ce329af 100644 --- a/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java +++ b/back/src/test/java/com/back/domain/node/controller/DecisionFlowControllerTest.java @@ -43,7 +43,6 @@ public class DecisionFlowControllerTest { void initUser() { String uid = UUID.randomUUID().toString().substring(0, 8); User user = User.builder() - .loginId("login_" + uid) .email("user_" + uid + "@test.local") .role(Role.GUEST) .birthdayAt(LocalDateTime.now().minusYears(25)) @@ -52,6 +51,7 @@ void initUser() { .beliefs("NONE") .authProvider(AuthProvider.GUEST) .nickname("tester-" + uid) + .username("name-" + uid) .build(); userId = userRepository.save(user).getId(); } diff --git a/back/src/test/java/com/back/domain/node/controller/DecisionLineControllerTest.java b/back/src/test/java/com/back/domain/node/controller/DecisionLineControllerTest.java index a09a0bd..f33093d 100644 --- a/back/src/test/java/com/back/domain/node/controller/DecisionLineControllerTest.java +++ b/back/src/test/java/com/back/domain/node/controller/DecisionLineControllerTest.java @@ -48,7 +48,6 @@ public class DecisionLineControllerTest { void initUser() { String uid = UUID.randomUUID().toString().substring(0, 8); User user = User.builder() - .loginId("login_" + uid) .email("user_" + uid + "@test.local") .role(Role.GUEST) .birthdayAt(LocalDateTime.now().minusYears(25)) @@ -57,6 +56,7 @@ void initUser() { .beliefs("NONE") .authProvider(AuthProvider.GUEST) .nickname("tester-" + uid) + .username("name-" + uid) .build(); userId = userRepository.save(user).getId(); } @@ -95,7 +95,6 @@ void success_listMultipleLines() throws Exception { void success_emptyListWhenNoLines() throws Exception { String uid2 = UUID.randomUUID().toString().substring(0, 8); Long newUserId = userRepository.save(User.builder() - .loginId("login_" + uid2) .email("user_" + uid2 + "@test.local") .role(Role.GUEST) .birthdayAt(LocalDateTime.now().minusYears(23)) @@ -104,6 +103,7 @@ void success_emptyListWhenNoLines() throws Exception { .beliefs("NONE") .authProvider(AuthProvider.GUEST) .nickname("tester2-" + uid2) + .username("tester2-" + uid2) .build()) .getId(); diff --git a/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java b/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java index c1e7000..0f6d6e2 100644 --- a/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java +++ b/back/src/test/java/com/back/domain/post/controller/PostControllerTest.java @@ -227,7 +227,7 @@ class UpdatePost { @Test @DisplayName("성공 - 본인 게시글 수정") @Sql(statements = { - "UPDATE users SET id = 1 WHERE login_id = 'testLoginId'" + "UPDATE users SET id = 1 WHERE email = 'testUser@example.com'" }) void success() throws Exception { // given @@ -248,8 +248,8 @@ void success() throws Exception { @Test @DisplayName("실패 - 다른 사용자 게시글 수정") @Sql(statements = { - "UPDATE users SET id = 1 WHERE login_id = 'testLoginId'", - "UPDATE users SET id = 2 WHERE login_id = 'anotherLoginId'" + "UPDATE users SET id = 1 WHERE email = 'testUser@example.com'", + "UPDATE users SET id = 2 WHERE email = 'anothertestUser@example.com'" }) void fail_UnauthorizedUser() throws Exception { // given diff --git a/back/src/test/java/com/back/domain/post/fixture/PostFixture.java b/back/src/test/java/com/back/domain/post/fixture/PostFixture.java index 17288c8..559574b 100644 --- a/back/src/test/java/com/back/domain/post/fixture/PostFixture.java +++ b/back/src/test/java/com/back/domain/post/fixture/PostFixture.java @@ -33,18 +33,18 @@ public PostFixture(UserRepository userRepository, PostRepository postRepository) // User 생성 public User createTestUser() { - return createUser("testLoginId", "test@example.com", "testPassword", "작성자1", Gender.M); + return createUser("testLoginId", "test@example.com", "testPassword", "작성자1", "닉네임1", Gender.M); } public User createAnotherUser() { - return createUser("anotherLoginId", "another@example.com", "another", "작성자2", Gender.F); + return createUser("anotherLoginId", "another@example.com", "another", "작성자2", "닉네임2", Gender.F); } - private User createUser(String loginId, String email, String password, String nickname, Gender gender) { + private User createUser(String loginId, String email, String password, String username, String nickname, Gender gender) { return userRepository.save(User.builder() - .loginId(loginId) .email(email) .password(password) + .username(username) .nickname(nickname) .beliefs("도전") .gender(gender) diff --git a/back/src/test/java/com/back/domain/user/controller/UserAuthControllerTest.java b/back/src/test/java/com/back/domain/user/controller/UserAuthControllerTest.java new file mode 100644 index 0000000..3c65bbe --- /dev/null +++ b/back/src/test/java/com/back/domain/user/controller/UserAuthControllerTest.java @@ -0,0 +1,216 @@ +package com.back.domain.user.controller; + +import com.back.domain.user.dto.LoginRequest; +import com.back.domain.user.entity.AuthProvider; +import com.back.domain.user.entity.Role; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * UserAuthController의 주요 인증/인가 기능을 검증하는 통합 테스트 클래스입니다. + * SpringBootTest + MockMvc 환경에서 실제 요청/응답 흐름을 시뮬레이션합니다. + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class UserAuthControllerTest { + + @Autowired MockMvc mvc; + @Autowired ObjectMapper om; + @Autowired UserRepository userRepository; + @Autowired PasswordEncoder passwordEncoder; + + private static final String BASE = "/api/v1/users-auth"; + + private String toJson(Object o) throws Exception { return om.writeValueAsString(o); } + + private String uniqueEmail(String prefix) { + return prefix + "+" + UUID.randomUUID().toString().substring(0,8) + "@example.com"; + } + + private User seedLocalUser(String email, String rawPassword) { + User u = User.builder() + .email(email) + .password(passwordEncoder.encode(rawPassword)) + .username("tester") + .nickname("tester_" + UUID.randomUUID().toString().substring(0,4)) + .birthdayAt(LocalDateTime.of(2000,1,1,0,0)) + .role(Role.USER) + .authProvider(AuthProvider.LOCAL) + .build(); + return userRepository.save(u); + } + + @Test + @DisplayName("성공 - 회원가입") + void t1() throws Exception { + var body = toJson(new SignupReq( + uniqueEmail("join"), + "Aa!23456", + "홍길동", + "길동이", + LocalDateTime.of(1999,1,1,0,0) + )); + + mvc.perform(post(BASE + "/signup") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.email").exists()) + .andExpect(jsonPath("$.data.role").value("USER")) + .andExpect(jsonPath("$.message").exists()); + } + + @Test + @DisplayName("실패 - 회원가입 중복 이메일") + void t2() throws Exception { + String email = uniqueEmail("dup"); + seedLocalUser(email, "Aa!23456"); + + var body = toJson(new SignupReq( + email, + "Aa!23456", + "김중복", + "중복이", + LocalDateTime.of(1995,5,5,0,0) + )); + + mvc.perform(post(BASE + "/signup") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().is4xxClientError()); + } + + @Test + @DisplayName("성공 - 로그인") + void t3() throws Exception { + String email = uniqueEmail("login-ok"); + seedLocalUser(email, "Aa!23456"); + + var body = toJson(new LoginReq(email, "Aa!23456")); + + var result = mvc.perform(post(BASE + "/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.email").value(email)) + .andReturn(); + + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(false); + assertThat(session).isNotNull(); + } + + @Test + @DisplayName("실패 - 로그인 비밀번호 오류") + void t4() throws Exception { + String email = uniqueEmail("login-fail"); + seedLocalUser(email, "Aa!23456"); + + var body = toJson(new LoginReq(email, "Wrong!234")); + + mvc.perform(post(BASE + "/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("성공 - 게스트 로그인") + void t5() throws Exception { + var result = mvc.perform(post(BASE + "/guest").with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("게스트 로그인 성공")) + .andReturn(); + + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(false); + assertThat(session).isNotNull(); + } + + @Test + @DisplayName("성공 - /me (익명)") + void t6() throws Exception { + mvc.perform(get(BASE + "/me")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("anonymous")) + .andExpect(jsonPath("$.data").doesNotExist()); + } + + @Test + @DisplayName("성공 - /me (인증됨)") + void t7() throws Exception { + String email = uniqueEmail("me"); + seedLocalUser(email, "Aa!23456"); + + String loginJson = toJson(new LoginRequest(email, "Aa!23456")); + + MvcResult loginRes = mvc.perform(post(BASE + "/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(loginJson)) + .andExpect(status().isOk()) + .andReturn(); + + MockHttpSession session = (MockHttpSession) loginRes.getRequest().getSession(false); + + mvc.perform(get(BASE + "/me") + .session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("authenticated")); + } + + @Test + @DisplayName("성공 - 로그아웃") + void t8() throws Exception { + String email = uniqueEmail("logout"); + seedLocalUser(email, "Aa!23456"); + var body = toJson(new LoginReq(email, "Aa!23456")); + + var loginRes = mvc.perform(post(BASE + "/login") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andReturn(); + + MockHttpSession session = (MockHttpSession) loginRes.getRequest().getSession(false); + assertThat(session).isNotNull(); + + mvc.perform(post(BASE + "/logout") + .with(csrf()) + .session(session)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.message").value("로그아웃되었습니다")) + .andExpect(jsonPath("$.message").value("로그아웃이 완료되었습니다")); + } + + private record SignupReq(String email, String password, String username, String nickname, LocalDateTime birthdayAt) {} + private record LoginReq(String email, String password) {} +}