跳转到内容

验证邮箱

要求用户在访问受保护路由之前验证邮箱。

本指南添加 verify-email 功能。

verify-email 功能要求用户在访问受保护路由之前验证一个邮箱。它把 otp 或 magic-link 这样的验证功能用作验证策略。这些功能不仅可以用于登录,也可以被其他需要验证账户所有权的流程复用。

本指南把已验证的邮箱存储在一个可为空的 verifiedEmail 字段中。

更新 prisma/schema.prisma:

prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
name String
email String @unique
password String
verifiedEmail String?
}

创建并应用一次迁移:

Terminal window
pnpm dlx [email protected] migrate dev --name add-verified-email

然后生成 Prisma Client:

Terminal window
pnpm dlx [email protected] generate

添加功能

运行 CLI 命令:

Terminal window
brkpt auth add verify-email

CLI 会添加一个新的 features/verify-email/ 文件夹。

校验 DTO

本指南已经使用了 ValidationPipe,所以只需为生成的 DTO 添加校验装饰器:

src/brkpt-auth/features/verify-email/dto/send.dto.ts
import { IsString } from 'class-validator';
export class SendDto {
@IsString()
target!: string;
@IsString()
strategy!: string;
}
src/brkpt-auth/features/verify-email/dto/verify.dto.ts
import { IsOptional, IsString } from 'class-validator';
export class VerifyDto {
@IsOptional()
@IsString()
target?: string;
@IsString()
strategy!: string;
@IsString()
proof!: string;
}

verify-email 功能始终是验证邮箱,所以发送请求不需要 method 字段。

实现适配器

创建一个适配器,负责读取和更新用户已验证的邮箱:

  • 文件夹src/
    • 文件夹brkpt-auth/
      • 文件夹adapters/
        • verify-email.adapter.ts
src/brkpt-auth/adapters/verify-email.adapter.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { VerifyEmailPort } from '../features/verify-email/verify-email.port';
import { AuthJwtPayload } from './types';
@Injectable()
export class VerifyEmailAdapter implements VerifyEmailPort {
constructor(private readonly prisma: PrismaService) {}
async isVerified(payload: AuthJwtPayload): Promise<boolean> {
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
});
return user?.verifiedEmail != null;
}
async markVerified(
payload: AuthJwtPayload,
target: string,
): Promise<void> {
await this.prisma.user.update({
where: { id: payload.sub },
data: {
verifiedEmail: target,
},
});
}
extractUserIdFromJwtPayload(payload: AuthJwtPayload): number {
return payload.sub;
}
}

注册功能

更新 features.ts,把 VerifyEmailAdapter 传给 verifyEmailFeature:

src/brkpt-auth/features.ts
import { BlacklistAdapter } from './adapters/blacklist.adapter';
import { ChangePasswordAdapter } from './adapters/change-password.adapter';
import { CoreAdapter } from './adapters/core.adapter';
import { CredentialsAdapter } from './adapters/credentials.adapter';
import { MagicLinkAdapter } from './adapters/magic-link.adapter';
import { OAuthAdapter } from './adapters/oauth.adapter';
import { OtpAdapter } from './adapters/otp.adapter';
import { ResetPasswordAdapter } from './adapters/reset-password.adapter';
import { SessionAdapter } from './adapters/session.adapter';
import { VerifyEmailAdapter } from './adapters/verify-email.adapter';
import { FeatureConfig } from './common/interfaces';
import { blacklistFeature } from './features/blacklist/blacklist.feature';
import { changePasswordFeature } from './features/change-password/change-password.feature';
import { coreFeature } from './features/core/core.feature';
import { credentialsFeature } from './features/credentials/credentials.feature';
import { EmailMagicLinkDriver } from './features/magic-link/drivers/email.driver';
import { magicLinkFeature } from './features/magic-link/magic-link.feature';
import { GithubOAuthDriver } from './features/oauth/drivers/github.driver';
import { GoogleOAuthDriver } from './features/oauth/drivers/google.driver';
import { oauthFeature } from './features/oauth/oauth.feature';
import { EmailOtpDriver } from './features/otp/drivers/email.driver';
import { otpFeature } from './features/otp/otp.feature';
import { resetPasswordFeature } from './features/reset-password/reset-password.feature';
import { sessionFeature } from './features/session/session.feature';
import { verifyEmailFeature } from './features/verify-email/verify-email.feature';
export const features: FeatureConfig[] = [
coreFeature(CoreAdapter),
blacklistFeature(BlacklistAdapter),
verifyEmailFeature(VerifyEmailAdapter),
credentialsFeature(CredentialsAdapter),
sessionFeature(SessionAdapter),
oauthFeature(OAuthAdapter, GoogleOAuthDriver, GithubOAuthDriver),
otpFeature(OtpAdapter, EmailOtpDriver),
magicLinkFeature(MagicLinkAdapter, EmailMagicLinkDriver),
changePasswordFeature(ChangePasswordAdapter),
resetPasswordFeature(ResetPasswordAdapter),
];

注册 verify-email 同时也会启用它的全局守卫。公开路由仍然可以访问,而其他路由则要求邮箱已验证,除非被显式豁免。

在应该保持可访问、直到用户验证邮箱之前都不受限制的路由上使用 @SkipVerifyEmail()。

例如,你可能希望允许用户查看账户信息和登出:

src/brkpt-auth/features/core/core.controller.ts
import { SkipVerifyEmail } from '../../common/decorators/skip-verify-email.decorator';
@SkipVerifyEmail()
@Get('me')
me(@Req() req: BrkptAuthRequest) {
return this.coreService.me(req.user!);
}
@SkipVerifyEmail()
@Post('sign-out')
@HttpCode(200)
async signOut(
@Req() req: BrkptAuthRequest,
@Res({ passthrough: true }) response: Response,
) {
await this.coreService.signOut(req.user!, extractRequestMetadata(req));
clearRefreshToken(response);
return 'Signed out successfully';
}

哪些路由应该豁免取决于你的应用。为了下面的验证测试能演示验证前后的区别,这里先不在 me() 上加 @SkipVerifyEmail(),让 /auth/me 保留限制。

结合前面几篇指南的配置,OTP 可以直接使用。

要让 magic link 也能用于邮箱验证,添加 verifyEmail 回调 URL:

src/app.module.ts
magicLink: {
expiresIn: '5m',
callbackUrls: {
authenticate: 'http://localhost:3000/auth/magic-link/authenticate',
resetPassword: 'http://localhost:3000/auth/reset-password/reset',
verifyEmail: 'http://localhost:3000/auth/verify-email/verify',
},
},

启动应用:

Terminal window
pnpm start:dev

verify-email 功能新增两个端点:

Method Path Description
POST /auth/verify-email/send 发送邮箱验证
POST /auth/verify-email/verify 验证邮箱

在验证之前,像 /auth/me 这样的受保护路由会返回 403 Forbidden:

GET /auth/me
Authorization: Bearer <access-token>
{
"message": "Your account does not have a verified email. Please verify an email to continue.",
"error": "Forbidden",
"statusCode": 403
}

用 OTP 验证邮箱

给要验证的邮箱发送一个 OTP:

POST /auth/verify-email/send
Authorization: Bearer <access-token>
Content-Type: application/json
{
"target": "[email protected]",
"strategy": "otp"
}

邮件中包含用于验证流程的 OTP:

Subject: Your OTP code to verify your email
Your OTP code is: 127419

提交 target 和验证凭证:

POST /auth/verify-email/verify
Authorization: Bearer <access-token>
Content-Type: application/json
{
"target": "[email protected]",
"strategy": "otp",
"proof": "127419"
}

验证完成后,/auth/me 就可以访问了:

GET /auth/me
Authorization: Bearer <access-token>
{
"id": 1,
"name": "Kevin",
"email": "[email protected]",
"verifiedEmail": "[email protected]"
}

用 magic link 验证邮箱

给要验证的邮箱发送一个 magic link:

POST /auth/verify-email/send
Authorization: Bearer <access-token>
Content-Type: application/json
{
"target": "[email protected]",
"strategy": "magic-link"
}

邮件中包含类似这样的链接:

Subject: Your magic link to verify your email
Click the link to continue: http://localhost:3000/auth/verify-email/verify?token=<magic-link-token>

在这个纯后端的示例中,从链接中复制令牌,把它作为验证凭证提交:

POST /auth/verify-email/verify
Authorization: Bearer <access-token>
Content-Type: application/json
{
"strategy": "magic-link",
"proof": "<magic-link-token>"
}

magic link 验证不需要 target,因为已验证的邮箱会从令牌中还原出来。

验证成功后,已验证的邮箱会通过你的适配器存储下来,用户也就能访问受全局 verify-email 守卫保护的路由了。