diff --git a/src/main/java/darkoverload/itzip/feature/jwt/util/JwtTokenizer.java b/src/main/java/darkoverload/itzip/feature/jwt/util/JwtTokenizer.java index c3e1e873..463704eb 100644 --- a/src/main/java/darkoverload/itzip/feature/jwt/util/JwtTokenizer.java +++ b/src/main/java/darkoverload/itzip/feature/jwt/util/JwtTokenizer.java @@ -4,6 +4,7 @@ import darkoverload.itzip.global.config.response.code.CommonExceptionCode; import darkoverload.itzip.global.config.response.exception.RestApiException; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.MalformedJwtException; import io.jsonwebtoken.security.Keys; @@ -141,6 +142,8 @@ public Claims parseToken(String token, byte[] secretKey) { .getBody(); } catch (SignatureException | MalformedJwtException e) { // 토큰 유효성 체크 실패 시 throw new RestApiException(CommonExceptionCode.JWT_INVALID_ERROR); + } catch (ExpiredJwtException e){ // 만료된 토큰일 경우 + throw new RestApiException(CommonExceptionCode.JWT_EXPIRED_ERROR); } return claims; diff --git a/src/main/java/darkoverload/itzip/feature/user/controller/OAuthController.java b/src/main/java/darkoverload/itzip/feature/user/controller/OAuthController.java new file mode 100644 index 00000000..350aa257 --- /dev/null +++ b/src/main/java/darkoverload/itzip/feature/user/controller/OAuthController.java @@ -0,0 +1,37 @@ +package darkoverload.itzip.feature.user.controller; + +import darkoverload.itzip.feature.user.controller.request.GithubUserRequest; +import darkoverload.itzip.feature.user.controller.request.GoogleUserRequest; +import darkoverload.itzip.feature.user.service.OAuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "OAuth", description = "SNS 로그인 기능 API") +@RestController +@RequestMapping("/oauth") +@RequiredArgsConstructor +public class OAuthController { + private final OAuthService oAuthService; + + @Operation( + summary = "구글 로그인", + description = "구글 엑세스 토큰 입력받아 로그인에 필요한 itzip 엑세스 토큰을 발급하거나 회원가입 합니다." + ) + @PostMapping("/google") + public ResponseEntity google(@RequestBody @Valid GoogleUserRequest googleUserRequest) { + return oAuthService.google(googleUserRequest); + } + + @Operation( + summary = "깃허브 로그인", + description = "깃허브 엑세스 토큰 입력받아 로그인에 필요한 itzip 엑세스 토큰을 발급하거나 회원가입 합니다." + ) + @PostMapping("/github") + public ResponseEntity github(@RequestBody @Valid GithubUserRequest githubUserRequest) { + return oAuthService.github(githubUserRequest); + } +} diff --git a/src/main/java/darkoverload/itzip/feature/user/controller/request/GithubUserInfo.java b/src/main/java/darkoverload/itzip/feature/user/controller/request/GithubUserInfo.java new file mode 100644 index 00000000..9374169b --- /dev/null +++ b/src/main/java/darkoverload/itzip/feature/user/controller/request/GithubUserInfo.java @@ -0,0 +1,28 @@ +package darkoverload.itzip.feature.user.controller.request; + +import darkoverload.itzip.feature.user.domain.User; +import darkoverload.itzip.feature.user.entity.Authority; +import lombok.Getter; +import lombok.Setter; + +/** + * 깃허브 api 유저 정보 dto + */ +@Getter +@Setter +public class GithubUserInfo { + private String login; + private String id; + private String avatar_url; + private String email; + + public User toUserDomain() { + return User.builder() + .email(this.email) + .password(this.id) + .authority(Authority.USER) + .snsType("github") + .imageUrl(avatar_url) + .build(); + } +} diff --git a/src/main/java/darkoverload/itzip/feature/user/controller/request/GithubUserRequest.java b/src/main/java/darkoverload/itzip/feature/user/controller/request/GithubUserRequest.java new file mode 100644 index 00000000..813bfb5c --- /dev/null +++ b/src/main/java/darkoverload/itzip/feature/user/controller/request/GithubUserRequest.java @@ -0,0 +1,16 @@ +package darkoverload.itzip.feature.user.controller.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class GithubUserRequest { + @NotBlank(message = "깃허브 엑세스 토큰값을 입력해주세요.") + @Schema(description = "깃허브 엑세스 토큰값", example = "gho_kEwNV1237NGFEyZsls8S7or3HzZGkm1huWce") + public String accessToken; +} diff --git a/src/main/java/darkoverload/itzip/feature/user/controller/request/GoogleUserInfo.java b/src/main/java/darkoverload/itzip/feature/user/controller/request/GoogleUserInfo.java new file mode 100644 index 00000000..1940988e --- /dev/null +++ b/src/main/java/darkoverload/itzip/feature/user/controller/request/GoogleUserInfo.java @@ -0,0 +1,30 @@ +package darkoverload.itzip.feature.user.controller.request; + +import darkoverload.itzip.feature.user.domain.User; +import darkoverload.itzip.feature.user.entity.Authority; +import lombok.Getter; +import lombok.Setter; + +/** + * 구글 api 유저 정보 dto + */ +@Getter +@Setter +public class GoogleUserInfo { + private String id; + private String email; + private String name; + private String given_name; + private String family_name; + private String picture; + + public User toUserDomain() { + return User.builder() + .email(this.email) + .password(this.id) + .authority(Authority.USER) + .snsType("google") + .imageUrl(picture) + .build(); + } +} diff --git a/src/main/java/darkoverload/itzip/feature/user/controller/request/GoogleUserRequest.java b/src/main/java/darkoverload/itzip/feature/user/controller/request/GoogleUserRequest.java new file mode 100644 index 00000000..9bfe3031 --- /dev/null +++ b/src/main/java/darkoverload/itzip/feature/user/controller/request/GoogleUserRequest.java @@ -0,0 +1,16 @@ +package darkoverload.itzip.feature.user.controller.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class GoogleUserRequest { + @NotBlank(message = "구글 엑세스 토큰값을 입력해주세요.") + @Schema(description = "구글 엑세스 토큰값", example = "ya29.a0ARW5m77UXvL8KtAR1OOB8g0ttMvCAvu123EeFhTwSyRpGvyXeiRka8NeLyzDxKzxAdv8wxwFWLnJ-CnwT_LvTUL3W1Tr4fPdgaxhGfLqGJgxAQyKrvIBb58V8L7jNh0ytUevDw5UaCw8-h4uXsHAszjETzelUZZWdoaCgYKAdwSARISFQHGX2Mi8ZZcBbzDMd0IVfG0rqG8eQ0170") + public String accessToken; +} diff --git a/src/main/java/darkoverload/itzip/feature/user/controller/request/PasswordResetRequest.java b/src/main/java/darkoverload/itzip/feature/user/controller/request/PasswordResetRequest.java index 35eda0bc..ff893dfc 100644 --- a/src/main/java/darkoverload/itzip/feature/user/controller/request/PasswordResetRequest.java +++ b/src/main/java/darkoverload/itzip/feature/user/controller/request/PasswordResetRequest.java @@ -5,11 +5,13 @@ import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; /** * 비밀번호 재설정 요청 dto */ @Getter +@NoArgsConstructor @AllArgsConstructor public class PasswordResetRequest { @NotEmpty(message = "이메일을 입력해주세요.") diff --git a/src/main/java/darkoverload/itzip/feature/user/service/OAuthService.java b/src/main/java/darkoverload/itzip/feature/user/service/OAuthService.java new file mode 100644 index 00000000..fa6c141f --- /dev/null +++ b/src/main/java/darkoverload/itzip/feature/user/service/OAuthService.java @@ -0,0 +1,11 @@ +package darkoverload.itzip.feature.user.service; + +import darkoverload.itzip.feature.user.controller.request.GithubUserRequest; +import darkoverload.itzip.feature.user.controller.request.GoogleUserRequest; +import org.springframework.http.ResponseEntity; + +public interface OAuthService { + ResponseEntity google(GoogleUserRequest googleUserRequest); + + ResponseEntity github(GithubUserRequest githubUserRequest); +} diff --git a/src/main/java/darkoverload/itzip/feature/user/service/OAuthServiceImpl.java b/src/main/java/darkoverload/itzip/feature/user/service/OAuthServiceImpl.java new file mode 100644 index 00000000..ee2ce966 --- /dev/null +++ b/src/main/java/darkoverload/itzip/feature/user/service/OAuthServiceImpl.java @@ -0,0 +1,138 @@ +package darkoverload.itzip.feature.user.service; + +import darkoverload.itzip.feature.techinfo.service.blog.BlogCommandService; +import darkoverload.itzip.feature.user.controller.request.GithubUserInfo; +import darkoverload.itzip.feature.user.controller.request.GithubUserRequest; +import darkoverload.itzip.feature.user.controller.request.GoogleUserInfo; +import darkoverload.itzip.feature.user.controller.request.GoogleUserRequest; +import darkoverload.itzip.feature.user.domain.User; +import darkoverload.itzip.feature.user.repository.UserRepository; +import darkoverload.itzip.global.config.response.code.CommonExceptionCode; +import darkoverload.itzip.global.config.response.exception.RestApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class OAuthServiceImpl implements OAuthService { + + private final UserService userService; + private final UserRepository userRepository; + private final BlogCommandService blogCommandService; + + @Override + public ResponseEntity google(GoogleUserRequest googleUserRequest) { + // 구글 유저 정보 조회 + GoogleUserInfo googleUserInfo = fetchGoogleUserInfo(googleUserRequest); + + // 로그인/회원가입 처리 + return handleSnsLogin( + googleUserInfo.toUserDomain() + ); + } + + @Override + public ResponseEntity github(GithubUserRequest githubUserRequest) { + // 깃허브 유저 정보 조회 + GithubUserInfo githubUserInfo = fetchGithubUserInfo(githubUserRequest); + + // 로그인/회원가입 처리 + return handleSnsLogin( + githubUserInfo.toUserDomain() + ); + } + + /** + * SNS 로그인/회원가입 공통 처리 로직 + * + * @param userDomain user 도메인 + * @return ResponseEntity + */ + private ResponseEntity handleSnsLogin(User userDomain) { + Optional userOptional = userService.findByEmail(userDomain.getEmail()); + + // 회원가입 + if (!userOptional.isPresent()) { + return save(userDomain); + } + + User user = userOptional.get(); + + if (user.getSnsType() == null) { // 기존 이메일 회원이 SNS로 로그인 시도할 경우 예외 + throw new RestApiException(CommonExceptionCode.EMAIL_USER_SNS_LOGIN); + } + + if (!user.getSnsType().equals(userDomain.getSnsType())) { + switch (user.getSnsType()) { + case "google": + throw new RestApiException(CommonExceptionCode.GOOGLE_USER_GITHUB_LOGIN); + case "github": + throw new RestApiException(CommonExceptionCode.GITHUB_USER_GOOGLE_LOGIN); + } + } + + // SNS 회원이라면 로그인 처리 + return userService.loginResponse(user); + } + + /** + * 구글 유저 정보 가져오기 + */ + private GoogleUserInfo fetchGoogleUserInfo(GoogleUserRequest googleUserRequest) { + try { + WebClient client = WebClient.create("https://www.googleapis.com"); + return client + .get() + .uri(uriBuilder -> uriBuilder + .path("/oauth2/v1/userinfo") + .queryParam("access_token", googleUserRequest.getAccessToken()) + .build() + ) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(GoogleUserInfo.class) + .block(); + } catch (Exception e) { + // 구글 API 호출 오류 + throw new RestApiException(CommonExceptionCode.FILED_ERROR); + } + } + + /** + * 깃허브 유저 정보 가져오기 + */ + private GithubUserInfo fetchGithubUserInfo(GithubUserRequest githubUserRequest) { + try { + WebClient client = WebClient.create("https://api.github.com"); + return client + .get() + .uri("/user") + .header(HttpHeaders.AUTHORIZATION, "token " + githubUserRequest.getAccessToken()) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .bodyToMono(GithubUserInfo.class) + .block(); + } catch (Exception e) { + // 깃허브 API 호출 오류 + throw new RestApiException(CommonExceptionCode.FILED_ERROR); + } + } + + /** + * SNS 로그인 회원가입 + * + * @param user 회원가입할 유저 + */ + private ResponseEntity save(User user) { + user.setNickname(userService.getUniqueNickname()); // 닉네임 중복 방지 로직 + User savedUser = userRepository.save(user.convertToEntity()).convertToDomain(); + blogCommandService.create(savedUser); // 블로그 생성 로직 + return ResponseEntity.ok("회원가입이 완료되었습니다."); + } +} diff --git a/src/main/java/darkoverload/itzip/feature/user/service/UserService.java b/src/main/java/darkoverload/itzip/feature/user/service/UserService.java index 845d3d74..45da3c60 100644 --- a/src/main/java/darkoverload/itzip/feature/user/service/UserService.java +++ b/src/main/java/darkoverload/itzip/feature/user/service/UserService.java @@ -16,6 +16,8 @@ public interface UserService { ResponseEntity login(UserLoginRequest userLoginRequest); + ResponseEntity loginResponse(User user); + String logout(HttpServletRequest request); ResponseEntity refreshAccessToken(RefreshAccessTokenRequest refreshAccessTokenRequest); diff --git a/src/main/java/darkoverload/itzip/feature/user/service/UserServiceImpl.java b/src/main/java/darkoverload/itzip/feature/user/service/UserServiceImpl.java index 49ae1dfa..38f861f4 100644 --- a/src/main/java/darkoverload/itzip/feature/user/service/UserServiceImpl.java +++ b/src/main/java/darkoverload/itzip/feature/user/service/UserServiceImpl.java @@ -87,7 +87,11 @@ public ResponseEntity login(UserLoginRequest userLoginRequest throw new RestApiException(CommonExceptionCode.NOT_MATCH_PASSWORD); } - // 토큰 발급 + // 토큰 발급 및 응답 처리 + return loginResponse(user); + } + + public ResponseEntity loginResponse(User user) { String accessToken = jwtTokenizer.createAccessToken(user.getId(), user.getEmail(), user.getNickname(), user.getAuthority()); String refreshToken = jwtTokenizer.createRefreshToken(user.getId(), user.getEmail(), user.getNickname(), user.getAuthority()); @@ -266,8 +270,9 @@ public String tempUserOut(CustomUserDetails userDetails, HttpServletRequest requ /** * 비밀번호 재설정 요청 메서드 + * * @param passwordResetRequest 비밀번호 재설정 요청 dto - * @param request 요청 객체 + * @param request 요청 객체 */ @Override public String requestPasswordReset(PasswordResetRequest passwordResetRequest, HttpServletRequest request) { @@ -276,6 +281,15 @@ public String requestPasswordReset(PasswordResetRequest passwordResetRequest, Ht // 올바른 이메일인지 체크 User user = getByEmail(email); + // sns 로그인 회원 체크 + if (user.getSnsType() != null) { + switch (user.getSnsType()) { + case "google" -> throw new RestApiException(CommonExceptionCode.GOOGLE_LOGIN_USER); + case "github" -> throw new RestApiException(CommonExceptionCode.GITHUB_LOGIN_USER); + } + } + + String tempPassword = PasswordUtil.generatePassword(); // JWT 토큰 생성 @@ -298,8 +312,9 @@ public String requestPasswordReset(PasswordResetRequest passwordResetRequest, Ht /** * 비밀번호 재설정 승인 메서드 + * * @param response 응답 객체 - * @param token 비밀번호 재설정 토큰 + * @param token 비밀번호 재설정 토큰 */ @Override public void confirmPasswordReset(HttpServletResponse response, String token) { diff --git a/src/main/java/darkoverload/itzip/feature/user/util/RandomNickname.java b/src/main/java/darkoverload/itzip/feature/user/util/RandomNickname.java index a684fd43..64e1087d 100644 --- a/src/main/java/darkoverload/itzip/feature/user/util/RandomNickname.java +++ b/src/main/java/darkoverload/itzip/feature/user/util/RandomNickname.java @@ -29,7 +29,7 @@ public String generate() { // 랜덤 형용사 String adjective = ADJECTIVES[RANDOM.nextInt(ADJECTIVES.length)]; // 랜덤 숫자 - String randomInt = String.valueOf(RANDOM.nextInt(999)); + String randomInt = String.valueOf(RANDOM.nextInt(999) + 1); // 랜덤 명사 String noun = NOUNS[RANDOM.nextInt(NOUNS.length)]; diff --git a/src/main/java/darkoverload/itzip/global/config/response/code/CommonExceptionCode.java b/src/main/java/darkoverload/itzip/global/config/response/code/CommonExceptionCode.java index 1beda8c1..160642ee 100644 --- a/src/main/java/darkoverload/itzip/global/config/response/code/CommonExceptionCode.java +++ b/src/main/java/darkoverload/itzip/global/config/response/code/CommonExceptionCode.java @@ -74,6 +74,16 @@ public enum CommonExceptionCode implements ResponseCode { NOT_MATCH_PASSWORD(HttpStatus.BAD_REQUEST, "이메일과 비밀번호가 일치하지 않습니다."), // 유저를 찾을 수 없음 NOT_FOUND_USER(HttpStatus.BAD_REQUEST, "사용자를 찾을 수 없습니다."), + // 이메일 회원이 sns 로그인 시 + EMAIL_USER_SNS_LOGIN(HttpStatus.BAD_REQUEST, "이메일 회원가입 계정은 sns 로그인이 불가합니다."), + // 구글 회원이 깃허브 로그인 시 + GOOGLE_USER_GITHUB_LOGIN(HttpStatus.BAD_REQUEST, "구글 로그인 계정입니다. 구글 로그인으로 다시 시도해주세요."), + // 깃허브 회원이 구글 로그인 시 + GITHUB_USER_GOOGLE_LOGIN(HttpStatus.BAD_REQUEST, "깃허브 로그인 계정입니다. 깃허브 로그인으로 다시 시도해주세요."), + // 비밀번호 재설정 시 구글 로그인 계정 + GOOGLE_LOGIN_USER(HttpStatus.BAD_REQUEST, "구글 로그인으로 회원가입한 계정은 비밀번호 재설정이 불가합니다."), + // 비밀번호 재설정 시 깃허브 로그인 계정 + GITHUB_LOGIN_USER(HttpStatus.BAD_REQUEST, "깃허브 로그인으로 회원가입한 계정은 비밀번호 재설정이 불가합니다."), /** * TechInfo - Blog Error diff --git a/src/main/java/darkoverload/itzip/global/config/security/SecurityConfig.java b/src/main/java/darkoverload/itzip/global/config/security/SecurityConfig.java index 27001691..1588438d 100644 --- a/src/main/java/darkoverload/itzip/global/config/security/SecurityConfig.java +++ b/src/main/java/darkoverload/itzip/global/config/security/SecurityConfig.java @@ -34,6 +34,7 @@ public class SecurityConfig { "/user/authEmail", // 인증 메일 페이지 "/user/checkDuplicateEmail", // 이메일 중복 체크 페이지 "/user/passwordReset", // 이메일 중복 체크 페이지 + "/oauth/**", // sns 로그인 관련 페이지 "/swagger-ui/**", // Swagger UI "/v3/api-docs/**", // Swagger API docs "/swagger-resources/**", // Swagger resources