controller
->Guard
->Strategy
->Service
유저 CRUD 없이 바로 진행한 상태라 간단하게 CRUD 작성 후 진행함.
NestJS | NestJS CRUD
$ npm install passport-local
$ npm i --legacy-peer-deps @nestjs/passport
$ npm i passport-jwt
$ npm i --legacy-peer-deps @nestjs/jwt
$ npm i --legacy-peer-deps @types/passport-jwt
//src/auth
$ nest g resource auth --no-spec
=======================================================
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? No
기존에 users 폴더에 login dto를 작성하였는데 auth로 옮겼다.
users/dto -> auth/dto
// ./src/auth/dto/login-user.dto.ts
import { IsString } from "class-validator";
export class LoginUserDto {
@IsString()
email: string;
@IsString()
password: string;
}
로그인 시 email, password 일치 검증
//auth.service.ts
import { HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { LoginUserDto } from 'src/auth/dto/login-user.dto';
import { User } from 'src/users/entities/user.entity';
import { Repository } from 'typeorm';
import * as bcrypt from "bcrypt";
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private userRepository: Repository<User>,
private jwtService: JwtService
) {}
async validateUser(loginUserDto: LoginUserDto): Promise<any> {
// DB ID 일치 검증
const userInfo = await this.userRepository.findOne({email: loginUserDto.email});
if(!userInfo) {
throw new UnauthorizedException({
statusCode: HttpStatus.UNAUTHORIZED,
message: ['USER_INFORMATION_DOES_NOT_MATCH'],
error: 'Unauthorized'
})
}
// DB PASSWORD 일치 검증
const isMatch = await bcrypt.compare(loginUserDto.password, userInfo.password);
if(isMatch) {
const { password, ...result } = userInfo;
return result;
} else {
throw new UnauthorizedException({
statusCode: HttpStatus.UNAUTHORIZED,
message: ['USER_INFORMATION_DOES_NOT_MATCH'],
error: 'Unauthorized'
})
}
}
// 토큰 생성 후 반환
async login(data: any) { //이름이 꼭 user일 필요는 없다
const payload = { email: data.email, type: data.type};
return {
email: payload.email,
accessToken: this.jwtService.sign(payload)
}
}
}
사용자의 EMAIL & PASSWORD 유효성 검사를 한다.
import { HttpStatus, Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { LoginUserDto } from "src/auth/dto/login-user.dto";
import { AuthService } from "../auth.service";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private authService: AuthService) {
super({
usernameField: 'email'
});
}
async validate(email: string, password: string): Promise<any> {
const loginUserDto: LoginUserDto = { email, password }
const userInfo = await this.authService.validateUser(loginUserDto);
if(!userInfo) {
throw new UnauthorizedException({
statusCode: HttpStatus.UNAUTHORIZED,
message: ['USER_INFORMATION_DOES_NOT_MATCH'],
error: 'Unauthorized'
})
}
return userInfo;
}
}
LocalAuthGuard는 Passport의 local-strategy를 수행하는 Guard이다.
//auth/guards/local-auth.guard.ts
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {}
RandomKeygen에서 256-bit 형식의 CodeIgniter Encryption Keys를 사용한다.
해당 파일은 꼭 .gitignore 파일에서 숨김 처리
//src/constants.ts
export const bcryptConstant = {
saltOrRounds: 10,
};
export const jwtConstants = {
secret: '랜덤 시크릿 키',
};
로그인 기능
//auth.controller.ts
import { Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from 'src/auth/guards/local-auth.guard'
import { authPublic } from 'src/auth/authPublic.decorator';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@UseGuards(LocalAuthGuard) // EMAIL&PASSWORD 유효성 검사 가드
@authPublic()
@Post('login')
@HttpCode(200) //Post statusCode는 기본값이 201이므로 200을 별도로 지정해줘야함.
async login(@Req() req) {
return this.authService.login(req.user);
//Passport는 validate() 메서드에서 반환한 값을 기반으로 user 객체를 자동으로 생성하고 req.user로 Request 객체에 할당
}
}
모듈과 스트레이트지, JWT를 사용할 수 있도록 설정한다.
//auth.module.ts
import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './strategies/local.strategy';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from 'src/users/entities/user.entity';
import { JwtModule } from '@nestjs/jwt';
import { jwtConstants } from 'src/constants'
@Module({
imports: [
TypeOrmModule.forFeature([User]),
PassportModule,
JwtModule.register({
secret: jwtConstants.secret,
signOptions: { expiresIn: '60s' }, //토큰 만료 시간 지정
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy],
exports: [AuthService]
})
export class AuthModule {}
JWT 발행 성공!
만료된 토큰은 사용 불가하게 기능 구현
//jwt.strategy.ts
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { jwtConstants } from "src/constants";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), //생성자에서 검증에 사용할 토큰의 종류 선택
ignoreExpiration: false, //만료된 토큰을 사용하지 못하도록 하려면 true로 저장하기.
secretOrKey: jwtConstants.secret,
});
}
async validate(data: any) {
return { email: data.email, type: data.type } //로그인 시 사용되는 ID, 추후에 역할 제한을 위한 type정보
}
}
Error: Unknown authentication strategy "jwt"
//auth.module.ts
...
import { JwtStrategy } from './strategies/jwt.strategy';
@Module({
...
providers: [AuthService, LocalStrategy, JwtStrategy],
...
})
export class AuthModule {}
JwtAurhGuard는 Passport의 jwt-strategy를 수행하는 Guard이다.
//jwt-auth.guard.ts
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
적용할 기능별로 지정이 가능하다.
//users.controller.ts
@UseGuards(JwtAuthGuard) //만료된 토큰은 사용불가
@Get()
findAll() {
return this.usersService.findAll();
}
auth.module에서 expiresIn에 설정된 시간이 지난 토큰은 만료되어 사용 불가
기능별이 아닌 만료된 토큰은 전체 기능에서 사용 불가로 지정한다.
//app.module.ts
...
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
...
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
@Module({
...,
{ //jwt-auth 가드 전역
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
],
})
export class AppModule {}
auth.controller
@authPublic 데코레이터가 실행 ->authPublic.decorator
에서 지정한'isPublic'
MetaData를 가드로 전달 ->jwt-auth.guard
파일에서 MetaData를 감지 후true
를 리턴하여 전역 가드를 통과 시켜준다.
MetaData 전달을 위해 데코레이터 작성
//auth/authPubilc.decorator.ts
import { SetMetadata } from "@nestjs/common";
export const authPublic = () => SetMetadata('isPublic', true)
//main.ts
...
import { NestFactory, Reflector } from '@nestjs/core';
...
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const reflector = app.get(Reflector); //추가
app.useGlobalGuards(new JwtAuthGuard(reflector)); //추가
...
await app.listen(3006);
}
bootstrap();
참고한 글에서는
jwt-auth.guard.ts
파일에canActivate
로직을 구현하는데 나는strategy
를 따로 생성했으니 스트레이트지 파일에 작성해야하는 줄 알았지만jwt-auth.guard.ts
파일에 로직을 구현하니 성공했다.
//jwt-auth.guard.ts
import { ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
const isPublic = this.reflector.get<boolean>(
'isPublic',
context.getHandler()
);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
canActivate
해당 가드는 라우터에 대해서 접근이 가능한지 불가능 한지 접근 권한을 체크 하는 기능을 한다. 해당 가드를 사용 하고자 한다면 구현하고자 하는 가드 클래스에 CanActivate 인터페이스 모듈을 구현 해야하며 해당 인터페이스에는canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) :boolean | Observable<boolean> | Promise<boolean>
메소드가 있으며 해당 메소드 안에서 라우터 접근 로직을 구현 하거나 구현된 서비스를 사용 하면 된다.
- 반환값이
true
이면 요청이 처리, 반환값이false
이면 Nest는 요청을 거부
#### 참고
...
import { authPublic } from "./authPublic.decorator";
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
@authPublic() // 데코레이터 추가
...
async login(@Req() req) {
return this.authService.login(req.user);
}
}
참고한 사이트
계정 별로 권한을 생성해 준다.
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;
@Column({comment: 'manager, user'}) //컬럼 추가
type: string;
@Column({unique: true})
email: string;
...
}
entity에 작성해도 되지만 구분을 위해 새 파일을 생성한다.
//auth/enums/role.enum.ts
export enum UserType {
USER = 'user',
ADMIN = 'manager'
// 상수는 대문자로 지정
}
MetaData 전달을 위해 데코레이터 작성
//auth/role.decorator.ts
import { SetMetadata } from "@nestjs/common";
export const Role = (type: string) => SetMetadata('role', type);
$ npm install --save @types/jwt-decode
role 가드는 NestJS 내장된 기능으로 스트레이트지가 필요없다.
//auth/guards/role.guard.ts
import { CanActivate, ExecutionContext, ForbiddenException, HttpCode, HttpStatus, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import jwt_decode from "jwt-decode";
@Injectable()
export class RoleGuard implements CanActivate {
constructor( private reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requireRoles = this.reflector.getAllAndOverride<string[]>( 'role', [
context.getHandler(),
context.getClass()
]);
if(!requireRoles) {
return true;
}
const request = context.switchToHttp().getRequest();
const token = jwt_decode(request.headers.authorization);
if(requireRoles === token['type']) {
return true
} else {
throw new ForbiddenException({
statusCode: HttpStatus.FORBIDDEN,
message: ['FORBIDDEN'],
error: 'Forbidden'
})
}
}
}
Unable to decode jwt with jwt-decode
When you import the jwt_decode, you should surpass a rule from tslint, your code will look exactly like this (with commented line above)
// @ts-ignore import jwt_decode from "jwt-decode";
jwt의 authorization을 header에서 가져오기 - tabnine
//참고한 코드
//src/guards/auth.guard.ts/AuthGuard/canActivate
async canActivate(context: ExecutionContext): Promise<any> {
const request = context.switchToHttp().getRequest();
const token = request.headers.authorization;
try {
request.user = await this.authService.verifyToken(token);
} catch (e) {
throw new UnauthorizedException(e);
}
return true;
}
@Role
데코로 지정한 기능만 권한제한 설정
//app.module.ts
...
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
...
import { RolesGuard } from './auth/guards/roles.guard';
@Module({
imports: [
...
],
controllers: [AppController],
providers: [AppService,
...
{ //role 가드 전역
provide: APP_GUARD,
useClass: RolesGuard,
},
],
})
export class AppModule {}
guard 파일에서
canActivate
기능으로 별도의@Role
을 지정하지 않으면 권한제한이 없다.
//users.controller.ts
...
import { Role } from 'src/auth/role.decorator';
import { UserType } from 'src/auth/enums/role.enum';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Role(UserType.ADMIN) //권한 지정. ADMIN 계정만 사용 가능
@Post()
create(@Body() createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
- 권한 없는 유저가 기능을 사용한 경우 403
참고