Skip to main content

Authentication with Passport and JWT

前一篇 有實作了基本的 Guard, 以及使用 Passport + passport-local 來完成登入, 接下來要繼續實作登入後的身份持續驗證

實作流程

  • 第一次登入成功後產生 accessToken 以及 refreshToken
    • 將 accessToken 存在 session 中, 並將 session.uniqueId 作為 Temp 的 Service Ticket (ST) 回傳
    • 將 refreshToken 存在 DB 中, 並將 refreshTokenID 儲存在該 domain 的 cookie
    • 前端收到 ST 後向後端發出請求並換取 accessToken
  • 後續每次請求都需要攜帶 accessToken 來進行驗證
    • 當 accessToken 過期時, 後端會 response 401 (Token is expired), 此時前端需要去詢問後端並更新 accessToken
    • 後端收到更新請求時, 會檢查是否有 refreshTokenId 存在於 cookie 中, 並且透過 refreshTokenId 來查詢 refreshToken 是否存在於 DB 中
    • 若 refreshToken 存在並且未過期, 則重新產生 accessToken 並回傳, 否則回傳 401 (Token is expired), 代表使用者必須重新登入換取新的 accessToken + refreshToken
note

JSON Web Token (JWT) 是一種開放標準, 它定義了一種簡單穩定的方式來表示身份驗證和授權數據


1. 透過 @nestjs/jwt 來產生 JWT Token

調整 configuration 並設定 jwtSecret

configuration.factory.ts
export const authConfigFactory = registerAs('auth', () => {
return {
jwtSecret: process.env.JWT_SECRET || 'secretKey',
salt: process.env.SALT || ''
};
});
configuration.import.ts
import { authConfigFactory, databaseConfigFactory, generalConfigFactory } from './configuration.factory';
export function loadConfigImports() {
return [
ConfigModule.forRoot({
envFilePath: ['.env', '.env.staging', '.env.prod'],
isGlobal: true,
load: [authConfigFactory, databaseConfigFactory, generalConfigFactory],
expandVariables: true // Enable environment variable expansion
})
//...
];
}

在 cas.module.ts 中新增 JwtModule

cas.module.ts
// ...
@Module({
imports: [
MongooseModule.forFeature([RefreshTokenDefinition, SessionDefinition, UserDefinition], dbConnectionName.AUTH),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const secret = configService.get<string>('auth.jwtSecret');
return {
secret: secret,
signOptions: {
expiresIn: '60s'
}
};
}
})
],
// ...
})
  • 我們可以在 module 這邊藉由 useFactory 來先設定好 jwtSecret 以及 expiresIn 的時間

2. 修改原本的 /signin

在前面登入驗證成功後, 取得 user 的資料

auth.controller.ts
// ...
@Controller('auth')
export class AuthController {
// ...
@UseGuards(PassportLocalGuard)
@Post('/signin')
async signin(
@Body()
_user: SigninDto,
@Request() req: ExpressRequest,
@Response() res: ExpressResponse
) {
const { accessToken, refreshTokenId }: { accessToken: string; refreshTokenId: string } = await this.authService.signin(req.user);
// ...
}
}

將 user 資料傳入 genRefreshTokenByUser() 來產生 accessToken 以及 refreshToken

auth.service.ts
// ...
@Injectable()
export class AuthService {
// ...
async signin(user: any) {
return this.tokenService.genRefreshTokenByUser(user);
}
}
token.service.ts
// ...
@Injectable()
export class TokenService {
constructor(
// ...
private readonly jwtService: JwtService
) {}
async genRefreshTokenByUser(user: { _id: string }) {
const payload = {
issuer: 'cas-server',
subject: user._id.toString()
// audience: userAgent
};

const accessToken = this.jwtService.sign(payload);
const refreshToken = this.jwtService.sign(payload, {
// TODO: set expiresIn to X days later
expiresIn: '180s'
});

const existsRefreshToken = await this.refreshTokenModel.findOne({ subject: user._id.toString() }).lean();

if (existsRefreshToken) {
await this.refreshTokenModel.updateOne(
{
subject: user._id.toString()
},
{
$set: {
...this.jwtService.decode(refreshToken)
}
}
);
return {
accessToken,
refreshTokenId: existsRefreshToken._id.toString()
};
} else {
const newRefreshToken = await this.refreshTokenModel.findOneAndUpdate(
{ subject: user._id.toString() },
{ ...this.jwtService.decode(refreshToken) },
{ new: true, upsert: true }
);

return {
accessToken,
refreshTokenId: newRefreshToken._id.toString()
};
}
}
}
  • 前面我們已經在 import JwtModule 的時候先設定好了 default 的 expiresIn 時間, 而裡面我們仍可以透過 sign 的第二個參數來設定不同的 secret, expiresIn 等參數
  • 這邊會從 db 裡面去尋找是否有已經存在的 refreshToken, 如果有, 就代表這個 user 已經登入過, 我們就更新這個 refreshToken, 如果沒有, 就新增一個 refreshToken

上面最後會回傳一把 accessToken, 以及一個 refreshTokenId, 我們會將 accessToken 存在 session, 並將 refreshTokenId 存在 cookie 中.

附上 refressTokensession 的 schema

refresh-token.schema.ts
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Document } from 'mongoose';

export type RefreshTokenDocument = RefreshToken & Document;

@Schema()
export class RefreshToken {
@Prop({
required: true
})
issuer: string;

@Prop({
required: true
})
iat: number;

@Prop({
required: true
})
exp: number;

@Prop({
required: true
})
subject: string;

@Prop({
type: String
})
audience: string;

@Prop({
required: true,
default: Date.now
})
createdAt: Date;
}

export const RefreshTokenSchema = SchemaFactory.createForClass(RefreshToken);
export const REFRESH_TOKEN_MODEL_TOKEN = RefreshToken.name;

export const RefreshTokenDefinition: ModelDefinition = {
name: REFRESH_TOKEN_MODEL_TOKEN,
schema: RefreshTokenSchema
};
session.schema.ts
import { ModelDefinition, Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import mongoose, { Document } from 'mongoose';

export type SessionDocument = Session & Document;

@Schema()
export class Session {
@Prop({
required: true,
default: Date.now
})
expires: Date;

@Prop({
required: true,
type: mongoose.Schema.Types.Mixed
})
session: {
cookie: object;
accessToken: string;
uniqueId: mongoose.Types.ObjectId;
};

@Prop({
required: true,
default: Date.now
})
createdAt: Date;
}

export const SessionSchema = SchemaFactory.createForClass(Session);
export const SESSION_MODEL_TOKEN = Session.name;

export const SessionDefinition: ModelDefinition = {
name: SESSION_MODEL_TOKEN,
schema: SessionSchema
};
  • session 相關參數設定在 configuration 並同上面一樣 import 至 app.module.ts 中
configuration.factory.ts
export const databaseConfigFactory = registerAs('database', () => {
// ...
return {
// ...
sessionCollection: process.env.MONGO_SESSION_DB_COLLECTION || 'sessions',
sessionDomain: process.env.MONGO_SESSION_DOMAIN || 'localhost',
sessionName: process.env.MONGO_SESSION_NAME || 'connect.cas.sid',
sessionSecret: process.env.MONGO_SESSION_SECRET,
ssl: process.env.SSL
};
});
  • 在 main.ts 中設定 express-session 以及 connect-mongodb-session
main.ts
// ...
import { NestExpressApplication } from '@nestjs/platform-express';
import * as connectMongo from 'connect-mongodb-session';
import * as cookieParser from 'cookie-parser';
import * as session from 'express-session';

import { AppModule } from './app.module';

const MongoDBStore = connectMongo(session);

async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const configService = app.get(ConfigService);

const uri = configService.get<string>('database.authUri');
const sessionDomain = configService.get<string>('database.sessionDomain');
const sessionName = configService.get<string>('database.sessionName');
const sessionSecret = configService.get<string>('database.sessionSecret');
const sessionCollection = configService.get<string>('database.sessionCollection');

const sessionMap: session.SessionOptions = {
resave: false,
saveUninitialized: false,
secret: sessionSecret,
proxy: true,
name: sessionName,
cookie: {
sameSite: 'none',
secure: true,
maxAge: 0.5 * 60 * 1000,
domain: sessionDomain
}
};
const store = new MongoDBStore({
uri,
collection: sessionCollection
});

app.set('trust proxy', 1);
app.use(cookieParser());
app.use(
session({
store: store,
...sessionMap
})
);
// ...
}
bootstrap();
auth.controller.ts
// ...
@Controller('auth')
export class AuthController {
// ...
@UseGuards(PassportLocalGuard)
@Post('/signin')
async signin(
@Body()
_user: SigninDto,
@Request() req: ExpressRequest,
@Response() res: ExpressResponse
) {
const { accessToken, refreshTokenId }: { accessToken: string; refreshTokenId: string } = await this.authService.signin(req.user);
req.session.accessToken = accessToken;
req.session.uniqueId = new mongoose.Types.ObjectId();
await this.tokenService.setRefreshTokenIdToCookie(res, refreshTokenId);

return {
st: req.session.uniqueId
};
}
}
token.service.ts
// ...
@Injectable()
export class TokenService {
// ...
async setRefreshTokenIdToCookie(res: Response, refreshTokenId: string) {
return res.cookie('rti', refreshTokenId, {
domain: this.domain,
maxAge: 2592000000,
secure: true
});
}
}

3. 新增 /access-token/:st 來換取有效的 accessToken

當前面登入成功後最終會回傳 st 給前端, 這時前端需要用這把 st 來換取有效的 accessToken

// ...
@Controller('auth')
export class AuthController {
// ...
@Get('access-token/:st')
getAccessTokenBySessionId(@Param('st') sessionUniqueId: string) {
return this.tokenService.genAccessTokenBySessionId(sessionUniqueId);
}
}
token.service.ts
// ...
@Injectable()
export class TokenService {
// ...
async genAccessTokenBySessionId(uniqueId: string) {
const session: SessionDocument = await this.sessionModel.findOne({ 'session.uniqueId': new mongoose.Types.ObjectId(uniqueId) }).lean();

if (!session) {
return throwValidationError(authExceptions.SESSION_NOT_FOUND_OR_EXPIRED, BadRequestException);
}
if (!session.session.accessToken) {
return throwValidationError(authExceptions.TOKEN_IS_NOT_VALID, UnauthorizedException);
}

return session.session.accessToken;
}
}

session 在上面我們有先跟 mongodb 連接, 並且我們有設定了 maxAge, 所以當 session 過期時, 會自動刪除

4. 新增 /profile 並且建立 JwtAuthGuard 來驗證 accessToken

同前一篇一樣, 我們可以建立一個 JwtStrategy 搭配 AuthGuard 來驗證 accessToken

jwt.strategy.ts
// ...
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly configService: ConfigService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.get<string>('auth.jwtSecret')
// passReqToCallback: true
// issuer: 'cas-server'
});
}

async validate(payload: { iat: number; exp: number; issuer?: string; subject?: string }) {
const accessTokenExpired = Date.now() >= payload.exp * 1000;
if (!accessTokenExpired) {
return { userId: payload.subject };
}

return throwValidationError(authExceptions.TOKEN_IS_EXPIRED, UnauthorizedException);
}
}
  • 同 LocalStrategy 實作方式一樣, passport-jwt 的 strategy 這邊在 super 的地方提供了一些參數設定
    • jwtFromRequest: 指定從請求中的哪裡提取 jwt token, 這邊我們從 headers 的 Bearer token 中提取
    • ignoreExpiration: 是否忽略過期的 jwt token, 預設是 false, 如果想要在過期的當下就做 renew accessToken 的話也可以這邊設定 true 並且在 validate 中做 renew 的邏輯
    • secretOrKey: jwt 簽章時用的 key
  • validate 中的 payload.exp 小於現在的時間時, 代表 accessToken 已經過期, 我們就回傳 TOKEN_IS_EXPIRED 的錯誤

建立 JwtAuthGuard

jwt-auth.guard.ts
// ...
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor() {
super();
}

async canActivate(context: ExecutionContext) {
const result = (await super.canActivate(context)) as boolean;
const request = context.switchToHttp().getRequest();
await super.logIn(request);
return result;
}

handleRequest(error: any, user: any, info: any) {
// You can throw an exception based on either "info" or "err" arguments
if (error || !user) {
if (error) {
throw error;
}

if (info && info.message.includes('jwt expired')) {
return throwValidationError(authExceptions.TOKEN_IS_EXPIRED, UnauthorizedException);
}

return throwValidationError(authExceptions.TOKEN_IS_NOT_VALID, UnauthorizedException);
}
return user;
}
}
  • info 中的 message 包含 jwt expired 時, 代表 accessToken 已經過期, 我們就回傳 TOKEN_IS_EXPIRED 的錯誤來讓前端知道這把 accessToken 已經過期, 你需要去換一把新的 accessToken

import JwtStrategy 以及 JwtAuthGuard

cas.module.ts
// ...
@Module({
imports: [
// ...
],
providers: [AuthService, CryptUtility, TokenService, UserService, JwtStrategy, LocalStrategy, TokenService],
exports: [AuthService, UserService],
controllers: [AuthController, UserController]
})
export class CasModule {}
auth.controller.ts
// ...
@Controller('auth')
export class AuthController {
// ...
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile(@Request() req: ExpressRequest) {
const _id = (req.user as any).userId;
return this.userService.getUserById(_id);
}
}

當驗證通過後, 我們就可以繼續去取得 user 的資料

5. 當 accessToken 過期, 新增 /refresh-token 來換取新的 accessToken

在上面如果發生 accessToken 過期, 會回傳 TOKEN_IS_EXPIRED 的錯誤, 要求前端去換取新的 accessToken, 這時我們可以透過 refreshToken 來判斷是否可以換取新的 token

auth.controller.ts
// ...
@Controller('auth')
export class AuthController {
// ...
@Get('access-token/renew')
async getAccessToken(@Request() req: ExpressRequest) {
const refreshTokenId = req.cookies['rti'];
if (!refreshTokenId) {
return throwValidationError(authExceptions.TOKEN_NOT_FOUND, BadRequestException);
}
return this.tokenService.genAccessTokenByRefreshToken(refreshTokenId);
}
}

如果連 refreshToken 都過期的話, 就代表使用者必須重新登入

token.service.ts
// ...
@Injectable()
export class TokenService {
// ...
async genAccessTokenByRefreshToken(refreshTokenId: string) {
const refreshToken = await this.refreshTokenModel.findOne({ _id: refreshTokenId }).lean();

if (!refreshToken) {
return throwValidationError(authExceptions.TOKEN_NOT_FOUND, BadRequestException);
}

if (Date.now() >= refreshToken.exp * 1000) {
return throwValidationError(authExceptions.TOKEN_IS_EXPIRED, UnauthorizedException);
}

return this.jwtService.sign({ issuer: 'cas-server', subject: refreshToken.subject });
}
}

Example

這樣就完成了整個 Authentication 的流程, 現在可以使用 postman 嘗試看看

note

這邊有使用 nginx 去做 reverse proxy, 所以在設定 cookie 的時候, 我有設定 domain, 如果沒有使用 nginx 的話, 可以將 domain 設定為 localhost

但如果有使用 domain 的話, 在使用 cookie-parser 時,你需要指定 domain 以及設定 credentials, 對應的請求方在 request 的部分也要設定 credentials

configuration.factory.ts
const appDomain = process.env.APP_DOMAIN;
const devRegexDomain = new RegExp(`^https?://(.*\\.)?localhost(:\\d+)?$`);
const regexDomain = new RegExp(`^https?://(.*\\.)?${appDomain.replace('.', '\\.')}$`);

const allowedCredentialDomains = process.env.NODE_ENV === 'development' ? [devRegexDomain, regexDomain] : [regexDomain];

return {
allowedCredentialDomains
// ...
};
main.ts
const allowedCredentialDomains = configService.get<string[]>('general.allowedCredentialDomains');
app.enableCors({
origin: allowedCredentialDomains,
methods: 'GET,PUT,PATCH,POST,DELETE',
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true
});

這樣才能正確在 Request 中取得 cookie

1. 登入成功取得 ST

2.png

2. 用 ST 換取 accessToken

3.png

3. 用 accessToken 取得 user 資料

4.png

4. 當 accessToken 過期, 回傳 TOKEN_IS_EXPIRED

5.png

5. 用 refreshToken 換取新的 accessToken

6.png

6. 當 refreshToken 過期, 回傳 TOKEN_IS_EXPIRED

7.png