# 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'))