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 來進行驗證
- 當 accessToken 過期時, 後端會 response 401 (
Token is expired
), 此時前端需要去詢問後端並更新 accessToken - 後端收到更新請求時, 會檢查是否有 refreshTokenId 存在於 cookie 中, 並且透過 refreshTokenId 來查詢 refreshToken 是否存在於 DB 中
- 若 refreshToken 存在並且未過期, 則重新產生 accessToken 並回傳, 否則回傳 401 (Token is expired), 代表使用者必須重新登入換取新的 accessToken + refreshToken
- 當 accessToken 過期時, 後端會 response 401 (
JSON Web Token (JWT) 是一種開放標準, 它定義了一種簡單穩定的方式來表示身份驗證和授權數據
1. 透過 @nestjs/jwt
來產生 JWT Token
調整 configuration 並設定 jwtSecret
export const authConfigFactory = registerAs('auth', () => {
return {
jwtSecret: process.env.JWT_SECRET || 'secretKey',
salt: process.env.SALT || ''
};
});
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
// ...
@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 的資料
// ...
@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
// ...
@Injectable()
export class AuthService {
// ...
async signin(user: any) {
return this.tokenService.genRefreshTokenByUser(user);
}
}
// ...
@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
中.
附上 refressToken
及 session
的 schema
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
};
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
};
藉由 express-session
及 connect-mongodb-session
來將資料暫存在 MongoDB 中, 並藉由 cookie-parser
來處理 cookie
- 將
session
相關參數設定在configuration
並同上面一樣 import 至 app.module.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
// ...
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();
將 accessToken
存在 session
中, 並將 refreshTokenId
存在 cookie
中
// ...
@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
};
}
}
// ...
@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);
}
}
// ...
@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
// ...
@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
- jwtFromRequest: 指定從請求中的哪裡提取 jwt token, 這邊我們從 headers 的
- 當
validate
中的payload.exp
小於現在的時間時, 代表 accessToken 已經過期, 我們就回傳TOKEN_IS_EXPIRED
的錯誤
建立 JwtAuthGuard
// ...
@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
// ...
@Module({
imports: [
// ...
],
providers: [AuthService, CryptUtility, TokenService, UserService, JwtStrategy, LocalStrategy, TokenService],
exports: [AuthService, UserService],
controllers: [AuthController, UserController]
})
export class CasModule {}
// ...
@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
// ...
@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 都過期的話, 就代表使用者必須重新登入
// ...
@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 嘗試看看
這邊有使用 nginx 去做 reverse proxy, 所以在設定 cookie
的時候, 我有設定 domain
, 如果 沒有使用 nginx 的話, 可以將 domain
設定為 localhost
但如果有使用 domain
的話, 在使用 cookie-parser
時,你需要指定 domain
以及設定 credentials
, 對應的請求方在 request 的部分也要設定 credentials
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
// ...
};
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