# NESTJS-JWT https://docs.nestjs.com/security/authentication https://www.youtube.com/watch?v=uAKzFhE3rxU ## 需要的套件 ```shell= npm install --save @nestjs/passport npm install --save @nestjs/jwt passport-jwt npm install --save-dev @types/passport-jwt ``` @types/passport, @types/passport-jwt是為了讓TypeScript能夠好好辨認他們的type 加密法不用bcrypt,因為我們要加密token,通常它的長度會非常的長 bcrypt的最長長度為72bytes(71 characters + 1 byte null terminator) 如果在長度超過72bytes的字串使用bcrypt,超出長度的字串將不會被加密到,導致只要前面72byte的字串相符,bcrypt就會通過比對 這在jwt是非常致命的,因為jwt的secret被存在最後段,而前面兩段的payload是可以透過他們官網解出來的 我們這裡用的是argon2,他的最大輸入長度是2^32-1 bytes [Does bcrypt have a maximum password length?](https://security.stackexchange.com/questions/39849/does-bcrypt-have-a-maximum-password-length) [Maximum input and output length for Argon2](https://stackoverflow.com/questions/55597857/maximum-input-and-output-length-for-argon2) ## refresh token的意義 一般的access token驗證時不必經過資料庫,也因此無法把它銷毀,若在過期之前有人竊取到了token便可以拿它做任何事,只能透過設定過期時間讓他失效 但設定的時間太短會影響使用體驗,太長則提高安全性風險 因此提出了refresh token的概念,讓access token的有效時間可以設的很短,在過期之後透過refresh token的驗證換發,如果refresh token被竊取,則因為refresh token會存在資料庫的關係,直接刪掉就可以了 ## 前端儲存位置 https://dotblogs.com.tw/wasichris/2020/10/25/223728 - access token localStorage or in-memory or HttpOnly Cookie - refresh token cookie(HttpOnly, Secure, SameSite) 要特別挑出存放的位置,才不會發出每個request都帶有他 https://stackoverflow.com/questions/57650692/where-to-store-the-refresh-token-on-the-client ## 流程 在這裡我做了4個API - signup 確認無重複email之後,把email跟hash過的密碼存到資料庫 生成access token跟refresh token 把hash過的refresh token存到資料庫 回傳access token跟refresh token - signin 確認信箱跟密碼無誤 生成access token跟refresh token 把hash過的refresh token存到資料庫 回傳access token跟refresh token - logout 確認access token的正確性 把資料庫裡的refresh token刪除 - refresh 確認refresh token的正確性 生成access token跟refresh token 把hash過的refresh token存到資料庫 回傳access token跟refresh token ## JwtService ### initial 因為jwt可能會在很多個地方用到,所以把它放到common裡 在register裡面可以放一些共用的payload,他會跟我們之後傳進的payload合在一起 ```typescript= //common.module.ts @Global() @Module({ imports: [JwtModule.register({})], }) export class CommonModule {} ``` ### Generate Token ```typescript= //tokens.ts export type tokens = { accessToken: string; refreshToken: string; }; //jwt.service.ts import { Injectable } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { tokens } from 'src/types/tokens'; @Injectable() export class JWTService { constructor(private jwtService: JwtService) {} async getTokens(id: number, email: string): Promise<tokens> { const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync( { id, email, }, { secret: 'VeryLongStringForAccessToken', expiresIn: 60 * 15, }, ), this.jwtService.signAsync( { id, email, }, { secret: 'VeryLongStringForRefreshToken', expiresIn: 60 * 60 * 24 * 7, }, ), ]); return { accessToken: accessToken, refreshToken: refreshToken, }; } } ``` 用signAsync()生成token 在這個檔案裡決定了payload裡面要放什麼以及他的secret跟過期時間 ### strategy strategy決定了我們驗證完token之後要做什麼動作 ```typescript= //access-token.strategy.ts import { ExtractJwt, Strategy } from 'passport-jwt'; import { PassportStrategy } from '@nestjs/passport'; export class AccessTokenStrategy extends PassportStrategy(Strategy, 'jwt') { constructor() { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: 'VeryLongStringForAccessToken', }); } validate(payload: any) { return payload; //req.user = payload } } ``` extends PassportStrategy的第二個參數是那個strategy的名字,這樣我們才能透過名字指定要用的strategy 在constructor裡有三個property - jwtFromRequest 決定我們如何得到token,在這裡用的是從header裡提取出來 headerKey:Authorization headerValue:bearer \<token> 在bearer跟token中間有一個空白,我們之後可以用split來提取 - ignoreExpiration 在nest官網裡這個選項有被拿出來講,設為false代表token會過期,而預設就是false,所以可以不用特地寫出來 - secretOrKey jwt的secret,用來加密訊息 在validate裡面有return payload,在這裡因為我們使用的是express來處理http request,所以它會自動幫我們做req.user = payload的動作 ```typescript= //refresh-token.strategy.ts import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { Request } from 'express'; @Injectable() export class RefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { constructor() { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, secretOrKey: 'VeryLongStringForRefreshToken', passReqToCallback: true, }); } validate(req: Request, payload: any) { const refreshToken = req.get('authorization').replace('Bearer', '').trim(); return { ...payload, refreshToken }; //req.user = { ...payload, refreshToken } } } ``` 基本上多了一個passReqToCallback,這是為了把request傳到validate這個method裡面 這樣我們就可以取得refresh token並拿回到controller用 ## USER ENTITY ```typescript= import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() email: string; @Column() password: string; @Column({ nullable: true }) refreshToken: string; } ``` ## ROUTES ### signup ```typescript= //auth.controller.ts @Post('/signup') @HttpCode(HttpStatus.CREATED) signup(@Body() dto: UserDto): Promise<tokens> { return this.authService.signup(dto); } //auth.service.ts async signup(dto: UserDto): Promise<tokens> { const existingUser = await this.repo.findOneBy({ email: dto.email }); if (existingUser) { throw new BadRequestException(); } const hashedPassword = await argon.hash(dto.password); const user = await this.repo.create({ email: dto.email, password: hashedPassword, refreshToken: '', }); await this.repo.save(user); const tokens = await this.jwtService.getTokens(user.id, user.email); await this.updateRefreshToken(user.id, tokens.refreshToken); return tokens; } ``` ```json= { "email": "a@email.com", "password": "noToken" } ``` 傳進信箱跟密碼,nest就會把他轉成dto 這裡可以不用做`refreshToken: ''`就可以存,只是我在操作的時候是用sqlite,他不給存null,而我也沒有特別去找解決方法 小發現:在user被create出來的時候,id是undefined 但在save之後,id會存進去user這個變數 因此就可以直接把id寫進payload裡了 最後回傳access token跟refresh token ### signin ```typescript= //auth.controller.ts @Post('/signin') @HttpCode(200) signin(@Body() dto: UserDto): Promise<tokens> { return this.authService.signin(dto); } //auth.service.ts async signin(dto: UserDto): Promise<tokens> { const user = await this.repo.findOneBy({ email: dto.email }); if (!user) { throw new ForbiddenException(); } const isMatch = await argon.verify(user.password, dto.password); if (!isMatch) { throw new ForbiddenException(); } const tokens = await this.jwtService.getTokens(user.id, user.email); await this.updateRefreshToken(user.id, tokens.refreshToken); return tokens; } ``` 帳號密碼錯誤的話,丟403 forbidden 假如信箱密碼都相符,則回傳access token跟refresh token 小發現:基本上使用到POST的API他的預設HttpStatus都是201 我們可以用nest的裝飾器"@HttpCode()"來改變這個API的HttpStatus ### logout 這邊要使用guard解析payload,因為payload裡面有id ```typescript= //auth.controller.ts @UseGuards(AuthGuard('jwt')) @Post('/logout') @HttpCode(200) logout(@Req() req: Request) { const user = req.user; return this.authService.logout(user['id']); } //auth.service.ts async logout(id: number) { return await this.repo.update( { id, refreshToken: Not('') }, { refreshToken: '' }, ); } ``` `@UseGuards(AuthGuard('jwt'))`代表使用名字為jwt的strategy來做jwt token認證 回到strategy這邊 ```typescript= //access-token.strategy.ts ... export class AccessTokenStrategy extends PassportStrategy(Strategy, 'jwt') { ... validate(payload: any) { return payload; //req.user = payload } } ``` 這裡講過return payload就是把payload放到req.user裡 因此原本在jwt token裡的payload被解析出來之後會被放到裡面 其中包還了id 因此在controller裡才能把id傳進去 而在service的update中,在教學影片裡是id符合且refresh token不為空的才會把空字串寫進去,他說這樣是為了防止有人spam logout的API進而造成資料庫一直被寫入的狀況 ### refresh ```typescript= //auth.controller.ts @UseGuards(AuthGuard('jwt-refresh')) @Post('/refresh') @HttpCode(200) refresh(@Req() req: Request) { const user = req.user; return this.authService.refresh(user['id'], user['refreshToken']); } //auth.service.ts async refresh(id: number, refreshToken: string) { const user = await this.repo.findOneBy({ id }); if (!user || !user.refreshToken) { throw new ForbiddenException(); } const isMatch = await argon.verify(user.refreshToken, refreshToken); if (!isMatch) { throw new ForbiddenException(); } const tokens = await this.jwtService.getTokens(user.id, user.email); await this.updateRefreshToken(user.id, tokens.refreshToken); return tokens; } ``` 再次回到strategy看 ```typescript= //refresh-token.strategy.ts ... export class RefreshTokenStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { ... validate(req: Request, payload: any) { const refreshToken = req.get('authorization').replace('Bearer', '').trim(); return { ...payload, refreshToken }; //req.user = { ...payload, refreshToken } } } ``` ## Custom Decorator 上面的作法`return this.authService.logout(user['id']);`TS無法檢測user['id']的型別 因此用custom decorator的ParamDecorator稍微加強型別判定 ### Param decorator ```typescript= //get-user.decorator.ts import { createParamDecorator, ExecutionContext } from '@nestjs/common'; export const GetUser = createParamDecorator( (data: string, context: ExecutionContext) => { const request = context.switchToHttp().getRequest(); const user = request.user; return data ? user[data] : user; }, ); //auth.controller.ts @UseGuards(RefreshTokenGuard) @Post('/refresh') @HttpCode(200) refresh( @GetUser('id') id: number, @GetUser('refreshToken') refreshToken: string, ) { return this.authService.refresh(id, refreshToken); } ``` 在這邊,data就是我們傳進去的值 `@GetUser('refreshToken')`這段code我們傳進去的就是refreshToken這段string 通過ParamDecorator我們可以把request裡面key為該string的value回傳回去 然後在controller裡設定型別,便可以稍微增加嚴格性 ### Metadata decorator 使用這個decorator可以設置metadata 可以放在個別的handler上或整個controller上 ```typescript= // public.decorator.ts import { SetMetadata } from '@nestjs/common'; export const Public = () => SetMetadata('isPublic', true); // auth.controller.ts @Public() @Controller('auth') export class AuthController { ... } // or @Public() @Post('/signup') @HttpCode(201) signup(@Body() dto: UserDto): Promise<tokens> { return this.authService.signup(dto); } ``` ## Custom JWT Guard 我們可能會針對payload裡面的內容做進一步的權限檢查或其他處理 所以有必要用到custom jwt guard 在custom guard裡可能會使用到reflector來取得metadata來作為判斷的標準 ```typescript= @Injectable() export class AccessTokenGuard extends AuthGuard('jwt') { constructor(private reflector: Reflector) { super(); } } ``` ### Global Guard access token guard會用在很多handler上 因此可以把它設為全域的guard 有兩種設法 1. 在main.ts裡加上`app.useGlobalGuards(new AccessTokenGuard());` 但只有這樣guard不能使用dependency injection 必須new一個reflector放到參數裡面他才能使用reflector 如果還有其他要放到constructor的東西就要一直new ```typescript= //main.ts ... bootstrap(){ ... const reflector = new reflector(); app.useGlobalGuards(new AccessTokenGuard(reflector)) } ``` 2. 在app.module裡使用APP_GUARD ```typescript= //app.module.ts ... @Module({ ... providers: [{ provide: APP_GUARD, useClass: AccessTokenGuard }], }) export class AppModule {} ``` 這樣就可以在要用的guard裡用dependency injection了 ### 在特定的handler不使用Global Guard 在上面有寫到metadata decorator 我們可以透過metadata來辨別該handler該不該使用global guard 因為所有的route都設置access token guard的話,連登入都會被擋住 ```typescript= import { ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { AuthGuard } from '@nestjs/passport'; import { Observable } from 'rxjs'; @Injectable() export class AccessTokenGuard extends AuthGuard('jwt') { constructor(private reflector: Reflector) { super(); } canActivate( context: ExecutionContext, ): boolean | Promise<boolean> | Observable<boolean> { const isPublic = this.reflector.getAllAndOverride('isPublic', [ context.getHandler(), context.getClass(), ]); if (isPublic) { return true; } return super.canActivate(context); } } ``` getAllAndOverride()的第一個參數是想要找的metadata,第二個參數是想要找的位置 context.getHandler() -> 從handler找 context.getClass() -> 從class找(即controller) 如果有找到,回傳true直接通過guard 沒找到的話用super執行原本的access token guard(即AuthGuard('jwt'))