모든 웹 서비스, 앱 서비스 나아가 인트라넷을 사용할 때 반드시 필수로 구현하는 기능이 로그인 기능이다.
개발을 깊게 공부하기 전에는 로그인 기능이 복잡한 기능인지 몰랐다.
(예를 들자면 쇼핑몰? 그게 어려워?)
ToDoList 수준에 토이프로젝트를 만들 때 다음과 같이 생각했었다.
EX)
if(req.body.id === user.id && req.body.password == user.password) {
return true;
} else {
return false;
}
하지만 실제 사람들이 이용할 서비스를 만들면서 이런 식으로 만들어 놓으면 안되는 다는 것을 피드백, 경험 등을 통해서 알았다.
아마 제 생각에 극초기 스타트업들(시리즈A 이전)이나 예비 법인설립자 팀에서 서비스를 만들 때 다음과 같은 이유로 대부분 JWT 를 선택하지 않을까 싶습니다.
(통계적인 자료는 없고 그냥 제 뇌피셜+경험입니다)
(참고로 NodeJs, NestJs 를 사용하는 입장에서 작성했습니다.)
- 메모리 DB 를 따로 두기엔 돈이 부족하다.
- 서비스 초기엔 강제 로그아웃 등의 사용자들을 직접 관리할 일이 드물고,
보통 웹과 앱을 동시에 운영하는 팀이 많이 없다
1. 메모리 DB 를 따로 두기엔 돈이 부족하다 :
우선 다음과 같은 상황이라고 가정하겠습니다
세션을 저장할 때 보통 3가지 선택지가 있습니다.
- 톰켓 세션
- mysql, postgresql 등 RDMS
- 메모리 DB (Redis 등) 에 Session 저장
주로 선택하는 방법은 메모리 DB 에 저장하는 방법을 많이 선택합니다.
톰켓 세션은 서버 재시작 시 로그인을 다시 해야하고 RDMS 는 로그인 요청이 많아지면 성능이슈가 발생하기 때문입니다.
위에 언급한 환경이 가장 싸게 할 수 있고 개발자가 많이 없을 때 사용하는 방법인 거 같습니다.
제가 현재 118MB 크기에 NestJS 를 실행시키고 개발용으로 저만 사용했을 기준으로 ECR/ECS 에서 고정적으로 18달러 정도가 나오고 있고 그 외 부가적인 리소스들까지 합치면 40달러 정도가 나오고 있습니다(VAT 제외)
만약 RDS 를 t2.micro 보다 높은 것을 사용하거나, 프리티어 계정이 없는 경우 비용은 당연히 더 많이 청구될 것입니다. 여담이지만 AWS에 익숙하지 않으시거나 인스턴스 끄는 걸 깜빡했을 때 1~2달 사이에 많은 비용이 청구된 경험을 주변에서 심심치 않게 보는 거 같습니다. 물론 저도 20만원 청구됐던 경험이 있습니다...
(법인이 있다면 크레딧을 받을 수 있는 방법이 있는데 이 경우는 제외하겠습니다.)
고작 40달러 아니야? 라고 생각하실 수도 있지만 저처럼 거의 자본없이 창업을 하시는 분들이라면 40달러가 결코 작은 돈이 아니라는 것에 공감하실 수 있을 것입니다.
만약 여기서 메모리 DB 를 도입한다면 대략적인 추가 비용은 다음과 같습니다.
출처 : https://aws.amazon.com/ko/elasticache/pricing/
개발용으로 낮은 유형의 t2.micro 를 사용하면 월 18.98달러의 추가 비용이 발생합니다.
만약 범용적인 인스턴스 m6g.large 를 선택한다면 132.13 달러가 발생합니다.
세션이 좋다, Jwt 가 좋다를 개발적으로 논하기 이전에 사업을 운영하는 관점에서 바라본다면 발생하지 않을 수 있고 꼭 필요한 기능이 아니라면 돈이 덜 드는 방향으로 선택하는 것이 사업적으로 맞는 판단이라고 생각합니다.
(혹시 aws 예상비용 계산하시고 싶으신 분들은 aws예상비용 을 참고하시면 좋을 거 같습니다)
2.서비스 초기엔 강제 로그아웃 등의 사용자들을 직접 관리할 일이 드물고,
보통 웹과 앱을 동시에 운영하는 팀이 많이 없다 :
여기서 제가 말하는 '웹과 앱을 동시에 운영한다' 는 넷플릭스처럼 모바일에서도 볼 수 있고 데스크톱으로도 볼 수 있는 데 동시에 같은 사람이 로그인하면 문제가 발생하는 서비스를 말합니다.
대개 초기 스타트업의 웹,앱은 MVP(Minimum Viable Product) 성격을 많이 띄는 거 같습니다. 물론 웹과 앱 모두 지원한다면 좋겠죠.
하지만 VC도 만나러 다녀야하고 IR 자료도 만들어야하고 PPT 연습도 해야 하고
심지어 서비스 기능도 계속해서 Develop 됩니다.
계속되는 수정사항이 얼마나 화나는 일인지 개발자분들이라면 매우 공감하실겁니다......하아...
이러한 해결해야하는 문제가 많은 상황 속에서 '앱과 웹을 모두 지원하면서 2가지 디바이스에서 동시에 로그인하는 상황을 제어하겠다' 는 쉽지 않을 겁니다.
( 물론 반드시 이런 상황을 제어해야하는 서비스는 예외입니다. )
저희 팀은 저 혼자 개발자였고 이 상황을 혼자서 컨트롤하기엔 너무 어려울 거 같았습니다. 유저를 계속해서 추적해야할 이유도 없었고요.
그래서 저는 Session 방식에 비해 돈이 덜 들고, 신경쓸게 session 대비 아주 조금 더 적은 jwt 를 선택했습니다.
1) 환경
2) 구현 흐름도
3) 구현 예제 코드
// local.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { AuthService } from '../auth.service';
import { Strategy } from 'passport-local';
import {User} from "../../user/entities/user.entity";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({
usernameField: 'phoneNumber',
passwordField: 'password',
});
}
async validate(phoneNumber: string, password: string): Promise<User | UnauthorizedException> {
const user: User = await this.authService.validateUser(phoneNumber, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}
// auth.service.ts
async validateUser(phoneNumber: string, password: string): Promise<any> {
const user = await this.userRepository.findOne({
where: { phoneNumber },
select: ['phoneNumber', 'password', 'id'],
});
if (user && (await bcrypt.compare(password, user.password))) {
const { ...result } = user;
return result;
}
return null;
}
// local-auth.guard.ts
import { AuthGuard } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
//auth.controller.ts
@UseGuards(LocalAuthGuard)
@Post('/login')
async login(@Request() req) {
return await this.authService.login(req);
}
jwt 에 담는 유저 정보는 userId만 담는 코드로 수정했습니다.
// jwt.strategy.ts
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.ACCESS_SECRET_KEY,
});
}
async validate(payload: any) {
return {
userId: payload.userId,
};
}
}
// jwt.guard.ts
import { AuthGuard } from '@nestjs/passport';
export class JwtAuthGuard extends AuthGuard('jwt') {}
// jwt-refresh.strategy.ts
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { Request } from 'express';
export class JwtRefreshStrategy extends PassportStrategy(
Strategy,
'jwt-refresh'
) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.REFRESH_SECRET_KEY,
passReqToCallback: true,
});
}
async validate(req: Request, payload: any) {
return {
userId: payload.userId,
};
}
}
// jwt-refresh.guard.ts
import { AuthGuard } from '@nestjs/passport';
export class JwtRefreshAuthGuard extends AuthGuard('jwt-refresh') {}
controller 의 Path 은 일부러 삭제했습니다.
// auth.controller.ts
import {
Body,
Controller,
Get,
NotFoundException,
Post,
Query,
Request,
UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './local/local-auth.guard';
import { ApiTags } from '@nestjs/swagger';
import { CoreOutput } from '../common/dto/core-output.dto';
import { JwtRefreshAuthGuard } from './jwt/jwt-refresh-auth.guard';
import { CreateUserInput } from './dto/create-user.dto';
import { JwtAuthGuard } from './jwt/jwt-auth.guard';
@Controller('api/v1/auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
/**
* Access Token, Refresh Token 발급
* @param req
*/
@UseGuards(LocalAuthGuard)
@Post()
async login(@Request() req, @Res({ passthrough: true }) response) {
await this.authService.login(req, response);
}
/**
* Access token, Refresh Token 재발급
* @param req
*/
@UseGuards(JwtRefreshAuthGuard)
@Get()
async refresh(@Request() req) {
return await this.authService.refreshTokens(req);
}
/**
* Refresh token null 처리
* @param req
*/
@UseGuards(JwtRefreshAuthGuard)
@Get()
async logout(@Request() req): Promise<NotFoundException | CoreOutput> {
const { userId } = req.user;
return await this.authService.logout(userId);
}
import {
Injectable,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from '../user/entities/user.entity';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import { CreateUserInput } from './dto/create-user.dto';
import { CoreOutput } from '../common/dto/core-output.dto';
import { HttpService } from '@nestjs/axios';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class AuthService {
constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService,
private readonly httpService: HttpService,
@InjectRepository(User)
private readonly userRepository: Repository<User>
) {}
async validateUser(phoneNumber: string, password: string): Promise<any> {
const user = await this.userRepository.findOne({
where: { phoneNumber },
select: ['phoneNumber', 'password', 'id'],
});
if (user && (await bcrypt.compare(password, user.password))) {
const { ...result } = user;
return result;
}
return null;
}
async login(req: any,response: Reponse) {
const { id, phoneNumber } = req.user;
const { accessToken, refreshToken } = await this.getTokens(
id,
);
// refresh token 갱신
await this.updateRefreshToken(id, refreshToken);
response.cookie('jwt', access_token, { httpOnly: true });
response.cookie('jwt-refresh', refresh_token, { httpOnly: true });
return {
ok : true,
};
}
async refreshTokens(req: any) {
const { userId, role, phoneNumber, refresh_token } = req.user;
const user = await this.userRepository.findOne({
where: { id: userId },
select: ['refreshToken'],
});
if (!user) {
return new NotFoundException();
}
if (refresh_token !== user.refreshToken) {
return new UnauthorizedException();
}
const { accessToken, refreshToken } = await this.getTokens(
userId,
role,
phoneNumber
);
await this.updateRefreshToken(userId, refreshToken);
response.cookie('jwt', access_token, { httpOnly: true });
response.cookie('jwt-refresh', refresh_token, { httpOnly: true });
return {
ok : true,
};
}
async logout(userId: number): Promise<NotFoundException | CoreOutput> {
const user = await this.userRepository.findOne({
where: { id: userId },
});
if (!user) {
return new NotFoundException();
}
await this.userRepository.update(userId, {
refreshToken: null,
});
return {
ok: true,
};
}
async updateRefreshToken(userId: number, refreshToken: string) {
await this.userRepository.update(userId, {
refreshToken: refreshToken,
});
}
async getTokens(userId: number, phoneNumber: string, userRole: string) {
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{
userId: userId,
},
{
secret: this.configService.get<string>('ACCESS_SECRET_KEY'),
expiresIn: this.configService.get<string>('ACCESS_EXPIRES_IN'),
}
),
this.jwtService.signAsync(
{
userId: userId,
phoneNumber: phoneNumber,
},
{
secret: this.configService.get<string>('REFRESH_SECRET_KEY'),
expiresIn: this.configService.get<string>('REFRESH_EXPIRES_IN'),
}
),
]);
return {
accessToken: accessToken,
refreshToken: refreshToken,
};
}
}
1) 서비스의 특성상 매일 매일 접속할 필요는 없습니다. 그래서 Refresh Token 의 유효 시간을 너무 짧게 잡으면 사용자가 매번 다시 로그인을 해야합니다.
이런 UX는 너무 안 좋기 때문에 Refresh Token 의 유효 기간을 길게 잡아야된다고 생각했습니다.2) 하지만 보안상 유효 시간을 너무 길게 잡으면 refresh token 이 탈취당했을 때 대처할 방법이 없습니다.
그래서 제가 선택한 방법은 access token 갱신을 요청할 때 refresh token 도 같이 갱신하는 방법을 선택했습니다. 해당 방법을 선택하면 2가지가 해결된다고 생각했습니다.
- 사용자가 로그인을 자주 안하더라도 너무 자주 로그아웃되지 않는다.
- refresh token 이 매번 갱신되기 때문에 유효기간이 짧은 효과를 가져온다.
사실 이 방법이 맞는 지는 확신이 안 들긴 합니다만 제가 알고 있는 지식과 자료 조사를 했을 때 가장 최선의 방법이라고 생각했습니다.
(저는 주니어 개발자이기 때문에 해당 방법이 잘못됐거나 더 좋은 해결책이 있으면 댓글 달아주세요... 정말 도움이 많이 됩니다..ㅠ😂)
updateRefreshToken : 해당 함수에서 token 을 갱신할 때 save 가 아니라 update 를 사용했습니다. update 를 사용한 이유는 이미 token 을 통해 user 가 있다는 것을 확인 상태이기 때문에 update 를 쓰더라도 문제가 없다고 판단했습니다.
getTokens : NestJS 에서는 기본적으로 JwtConfigure 를 제공해줍니다.
저는 Access Token 과 Refresh Token 의 만료 시간과 Secret 키를 따로 관리하고 싶어서 해당 함수를 만들어주었습니다.
지금까지 jwt를 활용한 NestJs Login 이었습니다. 잘못된 부분이 있거나 궁금한 부분있으면 편하게 댓글 남겨주시면 감사하겠습니다😊
client-side 는 Flutter 로 구현했는데 해당 부분은 차차 업로드하겠습니다.
안녕하세요. 게시글 잘읽었습니다!
혹시 jwt 인증이 필요한 API를 호출할 때 클라이언트에서 Access token을 어떻게 보내야하나요?