xChar
·4 months ago

Auth.js 是一个可以非常方便接入 OAuth 的一个身份验证库。他起初是为 Next.js 而设计。如今官方以为其供一些常用框架的集成,但是不幸的是,并没有 Nest.js 的官方支持。

这篇文章将从零开始构造一个适用于 Nest.js 的 AuthModule。那我们开始吧。

准备

我们需要使用 @auth/core 的底层依赖,在此基础上进行封装。

npm i @auth/core

然后,我们需要知道一个 Auth 是如何适配并接入一个 Framework 的。官方提供了 Express 的支持,我们可以去源码中学习接入的步骤。

https://github.com/nextauthjs/next-auth/blob/main/packages/frameworks-express/src/index.ts

从源码中得知,@auth/core 是一个抽象层,我们需要做两件事:第一把原框架的 Request 转换到 WebRequest,然后由 AuthCore 处理;第二把 AuthCore 处理完成后的 WebResponse 转换到原框架的 Response。

知道了原理,那么接下来就好办了。

编写一个转换器

首先,我们创建一个 auth 模块,例如 src/modules/auth.module.ts。模块内容展示为空。然后编写一个 AuthCore 需要的 Request/Response 转换器。这里我们创建为 src/module/auth/auth.implement.ts

```ts filename="src/module/auth/auth.implement.ts" export type ServerAuthConfig = Omit & { 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)

}


</Tab>

<Tab label="getRequest">
```ts filename="src/module/auth/req.transformer.ts"
import { PayloadTooLargeException } from '@nestjs/common'
import type { IncomingMessage } from 'node:http'

/**
 * @param {import('http').IncomingMessage} req

 */
function get_raw_body(req) {
  const h = req.headers

  if (!h['content-type']) {
    return null
  }

  const content_length = Number(h['content-length'])

  // check if no request body
  if (
    (req.httpVersionMajor === 1 &&
      isNaN(content_length) &&
      h['transfer-encoding'] == null) ||
    content_length === 0
  ) {
    return null
  }

  if (req.destroyed) {
    const readable = new ReadableStream()
    readable.cancel()
    return readable
  }

  let size = 0
  let cancelled = false

  return new ReadableStream({
    start(controller) {
      req.on('error', (error) => {
        cancelled = true
        controller.error(error)
      })

      req.on('end', () => {
        if (cancelled) return
        controller.close()
      })

      req.on('data', (chunk) => {
        if (cancelled) return

        size += chunk.length
        if (size > content_length) {
          cancelled = true

          const constraint = content_length
            ? 'content-length'
            : 'BODY_SIZE_LIMIT'
          const message = `request body size exceeded ${constraint} of ${content_length}`

          const error = new PayloadTooLargeException(message)
          controller.error(error)

          return
        }

        controller.enqueue(chunk)

        if (controller.desiredSize === null || controller.desiredSize <= 0) {
          req.pause()
        }
      })
    },

    pull() {
      req.resume()
    },

    cancel(reason) {
      cancelled = true
      req.destroy(reason)
    },
  })
}

export async function getRequest(
  base: string,
  req: IncomingMessage,
): Promise<Request> {
  const headers = req.headers as Record<string, string>

  // @ts-expect-error
  const request = new Request(base + req.originalUrl, {
    method: req.method,
    headers,
    body: get_raw_body(req),
    credentials: 'include',
    // @ts-expect-error
    duplex: 'half',
  })
  return request
}

拦截请求由 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。

```ts filename="auth.module.ts" 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'}/(.*)`)
}

}

</Tab>
<Tab label="Service">
```ts filename="auth.service.ts" {19}
import { Injectable } from '@nestjs/common'
import { ServerAuthConfig } from './auth.implement'

import {
  Auth,
  createActionURL,
  setEnvDefaults,
  type Session,
} from '@meta-muse/complied'
import { IncomingMessage } from 'http'

export interface SessionUser {
  sessionToken: string
  userId: string
  expires: string
}
@Injectable()
export class AuthService {
  constructor(private readonly authConfig: ServerAuthConfig) {}

  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) {
    const { authConfig } = this
    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)
        }
      })
    })
  }
}

现在,/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 附加到原始请求上。

大功告成。

示例

最后,提供我开源的上述模板。

https://github.com/innei-template/nest-drizzle-authjs

或者上面的内容,你看了之后还是一头雾水,没有关系,可以直接使用模板,或者从模板中获得灵感。

此文由 Mix Space 同步更新至 xLog
原始链接为 https://innei.in/posts/tech/use-auth-js-in-nestjs


Loading comments...