Skip to content

Commit

Permalink
Merge pull request #209 from ITZipProject/feature/user-sns-login
Browse files Browse the repository at this point in the history
✨ 새 기능 : SNS 로그인 기능
  • Loading branch information
rowing0328 authored Jan 19, 2025
2 parents 81ce66a + 9e56b07 commit 71eef92
Show file tree
Hide file tree
Showing 14 changed files with 313 additions and 4 deletions.
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) {
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

0 comments on commit 71eef92

Please sign in to comment.