공식문서 기반으로 만들어야 정상이지만 저는 다른 방법을 사용했습니다. 일단 공식문서 기반으로 설명 드리겠습니다. 기본적으로 passport.js 기반으로 만들어져있습니다.
https://docs.nestjs.com/recipes/passport#enable-authentication-globally
참고로 아래 프로세스 기반으로 구성했습니다.
https://mondaymonday2.tistory.com/992
📝인증 (공식문서)
jwt.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
UseGuard랑 Strategy은 세트입니다. Guard에서 통과한 이후에 정상인 경우 JwtStrategy의 super 검증을 거친 이후 validate를 통과해 controller를 실행시킵니다. validate return값에는 request 객체의 user 객체 안에 값이 들어가게 되기 때문에 필요한 정보가 있는 경우 return 해주면 됩니다.
auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalStrategy } from './local.strategy';
import { JwtStrategy } from './jwt.strategy';
import { UsersModule } from '../users/users.module';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from './constants';
@Module({
imports: [
UsersModule,
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '60s' },
}),
],
providers: [AuthService, LocalStrategy, JwtStrategy],
exports: [AuthService],
})
export class AuthModule {}
여기에서 jwtModule을 선언해야 jwtService를 이용한 verify작업이나 jwt 토큰 생성을 할 수 있습니다.
jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { jwtConstants } from './constants';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy,'jwt') {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: jwtConstants.secret,
});
}
async validate(payload: any) {
return { userId: payload.sub, username: payload.username };
}
}
- jwtFromRequest를 통해 Request정보를 어떻게 파싱할지에 대해 선언합니다. 여기에서는 Header에서 Bearer를 통해 보낸 경우에 대해 파싱하는 메소드를 기본적으로 사용합니다.
- secretOrKey의 경우 jwt에서 사용되는 secret 키 입니다.
- validate의 경우 위에 파싱해서 secret 키로 검증을 성공하면 동작하게 됩니다. return값은 controller에 request.user에 담아 보낼 값을 의미합니다.
저는 토큰 정보 등을 Header를 통해 보냈습니다. 왜냐하면 return type이 무조건 application/json가 아닐 수도 있다고 생각을 해서 그렇게 만들었습니다만 json으로 response에 담아서 보내도 충분할 것 같습니다.
📝회원가입
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import * as bcrypt from 'bcrypt';
import { SALT_OR_ROUNDS } from '../common/constants';
import { SignInDto } from './user.dto';
@Injectable()
export class UserService {
constructor(private readonly prisma: PrismaService) {}
async signUp(signInDto: SignInDto) {
const hashPassword = await bcrypt.hash(signInDto.password, SALT_OR_ROUNDS);
try {
await this.prisma.member.create({
data: {
username: signInDto.username,
password: hashPassword,
refresh_token: '',
},
});
} catch (e) {
throw new HttpException(
'username already is existed',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
}
}
아이디랑 패스워드를 보내 패스워드는 bcrypt로 해싱했습니다.
📝로그인
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { PrismaService } from '../prisma/prisma.service';
import { JwtService } from '@nestjs/jwt';
import { SignInDto } from '../user/user.dto';
import { ConfigService } from '@nestjs/config';
import {
ACCESS_TOKEN_EXPIRE,
ACCESS_TOKEN_SECRET,
REFRESH_TOKEN_EXPIRE,
REFRESH_TOKEN_SECRET,
} from '../common/constants';
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
private refreshJwtService: JwtService,
) {}
async signIn(signInDto: SignInDto) {
/** Username Validate **/
const user = await this.prisma.member.findUnique({
where: { username: signInDto.username },
});
if (!user) {
throw new HttpException(
'username or password is wrong',
HttpStatus.UNAUTHORIZED,
);
}
/** Password Validate **/
const isMatch = await bcrypt.compare(signInDto.password, user.password);
if (!isMatch) {
throw new HttpException(
'username or password is wrong',
HttpStatus.UNAUTHORIZED,
);
}
/** Jwt Create **/
const payload = { username: user.username, role: user.role };
const accessToken = await this.jwtService.signAsync(payload, {
secret: this.configService.get<string>(ACCESS_TOKEN_SECRET),
expiresIn: this.configService.get<string>(ACCESS_TOKEN_EXPIRE),
});
const refreshToken = await this.refreshJwtService.signAsync(payload, {
secret: this.configService.get<string>(REFRESH_TOKEN_SECRET),
expiresIn: this.configService.get<string>(REFRESH_TOKEN_EXPIRE),
});
await this.prisma.member.update({
data: {
refresh_token: refreshToken,
},
where: { username: user.username },
});
return {
access_token: accessToken,
refresh_token: refreshToken,
};
}
}
로그인 정보 확인해서 DB값하고 비교한 이후에 정상인 경우 Access Token하고 Refresh Token을 생성해서 Refresh Token의 경우는 cookie에 보안을 위해 httpOnly로 보냅니다. Access Token의 경우는 Header에 담아서 보냅니다. Refresh Token의 경우는 Access Token을 재발급하는 용도이기 때문에 사용자가 가지고 요청마다 같이 보내야 Access Token이 만료 됐을 때 Refresh Token을 검증해서 Access Token을 재발급해줍니다.
📝로그아웃
@Public()
@Post('logout')
async logout(@Res() response: Response) {
response.clearCookie(REFRESH_TOKEN).send();
return { response: 'logout' };
}
로그아웃하는 경우 가지고 있는 Refresh Token쿠키를 삭제합니다.
📝인증 / 인가
auth.module
//node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PrismaModule } from '../prisma/prisma.module';
import { AuthService } from './auth.service';
import { ConfigService } from '@nestjs/config';
import { ACCESS_TOKEN_EXPIRE, ACCESS_TOKEN_SECRET } from '../common/constants';
import { PassportModule } from '@nestjs/passport';
import { GoogleStrategy } from '../common/strategies/google.strategy';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './auth.guard';
@Module({
imports: [
PassportModule,
JwtModule.registerAsync({
global: true,
useFactory: async (configService: ConfigService) => ({
secret: configService.get<string>(ACCESS_TOKEN_SECRET),
signOptions: {
expiresIn: configService.get<string>(ACCESS_TOKEN_EXPIRE),
},
}),
inject: [ConfigService],
}),
PrismaModule,
],
providers: [
AuthService,
GoogleStrategy,
{
provide: APP_GUARD,
useClass: AuthGuard,
},
],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
- module에는 JwtService를 사용하기 위해 JwtModule을 import합니다.
- useClass를 이용해 UseGuards를 따로 선언 안 해도 전역으로 해당 JwtGuard를 검증하게 합니다
secret key의 경우는 node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" 명령어로 랜덤 값으로 만듭니다.
auth.guard
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Request } from 'express';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import {
ACCESS_TOKEN_SECRET,
REFRESH_TOKEN,
REFRESH_TOKEN_SECRET,
} from '../common/constants';
import { ROLE } from '../common/enum';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private configService: ConfigService,
private prismaService: PrismaService,
private reflector: Reflector,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
/** Public Decorator Ignore Token **/
const isPublic = this.reflector.getAllAndOverride<boolean>('isPublic', [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
/** ─── JWT Validate ─── **/
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
const refreshToken = request.cookies.REFRESH_TOKEN;
const accessToken = this.extractAccessTokenFromHeader(request);
let payload: { username: string; role: ROLE };
/** Refresh Token Validate (검증 실패 → Logout) **/
try {
payload = await this.jwtService.verifyAsync(refreshToken, {
secret: this.configService.get<string>(REFRESH_TOKEN_SECRET),
});
} catch (e) {
response
.clearCookie(REFRESH_TOKEN)
.setHeader('Refresh-Token-Invalid', true)
.send();
throw new UnauthorizedException();
}
/** Refresh Token값 DB Refresh Token이랑 비교해 서버에서 강제 로그아웃 **/
const dbRefreshToken = await this.prismaService.member.findUnique({
where: { username: payload.username },
});
if (dbRefreshToken.refresh_token !== refreshToken) {
response
.clearCookie(REFRESH_TOKEN)
.setHeader('Refresh-Token-Invalid', true)
.send();
throw new UnauthorizedException();
}
/** Access Token Validate (검증 실패 → 재발급) **/
try {
await this.jwtService.verifyAsync(accessToken, {
secret: this.configService.get<string>(ACCESS_TOKEN_SECRET),
});
} catch (e) {
const accessToken = await this.jwtService.signAsync({
username: payload.username,
role: payload.role,
});
response
.setHeader('Access-Token', accessToken)
.setHeader('Role', payload.role);
}
return true;
}
private extractAccessTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}
- isPublic를 사용한 이유는 @isPublic() 어노테이션을 달면 jwtGuard가 따로 필요없는 메소드라고 선언하는 것입니다. (전역으로 jwtGuard를 사용하게끔 했기 때문에 회원가입이나 로그인 등에는 필요)
- Refresh Token를 시크릿키로 복호화했을 때 유효하면 넘어가고 유효하지 않으면 유효하지 않다는 값을 Header로 보내 클라이언트에서는 로그아웃시킨다.
- Refresh Token값하고 DB Refresh Token값하고 비교하는데 DB Refresh Token값을 따로 가지는 이유는 JWT의 경우 사용자에게 인증을 위임하기 때문에 서버에서는 강제로 로그아웃 시킬 수가 없다. 하지만 이 방법을 이용하면 강제 로그아웃을 시킬 수 있다. 다르면 Refresh Token이 유효하지 않다는 문구를 Header에 담아 보낸다. (이 방법을 쓰면 서버에서 세션관리하는 부담을 JWT의 장점인 사용자에게 부담을 넘기는 걸 다시 서버가 어느정도 안고 가게 된다. Trade Off라 어쩔 수 없음)
- Access Token을 검증해 정상적이지 않으면 Refresh Token을 이용해 재발급해서 사용자에게 보낸다 Role의 경우는 어떤 페이지는 사용자가 접근할 수 있지만 어떤 페이지는 관리자만 접근할 수 있게끔 하기 위해 따로 담았다. (Refresh Token은 이미 위에서 검증해서 내려온 거이기 때문에 정상이라 이렇게 재발급하면 된다.)
main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import helmet from 'helmet';
import * as dotenv from 'dotenv';
import * as cookieParser from 'cookie-parser';
import { ConfigService } from '@nestjs/config';
import { FRONT_URL, PORT } from './common/constants';
dotenv.config();
// prettier-ignore
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const configService= app.get(ConfigService);
/** server setting **/
app.use(helmet()); // 보안 적용
app.use(cookieParser()); // 쿠키 라이브러리 사용
app.enableCors({ // cors 허용
origin: configService.get(FRONT_URL),
credentials: true,
exposedHeaders: ['Access-Token', 'Refresh-Token-Invalid', "Role"] // token header allow (header에서 parse하려면 필요)
});
await app.listen(configService.get(PORT)); // 포트 적용
}
bootstrap();
'[Nest.js]' 카테고리의 다른 글
[Nest.js] 나만의 환경 구축 (라이브러리 설치, IDE 설정, 네이밍 컨벤션, 코드 스타일) (0) | 2024.08.21 |
---|---|
[Nest.js] [Graphql] Decimal Input Type에서 사용하기 (1) | 2024.02.19 |
[Nest.js] Naming Convention (네이밍 컨벤션) (0) | 2024.01.24 |
[Nest.js] GraphQL 파일 업로드 구현하기 Multipart/form-data 전송 (0) | 2024.01.24 |