내가 만들려는 GitHub OAuth 흐름이다.
백엔드에서 GitHub 인증을 통해 회원가입 진행 및 로그인이 되길 원했다.
이 과정을 기록하고자 한다.
1. GitHub App 생성
New OAty App 버튼 클릭
필수 입력 진행
작성이 완료되면 하단 Register Application 버튼 클릭
Generate a new client secret 버튼 클릭해서 secret key를 생성한다.
그리고 생성된 키를 복사해서 기억해두는 것이 좋다.
2. gradle 추가
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-webflux'
현재 프로젝트에서 추가해야할 것만 추가했다.
3. SecurityConfiguration 수정
// 생략
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable())
.headers(headers -> headers.frameOptions(frame -> frame.disable()))
.formLogin(form -> form.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers(
// 생략
new AntPathRequestMatcher("/login/oauth2/**"),
new AntPathRequestMatcher("/oauth2/**"),
// 생략
).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/auth/signout")).authenticated()
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults())
.addFilterBefore(
new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService),
UsernamePasswordAuthenticationFilter.class
)
.addFilterAfter(sentryUserContextFilter, JwtAuthenticationFilter.class);
return http.build();
}
// 생략
사용자가 로그인 버튼을 클릭하면, /oauth2/authorization/{provider} 경로를 통해 인증 요청이 시작된다.
이후 Spring Security 내부적으로 /login/oauth2/code/{provider} 경로로 콜백을 처리한다.
이 모든 과정은 사용자가 아직 로그인되지 않은 상태에서 이루어지기 때문에, 인증 없이 접근이 가능해야 하기 때문에 Spring Security에서 OAuth2 로그인 경로를 permitAll()로 설정했다.
4. domain 추가
// 생략
public class OauthAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 소셜 로그인 제공자 (github, google 등)
@Column(name = "provider", nullable = false)
private String provider;
// provider가 부여한 고유 사용자 ID
@Column(name = "provider_user_id", nullable = false)
private String providerUserId;
// 생성 시 자동 기록
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false, foreignKey = @ForeignKey(name = "fk_oa_user"))
private User user;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
}
}
사용자(User)는 하나 이상의 소셜 계정으로 로그인할 수 있어야 한다.
예를 들어, 같은 사용자가 GitHub과 Google 계정을 모두 연동할 수 있는 구조를 고려해야 한다.
이를 위해 User와 1:N 관계를 갖는 OauthAccount 도메인을 설계했다.
- id: OauthAccount의 기본 키. DB에서 자동 증가되는 값으로 관리
- provider: OAuth2 제공자 이름을 저장
- providerUserId: 소셜 로그인 제공자가 발급한 사용자 고유 ID
- user
- OauthAccount는 반드시 하나의 User와 연결되어야 함.
- fetch = LAZY를 사용하여 실제로 접근할 때만 연관된 사용자 정보를 가져옴
- 외래키 이름은 fk_oa_user로 명시하여 가독성과 유지보수성을 높임
5. DTO 추가
// 기본 사용자 정보 응답
@Getter
@Setter
@AllArgsConstructor
public class GithubUserResponse {
private long id;
private String login;
private String name;
private String email;
@JsonProperty("avatar_url")
private String avatarUrl;
}
// 추가 이메일 정보 응답
@Data
public class GithubEmailResponse {
private String email;
private Boolean primary;
private Boolean verified;
private String visibility;
}
GitHub OAuth2 로그인 과정에서 이메일 정보가 null인 경우, /user/emails API를 통해 보완해야 한다.
이를 위해 GithubEmailResponse DTO를 별도로 설계하여 응답 값을 명확히 매핑하고, 이후 사용자 식별 및 회원가입 로직에 활용했다.
6. Repository 추가
@Repository
public interface OauthAccountRepository extends JpaRepository<OauthAccount, Long> {
Optional<OauthAccount> findByProviderAndProviderUserId(String provider, String providerUserId);
boolean existsByProviderAndProviderUserId(String provider, String providerUserId);
}
OauthAccountRepository는 소셜 로그인 사용자 식별의 핵심 도구이다.
OAuth 제공자에서 전달받은 provider + providerUserId 조합을 기반으로 우리 시스템의 사용자와 연결하거나, 신규 회원가입 여부를 판단하는 데 사용한다.
- findByProviderAndProviderUserId
- 소셜 로그인 시 사용자가 기존에 등록된 사용자인지 조회할 때 사용
- existsByProviderAndProviderUserId
- 회원가입 전에 동일한 소셜 계정이 이미 존재하는지 검증할 때 사용
7. Serivce 추가
GitHub OAuth2 로그인 흐름을 실제로 처리하는 핵심 비즈니스 로직은 OAuthService에 구현했다.
전체 흐름은 다음과 같다:
- 프론트엔드에서 전달받은 code를 기반으로 access token을 요청하고
- access token으로 사용자 정보를 조회
- 이메일이 누락된 경우에는 /user/emails API를 통해 이메일을 추가 조회
- 기존 사용자 여부를 판단해 로그인 또는 회원가입 처리
- access & refresh token을 발급하여 반환
// 생략
@Transactional
public SignInResponse processGitHubLogin(String code, HttpServletResponse servletResponse) {
String accessToken = getGitHubAccessToken(code);
GithubUserResponse githubUser = getGitHubUserInfo(accessToken);
// 이메일이 null인 경우 별도 API로 가져오기
if (!StringUtils.hasText(githubUser.getEmail())) {
String email = getGitHubUserEmail(accessToken);
githubUser.setEmail(email);
}
// 이메일이 여전히 없으면 에러
if (!StringUtils.hasText(githubUser.getEmail())) {
throw new GlobalException(ErrorCode.OAUTH_EMAIL_NOT_FOUND);
}
Optional<OauthAccount> existingOAuth = oauthAccountRepository.findByProviderAndProviderUserId("github", String.valueOf(githubUser.getId()));
User user;
if (existingOAuth.isPresent()) {
user = existingOAuth.get().getUser();
} else {
user = handleNewGitHubUser(githubUser);
}
return generateTokenResponse(user, servletResponse);
}
// 생략
이 메서드는 GitHub 로그인 전체 프로세스를 처리한다.
코드를 기반으로 access token을 먼저 발급받고, 그 토큰을 사용하여 사용자 정보를 요청한다.
GitHub의 특성상 사용자 이메일이 null로 반환될 수 있기 때문에, 이 경우 /user/emails API를 별도 호출하여 이메일을 보완했다.
// 생략
private String getGitHubAccessToken(String code) {
String tokenUrl = "https://github.com/login/oauth/access_token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
headers.set("Accept", "application/json");
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", githubClientId);
params.add("client_secret", githubClientSecret);
params.add("code", code);
params.add("redirect_uri", githubRedirectUri);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
try {
ResponseEntity<TokenResponse> response = restTemplate.postForEntity(tokenUrl, request, TokenResponse.class);
if (response.getBody() == null || response.getBody().getAccessToken() == null) {
throw new GlobalException(ErrorCode.OAUTH_TOKEN_ERROR);
}
return response.getBody().getAccessToken();
} catch (Exception e) {
log.error("GitHub 액세스 토큰 요청 실패", e);
throw new GlobalException(ErrorCode.OAUTH_TOKEN_ERROR);
}
}
// 생략
GitHub의 OAuth2 토큰 발급 API (/login/oauth/access_token)를 호출하여 access token을 획득한다.
요청 시 Accept: application/json 헤더를 반드시 포함해 JSON 형식의 응답을 받을 수 있도록 설정했다.
// 생략
private GithubUserResponse getGitHubUserInfo(String accessToken) {
String userUrl = "https://api.github.com/user";
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
headers.set("Accept", "application/vnd.github.v3+json");
HttpEntity<String> request = new HttpEntity<>(headers);
try {
ResponseEntity<GithubUserResponse> response = restTemplate.exchange(userUrl, HttpMethod.GET, request, GithubUserResponse.class);
if (response.getBody() == null) {
throw new GlobalException(ErrorCode.OAUTH_USER_INFO_ERROR);
}
return response.getBody();
} catch (Exception e) {
log.error("GitHub 사용자 정보 요청 실패", e);
throw new GlobalException(ErrorCode.OAUTH_USER_INFO_ERROR);
}
}
// 생략
https://api.github.com/user API를 호출하여 사용자 정보를 조회한다.
여기서 id, login, name, avatar_url 등을 받을 수 있지만, email 필드는 종종 null일 수 있다.
// 새로 추가된 메서드: GitHub 이메일 정보 가져오기
private String getGitHubUserEmail(String accessToken) {
String emailUrl = "https://api.github.com/user/emails";
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + accessToken);
headers.set("Accept", "application/vnd.github.v3+json");
HttpEntity<String> request = new HttpEntity<>(headers);
try {
ResponseEntity<List<GithubEmailResponse>> response = restTemplate.exchange(
emailUrl,
HttpMethod.GET,
request,
new ParameterizedTypeReference<List<GithubEmailResponse>>() {}
);
if (response.getBody() == null || response.getBody().isEmpty()) {
log.warn("GitHub 이메일 정보를 가져올 수 없습니다.");
return null;
}
// 우선순위: primary이면서 verified인 이메일 > primary인 이메일 > verified인 이메일 > 첫 번째 이메일
List<GithubEmailResponse> emails = response.getBody();
// 1순위: primary이면서 verified
Optional<GithubEmailResponse> primaryVerified = emails.stream()
.filter(email -> Boolean.TRUE.equals(email.getPrimary()) && Boolean.TRUE.equals(email.getVerified()))
.findFirst();
if (primaryVerified.isPresent()) {
return primaryVerified.get().getEmail();
}
// 2순위: primary
Optional<GithubEmailResponse> primary = emails.stream()
.filter(email -> Boolean.TRUE.equals(email.getPrimary()))
.findFirst();
if (primary.isPresent()) {
return primary.get().getEmail();
}
// 3순위: verified
Optional<GithubEmailResponse> verified = emails.stream()
.filter(email -> Boolean.TRUE.equals(email.getVerified()))
.findFirst();
if (verified.isPresent()) {
return verified.get().getEmail();
}
// 4순위: 첫 번째 이메일
return emails.get(0).getEmail();
} catch (Exception e) {
log.error("GitHub 이메일 정보 요청 실패", e);
return null;
}
}
사용자의 이메일 정보를 가져오기 위한 메서드다.
/user/emails API를 호출하여 이메일 목록을 받아온 뒤, 아래와 같은 우선순위 로직으로 가장 적절한 이메일을 선택한다:
- primary && verified == true
- primary == true
- verified == true
- 첫 번째 이메일
이메일이 완전히 누락된 경우 예외를 던져 회원가입을 막는다.
// 생략
private User handleNewGitHubUser(GithubUserResponse githubUser) {
Optional<User> existingUser = userRepository.findByEmail(githubUser.getEmail());
User user;
if (existingUser.isPresent()) {
user = existingUser.get();
} else {
user = createNewUserFromGitHub(githubUser);
}
OauthAccount oauthAccount = OauthAccount.builder()
.provider("github")
.providerUserId(String.valueOf(githubUser.getId()))
.user(user)
.build();
oauthAccountRepository.save(oauthAccount);
return user;
}
// 생략
해당 이메일로 가입된 사용자가 이미 있다면 그대로 사용하고, 없을 경우 새로 회원을 생성한다.
이때 OauthAccount도 함께 저장하여 소셜 계정과 사용자를 연결한다.
// 생략
private User createNewUserFromGitHub(GithubUserResponse githubUser) {
String nickname = userService.generateUniqueNickname(NicknameGenerator.generate());
String profileImageUrl = profileImageService.generateProfileImageUrl(nickname, 48);
User user = User.builder()
.username(githubUser.getName() != null ? githubUser.getName() : githubUser.getLogin())
.nickname(nickname)
.email(githubUser.getEmail())
.phoneNumber("")
.password("")
.profileImageUrl(profileImageUrl)
.emailVerified(true) // GitHub에서 인증된 이메일로 간주
.build();
return userRepository.save(user);
}
// 생략
신규 유저를 생성할 때는 다음과 같은 규칙을 적용했다:
- username: GitHub name → 없으면 login
- nickname: 랜덤 생성 + 중복 방지 (NicknameGenerator)
- profileImageUrl: 닉네임 기반 자동 생성 이미지
- 비밀번호/전화번호는 빈 문자열로 설정
- 이메일 인증은 GitHub에서 verified된 것으로 간주하고 emailVerified = true 처리
// 생략
@Transactional
public SignInResponse generateTokenResponse(User user, HttpServletResponse servletResponse) {
String accessToken = jwtTokenProvider.createToken(user.getId());
String refreshToken = jwtTokenProvider.createRefreshToken(user.getId());
refreshTokenService.save(user.getId(), refreshToken);
Cookie refreshCookie = new Cookie("refreshToken", refreshToken);
refreshCookie.setHttpOnly(true);
refreshCookie.setPath("/");
refreshCookie.setMaxAge(60 * 60 * 24 * 14); // 2주
refreshCookie.setSecure(true); // SameSite=None 사용 시 필수
servletResponse.addCookie(refreshCookie);
// SameSite=None 설정을 위한 Set-Cookie 헤더 추가
servletResponse.setHeader(
"Set-Cookie",
"refreshToken=" + refreshToken + "; HttpOnly; Secure; Path=/; Max-Age=" + (60 * 60 * 24 * 14) + "; SameSite=None"
);
return new SignInResponse(accessToken, new SignInUserDto(user));
}
// 생략
최종적으로 access token과 refresh token을 발급하고 반환한다.
refresh token은 HttpOnly, Secure, SameSite=None 옵션으로 쿠키에 담아 반환한다.
이와 동시에 Set-Cookie 헤더를 명시적으로 설정해 크로스 도메인 환경에서도 쿠키가 정상적으로 동작하도록 처리했다.
// 생략
private static class TokenResponse {
@JsonProperty("access_token")
private String access_token;
@JsonProperty("token_type")
private String token_type;
@JsonProperty("scope")
private String scope;
public String getAccessToken() { return access_token; }
public void setAccessToken(String access_token) { this.access_token = access_token; }
public String getTokenType() { return token_type; }
public void setTokenType(String token_type) { this.token_type = token_type; }
public String getScope() { return scope; }
public void setScope(String scope) { this.scope = scope; }
}
// 생략
8. Controller 추가
GitHub OAuth2 로그인 요청이 완료된 후, GitHub는 code 파라미터를 포함한 콜백 요청을 백엔드로 보낸다.
이 콜백을 처리하는 역할은 OAuthController에서 담당한다
// 생략
@GetMapping("/github/callback")
public void githubCallback(
@RequestParam("code") String code,
HttpServletResponse response) throws IOException {
try {
SignInResponse signInResponse = oAuthService.processGitHubLogin(code, response);
sendSuccessResponse(response, signInResponse);
} catch (Exception e) {
sendErrorResponse(response, e.getMessage());
}
}
// 생략
GitHub OAuth 인증이 완료되면 사용자는 이 엔드포인트로 리디렉션된다.
이때 전달된 code를 바탕으로 실제 로그인 처리를 수행하는 서비스(OAuthService.processGitHubLogin)를 호출한다.
성공응답
// 생략
private void sendSuccessResponse(HttpServletResponse response, SignInResponse data) throws IOException {
ObjectMapper objectMapper = new ObjectMapper();
String jsonData = objectMapper.writeValueAsString(data);
response.setContentType("text/html; charset=UTF-8");
PrintWriter out = response.getWriter();
String html = String.format("""
<!DOCTYPE html>
<html>
<head><title>GitHub Login Success</title></head>
<body>
<script>
console.log('GitHub 로그인 성공');
if (window.opener) {
window.opener.postMessage({
type: 'GITHUB_LOGIN_SUCCESS',
response: %s
}, 'http://localhost:5173');
window.close();
}
</script>
<p>로그인 성공! 창이 자동으로 닫힙니다.</p>
</body>
</html>
""", jsonData);
out.println(html);
out.flush();
}
// 생략
- 응답 데이터를 JSON 문자열로 직렬화
- HTML 문서 내 <script>에서 postMessage로 프론트엔드에 로그인 결과 전달
- 이후 자동으로 팝업 창을 닫음
- Content-Type을 text/html; charset=UTF-8로 명시하여 한글 깨짐 방지
실패 응답
// 생략
private void sendErrorResponse(HttpServletResponse response, String errorMessage) throws IOException {
response.setContentType("text/html; charset=UTF-8");
PrintWriter out = response.getWriter();
String html = String.format("""
<script>
if (window.opener) {
window.opener.postMessage({
type: 'GITHUB_LOGIN_ERROR',
error: '%s'
}, 'http://localhost:5173');
window.close();
}
</script>
""", errorMessage);
out.println(html);
out.flush();
}
// 생략
- 예외 발생 시 에러 메시지를 포함한 GITHUB_LOGIN_ERROR 메시지를 postMessage로 전송
- 동일하게 창을 자동으로 닫음
이제 프론트 작업이다.
사용자가 로그인 버튼을 클릭하면, 백엔드의 인증 URL로 새로운 팝업을 띄우고, 로그인 완료 시 postMessage로 결과를 수신받는다
// 생략
const handleClickGithubLogin = () => {
const popup = window.open(
GITHUB_LOGIN_URL,
'GithubLoginPopup',
`width=${width},height=${height},left=${left},top=${top},resizable=no,scrollbars=yes,status=no`
);
if (!popup) {
alert('팝업이 차단되었습니다. 브라우저 설정을 확인해주세요.');
return;
}
const handleMessage = (event: MessageEvent) => {
if (event.origin !== 'http://localhost:8080') return;
const { type, response } = event.data;
if (type === 'GITHUB_LOGIN_SUCCESS') {
setAuthSocialLogin(response);
popup?.close();
window.removeEventListener('message', handleMessage);
} else if (type === 'GITHUB_LOGIN_ERROR') {
alert('GitHub 로그인에 실패했습니다.');
popup?.close();
window.removeEventListener('message', handleMessage);
}
};
window.addEventListener('message', handleMessage);
// 팝업이 닫혔는지 확인 (사용자가 직접 닫은 경우)
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', handleMessage);
}
}, 1000);
};
// 생략
- window.open()으로 OAuth 인증용 팝업을 띄움
- 인증 완료 후, 백엔드에서 팝업 창 내 postMessage()로 결과 전송
- 메인 창에서는 window.addEventListener('message')를 통해 결과 수신
- 수신 데이터의 type에 따라 로그인 성공 또는 실패 처리
- 사용자가 직접 팝업을 닫은 경우를 대비해 setInterval()로 닫힘 여부 확인
사실 처음엔 팝업 생각을 하지 못했다.
그냥 버튼 클릭 시 깃헙 입증이 되도록 url 요청을 보내는걸로 만들었는데
새로운 탭에 인증을 진행하면서 인증 완료된 이후 데이터를 가져오지 못하는 문제가 발생했다.
그러던 중 깃북에서 팝업을 통해 깃헙입증을 하는 것을 보고 팝업으로 수정하게 되어 위와 같은 코드가 만들어졌다.
전체 코드는 BE GitHub & FE GitHub 에서 확인이 가능하다.
'공부합시다 > 찍먹' 카테고리의 다른 글
Google SMTP 이메일 인증 기능 추가하기 (0) | 2025.07.22 |
---|---|
HTTP Digest 인증 (0) | 2024.04.05 |
[MySQL] DATABASE 만들기(feat.groomIDE) (0) | 2024.03.20 |
[C++] WebAssembly in Action - 4.1 엠스크립튼 연결 코드로 동작하는 웹어셈블리 모듈 생성하기 (0) | 2022.07.07 |
[WebRTC] openVidu 튜토리얼 따라하기(openvidu-hello-world) (0) | 2022.06.08 |