Blog

[OAuth2.0] Google Social Login 구현 - 클라이언트 사이드 약식

Category
Author
citeFred
citeFred
PinOnMain
1 more property
소셜 로그인 기능 구현 및 배포환경에서의 테스트
Table of Content

소셜 로그인은 모두 비슷한 과정

이전 카카오 로그인 구현과 결과적으로 동일한 과정을 거친다.
1.
인증 제공자(Provider)인 Google 디벨로퍼 콘솔에 접근해서 API 기능을 이용할 애플리케이션을 생성
2.
REST API 키 등 인증 키, 토큰 등을 받음
3.
해당 토큰과 함께 백엔드서버가 프로바이더 서버로 요청하는 중첩 구조를 만들게 됨
일반로그인 : 사용자 → 서버(서비스 서버) → 사용자
소셜로그인 : 사용자 → 서버(서비스 서버 → 프로바이더 서버 → 서비스 서버) → 사용자
실제 애플리케이션에서 구현하는 과정을 정리

1. 구글에서 애플리케이션 생성

프로젝트 만들기

1-1. 동의화면 구성

콘솔에서 OAuth 동의화면을 먼저 생성해준다.
애플리케이션 이름과 문의 지원 이메일(개발자)을 입력
만들기 까지 아래와 같이 진행

1-2. API 키 생성

사용자 인증 정보에서 API키를 생성

1-3. 데이터액세스 범위 설정

1-4. 클라이언트 ID 발급

클라이언트 생성 및 ID 발급
OAuth 클라이언트 생성 후 클라이언트 ID, 시크릿 키 보관
해당 민감 정보는 .env와 같은 파일에 저장하여 은닉(github Push 유의 .gitignore 등록 확인)

1-5. API 사용 설정

Google+ API 사용 설정

2. 클라이언트 사이드 방식 코드 구현

현재까지 Google Oauth 2.0 API를 사용하기 위한 애플리케이션 준비과정을 통해서 다음과 같은 결과물들이 있었다.
API KEY
CLIENT_ID
CLIENT_SECRET_KEY
CALLBACK(REDIRECT) URI
이제 직접 백엔드 및 프론트엔드 프로젝트에서 해당 값들을 활용하여 나의 백엔드가 구글 API 서버로 요청하는 부분을 구현할 차례
하지만 다소 복잡한 백엔드 구성 이전에 클라이언트 사이드에서 Credential(사용자정보포함) 까지 획득하고 이것으로 백엔드가 로그인 및 회원가입을 진행 시키는 간단한 방법 부터 우선 구현하고자 한다.
OAuth 2.0은 백엔드 서버측에서 Callback URI를 활용한 구현을 권장하고 있으며 아래 코드는 리팩토링 예정이다.
아직 기능이 구현되기 전이므로 테스트 단계에서 진행

2-1. 구글 로그인 화면 구현(프론트엔드)

우선 프론트엔드에서부터 특정 버튼을 통해 구글 로그인 API를 사용 할 수 있는 화면을 나타내는 것 부터 시작
CLIENT_ID를 프론트엔드 프로젝트의 상수를 관리하는 곳에 작성해둔다.
index.html 구글 gsi 클라이언트 라이브러리를 주입받아서 사용
<!DOCTYPE html> <html lang="en" data-theme="base"> <head> <meta charset="utf-8" /> <title>METAVERSE(LMS)</title> <base href="/" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"> <link rel="icon" type="image/x-icon" href="favicon.ico" /> <script src="https://accounts.google.com/gsi/client" async></script> </head> <body class="bg-background font-poppins selection:bg-primary selection:text-primary-foreground"> <app-root> <div class="flex h-screen"> ... </div> </app-root> </body> </html>
HTML
복사
sign-in.component.ts 여기서 로그인 컴포넌트의 함수에서 위에서 상수에 저장한 클라이언트 ID를 주입받고, google이라는 변수를 정의한다. ngOninit에서 DOM로드시 수행되는 부분을 작성, 버튼 렌더링 또한 여기서 설정
import { Component, ViewChild, ElementRef, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { SignInRequest } from 'src/app/core/models/auth/sign-in-request.interface'; import { AuthService } from 'src/app/core/services/auth/auth.service'; import { ToastComponent } from 'src/app/core/components/toast/toast.component'; import { GOOGLE_CLIENT_ID } from 'src/app/core/constants/auth.constant'; declare var google : any; @Component({ selector: 'app-sign-in', templateUrl: './sign-in.component.html', styleUrls: ['./sign-in.component.scss'], }) export class SignInComponent implements OnInit { signInForm: FormGroup; errorMessage: string = ''; googleClientId = GOOGLE_CLIENT_ID; @ViewChild('emailInput') emailInput!: ElementRef; @ViewChild(ToastComponent) toast!: ToastComponent; constructor( private fb: FormBuilder, private authService: AuthService, private router: Router ) { this.signInForm = this.fb.group({ email: ['', Validators.required], password: ['', Validators.required], rememberMe: [false], }); } ngOnInit(): void { google.accounts.id.initialize({ client_id: this.googleClientId, callback: (response: any) => { console.log('Google Sign-In Response:', response); }}); google.accounts.id.renderButton( document.getElementById('google-btn'), { theme: 'filled_blue', size: 'large', shape: 'rectangle', }); const storedEmail = localStorage.getItem('rememberedEmail'); const isRemembered = localStorage.getItem('rememberMe') === 'true'; if (storedEmail && isRemembered) { this.signInForm.patchValue({ email: storedEmail, rememberMe: true, }); } } onSignIn() { ... } }
TypeScript
복사
sign-in.component.html 에서 직접 버튼을 id를 통해 생성
<app-toast [message]="errorMessage"></app-toast> <div class="my-10 space-y-6"> <div class="text-center"> <h2 class="mb-1 text-3xl font-semibold text-foreground">Hello Again <span class="text-primary">!</span></h2> <p class="text-sm text-muted-foreground">Enter your credential to access your account.</p> </div> <!-- Oauth 2.0 Google --> <div id="google-btn"></div> ... </div>
HTML
복사
이와 같이 설정되면 기본적으로 아래와 같이 버튼이 생성된다.
버튼을 통해서 팝업이 나타나고,
console.log('Google Sign-In Response:', response); 로 해당 클라이언트 ID를 통해 요청한 응답을 받을 수 있게 된다.
특히 여기서 credential부분의 토큰을 jwt.io를 통해서 디코딩해보면, 다음과 같은 기본 정보들을 가지고 있는 것을 확인 할 수 있다.
위 까지의 진행이 로그인이 완성된 것이 아니다. Google Sign-In으로부터 받은 응답에 credential 필드가 포함되어 있는 것은 사용자가 Google 계정으로 성공적으로 인증했다는 의미
credential 값은 ID 토큰이라고 불리며, 사용자의 신원에 대한 정보를 담고 있는 JSON Web Token (JWT)이며
ID 토큰 (credential 값)을 백엔드 서버로 전송하고 백엔드 서버에서는 Google의 공개 키를 사용하여 받은 ID 토큰의 서명을 검증해야 한다.
아래 google-auth-library가 공개키 검증 등을 수행하게 됨

2-2. 백엔드 구성

구글 로그인 검증을 위한 라이브러리를 설치
npm install google-auth-library
HTML
복사
백엔드에서는 creadential의 email 주소를 통해 회원 등록이 되었는지 조회하고, 등록되지 않은 회원이면 회원가입+로그인을 진행하도록 구성한다.
auth.controller.ts 에서 google 로그인 라우터를 새로 생성해준다.
import { BadRequestException, Body, ConflictException, Controller, HttpStatus, Logger, Post, Res } from '@nestjs/common'; import { AuthService } from './auth.service'; import { SignInRequestDto } from './dto/sign-in-request.dto'; import { Response } from 'express'; import { ApiResponseDto } from 'src/common/api-response-dto/api-response.dto'; import { CreateUserRequestDto } from 'src/users/dto/create-user-request.dto'; import { UsersService } from 'src/users/users.service'; @Controller('api/auth') export class AuthController { private readonly logger = new Logger(AuthController.name); constructor( private authService: AuthService, private usersService: UsersService, ){} // Sign-Up @Post('/signup') ... } // Sign-In @Post('/signin') ... } // Google Sign-In @Post('/google/signin') async googleSignIn( @Body('credential') credential: string, @Res() res: Response ): Promise<void> { this.logger.verbose(`Attempting Google Sign-In with credential: ${credential.substring(0, 50)}...`); try { const jwtToken = await this.authService.signInWithGoogle(credential); this.logger.verbose(`Google Sign-In successful, issued JWT: ${jwtToken.substring(0, 50)}...`); res.setHeader('Authorization', `Bearer ${jwtToken}`); const response = new ApiResponseDto(true, HttpStatus.OK, 'Google login successful', { jwtToken }); res.send(response); } catch (error) { this.logger.error(`Google Sign-In failed: ${error.message}`, error.stack); const response = new ApiResponseDto(false, HttpStatus.UNAUTHORIZED, 'Google login failed'); res.status(HttpStatus.UNAUTHORIZED).send(response); } } }
TypeScript
복사
auth.service.ts 에서는 로그인 및 회원가입 로직을 구성한다.
구글 검증 라이브러리로 추가된 OAuth2Client 객체를 통해 검증 작업이 모두 진행됨
import { Injectable, InternalServerErrorException, Logger, UnauthorizedException } from '@nestjs/common'; import * as bcrypt from 'bcryptjs' import { SignInRequestDto } from './dto/sign-in-request.dto'; import { JwtService } from '@nestjs/jwt'; import { UsersService } from 'src/users/users.service'; import { OAuth2Client } from 'google-auth-library'; import { UserRole } from 'src/users/entities/user-role.enum'; import { CreateGoogleUserRequestDto } from 'src/users/dto/create-google-user-request.dto'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); private readonly googleClientId: string; constructor( private jwtService: JwtService, private userService: UsersService, ){ this.googleClientId = process.env.GOOGLE_CLIENT_ID; } // Sign-In async signIn( ... } // Google Sign-In async signInWithGoogle(credential: string): Promise<string> { const googlePayload = await this.verifyGoogleIdToken(credential); if (!googlePayload?.email_verified) { throw new UnauthorizedException('Google email not verified'); } const { email, name } = googlePayload; // 사용자가 없으면 null 반환, 예외X 회원가입으로 이어지도록함 let existingUser = await this.userService.findUserByEmail(email).catch(() => null); if (!existingUser) { // Google 계정으로 새로운 사용자 생성 (회원 가입) const newGoogleUserDto: CreateGoogleUserRequestDto = { email, username: name || email.split('@')[0], role: UserRole.STUDENT, }; await this.userService.createUserByGoogle(newGoogleUserDto); existingUser = await this.userService.findUserByEmail(email); // 생성된 사용자 다시 조회 } if (!existingUser) { throw new InternalServerErrorException('Failed to create or find user after Google login.'); } // JWT 토큰 생성 const payload = { id: existingUser.id, email: existingUser.email, username: existingUser.username, role: existingUser.role, }; const jwtToken = await this.jwtService.sign(payload); return jwtToken; } async verifyGoogleIdToken(credential: string): Promise<any> { try { const client = new OAuth2Client(this.googleClientId); const ticket = await client.verifyIdToken({ idToken: credential, audience: this.googleClientId, }); const payload = ticket.getPayload(); this.logger.debug('Google Payload:', payload); return payload; } catch (error) { this.logger.error(`Google ID token verification failed: ${error.message}`, error.stack); throw new UnauthorizedException('Invalid Google ID token'); } } }
TypeScript
복사
user.service.ts 에서 해당 유저의 정보를 활용해서 회원가입(패스워드가 없기 때문에 uuid를 통한 임시 비밀번호 발급)
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { User } from './entities/user.entity'; import { CreateUserRequestDto } from './dto/create-user-request.dto'; import { BadRequestException } from '@nestjs/common'; import * as bcrypt from 'bcrypt'; import { ConflictException } from '@nestjs/common'; import { UpdateUserRequestDto } from './dto/update-user-request.dto'; import { CreateGoogleUserRequestDto } from './dto/create-google-user-request.dto'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class UsersService { private readonly logger = new Logger(UsersService.name); constructor( @InjectRepository(User) private usersRepository: Repository<User>, ) {} // CREATE async createUser( ... } // CREATE - Google User async createUserByGoogle( createGoogleUserRequestDto: CreateGoogleUserRequestDto ): Promise<void> { this.logger.verbose(`Creating a new Google account with email: ${createGoogleUserRequestDto.email}`); const { username, email, role } = createGoogleUserRequestDto; if (!username || !email || !role) { this.logger.error('Required fields missing for Google user creation.'); throw new BadRequestException('Username, email, and role must be provided for Google login.'); } try { await this.checkEmailExist(email); const tempPassword = uuidv4(); const hashedPassword = await this.hashPassword(tempPassword); const newUser = this.usersRepository.create({ username, password: hashedPassword, email, role }); const savedUser = await this.usersRepository.save(newUser); this.logger.verbose(`New Google account created successfully: ${savedUser.email} (ID: ${savedUser.id})`); } catch (error) { this.logger.error(`Error creating Google user: ${error.message}`, error.stack); throw error; } } ... }
TypeScript
복사
이후 프론트엔드의 코드 수정
sign-in.component.ts 에서 credential을 요청 바디에 포함 시켜서 호출
@Component({ selector: 'app-sign-in', templateUrl: './sign-in.component.html', styleUrls: ['./sign-in.component.scss'], }) export class SignInComponent implements OnInit { signInForm: FormGroup; errorMessage: string = ''; googleClientId = GOOGLE_CLIENT_ID; @ViewChild('emailInput') emailInput!: ElementRef; @ViewChild(ToastComponent) toast!: ToastComponent; constructor( private fb: FormBuilder, private authService: AuthService, private router: Router ) { this.signInForm = this.fb.group({ email: ['', Validators.required], password: ['', Validators.required], rememberMe: [false], }); } ngOnInit(): void { google.accounts.id.initialize({ client_id: this.googleClientId, callback: (response: any) => { console.log('Google Sign-In Response:', response); if (response?.credential) { this.authService.signInWithGoogle(response.credential).subscribe({ next: (authResponse) => { this.router.navigate(['/']); // 로그인 성공 후 리다이렉트 }, error: (error) => { console.error('Google Sign-in error:', error); this.showToastMessage('Google 로그인에 실패했습니다.'); } }); } else { this.showToastMessage('Google 로그인에 실패했습니다. 토큰이 없습니다.'); } } }); ...
TypeScript
복사
auth.service.ts 백엔드로 API 요청을 보내는 부분의 수정
@Injectable( { providedIn: 'root' } ) export class AuthService { private currentUserSubject = new BehaviorSubject<DecodedToken | null>(null); public currentUser$ = this.currentUserSubject.asObservable(); private apiUrl = environment.apiUrl + '/auth'; private readonly TOKEN_KEY = 'jwtToken'; constructor(private http: HttpClient, private router: Router) { this.loadUserFromToken(); } getToken(): string | null { return sessionStorage.getItem(this.TOKEN_KEY); } ... signInWithGoogle(credential: string): Observable<AuthResponse> { const headers = new HttpHeaders({ 'Content-Type': 'application/json' }); const body = { credential }; return this.http.post<AuthResponse>(`${this.apiUrl}/google/signin`, body, { headers, withCredentials: true, observe: 'response' }).pipe( tap(response => { const jwtToken = response.headers.get('Authorization'); if (jwtToken) { sessionStorage.setItem('jwtToken', jwtToken); const decoded = (jwtDecode as (token: string) => DecodedToken)(jwtToken); console.log('Google Decoded Token:', decoded); this.currentUserSubject.next(decoded); } }), map(response => { if (response.body) { return response.body; } throw new Error('Response body is null'); }) ); } ... }
TypeScript
복사

2-3. 1차 구현 이후 테스트

최초 로그인 시 INSERT 문을 통한 회원 가입 화면
로그인 시도 시 아래와 같은 팝업 노출
기존 로그인과 동일하게 JWT 토큰을 통한 로그인 상태 유지 화면
Search
 | Main Page | Category |  Tags | About Me | Contact | Portfolio