在 Nest.js 中使用 Auth.js
Auth.js 是一个可以非常方便接入 OAuth 的一个身份验证库。他起初是为 Next.js 而设计。如今官方以为其供一些常用框架的集成,但是不幸的是,并没有 Nest.js 的官方支持。
这篇文章将从零开始构造一个适用于 Nest.js 的 AuthModule。那我们开始吧。
准备
我们需要使用 @auth/core
的底层依赖,在此基础上进行封装。
npm i @auth/core
然后,我们需要知道一个 Auth 是如何适配并接入一个 Framework 的。官方提供了 Express 的支持,我们可以去源码中学习接入的步骤。
从源码中得知,@auth/core
是一个抽象层,我们需要做两件事:第一把原框架的 Request 转换到 WebRequest,然后由 AuthCore 处理;第二把 AuthCore 处理完成后的 WebResponse 转换到原框架的 Response。
知道了原理,那么接下来就好办了。
编写一个转换器
首先,我们创建一个 auth
模块,例如 src/modules/auth.module.ts
。模块内容展示为空。然后编写一个 AuthCore
需要的 Request/Response 转换器。这里我们创建为 src/module/auth/auth.implement.ts
export type ServerAuthConfig = Omit<AuthConfig, 'basePath'> & {
basePath: string
}
export function CreateAuth(config: ServerAuthConfig) {
return async (req: IncomingMessage, res: ServerResponse) => {
try {
setEnvDefaults(process.env, config)
const auth = await Auth(await toWebRequest(req), config)
await toServerResponse(req, auth, res)
} catch (error) {
console.error(error)
// throw error
res.end(error.message)
}
}
}
async function toWebRequest(req: IncomingMessage) {
const host = req.headers.host || 'localhost'
const protocol = req.headers['x-forwarded-proto'] || 'http'
const base = `${protocol}://${host}`
return getRequest(base, req)
}
async function toServerResponse(
req: IncomingMessage,
response: Response,
res: ServerResponse,
) {
response.headers.forEach((value, key) => {
if (value) {
res.setHeader(key, value)
}
})
res.setHeader('Content-Type', response.headers.get('content-type') || '')
res.setHeader('access-control-allow-methods', 'GET, POST')
res.setHeader('access-control-allow-headers', 'content-type')
res.setHeader(
'access-control-allow-origin',
req.headers.origin || req.headers.referer || req.headers.host || '*',
)
res.setHeader('access-control-allow-credentials', 'true')
const text = await response.text()
res.writeHead(response.status, response.statusText)
res.end(text)
}
拦截请求由 Auth.js 处理
综上所述,现在我们已经实现了 AuthCore 的适配,接下来,我们就需要将请求转交给 AuthCore 处理。
// Create a auth handler
const authHandler = CreateAuth(config) // your auth config
在 Nest.js 中有两种方法可以捕获路由,我们可以使用 Controller 的正则匹配一个泛路径,然后由 authHandler 处理。或者用 middleware。
Controller 如下。
@Controller('auth')
export class AuthController {
@Get('/*')
@Post('/*')
async handle(@Req() req: FastifyRequest, @Res() res: FastifyReply) {
return authHandler(req, res)
}
}
这里我们使用 Middleware 去做,因为 middleware 的优先级高于一切。
编写一个 Middleware。
export class AuthMiddleware implements NestMiddleware {
async use(req: IncomingMessage, res: ServerResponse, next: () => void) {
if (req.method !== 'GET' && req.method !== 'POST') {
next()
return
}
await authHandler(req, res)
next()
}
}
在 AuthModule 中使用这个 Middleware。
@Module({})
export class AuthModule {
configure(consumer: MiddlewareConsumer) {
consumer
.apply(AuthMiddleware)
.forRoutes(`/auth/(.*)`)
}
}
那么,这样所有的 /auth/*
都会由 authHandler 接管了。那么到这里为止,已经可以使用了。例如下面的 authConfig。
export const authConfig: ServerAuthConfig = {
basePath: isDev ? '/auth' : `/api/v${API_VERSION}/auth`,
secret: AUTH.secret,
callbacks: {
redirect({ url }) {
return url
},
},
providers: [
GitHub({
clientId: AUTH.github.clientId,
clientSecret: AUTH.github.clientSecret,
}),
],
adapter: DrizzleAdapter(db, {
usersTable: users,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
authenticatorsTable: authenticators,
}),
}
就可以实现 GitHub 的 OAuth 登录和记录 User 信息到 Database 中。
User Session
登录完成之后,我们需要获取 Session 来判断登录状态。
编写一个 Service。
export interface SessionUser {
sessionToken: string
userId: string
expires: string
}
@Injectable()
export class AuthService {
private async getSessionBase(req: IncomingMessage, config: ServerAuthConfig) {
setEnvDefaults(process.env, config)
const protocol = (req.headers['x-forwarded-proto'] || 'http') as string
const url = createActionURL(
'session',
protocol,
// @ts-expect-error
new Headers(req.headers),
process.env,
config.basePath,
)
const response = await Auth(
new Request(url, { headers: { cookie: req.headers.cookie ?? '' } }),
config,
)
const { status = 200 } = response
const data = await response.json()
if (!data || !Object.keys(data).length) return null
if (status === 200) return data
}
getSessionUser(req: IncomingMessage) {
return new Promise<SessionUser | null>((resolve) => {
this.getSessionBase(req, {
...authConfig,
callbacks: {
...authConfig.callbacks,
async session(...args) {
resolve(args[0].session as SessionUser)
const session =
(await authConfig.callbacks?.session?.(...args)) ??
args[0].session
const user = args[0].user ?? args[0].token
return { user, ...session } satisfies Session
},
},
}).then((session) => {
if (!session) {
resolve(null)
}
})
})
}
}
模块化
接下来我们可以让上面的代码更加符合 Nest 模块的规范。
我们创建一个 DynamicModule 用于配置 AuthModule。
const AuthConfigInjectKey = Symbol()
@Module({})
@Global()
export class AuthModule implements NestModule {
constructor(
@Inject(AuthConfigInjectKey) private readonly config: ServerAuthConfig,
) {}
static forRoot(config: ServerAuthConfig): DynamicModule {
return {
module: AuthModule,
global: true,
exports: [AuthService],
providers: [
{
provide: AuthService,
useFactory() {
return new AuthService(config)
},
},
{
provide: AuthConfigInjectKey,
useValue: config,
},
],
}
}
configure(consumer: MiddlewareConsumer) {
const config = this.config
consumer
.apply(AuthMiddleware)
.forRoutes(`${config.basePath || '/auth'}/(.*)`)
}
}
现在,/auth
在 Middleware 中也是可配置的了。
在 AppModule 中注册:
@Module({
imports: [
AuthModule.forRoot(authConfig),
],
controllers: [],
providers: []
})
export class AppModule {}
认证守卫
我们需要编写一个只有登录验证成功之后,才可以访问某些路由的守卫。
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@Inject(AuthService)
private readonly authService: AuthService,
) {}
async canActivate(context: ExecutionContext): Promise<any> {
const req = context.switchToHttp().getRequest()
const session = await this.authService.getSessionUser(req.raw)
req.raw['session'] = session
req.raw['isAuthenticated'] = !!session
if (!session) {
throw new UnauthorizedException()
}
return !!session
}
}
同时,我们会把 session
isAuthenticated
附加到原始请求上。
大功告成。
示例
最后,提供我开源的上述模板。
或者上面的内容,你看了之后还是一头雾水,没有关系,可以直接使用模板,或者从模板中获得灵感。