diff --git a/backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java b/backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java index d892f641..ac7deebb 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/controller/AuthController.java @@ -47,31 +47,11 @@ ) public class AuthController { - private final Map> oauth2Services; private final LoginService loginService; private final UserService userService; private final JwtProvider jwtProvider; - private final OauthStateService oauthStateService; private final RefreshTokenService refreshTokenService; - @Value("${google.client.id}") - private String googleClientId; - - @Value("${google.redirect.uri}") - private String googleRedirectUri; - - @Value("${kakao.client.id}") - private String kakaoClientId; - - @Value("${kakao.redirect.uri}") - private String kakaoRedirectUri; - - @Value("${github.client.id}") - private String githubClientId; - - @Value("${github.redirect.uri}") - private String githubRedirectUri; - @Operation( summary = "회원가입 API", @@ -136,7 +116,7 @@ public ResponseEntity signup(@Valid @RequestBody SignupRequest r "refreshToken": "eyJhbGciOiJIUzI1NiJ9...", "userId": "1c54b9f3-8234-4e8f-b001-11cc4d9012ab", "name": "홍길동", - "role": "USER", + "role": "TEAM_MEMBER", "phoneNumber": "01012345678" } """)) @@ -164,205 +144,6 @@ public ResponseEntity login(@Valid @RequestBody LoginRequest requ .body(response); } - // OAuth 로그인 시작 (state 생성 + 각 provider별 인증 URL 반환) - @Operation( - summary = "OAuth 로그인 시작 (INIT)", - description = "소셜 로그인 시작 시 각 Provider(GOOGLE, KAKAO, GITHUB)의 인증 URL을 반환합니다.", - responses = { - @ApiResponse( - responseCode = "200", - description = "OAuth 인증 URL 반환 성공", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = "\"https://accounts.google.com/o/oauth2/v2/auth?...\"")) - ), - @ApiResponse(responseCode = "400", description = "지원하지 않는 Provider 요청") - } - ) - @GetMapping("/oauth/{provider}/init") - public ResponseEntity startOauthLogin( - @Parameter(description = "소셜 로그인 제공자 (GOOGLE, KAKAO, GITHUB)", example = "GOOGLE") - @PathVariable String provider, - HttpSession session) { - String state = oauthStateService.generateAndSaveState(session); - String authUrl; - - switch (provider.toUpperCase()) { - case "GOOGLE" -> authUrl = "https://accounts.google.com/o/oauth2/v2/auth" + - "?client_id=" + googleClientId + - "&redirect_uri=" + googleRedirectUri + - "&response_type=code" + - "&scope=email%20profile" + - "&state=" + state; - case "KAKAO" -> authUrl = "https://kauth.kakao.com/oauth/authorize" + - "?client_id=" + kakaoClientId + - "&redirect_uri=" + kakaoRedirectUri + - "&response_type=code" + - "&state=" + state; - case "GITHUB" -> authUrl = "https://github.com/login/oauth/authorize" + - "?client_id=" + githubClientId + - "&redirect_uri=" + githubRedirectUri + - "&scope=user:email" + - "&state=" + state; - default -> throw new IllegalArgumentException("Unknown provider " + provider); - } - - log.debug("Generated OAuth URL for {}: {}", provider, authUrl); - return ResponseEntity.ok(authUrl); - } - - //redirection api - @Operation( - summary = "OAuth 로그인 리다이렉트 (GET)", - description = "소셜 로그인 후 리다이렉션 시 호출되는 엔드포인트입니다. " - + "code와 state 값을 받아 실제 로그인 과정을 처리하며 일반적으로 프론트엔드에서 이 요청을 자동으로 POST로 전달합니다." - - ) - @GetMapping("/login/{provider}") - public void handleOauthRedirect( - @Parameter(description = "소셜 로그인 제공자", example = "GOOGLE") - @PathVariable("provider") String provider, - - @Parameter(description = "OAuth 인증 코드", example = "4/0AbCdEfG...") - @RequestParam("code") String code, - - @Parameter(description = "CSRF 방지용 state 값", example = "a1b2c3d4") - @RequestParam("state") String state, - - HttpSession session, - HttpServletResponse response - ) throws IOException { - - log.info("[{}] OAuth GET redirect received: code={}, state={}", provider, code, state); - - // 기존 POST OauthLogin() 재활용 (로그인 처리 + 토큰 발급) - ResponseEntity result = OauthLogin(provider, code, state, session); - LoginResponse body = result.getBody(); - - if (body == null) { - log.error("OAuth 로그인 실패: 응답 본문이 null입니다."); - response.sendRedirect("http://localhost:5173/oauth/fail"); - return; - } - - // 프론트로 리다이렉트 (accessToken, userId, name 전달) - String redirectUrl = "http://localhost:5173/oauth/success" - + "?accessToken=" + URLEncoder.encode(body.getAccessToken(), StandardCharsets.UTF_8) - + "&userId=" + URLEncoder.encode(body.getUserId().toString(), StandardCharsets.UTF_8) - + "&name=" + URLEncoder.encode(body.getName(), StandardCharsets.UTF_8); - - log.info("[{}] OAuth 로그인 완료 → 프론트로 리다이렉트: {}", provider, redirectUrl); - response.sendRedirect(redirectUrl); - } - - - // OAuth 인증 완료 후 Code + State 처리 - @Operation( - summary = "OAuth 로그인 완료 (POST)", - description = "OAuth 인증 후 전달된 code와 state를 이용해 토큰을 발급받고 사용자 로그인 처리합니다.", - responses = { - @ApiResponse( - responseCode = "200", - description = "OAuth 로그인 성공", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "accessToken": "eyJhbGciOiJIUzI1NiJ9...", - "userId": "3a93f8c2-412b-4d9c-84a2-52bdfec91d11", - "name": "카카오홍길동", - "role": "USER", - "phoneNumber": "01099998888" - } - """)) - ), - @ApiResponse( - responseCode = "401", - description = "잘못된 state 값 또는 만료된 인증 코드", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "message": "Invalid OAuth state or expired authorization code" - } - """)) - ) - } - ) - @PostMapping("/login/{provider}") - public ResponseEntity OauthLogin( - @Parameter(description = "소셜 로그인 제공자", example = "KAKAO") @PathVariable("provider") String provider, - @Parameter(description = "OAuth 인증 코드", example = "4/0AbCdEfG...") @RequestParam("code") String code, - @Parameter(description = "CSRF 방지용 state 값", example = "a1b2c3d4") @RequestParam("state") String state, - HttpSession session) { - - // 서버에 저장된 state와 요청으로 받은 state 비교 - String savedState = oauthStateService.getStateFromSession(session); - - if(savedState == null || !savedState.equals(state)) { - log.warn("[{}] Invalid OAuth state detected. Expected={}, Received={}", provider, savedState, state); - return ResponseEntity.status(401).build(); - } - - oauthStateService.clearState(session); - - Oauth2Service service = oauth2Services.get(provider.toUpperCase()); - if (service == null) { - throw new IllegalArgumentException("Unknown provider " + provider); - } - - User user = switch (provider.toUpperCase()) { - case "GOOGLE" -> { - var googleService = (Oauth2Service) service; - var token = googleService.getAccessToken(code); - var info = googleService.getUserInfo(token.getAccessToken()); - yield userService.findOrCreateUser(new GoogleUserInfoAdapter(info, token.getAccessToken())); - } - case "KAKAO" -> { - var kakaoService = (Oauth2Service) service; - var token = kakaoService.getAccessToken(code); - var info = kakaoService.getUserInfo(token.getAccessToken()); - yield userService.findOrCreateUser(new KakaoUserInfoAdapter(info, token.getAccessToken())); - } - case "GITHUB" -> { - var githubService = (Oauth2Service) service; - var token = githubService.getAccessToken(code); - var info = githubService.getUserInfo(token.getAccessToken()); - yield userService.findOrCreateUser(new GithubUserInfoAdapter(info, token.getAccessToken())); - } - default -> throw new IllegalArgumentException("Unknown provider " + provider); - }; - - // Access 토큰 발급 - String accessToken = jwtProvider.createToken(user.getUserId(), user.getRole(), user.getEmail()); - - String refreshToken = jwtProvider.createRefreshToken(user.getUserId()); - - refreshTokenService.saveOrUpdateToken(user.getUserId(), refreshToken); - - // HttpOnly 쿠키에 담기 - ResponseCookie cookie = ResponseCookie.from("refresh", refreshToken) - .httpOnly(true) - .secure(true) - .sameSite("None") - .path("/") - .maxAge(60L * 60 * 24 * 14) // 2주 - .build(); - - // LoginResponse 생성 - LoginResponse response = LoginResponse.builder() - .accessToken(accessToken) - .userId(user.getUserId()) - .name(user.getName()) - .role(user.getRole()) - .phoneNumber(user.getPhoneNumber()) - .build(); - - log.info("{} 로그인 성공: userId={}, provider={}", provider.toUpperCase(), user.getUserId(), provider.toUpperCase()); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .body(response); - } - @Operation( summary = "Access Token 재발급 API", description = "만료된 Access Token을 Refresh Token으로 재발급받습니다.", diff --git a/backend/src/main/java/org/sejongisc/backend/auth/dto/SignupRequest.java b/backend/src/main/java/org/sejongisc/backend/auth/dto/SignupRequest.java index 11340635..6b443ab7 100644 --- a/backend/src/main/java/org/sejongisc/backend/auth/dto/SignupRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/auth/dto/SignupRequest.java @@ -40,7 +40,7 @@ public class SignupRequest { @Schema( description = "사용자 비밀번호 (8자 이상, 숫자/영문/특수문자 조합 권장)", - example = "abcd1234!", + example = "Abcd1234!", requiredMode = Schema.RequiredMode.REQUIRED ) @NotBlank(message = "비밀번호는 필수입니다.") @@ -48,7 +48,7 @@ public class SignupRequest { @Schema( description = "사용자 역할 (USER 또는 ADMIN 등)", - example = "USER", + example = "TEAM_MEMBER", requiredMode = Schema.RequiredMode.REQUIRED ) @NotNull(message = "역할은 필수입니다.") diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/CustomOAuth2UserService.java b/backend/src/main/java/org/sejongisc/backend/common/auth/config/CustomOAuth2UserService.java new file mode 100644 index 00000000..6676971f --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/config/CustomOAuth2UserService.java @@ -0,0 +1,120 @@ +package org.sejongisc.backend.common.auth.config; + +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.auth.dao.UserOauthAccountRepository; +import org.sejongisc.backend.auth.entity.AuthProvider; +import org.sejongisc.backend.auth.entity.UserOauthAccount; +import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.entity.Role; +import org.sejongisc.backend.user.entity.User; +import org.sejongisc.backend.user.service.UserServiceImpl; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class CustomOAuth2UserService implements OAuth2UserService { + private final UserRepository userRepository; + private final UserOauthAccountRepository oauthAccountRepository; + + + + @Override + public OAuth2User loadUser(OAuth2UserRequest req) throws OAuth2AuthenticationException { + + + DefaultOAuth2UserService delegate = new DefaultOAuth2UserService(); + OAuth2User oauthUser = delegate.loadUser(req); + + String provider = req.getClientRegistration().getRegistrationId(); + Map attrs = oauthUser.getAttributes(); + + log.info("[OAuth2] Provider = {}", provider); + log.info("[OAuth2] Attributes = {}", attrs); + + String providerUid; + String email = null; + String name = null; + + if (provider.equals("google")) { + providerUid = (String) attrs.get("sub"); + email = (String) attrs.get("email"); + name = (String) attrs.get("name"); + + } else if (provider.equals("kakao")) { + providerUid = String.valueOf(attrs.get("id")); + Map account = (Map) attrs.get("kakao_account"); + Map profile = (Map) account.get("profile"); + + email = (String) account.get("email"); + if (email == null) email = "no-email@kakao.com"; + + name = (String) profile.get("nickname"); + + } else if (provider.equals("github")) { + providerUid = String.valueOf(attrs.get("id")); + email = (String) attrs.get("email"); + if (email == null) email = "no-email@github.com"; + + name = (String) attrs.get("name"); + if (name == null) name = email; + + } else { + throw new OAuth2AuthenticationException("Unsupported provider: " + provider); + } + + // 순환참조 해결: 여기서 userService 호출하지 않음 + + final String fProviderUid = providerUid; + final String fEmail = email; + final String fName = name; + final AuthProvider fAuthProvider = AuthProvider.valueOf(provider.toUpperCase()); + + User user = oauthAccountRepository + .findByProviderAndProviderUid(fAuthProvider, providerUid) + .map(UserOauthAccount::getUser) + .orElseGet(() -> { + + User newUser = User.builder() + .email(fEmail) + .name(fName) + .role(Role.TEAM_MEMBER) + .build(); + + User saved = userRepository.save(newUser); + + UserOauthAccount oauth = UserOauthAccount.builder() + .user(saved) + .provider(fAuthProvider) + .providerUid(fProviderUid) + .build(); + + oauthAccountRepository.save(oauth); + + return saved; + }); + + return new DefaultOAuth2User( + List.of(new SimpleGrantedAuthority("ROLE_TEAM_MEMBER")), + Map.of( + "id", user.getUserId(), + "email", user.getEmail(), + "name", user.getName() + ), + "email" + ); + } +} diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/OAuth2SuccessHandler.java b/backend/src/main/java/org/sejongisc/backend/common/auth/config/OAuth2SuccessHandler.java new file mode 100644 index 00000000..be26a773 --- /dev/null +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/config/OAuth2SuccessHandler.java @@ -0,0 +1,91 @@ +package org.sejongisc.backend.common.auth.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.sejongisc.backend.common.auth.jwt.JwtProvider; +import org.sejongisc.backend.auth.service.RefreshTokenService; +import org.sejongisc.backend.user.dao.UserRepository; +import org.sejongisc.backend.user.entity.User; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtProvider jwtProvider; + private final RefreshTokenService refreshTokenService; + private final UserRepository userRepository; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication) throws IOException{ + + // 1. CustomOAuth2UserService에서 넣어준 attributes 가져오기 + DefaultOAuth2User oauthUser = (DefaultOAuth2User) authentication.getPrincipal(); + + String userIdStr = oauthUser.getAttributes().get("id").toString(); + UUID userId = UUID.fromString(userIdStr); + String email = (String) oauthUser.getAttributes().get("email"); + String name = (String) oauthUser.getAttributes().get("name"); + + log.info("[OAuth2 Success] userId = {}, email = {}", userId, email); + + // 2. User 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new RuntimeException("User not found during OAuth login")); + + // 3. AccessToken 발급 + String accessToken = jwtProvider.createToken( + user.getUserId(), + user.getRole(), + user.getEmail() + ); + + // 4. RefreshToken 생성 + String refreshToken = jwtProvider.createRefreshToken(user.getUserId()); + + // 5. RefreshToken 저장(DB or Redis) + refreshTokenService.saveOrUpdateToken(user.getUserId(), refreshToken); + + // 6. HttpOnly 쿠키로 refreshToken 저장 + ResponseCookie cookie = ResponseCookie.from("refresh", refreshToken) + .httpOnly(true) + .secure(false) // 로컬 개발 환경 + .sameSite("None") + .path("/") + .maxAge(60L * 60 * 24 * 14) + .build(); + + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + + // 7. 프론트로 redirect + String redirectUrl = "http://localhost:5173/oauth/success" + + "?accessToken=" + accessToken + + "&name=" + URLEncoder.encode(name, StandardCharsets.UTF_8) + + "&userId=" + userId; + + log.info("[OAuth2 Redirect] {}", redirectUrl); + + getRedirectStrategy().sendRedirect(request, response, redirectUrl); + } + +} diff --git a/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java b/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java index 391998cf..baba71ab 100644 --- a/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java +++ b/backend/src/main/java/org/sejongisc/backend/common/auth/config/SecurityConfig.java @@ -14,6 +14,9 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.web.cors.CorsConfiguration; @@ -31,6 +34,13 @@ public class SecurityConfig { private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2SuccessHandler oAuth2SuccessHandler; + + @Bean + public AuthorizationRequestRepository authorizationRequestRepository() { + return new HttpSessionOAuth2AuthorizationRequestRepository(); + } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http @@ -42,6 +52,19 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authenticationEntryPoint(jwtAuthenticationEntryPoint) // 인증 실패 시 JSON 응답 .accessDeniedHandler(jwtAccessDeniedHandler) // 인가 실패 시 JSON 응답 ) + .oauth2Login(oauth -> oauth + .authorizationEndpoint(a -> + a.authorizationRequestRepository(authorizationRequestRepository()) + ) + .userInfoEndpoint(u -> + u.userService(customOAuth2UserService) + ) + .successHandler(oAuth2SuccessHandler) + .failureHandler((req, res, ex) -> + res.sendRedirect("http://localhost:5173/oauth/fail") + ) + ) + .authorizeHttpRequests(auth -> { auth .requestMatchers( @@ -68,7 +91,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // .anyRequest().authenticated(); .anyRequest().permitAll(); }) - .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + //.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); if(jwtAuthenticationFilter != null) { http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -79,9 +104,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); -// config.setAllowedOrigins(List.of( -// "http://localhost:5173" // 허용할 프론트 주소 -// )); + config.setAllowedOrigins(List.of( + "http://localhost:5173" // 허용할 프론트 주소 + )); config.setAllowedOriginPatterns(List.of("*")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); config.setAllowedHeaders(List.of("*")); diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetCommitRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetCommitRequest.java index 1755fae9..3e135c14 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetCommitRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetCommitRequest.java @@ -1,12 +1,30 @@ package org.sejongisc.backend.user.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; public record PasswordResetCommitRequest( + + @Schema( + example = "b21c9f41-6f8b-4af7-bd42-93f2716c3142", + description = "비밀번호 재설정을 위한 임시 토큰" + ) @NotBlank(message = "resetToken은 필수입니다.") String resetToken, + + @Schema( + example = "Newpass123!", + description = """ + 새로운 비밀번호 입력 + - 8~20자 + - 대문자 최소 1개 + - 소문자 최소 1개 + - 숫자 최소 1개 + - 특수문자 최소 1개 (!@#$%^&*()_+=-{};:'",.<>/?) + """ + ) @NotBlank(message = "새 비밀번호는 필수입니다.") @Pattern( regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+=\\-{}\\[\\];:'\",.<>/?]).{8,20}$", diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetSendRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetSendRequest.java index ef0fee16..79fe5861 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetSendRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetSendRequest.java @@ -1,9 +1,15 @@ package org.sejongisc.backend.user.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; public record PasswordResetSendRequest( + + @Schema( + example = "testuser@example.com", + description = "사용자의 이메일 주소" + ) @NotBlank(message = "이메일은 필수입니다.") @Email(message = "올바른 이메일 형식이 아닙니다.") String email diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetVerifyRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetVerifyRequest.java index aaf5005e..d8407e18 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetVerifyRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/PasswordResetVerifyRequest.java @@ -1,14 +1,24 @@ package org.sejongisc.backend.user.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public record PasswordResetVerifyRequest( + + @Schema( + example = "testuser@example.com", + description = "사용자의 이메일 주소" + ) @NotBlank(message = "이메일은 필수입니다.") @Email(message = "올바른 이메일 형식이 아닙니다.") String email, + @Schema( + example = "482915", + description = "이메일로 발송된 6자리 인증 코드" + ) @NotBlank(message = "인증코드는 필수입니다.") @Size(min = 6, max = 6, message = "인증코드는 6자리여야 합니다.") String code diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/UserIdFindRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/UserIdFindRequest.java index 69a19219..0f679e7b 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dto/UserIdFindRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/UserIdFindRequest.java @@ -1,12 +1,22 @@ package org.sejongisc.backend.user.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; -public record UserIdFindRequest(@NotBlank(message = "이름은 필수입니다.") - String name, +public record UserIdFindRequest( + @Schema( + example = "홍길동", + description = "수정할 사용자 이름" + ) + @NotBlank(message = "이름은 필수입니다.") + String name, - @NotBlank(message = "전화번호는 필수입니다.") - @Pattern(regexp = "^010\\d{8}$", message = "전화번호 형식이 올바르지 않습니다.") - String phoneNumber) { + @Schema( + example = "01098765432", + description = "수정할 사용자 전화번호 (숫자만 입력)" + ) + @NotBlank(message = "전화번호는 필수입니다.") + @Pattern(regexp = "^010\\d{8}$", message = "전화번호 형식이 올바르지 않습니다.") + String phoneNumber) { } diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/UserInfoResponse.java b/backend/src/main/java/org/sejongisc/backend/user/dto/UserInfoResponse.java index 5caa117f..1c89f847 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dto/UserInfoResponse.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/UserInfoResponse.java @@ -47,7 +47,7 @@ public class UserInfoResponse { @Schema( description = "사용자 역할 (예: USER, ADMIN)", - example = "USER" + example = "TEAM_MEMBER" ) private String role; // enum Role을 String으로 변환 diff --git a/backend/src/main/java/org/sejongisc/backend/user/dto/UserUpdateRequest.java b/backend/src/main/java/org/sejongisc/backend/user/dto/UserUpdateRequest.java index e71c33f5..964067d2 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/dto/UserUpdateRequest.java +++ b/backend/src/main/java/org/sejongisc/backend/user/dto/UserUpdateRequest.java @@ -33,7 +33,7 @@ public class UserUpdateRequest { @Schema( description = "변경할 비밀번호 (선택 입력, 변경 시에만 포함)", - example = "newpassword123!" + example = "Newpassword123!" ) @Size(min = 8, message = "비밀번호는 최소 8자 이상 입력해야 합니다.") private String password; diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java index eede6c02..baefcb30 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserService.java @@ -27,4 +27,7 @@ public interface UserService { String verifyResetCodeAndIssueToken(String email, String code); void resetPasswordByToken(String resetToken, String newPassword); + + User upsertOAuthUser(String provider, String providerId, String email, String name); + } diff --git a/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java b/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java index 625519a5..bcb962f4 100644 --- a/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java +++ b/backend/src/main/java/org/sejongisc/backend/user/service/UserServiceImpl.java @@ -1,12 +1,19 @@ package org.sejongisc.backend.user.service; +import org.sejongisc.backend.auth.entity.AuthProvider; import org.sejongisc.backend.auth.service.EmailService; import org.sejongisc.backend.auth.service.OauthUnlinkService; import org.sejongisc.backend.auth.service.RefreshTokenService; import org.sejongisc.backend.common.auth.jwt.TokenEncryptor; import org.sejongisc.backend.user.util.PasswordPolicyValidator; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +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.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,6 +33,8 @@ import org.springframework.stereotype.Service; import java.time.Duration; +import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; @@ -314,4 +323,35 @@ public void resetPasswordByToken(String resetToken, String newPassword) { refreshTokenService.deleteByUserId(user.getUserId()); } + + + @Override + @Transactional + public User upsertOAuthUser(String provider, String providerUid, String email, String name) { + + AuthProvider authProvider = AuthProvider.valueOf(provider.toUpperCase()); + + return oauthAccountRepository + .findByProviderAndProviderUid(authProvider, providerUid) + .map(UserOauthAccount::getUser) + .orElseGet(() -> { + User newUser = User.builder() + .email(email) + .name(name) + .role(Role.TEAM_MEMBER) + .build(); + + User savedUser = userRepository.save(newUser); + + UserOauthAccount oauthAccount = UserOauthAccount.builder() + .user(savedUser) + .provider(authProvider) + .providerUid(providerUid) + .build(); + + oauthAccountRepository.save(oauthAccount); + + return savedUser; + }); + } } diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 738afc18..05e368cc 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -12,4 +12,5 @@ spring: show_sql: true format_sql: true open-in-view: false - use_sql_comments: true \ No newline at end of file + use_sql_comments: true +