Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ 새 기능 : SNS 로그인 기능 #209

Merged
merged 6 commits into from
Jan 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "이메일을 입력해주세요.")
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<User> 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<String> save(User user) {
rowing0328 marked this conversation as resolved.
Show resolved Hide resolved
user.setNickname(userService.getUniqueNickname()); // 닉네임 중복 방지 로직
User savedUser = userRepository.save(user.convertToEntity()).convertToDomain();
blogCommandService.create(savedUser); // 블로그 생성 로직
return ResponseEntity.ok("회원가입이 완료되었습니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public interface UserService {

ResponseEntity<UserLoginResponse> login(UserLoginRequest userLoginRequest);

ResponseEntity<UserLoginResponse> loginResponse(User user);

String logout(HttpServletRequest request);

ResponseEntity<UserLoginResponse> refreshAccessToken(RefreshAccessTokenRequest refreshAccessTokenRequest);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,11 @@ public ResponseEntity<UserLoginResponse> login(UserLoginRequest userLoginRequest
throw new RestApiException(CommonExceptionCode.NOT_MATCH_PASSWORD);
}

// 토큰 발급
// 토큰 발급 및 응답 처리
return loginResponse(user);
}

public ResponseEntity<UserLoginResponse> 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());

Expand Down Expand Up @@ -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) {
Expand All @@ -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 토큰 생성
Expand All @@ -298,8 +312,9 @@ public String requestPasswordReset(PasswordResetRequest passwordResetRequest, Ht

/**
* 비밀번호 재설정 승인 메서드
*
* @param response 응답 객체
* @param token 비밀번호 재설정 토큰
* @param token 비밀번호 재설정 토큰
*/
@Override
public void confirmPasswordReset(HttpServletResponse response, String token) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading