소셜 로그인 기능 구현 및 배포환경에서의 테스트
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 토큰을 통한 로그인 상태 유지 화면
Related Posts
Search