实现一套简单但完整的登录系统
登录基本是每个系统都必备的,当然,除了纯展示页面以外。作为最基础的,以及进入系统的第一道关卡,这也是开发人员必须掌握的技术要点。本文记录本人以Next.js项目为例在实现登录系统中的全步骤。
一、总体设计思路
- 认证方式:基于 JWT 的自签名会话(service-side 签发并写入 session cookie)。
- CSRF 防护:双重提交(double-submit cookie),登录时生成可被客户端读取的
csrf cookie,客户端在表单中/请求头中回传以供服务端校验。 - 密码存储:使用
bcrypt哈希(我的项目用了bcryptjs的compareSync进行校验)。 - 会话存储:不在服务端保存会话,使用 JWT 负载携带
sub/username/role,签名密钥由环境变量AUTH_SECRET提供。 - 限流:登录动作使用简单的内存限流(按 IP,在内存 Map 记录时间戳,比如窗口 5 分钟,最多 5 次。这里是基础实现,如果是生产环境或者对安全要求比较高的,可以使用Redis等集中式限流)。
所以整体流程就是:
1、先完成基础的登录和登出的表单;
2、生成AUTH_SECRET(AUTH_SECRET与用户密码无关,它是用于签发/验证 JWT 的对称密钥,用随机字符串生成并放在环境变量中);
3、创建一个初始的管理员账号,用 bcrypt 对用户的明文密码做哈希,哈希结果存到数据库(比如User.passwordHash),登录时用 compareSync 比对明文密码和 passwordHash;
4、账密检验通过以后JWT进行签发并写入session;
5、增加CSRF防护,用于表单提交等需要防止CSRF攻击的场景;
6、每次鉴权的时候就是通过不同的操作场景进行不同的检验:比如判断是否是管理员角色(role),是否通过了JWT验证、是否通过了JWT验证 + CSRF验证等。
二、知识点解析
什么是JWT?JWT(JSON Web Token),一种自包含的、经过签名的 token,常用来做无状态会话。服务端用密钥签发,客户端把 token 存在 cookie/localStorage 等(我代码中是用 httpOnly cookie 存放)。用的包是jose,具体代码和实现见后续。
关于CSRF 防护,我之前写过一篇,移步。
CSRF和双重提交
三、具体实现
1、登录和登出的页面:
最基本的表单,输入用户名和密码。

2、下载相关的包:
# 用于对明文密码做哈希
npm i bcryptjs
# 用于JWT相关
npm i jose
3、生成AUTH_SECRET:
function generateSecret(length = 32) {
return randomBytes(length).toString('base64');
}
推荐生产环境用脚本生成;
4、数据库建一张User的表:
用来存放用户信息,比如用prisma操作,model就是:
model User {
id String @id @default(cuid())
username String @unique
email String? @unique
passwordHash String
role String @default("user")
isActive Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
5、建立一个基本的用户管理页面:
展示用户名称、角色、账号是否被禁用等,可以进行增删改查等;

6、创建一个初始的管理员账号:
// 生成密码哈希
function hashPassword(password) {
return bcrypt.hashSync(password, 12);
}
// 自己提供的密码或者生成随机密码
const adminPassword = providedPassword || randomBytes(16).toString('hex');
const passwordHash = hashPassword(adminPassword);
const admin = await prisma.user.create({
data: {
username: adminUsername,
email: null,
passwordHash,
role: 'admin',
isActive: 1,
},
});
建议也写个脚本。
7、登录校验:
获取前端传过来的账号密码,进行校验(一些用到的方法见后续步骤):
首先需要引入的核心文件有:
import { cookies, headers } from 'next/headers'; // 相关cookie操作
import { signSession } from '@/lib/auth'; // 鉴权,见后面步骤
import { compareSync } from 'bcryptjs';
import { generateCsrfToken } from '@/lib/csrf'; // 统一的csrf工具函数,见后面步骤
然后核心代码:
try {
const user = await prisma.user.findUnique({
where: { username },
select: {
id: true,
username: true,
passwordHash: true,
role: true,
isActive: true,
},
});
// 用户不存在
if (!user) {
return { ok: false, message: '用户不存在' };
}
// 账号被禁用
if (!user.isActive) {
return { ok: false, message: '账号已被禁用,请联系管理员' };
}
// 验证密码:通过bcrypt和数据库中的哈希值比对密码
const passwordMatch = compareSync(password, user.passwordHash);
if (!passwordMatch) {
return { ok: false, message: '密码错误' };
}
// 登录成功,签发 session 和 CSRF token
const token = await signSession(
{
sub: user.id,
username: user.username,
role: user.role,
},
'8h'
);
const csrf = generateCsrfToken();
// 会话 Cookie(httpOnly,防止客户端脚本访问)
const cookieStore = await cookies();
cookieStore.set({
name: 'session',
value: token,
httpOnly: true,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 8,
});
// CSRF: 双提交 Cookie(非 httpOnly,供客户端读取并回传)
cookieStore.set({
name: 'csrf',
value: csrf,
httpOnly: false,
sameSite: 'lax',
path: '/',
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 8,
});
} catch (error) {
console.error('登录错误:', error);
return { ok: false, message: '登录失败,请稍后重试' };
}
8、鉴权:
建一个文件anth.ts,专门用来鉴权,比如第六步生成 JWT并写入session和CSRF防护等。
先进行重要的引入:
import { SignJWT, jwtVerify, type JWTPayload } from 'jose';
import { cookies, headers } from 'next/headers';
import { redirect } from 'next/navigation'; // 重定向用到的
(1) 生成 JWT并写入session:
// 定义会话负载类型
export type SessionPayload = JWTPayload & {
sub: string; // 用户ID
username: string; // 用户名
role: string; // 角色: admin, user
};
// 签发一个新的会话 JWT
export async function signSession(payload: SessionPayload, expiresIn: string = '8h') {
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
.setIssuedAt()
.setExpirationTime(expiresIn)
.sign(getSecret());
}
(2) JWT 验证:
主要用于路由保护/middleware、只读页面的权限判断、其他验证函数的内部调用等。
// 验证会话 JWT 并返回负载,如果无效则返回 null
export async function verifySession(token: string): Promise<SessionPayload | null> {
try {
const { payload } = await jwtVerify(token, getSecret());
return payload as SessionPayload;
} catch {
return null;
}
}
// 在服务器端强制要求认证,未认证则重定向到登录页
export async function requireAuth() {
const token = (await cookies()).get('session')?.value;
const payload = token ? await verifySession(token) : null;
if (!payload || !payload.sub) {
redirect('/login');
}
return {
userId: payload.sub,
username: payload.username,
role: payload.role,
};
}
(3) CSRF防护:
主要用于表单提交等需要防止CSRF攻击的场景。
// 验证 CSRF token(双重提交模式)
export async function verifyCsrf(formData: FormData): Promise<boolean> {
const csrfCookie = (await cookies()).get('csrf')?.value;
// 优先使用表单中的 csrf,其次尝试从自定义请求头中获取
let csrfClient = (formData.get('csrf') || '').toString();
if (!csrfClient) {
const h = await headers();
const headerToken = h.get('x-csrf-token') || '';
csrfClient = headerToken.toString();
}
return !!(csrfCookie && csrfClient && csrfCookie === csrfClient);
}
// 验证身份并检查 CSRF token,返回验证结果
export async function verifyAuthAndCsrf(formData: FormData): Promise<{
ok: boolean;
message?: string;
userId?: string;
username?: string;
role?: string;
}> {
// 认证校验
const token = (await cookies()).get('session')?.value;
const payload = token ? await verifySession(token) : null;
if (!payload || !payload.sub) {
return { ok: false, message: '未登录或会话失效' };
}
// CSRF 双提交校验
if (!(await verifyCsrf(formData))) {
return { ok: false, message: 'CSRF 校验失败' };
}
return {
ok: true,
userId: payload.sub,
username: payload.username,
role: payload.role,
};
}
(4) 管理员权限验证:
// 验证管理员权限
export async function verifyAdmin(): Promise<{
ok: boolean;
message?: string;
userId?: string;
username?: string;
role?: string;
}> {
const token = (await cookies()).get('session')?.value;
const payload = token ? await verifySession(token) : null;
if (!payload || !payload.sub || payload.role !== 'admin') {
return { ok: false, message: '需要管理员权限' };
}
return {
ok: true,
userId: payload.sub,
username: payload.username,
role: payload.role,
};
}
9、CSRF 工具函数统一管理:
因为有多个地方用到CSRF,所以建个csrf.ts统一管理,方法如下:
// 从 cookie 中获取 CSRF token
export function getCsrfToken(): string {
const csrfCookie = document.cookie.split('; ').find((c) => c.startsWith('csrf='));
return csrfCookie ? decodeURIComponent(csrfCookie.split('=')[1]) : '';
}
// 向 FormData 添加 CSRF token
export function appendCsrfToken(formData: FormData): void {
const token = getCsrfToken();
if (token) {
formData.append('csrf', token);
}
}
// 服务端生成 CSRF token(统一逻辑,供登录 action 和 middleware 共用)
export function generateCsrfToken(): string {
if (typeof globalThis !== 'undefined' && globalThis.crypto && 'randomUUID' in globalThis.crypto) {
return globalThis.crypto.randomUUID();
}
// 回退方案(Node.js 环境或不支持 randomUUID 的环境)
return `${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
10、登录限流:
一个简单的防护模拟,如果是生产环境或者对安全要求比较高的,可以使用Redis等集中式限流。
// 简单登录限流:5分钟内最多5次尝试(按IP)。
const WINDOW_MS = 5 * 60 * 1000;
const MAX_ATTEMPTS = 5;
const attempts: Map<string, number[]> = new Map();
// 记录一次尝试,返回当前窗口内的尝试次数
function recordAttempt(key: string) {
const now = Date.now();
const list = attempts.get(key) || [];
const fresh = list.filter((t: number) => now - t < WINDOW_MS);
fresh.push(now);
attempts.set(key, fresh);
return fresh.length;
}
// 限流:超过阈值拒绝
export async function loginAction(formData: FormData): Promise<ActionResult | void> {
// ...
// 限流:超过阈值拒绝
const count = recordAttempt(ip);
if (count > MAX_ATTEMPTS) {
return { ok: false, message: '尝试过多,请稍后再试' };
}
// ...
}
四、总结
-
签发会话(登录成功时)
在
loginAction中,校验用户名/密码(从prisma.user读取passwordHash并用bcryptjs.compareSync校对)。使用
signSession(src/lib/auth.ts)生成 JWT,默认有效期8h(通过SignJWT设置exp)。写入两个 Cookie:
session:httpOnly,sameSite: 'lax',secure在生产环境为 true,maxAge与 JWT 相符(8 小时)。此 cookie 存储 JWT,客户端不能通过脚本读取。csrf:非 httpOnly(httpOnly: false),客户端可读取并把 token 放入表单字段csrf或请求头x-csrf-token,服务端通过verifyCsrf比对csrfcookie 与提交值。
-
CSRF 校验
采用双重提交(double-submit):
- 登录时签发
csrfcookie(随机值,由generateCsrfToken()产生,优先使用 Web Crypto 的randomUUID,不可用时回退到时间戳+随机字符串)。 - 表单提交会把
csrf放入FormData。服务端的verifyCsrf(formData)会读取 cookie 中的csrf与表单/头中的值进行比较。
- 登录时签发
-
受保护路由 / 服务端保护
服务端可以在需要验证用户的页面/请求中调用
requireAuth(),它会:-
从
cookies()获取session,调用verifySession校验 JWT(使用jose.jwtVerify)。 -
如果未通过验证,会重定向到
/login。 -
用户角色校验示例:
requireAdmin()会检查payload.role === 'admin',否则重定向到/login。
-
-
密码与账号状态
- 密码在 DB 中以
passwordHash字段存储(由bcrypt哈希)。登录时使用compareSync()。 - 登录时会检查
isActive(若为假/0 则拒绝登录并提示“账号已被禁用”)。
- 密码在 DB 中以
-
简单登录限流
actions.ts内维护一个内存Map<string, number[]>(key 为 IP),记录时间戳数组,窗口5 分钟,最多5 次。- 注意:这是单实例内存限流,若部署多实例或横向扩展,需替换为 Redis 或其它集中式限流实现。
-
会话存储
- 当前是无状态会话,会话信息被编码在 JWT 里,签名后放在 session cookie 中;服务器不在 DB 中保存会话记录。验证时只是校验 JWT 签名与过期时间(jose.jwtVerify)。文件:auth.ts。
- 优点:水平扩展简单(无需共享会话存储),性能好(无需每次查询 DB),实现简单。
- 缺点:无法主动即时撤销已签发的 token(比如管理员强制登出),token 在未过期前仍然有效;若密钥泄露,必须旋转所有 token;对单点注销/会话管理支持弱。