Skip to content

Commit

Permalink
feat : Github 소셜 로그인 추가 (#281)
Browse files Browse the repository at this point in the history
* feat : github oauth code 받아 로그인하는 API 생성

* build : oauth-client 라이브러리 추가

* feat : User 필드로 githubName 추가

* feat : Github 소셜 로그인 및 Github 연동 구현

* feat : JwtAuthenticationFilter 대상에서 제외

* fix : access_token 파싱 실패 수정

* feat : OAuth2User에 불필요한 name 정보 제거

* refactor : getClaims 파라미터명 수정

* refactor : authority 값 수정

* refactor : 불필요한 상속 제거

* refactor : 유저 Github name 상시 동기화

* refactor : 유저 동기화 코드 early return 추가

* refactor : 메서드명 리팩토링

* feat : Github API 에러 시 로그 추가

* chore : github.yml 파일 import
  • Loading branch information
rladmstn authored Jan 16, 2025
1 parent fd75c10 commit b65e71a
Show file tree
Hide file tree
Showing 14 changed files with 262 additions and 3 deletions.
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ dependencies {
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.1'
// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
// OAuth2
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}

clean {
Expand Down
1 change: 1 addition & 0 deletions prepare-build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,6 @@ download_yml "jwt.yml"
download_yml "mysql-dev.yml"
download_yml "mysql-prod.yml"
download_yml "webhook.yml"
download_yml "github.yml"

wait
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.gamzabat.algohub.auth.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.gamzabat.algohub.auth.service.OAuth2Service;
import com.gamzabat.algohub.feature.user.dto.TokenResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api")
@Tag(name = "oauth 소셜 로그인 관련 API", description = "code를 사용해 Github 소셜 로그인하는 API")
public class OAuth2Controller {
private final OAuth2Service oAuth2Service;

@PostMapping("/oauth/github/sign-in")
@Operation(summary = "Github 소셜 로그인 API", description = "Github 소셜 로그인 후 발급된 code를 전달해 로그인하는 API")
public ResponseEntity<TokenResponse> signIn(@RequestParam String code) {
TokenResponse response = oAuth2Service.githubSignIn(code);
return ResponseEntity.ok(response);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.gamzabat.algohub.auth.dto;

import lombok.Getter;

@Getter
public class GithubEmailDto {
private String email;
}
11 changes: 11 additions & 0 deletions src/main/java/com/gamzabat/algohub/auth/dto/GithubUserDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.gamzabat.algohub.auth.dto;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class GithubUserDto {
private String login;
private String email;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.gamzabat.algohub.auth.dto;

import com.fasterxml.jackson.annotation.JsonProperty;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class OAuthAccessTokenDto {
@JsonProperty("access_token")
private String accessToken;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.gamzabat.algohub.auth.exception;

import lombok.Getter;

@Getter
public class GithubApiException extends RuntimeException {
public GithubApiException(String message) {
super(message);
}
}
164 changes: 164 additions & 0 deletions src/main/java/com/gamzabat/algohub/auth/service/OAuth2Service.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package com.gamzabat.algohub.auth.service;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;

import com.gamzabat.algohub.auth.dto.GithubEmailDto;
import com.gamzabat.algohub.auth.dto.GithubUserDto;
import com.gamzabat.algohub.auth.dto.OAuthAccessTokenDto;
import com.gamzabat.algohub.auth.exception.GithubApiException;
import com.gamzabat.algohub.common.jwt.TokenProvider;
import com.gamzabat.algohub.common.jwt.dto.JwtDTO;
import com.gamzabat.algohub.constants.ApiConstants;
import com.gamzabat.algohub.enums.Role;
import com.gamzabat.algohub.feature.user.domain.User;
import com.gamzabat.algohub.feature.user.dto.TokenResponse;
import com.gamzabat.algohub.feature.user.repository.UserRepository;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@RequiredArgsConstructor
public class OAuth2Service {
private final TokenProvider tokenProvider;
@Value("${github.client_id}")
private String clientId;
@Value("${github.client_secret}")
private String secretKey;
private final RestTemplate restTemplate;
private final UserRepository userRepository;

@Transactional
public TokenResponse githubSignIn(String code) {
String accessToken = getGithubAccessToken(code);
GithubUserDto githubUser = getGithubUserInfo(accessToken);
syncWithUser(githubUser);
Authentication authentication = createGithubAuthentication(githubUser);
JwtDTO tokens = tokenProvider.generateTokens(authentication);
return new TokenResponse(tokens.getAccessToken(), tokens.getRefreshToken());
}

private void syncWithUser(GithubUserDto githubUser) {
Optional<User> optionalUser = userRepository.findByEmail(githubUser.getEmail());
if (optionalUser.isEmpty()) {
register(githubUser);
return;
}
User user = optionalUser.get();
user.editGithubName(githubUser.getLogin());
}

private void register(GithubUserDto githubUser) {
User newUser = userRepository.save(User.builder()
.email(githubUser.getEmail())
.password(null)
.nickname(null)
.bjNickname(null)
.role(Role.USER)
.build());
newUser.editNickname(createTemporaryNickname(githubUser.getLogin(), newUser.getId()));
newUser.editGithubName(githubUser.getLogin());
}

private String createTemporaryNickname(String login, Long id) {
return login + "@" + id;
}

private String getGithubAccessToken(String code) {
HttpEntity<OAuthAccessTokenDto> response = restTemplate.exchange(
ApiConstants.GITHUB_ACCESS_TOKEN_URL,
HttpMethod.POST,
createAccessTokenRequest(code),
OAuthAccessTokenDto.class);

if (response.getBody() == null) {
log.error("failed to request GitHub access token : GitHub API response body is null.");
throw new GithubApiException("Github access token 응답에 실패했습니다.");
}

return response.getBody().getAccessToken();
}

private GithubUserDto getGithubUserInfo(String accessToken) {
ResponseEntity<GithubUserDto> response = restTemplate.exchange(
ApiConstants.GITHUB_USER_URL,
HttpMethod.GET,
createAuthorizationHeader(accessToken),
GithubUserDto.class);

GithubUserDto user = response.getBody();
if (user == null) {
log.error("failed to request GitHub user info : GitHub API response body is null.");
throw new GithubApiException("Github 사용자 정보를 가져오는데 실패했습니다.");
}

if (user.getEmail() == null || user.getEmail().isEmpty()) {
user.setEmail(getEmail(accessToken));
}

return user;
}

private String getEmail(String accessToken) {
ResponseEntity<List<GithubEmailDto>> emails = restTemplate.exchange(
ApiConstants.GITHUB_EMAIL_URL,
HttpMethod.GET,
createAuthorizationHeader(accessToken),
new ParameterizedTypeReference<>() {
}
);

if (emails.getBody() == null) {
log.error("failed to request GitHub user email : GitHub API response body is null.");
throw new GithubApiException("Github 유저의 이메일을 가져오는데 실패했습니다.");
}

return emails.getBody().getFirst().getEmail();
}

private Authentication createGithubAuthentication(GithubUserDto githubUser) {
OAuth2User oAuth2User = new DefaultOAuth2User(
Collections.singleton(new SimpleGrantedAuthority(Role.USER.toString())),
Map.of("email", githubUser.getEmail()),
"email"
);

return new OAuth2AuthenticationToken(oAuth2User, oAuth2User.getAuthorities(), "github");
}

private HttpEntity<MultiValueMap<String, String>> createAccessTokenRequest(String code) {
LinkedMultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", clientId);
params.add("client_secret", secretKey);
params.add("code", code);

return new HttpEntity<>(params, new HttpHeaders());
}

private HttpEntity<Map<String, String>> createAuthorizationHeader(String accessToken) {
HttpHeaders headers = new HttpHeaders();
headers.add("Authorization", "Bearer " + accessToken);
return new HttpEntity<>(headers);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
"/api/auth/reissue-token",
"/api/users/check-email",
"/api/users/check-nickname",
"/api/users/check-baekjoon-nickname");
"/api/users/check-baekjoon-nickname",
"/api/oauth/github/sign-in");

@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,8 @@ public String resolveToken(HttpServletRequest request) {
throw new JwtRequestException(HttpStatus.BAD_REQUEST.value(), "BAD_REQUEST", "유효한 형태의 토큰이 존재하지 않습니다.");
}

private Claims getClaims(String expiredToken) {
String token = expiredToken.replace("Bearer", "").trim();
private Claims getClaims(String inputToken) {
String token = inputToken.replace("Bearer", "").trim();
return parseClaims(token);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ public final class ApiConstants {
public static final String BOJ_USER_PROFILE_URL = "https://www.acmicpc.net/user/";
public static final String BOJ_PROBLEM_URL = "www.acmicpc.net";

public static final String GITHUB_ACCESS_TOKEN_URL = "https://github.com/login/oauth/access_token";
public static final String GITHUB_USER_URL = "https://api.github.com/user";
public static final String GITHUB_EMAIL_URL = "https://api.github.com/user/emails";

private ApiConstants() {
throw new RuntimeException("Can not instantiate : ApiConstants");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import com.gamzabat.algohub.auth.exception.GithubApiException;
import com.gamzabat.algohub.common.jwt.exception.ExpiredTokenException;
import com.gamzabat.algohub.common.jwt.exception.TokenException;
import com.gamzabat.algohub.feature.comment.exception.CommentValidationException;
Expand Down Expand Up @@ -183,4 +184,10 @@ protected ResponseEntity<ErrorResponse> handler(CheckPasswordFormException e) {
return ResponseEntity.status(e.getCode())
.body(new ErrorResponse(e.getCode(), e.getErrors(), null));
}

@ExceptionHandler(GithubApiException.class)
protected ResponseEntity<ErrorResponse> handler(GithubApiException e) {
return ResponseEntity.internalServerError()
.body(new ErrorResponse(HttpStatus.SERVICE_UNAVAILABLE.value(), e.getMessage(), null));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ public class User {
private String description = "";

private LocalDateTime deletedAt;
private String githubName;

@Enumerated(EnumType.STRING)
private Role role;
Expand All @@ -54,6 +55,7 @@ public User(String email, String password, String nickname, String bjNickname, S
this.profileImage = profileImage;
this.role = role;
this.deletedAt = null;
this.githubName = null;
}

public void editDescription(String description) {
Expand All @@ -75,4 +77,8 @@ public void editProfileImage(String profileImage) {
public void editPassword(String password) {
this.password = password;
}

public void editGithubName(String githubName) {
this.githubName = githubName;
}
}
1 change: 1 addition & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ spring:
import:
- "jwt.yml"
- "webhook.yml"
- "github.yml"
profiles:
active: dev
group:
Expand Down

0 comments on commit b65e71a

Please sign in to comment.