이번에 처음 써보는 nest로 사용자 인증 로직을 구현해봤는데 정말 편하더라구요.
express에서도 OAuth, JWT 연계해서 작업해 봤는데 제 실력이 부족해서인지 passport를 쓰면 오히려 흐름 파악하기가 어렵고 이해도 잘 안 됐어요. 그래서 express에서는 passport를 사용하지 않는 걸 선호했는데 nest에서는 정말 간단했어요.
아래 그림은 제가 이번에 구현한 인증 로직입니다.
Kakao로 로그인을 하고 난 이후로는 JWT 토큰으로 인증을 진행하고 있어요. 오늘은 이 흐름 순서대로 코드를 자세하게 한 번 설명해볼까해요.
다음 그림을 먼저 구현해보겠습니다.
제일 처음 시작하는 부분입니다. 클라이언트에서 카카오 로그인 버튼을 누르면 카카오 서버로 갔다가 제가 설정한 url로 redirect되어서 들어옵니다.
원래는 이 때 kakao에서 주는 인증코드를 가지고 다시 kakao 서버로 POST요청을 하며 Profile 정보를 받아 와야 하는데 passport의 데코레이터 하나만 써주면 이 작업을 알아서 해 줍니다.
// auth.controller
import { AuthGuard } from '@nestjs/passport';
@Get('kakao') // 카카오 서버를 거쳐서 도착하게 될 엔드포인트
@UseGuards(AuthGuard('kakao')) // kakao.strategy를 실행시켜 줍니다.
@HttpCode(301)
async kakaoLogin(@Req() req: Request, @Res() res: Response) {
const { accessToken, refreshToken } = await this.authService.getJWT(
req.user.kakaoId,
);
res.cookie('accessToken', accessToken, { httpOnly: true });
res.cookie('refreshToken', refreshToken, { httpOnly: true });
res.cookie('isLoggedIn', true, { httpOnly: false });
return res.redirect(this.configService.get('CLIENT_URL'));
}
@UseGuards(AuthGuard('kakao')) 데코레이터를 적어두면 kakao.strategy에 있는 로직이 실행됩니다.
// src/auth/strategies/kakao.strategy
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Profile, Strategy } from 'passport-kakao';
@Injectable()
export class KakaoStrategy extends PassportStrategy(Strategy) {
constructor(private readonly configService: ConfigService) {
super({ // 여기 적어준 정보를 가지고 카카오 서버에 POST /oauth/token 요청이 날아갑니다.
clientID: configService.get('KAKAO_CLIENT_ID'),
clientSecret: '',
callbackURL: `${configService.get('BACKEND_URL')}/auth/kakao`,
});
}
async validate( // POST /oauth/token 요청에 대한 응답이 담깁니다.
accessToken: string,
refreshToken: string,
profile: Profile,
done: (error: any, user?: any, info?: any) => void,
) {
try {
const { _json } = profile;
const user = {
kakaoId: _json.id,
};
done(null, user);
} catch (error) {
done(error);
}
}
}
데코레이터를 적용 했으면 Controller에 요청이 왔을 때 자동으로 strategy의 contructor가 실행이 되어서 카카오 서버로 POST 요청을 해줍니다. 그 때 받은 응답값이 validate의 인자로 전달됩니다.
profile의 _json 필드에는 다음과 같은 정보가 담겨 있습니다.
{ id: 0123456789, connected_at: '2023-08-25T10:15:22Z' }
여기서 id는 해당 유저가 카카오에서 가지고 있는 유일한 식별자 값이에요. 동일한 사용자가 여러번 로그인을 해도 저 식별자값은 항상 똑같습니다.
저는 여기에 담긴 id를 user객체에 담아서 done(null, user)로 함수를 호출했습니다.
이제 kakaoLogin 컨트롤러에서 req.user로 카카오 아이디에 접근이 가능해집니다.
🤔 질문 1. 저는 사용자 이메일 정보나 카카오 닉네임도 가져오고 싶은데 어떡하나요??
저는 로그인한 사용자를 '식별' 할 수만 있으면 상관없어서 다른 정보를 받지 않고 있습니다.
만약 추가적인 profile 정보를 원하신다면
위 사진에서 설정버튼을 통해 사용 안함 상태를 바꿔주시면 _json에 해당 정보가 같이 담겨서 올 거에요.
🤔 질문 2. validate의 인자중에 accessToken과 refreshToken은 뭔가요??
validate의 인자중에 profile은 제가 사용했지만 accessToken과 refreshToken은 사용하지 않았어요.
저 토큰은 카카오 서버에서 백엔드 서버에 주는 토큰입니다. 보통은 회원가입 로직에서 카카오 프로필에 있는 정보를 데이터베이스에 저장할 텐데요. 만약 저장하지 않고 그때 그때 필요한 정보를 api 호출해서 받아올 수도 있습니다. 그럴 때 accessToken이 필요해요. 혹은 profile 정보말고도 다른 기능을 하는 api들이 있는데 그런 api를 호출할 때도 필요합니다.
https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#req-user-info
다음 부분입니다.
이제 이 부분을 구현해볼게요. 다시 Controller 코드를 봅시다
async kakaoLogin(@Req() req: Request, @Res() res: Response) {
const { accessToken, refreshToken } = await this.authService.getJWT(
req.user.kakaoId,
);
res.cookie('accessToken', accessToken, { httpOnly: true });
res.cookie('refreshToken', refreshToken, { httpOnly: true });
res.cookie('isLoggedIn', true, { httpOnly: false });
return res.redirect(this.configService.get('CLIENT_URL'));
}
kakao strategy가 실행되었으므로 req에는 req.user 객체가 담겨있습니다. 저는 kakaoId만 담았어요.
여기서 service 레이어에 getJWT 함수를 호출합니다.
🔎 참고
import { Request } from 'express'; @Req() req: Request // express의 Request
여기서 Request는 express에 있는 타입입니다. 아마 req.user.kakaoId에 접근 하려고 할 때 빨간줄이 나타나실 텐데 express의 Request 타입을 재정의 해줘야 합니다.
// src/types/express.d.ts import { Request as Req } from 'express'; import { Types } from 'mongoose'; declare module 'express' { interface Request extends Req { user: { kakaoId?: number; userId?: Types.ObjectId; }; } }
이런식으로 express의 Request 타입을 재정의 할 수 있습니다.
처음 로그인 시에만 kakaoId가 담겨 있을 거고 로그인이 끝난 다음부터의 인증을 JWT로 할 거라 저는 userId를 담기로 했는데 타입이 mongo의 ObjectId 입니다. RDB를 쓰시는 분들은 UUID같은 PK의 타입으로 변경하시면 될 것 같습니다.
// auth.service
async getJWT(kakaoId: number) {
const user = await this.kakaoValidateUser(kakaoId); // 카카오 정보 검증 및 회원가입 로직
const accessToken = this.generateAccessToken(user); // AccessToken 생성
const refreshToken = await this.generateRefreshToken(user); // refreshToken 생성
return { accessToken, refreshToken };
}
여기서부터 복잡해집니다. 저는 최대한 알아보기 쉽도록 함수로 쪼개봤어요.
// auth.service
async kakaoValidateUser(kakaoId: number): Promise<UserDocument> {
let user: UserDocument =
await this.usersRepository.findUserByKakaoId(kakaoId); // 유저 조회
if (!user) { // 회원 가입 로직
user = await this.usersRepository.create({
kakaoId,
provider: 'kakao',
});
}
return user;
}
일단 저는 따로 검증 로직을 넣지는 않았지만 카카오 로그인에서도 검증 로직이 필요할 수도 있어서 메소드명을 Validate로 지었습니다.
유저 조회 후 유저가 없을땐 데이터베이스에 새로 만들어주는 식으로 회원가입을 진행했습니다.
이제 JWT 토큰을 발급해야 하는데요. 그 전에 passport-jwt 셋팅을 먼저 하겠습니다.
// auth.module
import { PassportModule } from '@nestjs/passport';
import { KakaoStrategy } from './strategies/kakao.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
ConfigModule,
PassportModule,
JwtModule.registerAsync({
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>('JWT_SECRET'),
signOptions: { expiresIn: configService.get<string>('JWT_EXPIRES_IN') },
}),
inject: [ConfigService],
}),
],
providers: [ KakaoStrategy, JwtStrategy],
})
// .env
JWT_SECRET=secret
JWT_EXPIRES_IN=1d //1일
JWT_REFRESH_SECRET=refresh
JWT_REFRESH_EXPIRES_IN=7d // 7일
만료일자는 30s, 30m, 1h, 1d 같은 단위를 쓰거나 60000 이런식으로 단위 없이 입력하면 밀리세컨드 단위입니다.
이제 accessToken을 만들어봅시다.
// auth.service
import { JwtService } from '@nestjs/jwt';
constructor(private readonly jwtService: JwtService) // 의존성 주입
generateAccessToken(user: UserDocument): string {
const payload = {
userId: user._id,
};
return this.jwtService.sign(payload);
}
payload에 서명할 내용을 담아줍니다. 저는 추가적인 정보는 필요가 없어서 pk에 해당하는 식별자만 담아주었습니다.
다음으로 refreshToken을 만듭니다.
// auth.service
async generateRefreshToken(user: UserDocument): Promise<string> {
const payload = {
userId: user._id,
};
const refreshToken = this.jwtService.sign(payload, {
secret: this.configService.get<string>('JWT_REFRESH_SECRET'),
expiresIn: this.configService.get<string>('JWT_REFRESH_EXPIRES_IN'),
});
const saltOrRounds = 10;
const currentRefreshToken = await bcrypt.hash(refreshToken, saltOrRounds);
await this.usersRepository.setCurrentRefreshToken(
payload.userId,
currentRefreshToken,
);
return refreshToken;
}
refreshToken은 bcrypt로 해싱한 뒤 데이터베이스에 저장해줍니다. 나중에 refreshToken을 검증할 때 데이터베이스에 저장된 해시값을 비교할 예정입니다.
🤔 질문 3. accessToken은 인자로 payload만 넘겨줬는데 refreshToken은 왜 SECRET과 EXPIRE_IN값도 넘겨주나요??
아까 auth.module에서 passport-jwt를 셋팅할 때 SECRET 값과 EXPIRE_IN 값을 넘겨줬던 걸 기억하시나요?
module에서 설정할 시 jwt 설정이 전역으로 설정됩니다. 따라서 인자 없이 sign을 할 경우엔 환경변수에 있는 옵션이 적용됩니다.
이제 accessToken, refreshToken을 생성했으니 controller 코드로 다시 돌아갑니다.
@Get('kakao')
@UseGuards(AuthGuard('kakao'))
@HttpCode(301)
async kakaoLogin(@Req() req: Request, @Res() res: Response) {
const { accessToken, refreshToken } = await this.authService.getJWT(
req.user.kakaoId,
);
res.cookie('accessToken', accessToken, { httpOnly: true });
res.cookie('refreshToken', refreshToken, { httpOnly: true });
res.cookie('isLoggedIn', true, { httpOnly: false });
return res.redirect(this.configService.get('CLIENT_URL'));
}
저는 CSR로 돌아가는 클라이언트 웹이 있어서 cookie에 담았습니다. 앱에서도 사용한다면 Athorization에 Bearer로 담아서 줘야합니다.
이제 로그인 로직은 끝났으니 검증 파트를 구현해볼게요.
jwt 검증은 controller에 요청이 왔을 때 이루어져야 합니다. 그래서 controller에 jwt guard를 붙이는데 kakao Guard와 마찬가지로 jwt guard를 붙이게 되면 요청이 왔을 때 jwt strategy가 자동으로 실행됩니다.
jwt strategy를 구현해보겠습니다.
// src/auth/strategies/jwt.strategy
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
// controller에 요청이 왔을 때 constructor가 실행
constructor(private readonly configService: ConfigService) {
super({ // accessToken 위치
jwtFromRequest: ExtractJwt.fromExtractors([
(request) => {
return request.cookies.accessToken;
},
]),
ignoreExpiration: false,
secretOrKey: configService.get<string>('JWT_SECRET'),
});
}
async validate(payload) {
return { userId: payload.userId };
}
}
kakao strategy와 생김새가 똑같습니다. 작동방식도 비슷한데 controller에 요청이 오면 strategy가 실행됩니다. constructor에서 super로 옵션을 건네주는데 jwtFromRequest에 accessToken이 있는 위치를 명시합니다.
저는 cookie에 담아 주었으므로 저렇게 설정했지만 만약 클라이언트에서 Bearer 토큰으로 보내준다면
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
이렇게 설정할 수 있습니다.
kakao passport를 사용했을 때와 비슷하게 옵션으로 필요한 정보만 넘겨주면 Strategy 내부적으로 알아서 jwt 토큰을 검증해주는 게 passport가 되게 편하다고 느꼈던 점입니다.
검증이 끝났다면 validate 메소드에 decode 된 payload가 담겨서 실행됩니다.
저는 payload에 userId를 담아서 jwt 토큰을 만들었기 때문에 decode 된 후에도 똑같이 userId에 접근할 수 있고 return을 하게 되면 controller에서 req.user로 접근이 가능합니다.
이제 간단한 controller를 한 번 만들어보겠습니다.
// app.controller
import { Controller, Get, UseGuards, Req } from '@nestjs/common';
import { AppService } from './app.service';
import { AuthGuard } from '@nestjs/passport';
import { Request } from 'express';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@UseGuards(AuthGuard('jwt'))
@Get()
get(@Req() req: Request) {
console.log(req.user.userId);
return 'JWT 인증 성공';
}
}
@UseGuards(AuthGuard('jwt')) 데코레이터로 인해 controller에 요청이 왔을 때 jwt strategy가 알아서 실행되어 req.user에 payload를 담아서 반환해서 주므로 user정보에 접근이 가능하며 jwt 토큰이 만료되었거나 검증이 실패했을 땐 알아서 401 응답을 클라이언트에 보내줍니다.
express의 Request 타입에 userId를 추가하는 법은 위에 설명했으니 생략하겠습니다.
이제 refresh를 구현할 차례입니다.
refresh 요청을 받을 controller를 만듭니다.
// auth.controller
@Get('refresh')
@HttpCode(200)
async refresh(@Req() req: Request, @Res() res: Response) {
try {
const newAccessToken = await this.authService.refresh(
req.cookies.refreshToken,
);
res.cookie('accessToken', newAccessToken, {
httpOnly: true,
});
return res.send();
} catch (err) {
res.clearCookie('accessToken');
res.clearCookie('refreshToken');
res.clearCookie('isLoggedIn');
throw new UnauthorizedException();
}
}
controller에서 service의 메소드를 호출해 새로운 accessToken을 발급받아 쿠키에 저장하여 보내줍니다.
service layer에서 실행될 검증 로직에서 예외가 발생한다면 refreshToken이 부적절한 것으로 판단하여 모든 쿠키를 지워줍니다.
// auth.service
async refresh(refreshToken: string): Promise<string> {
try {
// 1차 검증
const decodedRefreshToken = this.jwtService.verify(refreshToken, {
secret: this.configService.get('JWT_REFRESH_SECRET'),
});
const userId = decodedRefreshToken.userId;
// 데이터베이스에서 User 객체 가져오기
const user =
await this.usersRepository.getUserWithCurrentRefreshToken(userId);
// 2차 검증
const isRefreshTokenMatching = await bcrypt.compare(
refreshToken,
user.currentRefreshToken,
);
if (!isRefreshTokenMatching) {
throw new UnauthorizedException('Invalid refresh-token');
}
// 새로운 accessToken 생성
const accessToken = this.generateAccessToken(user);
return accessToken;
} catch (err) {
throw new UnauthorizedException('Invalid refresh-token');
}
}
jwtService의 verify 메소드로 refreshToken을 1차 검증합니다.
만약 부적절한 토큰이라면 예외가 발생해서 catch에서 잡히게 됩니다.
적절한 토큰이라면 jwt가 해독되어서 서명하기 전 payload을 반환해줍니다.
그 다음 데이터베이스에서 user객체를 받아와서 bcrypt.compare 메소드로 2차 검증을 하게 됩니다.
아무런 문제가 없다면 새로운 accessToken을 반환해줍니다.
🤔 질문 4. verify 메소드로 검증을 하는데 굳이 데이터베이스에 해싱까지 해서 저장한 다음 2차 검증을 해야하나요? 어차피 탈취된 토큰이라면 1차 검증이든 2차 검증이든 동일한 결과아닌가요??
네 맞아요 탈취된 토큰이라면 verify에서 통과했다면 데이터베이스에서 조회해서 다시 검증하더라도 똑같이 통과합니다.
하지만 만약에 사용자가 로그아웃 을 했다면 어떨까요?
로그아웃 로직에서 쿠키도 없애주고 데이터베이스에 있는 refreshToken도 없애줄 겁니다.
실 사용자는 로그아웃을 해서 refreshToken이 데이터베이스에 없는 상태일 때 탈취된 토큰으로 refresh 요청이 온다면 그걸 막을 필요가 있습니다.
로그아웃이 아니더라도 로그인을 여러 번 하게 된다면 최신의 refreshToken은 항상 바뀌게 됩니다.
그리고 실 사용자는 항상 최신의 refreshToken을 가지고 있을거구요. 따라서 이전에 탈취된 토큰의 사용도 자연스럽게 막을 수 있습니다.그리고 또 refreshToken의 만료일자를 따로 저장하지 않았던 이유도 최신 상태의 토큰이 만료되었다면 이전 토큰도 당연히 만료가 되었을거기 때문에 verify 함수에서 다 걸러질거라 생각했습니다.
해싱까지 해서 저장한 이유는 bcrypt의 saltOrRounds 값으로 인해 무작위 대입 공격에 대해 내성이 생기게 됩니다.
추가로 users.repository의 일반적인 getUser 메소드에서는 토큰이 반환되면 안 되기 때문에 토큰을 얻을 수 있는 메소드를 따로 만들어 주었어요.
refresh 토큰을 제대로 다룬 건 이번이 처음이었는데 신경 써야 할 부분이 한 두 가지가 아니면서도 정말 어렵고 정말 이게 최선인가? 라는 생각을 수도 없이 한 것 같아요. nest 자체가 처음이었어서 더 힘들었던 부분도 있었던 것 같구요 ㅎㅎ
혹시 제가 틀린 부분이나 빠진 부분이 있으면 지적 해주시면 감사하겠습니다!
안녕하세요:)
몇 가지 궁금한 게 있습니다~
토큰을 쿠키에 저장하는 게 일반적인 방법인가요?
그리고 저는 아직 카카오는 안 해보고 구글 oauth만 연습해 봤는데요 로그인을 하면 accessToken을 알아서 가져오는데 따로 accessToken을 설정하는 로직을 만들어야 하나요??
JWT 인증 방식만 해보다가 처음 oauth 해보는데 헷갈리는 게 많네요..ㅠㅠ