登录的过程中,后端发生了什么?
1. 概览:一次登录请求的后端旅程
用户在前端输入账号密码点下「登录」,请求进入后端,大致经过以下阶段:
flowchart TD
A[前端提交账号密码] --> B[网关层 参数校验]
B --> C[业务层 身份验证]
C --> D{验证通过?}
D -->|否| E[返回错误]
D -->|是| F[生成 Session/Token]
F --> G[返回凭证给客户端]
G --> H[后续请求 携带凭证做鉴权]
下面逐阶段拆解。
2. 参数校验(网关层 / 入口层)
请求进入后端,最先由网关层或入口中间件校验其是否符合格式要求,不符合直接返回,不让无效请求消耗业务层资源。这是「快速失败(fail fast)」原则的体现。
典型校验项:
- 必填校验:用户名、密码字段是否为空
- 格式校验:用户名长度、是否含非法字符;密码强度(长度、是否含数字字母)
- 类型校验:手机号是否符合 11 位数字格式、邮箱是否符合邮箱正则
注:参数校验只负责「长得对不对」,不负责「这个用户存不存在」。后者属于业务层范畴。
3. 密码验证(业务层)
业务层处理由网关层传入的请求,根据用户名从数据库取出该用户的哈希值(hash),再用用户输入的明文密码实时计算哈希值,比对两者是否一致。
3.1 为什么用用户输入算哈希,而不是反过来?
在现代安全体系中,数据库里保存的密码绝对不是「加密」的(可逆),而是通过单向哈希函数(One-Way Hash Function)处理过的。因为是单向的,从技术和数学原理上讲,根本无法从数据库里的值反向计算出原始密码。
所以验证只能正向进行:把用户输入也哈希一遍,看两个哈希值是否相等。
3.2 只哈希就够了吗?——盐值(Salt)
只做一次单纯的哈希(如 MD5(password)、SHA256(password))是不安全的:
- 彩虹表攻击:攻击者预先计算大量常见密码的哈希值建表,拿到库里的哈希后反查即可得到明文
- 相同密码哈希相同:两个用户用同一密码,库里的哈希一模一样,一处泄露即可关联识别
解决办法是加盐(Salt):为每个用户生成一段随机串,与密码拼接后再哈希:
stored = hash(password + salt)
盐值不需要保密,和哈希一起存在数据库里即可。它的作用是:
- 让相同密码产生不同的哈希,破坏彩虹表
- 迫使攻击者只能逐个用户暴力破解,无法批量复用
3.3 还不够:用慢哈希函数
MD5 / SHA-256 设计目标是「快」,这对密码存储反而是缺点——攻击者每秒能算上亿次,暴力破解成本低。
密码存储应使用故意变慢的哈希函数:
| 函数 | 特点 |
|---|---|
| bcrypt | 经典选择,内置盐,可调 cost 因子 |
| scrypt | 内存硬,抗 GPU/ASIC 并行破解 |
| argon2 | 现代推荐(密码哈希竞赛冠军),抗多硬件 |
它们通过可调的计算/内存成本,让单次哈希耗时在几十到几百毫秒,对正常登录无感,却让暴力破解成本高到不可行。
一句话总结密码存储:单向哈希 + 随机盐 + 慢哈希函数,缺一不可。
4. 手机号 + 验证码如何验证?
验证码登录不依赖密码哈希,流程如下:
sequenceDiagram
participant U as 用户
participant S as 后端
participant R as Redis
participant G as 短信网关
U->>S: 请求发送验证码(手机号)
S->>S: 校验手机号格式 + 频率限制
S->>S: 生成随机验证码(4-6位)
S->>R: 存 code=123456 TTL=5min
S->>G: 发送短信
G->>U: 收到验证码
U->>S: 提交手机号+验证码
S->>R: 取出 code 比对
R-->>S: 一致
S->>R: 删除 code(一次性)
S-->>U: 登录成功,颁发凭证
关键点:
- 验证码存缓存(如 Redis):带 TTL(如 5 分钟)自动过期,不落库
- 一次性:验证通过后立即删除,防止重放
- 频率限制:同一手机号发送间隔限制、每日次数限制,防短信轰炸与刷量
- 错误次数限制:连续输错 N 次锁定,防暴力枚举验证码
5. 生成 Session / Token
身份验证通过后,系统需颁发一个「凭证」给客户端,后续请求携带它即可证明身份,不必每次重输密码。主流有两种方案:
5.1 Session(有状态)
- 服务器在**服务端内存或共享存储(如 Redis)**里保存一份会话状态,生成一个
sessionId - 把
sessionId通过Set-Cookie返回客户端 - 后续请求浏览器自动带上 Cookie,服务器用
sessionId查到对应用户
特点:服务端有状态,可随时主动失效(删 session 即可);但多实例部署需共享 session 存储,否则要做会话粘滞。
5.2 Token / JWT(无状态)
- 服务器签发一个 JWT(JSON Web Token),载荷(payload)里含用户 ID、角色等,并用密钥签名
- 客户端把 Token 存在 localStorage / cookie,请求时放在
Authorization: Bearer <token>头里 - 服务器收到后只验签(无需查库),验签通过即信任载荷内容
特点:服务端无状态、易水平扩展;代价是难以主动吊销(Token 未过期前一直有效),常配合短有效期 + 刷新令牌(Refresh Token)或黑名单机制。
| 维度 | Session | JWT |
|---|---|---|
| 状态 | 服务端有状态 | 服务端无状态 |
| 扩展性 | 需共享存储 | 天然易扩展 |
| 主动失效 | 简单(删 session) | 较难(需黑名单/短有效期) |
| 凭证大小 | sessionId 很小 | 较大(含载荷+签名) |
6. 认证与鉴权
常被混用,其实是两件事:
- 认证(Authentication,AuthN):你是谁——验证身份
- 鉴权(Authorization,AuthZ):你能做什么——验证权限
登录阶段(认证):你输入密码通过了验证,系统颁发给你一个身份凭证(比如 JWT Token)。
请求阶段(鉴权):
- 你发起了请求:
DELETE /api/v1/users/123(删除某个用户) - 系统拦截器拿到你的 Token,解析出你的角色是
普通用户 - 系统比对权限规则,发现只有
管理员才能执行删除操作 - 系统拒绝请求,返回
403 Forbidden
在这个过程中,系统完全没有再次校验你的密码,但它完成了一次标准的鉴权。
HTTP 状态码层面也对应得很清楚:
401 Unauthorized其实是「未认证」(没登录或凭证失效);403 Forbidden才是「已认证但无权限」。
7. 串联与小结
| 阶段 | 做什么 | 关键点 |
|---|---|---|
| 参数校验 | 拦截格式错误的请求 | fail fast,不入业务层 |
| 密码验证 | 比对哈希 | 单向哈希 + 盐 + 慢哈希函数 |
| 验证码登录 | 比对缓存中的码 | TTL、一次性、频率限制 |
| 生成凭证 | 颁发 Session / Token | 有状态 vs 无状态的权衡 |
| 认证 | 确认你是谁 | 登录时完成 |
| 鉴权 | 确认你能做什么 | 每次请求都做 |
一次「登录」看似简单,背后其实是校验、加密学、状态管理、权限模型四块知识的交汇。理解了这条链路,再去看注册改密、单点登录(SSO)、OAuth 第三方登录,都是在这套骨架上的延伸。