로그인 요청을 하고 나서, 서버에 토큰을 프론트에게 넘겨줄 때, 토큰을 하나 더 만들어서 넘겨줍니다.
하나 더 만든 토큰을 refresh token
이라고 하고 기존에 발행하던 토큰을 access token
이라고 합니다.
refresh token
은 access token
이 만료되었을 때, access token
을 다시 발행하기 위한 용도로 쓸 것이기 때문에 access token
보다 유효기간이 길어야 합니다.
Access Token(JWT)
를 통한 인증 방식의 문제는 해킹을 당했을 경우 보안에 취약하다는 점이 있습니다.
유효기간이 짧은 토큰의 경우 그만큼 사용자는 로그인을 자주해서 새롭게 토큰을 받아야 하므로 불편해집니다.
그렇다고 유효기간을 늘리면 토큰을 해킹당했을 때 보안에 더 취약해지게 됩니다.
이러한 점들을 보완한 것이 refresh token
입니다.
Access Token 만료가 될 때마다 계속 4~7 과정을 거칠 필요는 없습니다. 프론트에서 access token의 payload를 통해 유효기간을 알 수 있으며, 프론트단에서 api요청 전에 토큰이 만료되었다면 바로 재발급 요청을 할 수도 있습니다.
Web Storage란 HTML5부터 제공하는 기능으로, 해당 도메인과 관련된 특정 데이터를 서버가 아니라 클라이언트 웹브라우저에 저장할 수 있도록 제공하는 기능입니다.
쿠키(Cookie)와 비슷한 기능이며, Web Storage의 개념의 키/값 쌍으로 데이터를 저장하고, 키를 기반으로 데이터를 조회하는 패턴입니다.
영구저장소(Local Storage)와 임시저장소(Session Storage)를 따로 두어 데이터의 지속성을 구분할 수 있어 응용 환경에 맞는 선택이 가능하다.
Web Storage는 쿠키와 마찬가지로 사이트의 도메인 단위로 접근이 제한된다.
예를 들면, A 도메인에서 저장한 데이터는 B 도메인에서 조회할 수 없다.
이는 데이터의 보안 측면에서 당연합니다.
저장된 데이터가 클라이언트에게 존재할 뿐 서버로 전송은 이루어지지 않는다.
이는 네트워크 비용을 줄여줍니다.
문자열 기반 데이터 이외에 체계적으로 구조화된 객체를 저장할 수 있는 점은 개발편의성을 제공해주는 주요한 장점입니다.
단, 브라우저의 지원 여부를 확인해 봐야 하는 항목이다.
만료 기간의 설정이 없습니다.
즉, 한번 저장한 데이터는 영구적으로 존재합니다.
쿠키와 Web Stroage 모두 브라우저에 저장되지만 쿠키는 아래와 같은 단점이 있습니다.
쿠키의 단점을 Web Storage를 사용함으로써 극복할 수 있습니다.
웹사이트에서 쿠키를 설정하면 이후 모든 웹 요청은 쿠키 정보를 포함해 서버로 전송됩니다.
Web Storage는 저장된 데이터가 클라이언트에 존재할 뿐 서버로 전송되지는 않습니다.
이는 네트워크 트래픽 비용을 줄여줍니다.
문자열 기반 데이터 외에 체계적으로 구조화된 객체를 저장할 수 있습니다.
이는 개발 편의성을 제공해주는 장점입니다.(단, 브라우저의 지원 여부를 확인해봐야 합니다)
쿠키는 개수와 용량에 제한이 있습니다.
클라이언트에 최대 300개의 쿠키를 저장할 수 있으며, 하나의 사이트(도메인)에서는 최대 20개를 저장할 수 있습니다.
또한, 하나의 쿠키값은 최대 4KB로 제한되어 있습니다.
쿠키는 만료일자를 지정하게 되어있어 언젠가 제거됩니다.
만약 만료일자를 지정하지 않으면 세션 쿠기가 됩니다.
만일 영구 쿠키를 원한다면 만료일자를 굉장히 멀게 설정하여 해결할 수 있습니다.
Web Storage의 만료기간의 설정이 없습니다.
즉, 한 번 저장한 데이터는 영구적으로 존재하게 됩니다.
Web Storage는 데이터의 지속성과 관련하여 두 가지의 용도의 저장소를 제공합니다.
기본적으로 Web Storage는 Cookie와 마찬가지로 사이트 도메인의 단위로 접근이 제한됩니다.
예를 들어, a 도메인에 저장한 데이터는 b 도메인에서 조회할 수 없습니다.
이는 데이터 보안측면에서 당연합니다.
💡 브라우저 컨텍스트는 Document를 표시하는 환경을 뜻합니다. 즉, 브라우저가 불러운 웹페이지라고 생각하면 됩니다.
쿠키는 클라이언트(브라우저) 로컬에 저장되는 키값이 들어있는 작은 데이터 파일입니다.
사용자 인증이 유효한 시간을 명시할 수 있으며, 유효 시간이 정해지면 브라우저가 종료되어도 인증이 유지된다는 특징이 있습니다.
쿠키는 클라이언트의 상태 정보를 로컬에 저장했다가 참조합니다.
클라이언트에 300개까지 쿠키저장 가능, 하나의 도메인당 20개의 값만 가질 수 있음, 하나의 쿠키값은 4KB까지 저장합니다
Response Header에 Set-Cookie 속성을 사용하면 클라이언트에 쿠키를 만들 수 있습니다.
쿠키는 사용자가 따로 요청하지 않아도 브라우저가 Request시에 Request Header를 넣어서 자동으로 서버에 전송합니다.
refresh token을 발행하면서 cookie에 대한 refresh token이 잘 들어가는지 확인해보고 토큰이 만료시켰을 때 에러를 확인해 보겠습니다.
20-04-login-auth-param
폴더를 복사하여 사본을 만들고 폴더명을 21-01-login-auth-with-refresh-token
으로 변경해주세요.
src/common
에 types
폴더를 만들어주시고 안에 context.ts
파일을 생성합니다.
import { Request, Response } from 'express';
export interface IContext {
req: Request;
res: Response;
}
NestJS는 express 기반이기 때문에 express에 해당하는 모듈을 모두 사용할 수 있습니다.
context.ts
의 하는 역할은 로그인할 때 사용할 것이기 때문에 하단에 설명해 드리겠습니다.
src/apis/auth/auth.servies.ts
파일을 수정하겠습니다.
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}
getAccessToken({ user }) {
const accessToken = this.jwtService.sign(
{ email: user.email, sub: user.id },
{ secret: 'myAccessKey', expiresIn: '1h' },
);
return accessToken;
}
setRefreshToken({ user, res }) {
const refreshToken = this.jwtService.sign(
{ email: user.email, sub: user.id },
{ secret: 'myRefreshKey', expiresIn: '1w' },
);
res.setHeader('Set-Cookie', `refreshToken=${refreshToken}`);
}
}
다음과 같이 setRefreshToken
이라는 비즈니스 로직을 추가해주세요.
항상 refreshToken의 expire 시간은 accessToken의 expire보다 길어야합니다.
setRefreshToken
은 refreshToken을 헤더에 추가합니다.
src/apis/auth/auth.resolver.ts
파일을 수정하겠습니다.
import { Args, Context, Mutation, Resolver } from '@nestjs/graphql';
import { AuthService } from './auth.service';
import { UserService } from '../user/user.service';
import { UnprocessableEntityException } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { IContext } from 'src/commons/types/context';
@Resolver()
export class AuthResolver {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
) {}
@Mutation(() => String)
async login(
@Args('email') email: string,
@Args('password') password: string,
@Context() context: IContext,
) {
const user = await this.userService.findOne({ email });
if (!user) throw new UnprocessableEntityException('이메일이 없습니다');
const isAuth = await bcrypt.compare(password, user.password);
if (!isAuth)
throw new UnprocessableEntityException('비밀번호가 일치하지 않습니다');
this.authService.setRefreshToken({ user, res: context.res });
const accessToken = this.authService.getAccessToken({ user });
return accessToken;
}
}
상위에서 만들어 놓은 context.ts
에 미리 정해 놓은 Request와 Response의 타입을 가져와 사용하겠습니다.
context의 res를 사용해 setRefreshToken
비즈니스 로직을 실행해 cookie에 refreshToken을 넣어줍니다.
yarn start:dev
를 통해 서버를 실행시켜주세요.
그리고 http://localhost:3000/graphql 에 접속하여 api를 요청해보겠습니다.
refreshToken
이 쿠키에 잘 받아와지는 것을 볼 수 있습니다.
이번에는 토큰을 accessToken을 발행하자마자 파괴시켜 요청을 보내보겠습니다.
// auth.service.ts
import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
@Injectable()
export class AuthService {
constructor(private readonly jwtService: JwtService) {}
getAccessToken({ user }) {
const accessToken = this.jwtService.sign(
{ email: user.email, sub: user.id },
{ secret: 'myAccessKey', expiresIn: '1s' }, // 1h => 1s 변경
);
return accessToken;
}
setRefreshToken({ user, res }) {
const refreshToken = this.jwtService.sign(
{ email: user.email, sub: user.id },
{ secret: 'myRefreshKey', expiresIn: '1w' },
);
res.setHeader('Set-Cookie', `refreshToken=${refreshToken}`);
}
}
accessToken 발생의 expiresIn
을 1s
로 변경해서 생성되어 1초만에 파괴시키게 변경해주세요.
// app.module.ts
import { Module } from '@nestjs/common';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from './apis/user/user.module';
import { AuthModule } from './apis/auth/auth.module';
@Module({
imports: [
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: 'src/commons/graphql/schema.gql',
context: ({ req, res }) => ({ req, res }), // 추가
}),
TypeOrmModule.forRoot({
type: 'mysql',
host: 'localhost',
port: 3306,
username: 'root',
password: 'duscks0826@',
database: 'test-db',
entities: [__dirname + '/apis/**/*.entity.*'],
synchronize: false,
logging: true,
}),
AuthModule,
UserModule,
],
})
export class AppModule {}
app.module.ts
파일에서 GraphQLModule
에 context: ({ req, res }) => ({ req, res })
를 추가해주세요.
로그인을 해서 토큰을 재발급 받으세요.
재발급한 토큰을 header에 넣어줘서 요청을 보냈을 경우 유효하지 않습니다.
그렇다면 이제부터 refreshToken을 이용해서 accessToken을 재발행 해보겠습니다.
이번에는 토큰이 만료되었을 경우 refreshToken을 사용해 accessToken을 재발행 해보겠습니다.
21-01-login-auth-with-refresh-token
폴더를 복사해 사본을 만들고 폴더명을 21-02-login-auth-param-with-refresh-restore
으로 변경해줍니다.
src/common/auth
에 jwt-refresh.strategy.ts
파일을 생성합니다.
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'refresh') {
constructor() {
super({
jwtFromRequest: (req) => {
const cookie = req.headers.cookie;
if (cookie) return cookie.replace('refreshToken=', '');
},
secretOrKey: 'myRefreshKey',
});
}
async validate(payload: any) {
return {
id: payload.sub,
email: payload.email,
};
}
}
이렇게 동일하게 passport 모듈을 사용했습니다.
요청에 헤더의 cookies를 가져오는데 만약 존재할 경우 문자열로 반환해서 발행했던 secretOrKey를 사용해 토큰을 열어줍니다.
토큰의 payload를 열어서 사용자의 정보를 반환합니다.
src/common/auth/gql-auth.guard.ts
파일을 열어봐주세요.
이전에 미리 추가했던 부분입니다.
gql에서 guard를 직접적으로 사용하지 못하기 때문에 다음과 같이 중간단계를 미리 만들어줘야 합니다.
auth.resolver.ts
에 accessToken을 재발급하는 기능을 추가하겠습니다.
import { Args, Context, Mutation, Resolver } from '@nestjs/graphql';
import { AuthService } from './auth.service';
import { UserService } from '../user/user.service';
import { UnprocessableEntityException, UseGuards } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { IContext } from 'src/commons/types/context';
import { GqlAuthRefreshGuard } from 'src/commons/auth/gql-auth.guard';
import { CurrentUser, ICurrentUser } from 'src/commons/auth/gql-user-params';
@Resolver()
export class AuthResolver {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
) {}
@Mutation(() => String)
async login(
@Args('email') email: string,
@Args('password') password: string,
@Context() context: IContext,
) {
const user = await this.userService.findOne({ email });
if (!user) throw new UnprocessableEntityException('이메일이 없습니다');
const isAuth = await bcrypt.compare(password, user.password);
if (!isAuth)
throw new UnprocessableEntityException('비밀번호가 일치하지 않습니다');
this.authService.setRefreshToken({ user, res: context.res });
return this.authService.getAccessToken({ user });
}
@UseGuards(GqlAuthRefreshGuard)
@Mutation(() => String)
async restoreAccessToken(
@Context() context: IContext,
@CurrentUser() currentUser: ICurrentUser,
) {
const user = currentUser;
// context.res.setHeader
this.authService.setRefreshToken({ user, res: context.res });
const accessToken = this.authService.getAccessToken({ user });
return accessToken;
}
}
@UseGuards(GqlAuthRefreshGuard)
를 사용해서 refreshToken을 검사합니다.
setRefreshToken
비즈니스 로직을 사용해 쿠키에 refreshToken을 넣어줍니다.
getAccessToken 비즈니스 로직을 사용해 토큰을 재발행 해줍니다.
yarn start:dev
를 입력해서 서버를 실행시켜주세요.
그리고 http://localhost:3000 에 접속해서 플레이그라운드로 api 요청을 해보세요.
먼저 로그인을 진행해주세요.
토큰이 발행되고 1초뒤에 파괴될 것 입니다.
graphql playground에서 다음과 같이 setting에서 "request.credentials": "same-origin"
을 설정해야 header의 cookie 값을 확인할 수 있습니다.
restoreAccessToken
을 통해 토큰을 재발행한 것을 확인할 수 있습니다.
소셜로그인을 진행하는 주체는 총 3명입니다.
즉 소셜로그인은, 구글이나 카카오에서 "나" 라는 제공자와 "사용자" 사이에서 로그인을 중개해주는 역할을 하는 것 입니다.
이 중개자의 역할을 가능하도록 해주는 서비스가 OAuth 입니다.
사용자가 소셜로그인에 로그인했을 때, 그 아이디와 비번을 서비스 제공자에게 주는 것이 아니라, OAuth를 거쳐서 소셜에서는 "나" 에게 Access Token을 제공하고, "나" 는 이 토큰을 통해서 소셜에 접근할 수 있게 되고, 사용자에게 로그인 페이지를 제공할 수 있는 것입니다.
내가 구현 할 어플리케이션(이하 Client)이 Resource Server을 사용하기 위해서는 등록이라는 절차를 거쳐야 합니다.
Facebook Developer, Google Developer와 같은 사이트에서 진행합니다.
등록과정을 거치게 되면, Client와 Resource Server는 아래 3가지를 공유하게 됩니다.
A. 로그인 하고자 하는 resource owner, 즉 데이터의 주인인 서비스 유저에게 승인을 받아야 합니다.
버튼을 누르면, resource owner가 소셜서비스(resource server)에 로그인을 시도하는 창으로 이동합니다.
1) 로그인 되어 있는 경우 : 소셜서비스(resource server)에서 로그인을 시도한 링크의 client ID를 점검합니다.
2) 로그인 되어 있지 않은 경우 : 로그인을 진행합니다.
-> 로그인 완료 후에, 로그인을 시도한 링크의 redirect URL을 비교합니다.
1) 소셜서비스(resource server)가 해당 URL을 가지고 있지 않다면 종료
2) 같은 URL을 가지고 있지 않다면, resource owner의 개인 정보를 client에게 제동해도 되는지 허용 여부에 대한 메시지를 띄웁니다.
-> 허용할 경우, 그 응답이 client에게 전달됩니다.
-> client는 그 응답에 담긴 데이터를 분석합니다.
B. resource owner에게 승인을 받았으니, 승인을 받았다는 증거를 가지고 소셜서비스(resource server)에게 해당 유저의 데이터를 전달해달라고 요청합니다.
location: http://[redirect URL]?code=[Authorizaation Code]
이라는 값을 주어 redirect합니다.-> access token을 가지고 소셜서비스(resource server)에 일종의 GET 요청을 보내서 resource owner의 데이터를 받아옵니다.
우선 Google Cloud에 접속해서 로그인해주세요.
우측 상단의 계정 옆 "콘솔"을 클릭하고, 새 프로젝트를 통해 프로젝트를 만듭니다.
상단 검색창에서 사용자의 정보를 편리하게 가져와줄 people api를 검색하여 사용합니다.
좌측 메뉴의 API 및 서비스에 사용자 인증정보를 클릭합니다.
우측 상단의 "동의 화면 구성"을 클릭합니다.
UserType
은 "외부" 선택 -> 만들기 클릭
필수 입력들만 적어주고, 저장 후 계속
을 누릅니다.
범위 추가 또는 석제
버튼 클릭 -> People API를 체크 후 업데이트 클릭 -> 저장 후 계속을 눌러줍니다.
Testing을 위해 add users
버튼을 눌러 테스트 사용자를 추가한 후 저장 후 계속을 누릅니다.
좌측 메뉴의 API 및 서비스 -> 사용자 인증 정보를 다시 클릭하여 + 사용자 인증 정보 만들기
-> API 키
를 클릭합니다. (자동으로 만들어집니다)
다시 좌측 메뉴의 API 및 서비스 -> 사용자 인증 정보를 클릭하여 + 사용자 인증 정보 만들기
-> OAuth 클라이언트 ID
를 클릭합니다.
애플리케이션 유형은 웹 애플리케이션을 선택해줍니다.
승인된 리디렉션 URI
에서 URI 추가를 누릅니다.
http://localhost:3000/login/google 로 입력해주세요.
그리구 만들기
를 누릅니다.
💡 리디렉션 URI에 추가된 URI만 구글 서버와 통신할 수 있습니다.
위와 같이 생성 된 ID와 비밀번호를 가지고 아래 실습을 진행하겠습니다.
21-03-login-oauth-google
폴더를 만들어주세요.
폴더 안에 frontend
와 backend
폴더를 만들어주세요.
backend
폴더 안에는 21-02-login-auth-param-with-refresh-restore
폴더의 파일을 모두 붙여넣기 해주세요.
frontend
폴더 안에는 social-login.html
파일을 만들어주세요.
<!-- social-login.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>소셜로그인</title>
</head>
<body>
<a href="http://localhost:3000/login/google">구글 로그인</a>
</body>
</html>
backend/src/common/auth
경로에 jwt-social-google.strategy.ts
파일을 생성해주세요.
// jwt-social-google.strategy.ts
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, Profile } from 'passport-google-oauth20';
@Injectable()
export class JwtGoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor() {
super({
clientID: '클라이언트 ID',
clientSecret: '클라이언트 보안 비밀',
callbackURL: '추가된 리디렉션 URL',
scope: ['email', 'profile'],
});
}
async validate(accessToken: string, refreshToken: string, profile: Profile) {
return {
email: profile.emails[0].value,
password: profile.id,
name: profile.displayName,
age: 0,
};
}
}
위와 같이 guard를 만들어줬습니다.
이전에 만들었던 guard와 동일한 구조지만 요청 필수 매개변수
위의 두가지 정보는 .env
파일에서 환경변수로 관리해주세요.
💡 Rest-API에서 Guard를 사용할때는 중간 단계 없이 사용할 수 있습니다.
backend/src/apis/auth
경로에 auth.controller.ts
파일을 만들어주세요.
// auth.controller.ts
import { Controller, Get, Req, Res, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Request, Response } from 'express';
import { AuthService } from 'src/apis/auth/auth.service';
import { User } from 'src/apis/user/entities/user.entity';
import { UserService } from 'src/apis/user/user.service';
interface IOAuthUser {
user: Pick<User, 'email' | 'password' | 'name' | 'age'>;
}
@Controller('/')
export class AuthController {
constructor(
private readonly userService: UserService,
private readonly authService: AuthService,
) {}
@Get('/login/google')
@UseGuards(AuthGuard('google'))
async loginGoogle(@Req() req: Request & IOAuthUser, @Res() res: Response) {
// 가입확인
let user = await this.userService.findOne({ email: req.user.email });
// 회원가입
if (!user) {
const { password, ...rest } = req.user;
const createUser = { ...rest, hashedPassword: password };
user = await this.userService.create({ ...createUser });
}
// 로그인
this.authService.setRefreshToken({ user, res });
res.redirect('http://localhost:5500/21-03-login-oauth-google/frontend/social-login.html');
}
}
Rest-API에서 라우터를 핸들링할때는 resolver를 사용합니다.
이전에 추가한 리디렉션 URI와 동일하게 엔드포인트를 지정해주세요.
Guard가 통과되면 다음과 같은 로직이 실행되는데 이미 회원가입이 되었다면 로그인해 주고 회원가입이 되지 않았다면 회원가입 후 로그인을 합니다.
이때 refreshToken
을 넘겨주는데 이는 accessToken
보다 refreshToken
의 생명주기가 길기 때문입니다.
로그인이 성공되면 redirect를 http://localhost:5500/폴더명/21-03-login-oauth-google/frontend/social-login.html
로 해주세요.
Live Server의 기본 Port는 5500입니다.
yarn start:dev
로 서버를 실행시켜주세요.
그리고 frontend
폴더에 social-login.html
파일을 Open with Live Server
로 실행시켜주세요.
구글 로그인을 클릭하고 계정을 선택합니다.
그리고 DB를 확인해보면 회원가입이 정상적으로 되었습니다.