黑马头条:APP 登录与 Gateway JWT 鉴权实战
黑马头条:APP 登录与 Gateway JWT 鉴权实战
登录看起来只是“手机号 + 密码换一个 token”,但放到微服务项目中,它至少要解决四个问题:
- 用户服务怎样区分正常登录、游客登录和错误参数。
- 密码怎样保存与校验,才能避免直接存储明文。
- 登录成功后怎样签发 JWT,让客户端后续能够证明身份。
- Gateway 怎样统一校验 JWT,并把可信用户身份传给下游服务。
本文以黑马头条手写项目中的真实实现为基础,从数据库、DTO、Controller、Service、JWT 到 Gateway 逐层搭建完整链路。读完后,你不仅能照着实现接口,也能解释每一层为什么存在、出错时该从哪里排查。
1. 项目背景与模块职责
黑马头条是一个基于 Spring Cloud 的微服务学习项目。APP 端不会直接访问用户微服务,而是先访问统一入口 Gateway,再由 Gateway 根据路由规则把请求转发给用户服务。
登录与鉴权涉及以下模块:
| 模块 | 主要职责 |
|---|---|
heima-leadnews-model | 保存 LoginDto、ApUser、ResponseResult 等公共模型 |
heima-leadnews-user | 查询用户、校验密码、签发 JWT |
heima-leadnews-utils | 提供用户服务使用的 JWT 工具 |
heima-leadnews-app-gateway | 路由 APP 请求、校验 JWT、传递可信用户 ID |
| Nacos | 服务注册发现,并在课程环境中保存 Gateway 动态路由配置 |
| MySQL | 保存 ap_user 用户身份数据 |
这里最重要的职责边界是:
- 用户服务负责回答“手机号和密码是否正确”,并签发凭证。
- Gateway 负责回答“这个请求携带的凭证是否可信”。
- 下游服务不再自行解析客户端提交的用户 ID,而是使用 Gateway 从 JWT 中恢复出的身份。
2. 本文用到的技术
| 技术 | 在本链路中的作用 |
|---|---|
| Spring Boot | 启动用户微服务与 APP Gateway |
| Spring MVC | 使用 @RestController、@PostMapping、@RequestBody 接收登录请求 |
| MyBatis-Plus | 使用 ServiceImpl 与 LambdaQueryWrapper 按手机号查询 ap_user |
| MySQL | 持久化用户、密码摘要、盐值和账号状态 |
Spring DigestUtils | 按课程实现计算加盐 MD5 摘要 |
| JJWT | 生成、解析并验证 JWT |
| Spring Cloud Gateway | 统一路由与全局鉴权 |
| Spring WebFlux / Reactor | Gateway 过滤器使用 Mono<Void> 异步结束或放行请求 |
| Nacos Discovery | 让 Gateway 通过服务名发现用户微服务实例 |
| Nacos Config | 在课程环境中保存 Gateway 路由配置 |
需要特别说明:本文会忠实讲解课程项目中的“加盐 MD5”,但 MD5 不适合作为生产系统的新密码方案。生产建议会在密码章节单独说明。
3. 端到端请求链路
3.1 登录并取得 JWT
3.2 携带 JWT 访问受保护接口
登录接口本身必须先放行,否则用户会陷入“没有 token 就不能登录,没有登录又拿不到 token”的死循环。除此以外的 APP 请求统一经过鉴权。
4. 数据库设计:ap_user 保存了什么
登录只查询 ap_user,不需要联表。它保存的是 APP 用户的身份与基础资料。
| 字段 | 作用 | 登录流程是否直接使用 |
|---|---|---|
id | 用户主键,也是 JWT 中的身份值 | 是 |
phone | 登录账号 | 是 |
password | 加盐后的密码摘要,不应存明文 | 是 |
salt | 每个用户的盐值 | 是 |
name、image、sex | 用户基础资料 | 登录成功时可返回 |
status | 账号是否正常或锁定 | 当前代码尚未判断 |
flag | 普通用户、自媒体人或大 V 等标记 | 否 |
is_certification、is_identity_authentication | 认证状态 | 否 |
created_time | 注册时间 | 否 |
实体类通过 MyBatis-Plus 注解和表建立映射:
@Data@TableName("ap_user")public class ApUser implements Serializable {
@TableId(value = "id", type = IdType.AUTO) private Integer id;
@TableField("salt") private String salt;
@TableField("name") private String name;
@TableField("password") private String password;
@TableField("phone") private String phone;
// image、sex、认证状态、status、flag、createdTime 等字段省略}正常登录的核心查询等价于:
SELECT *FROM ap_userWHERE phone = ?LIMIT 1;getOne() 默认期待查询结果至多一条,因此 phone 应具备唯一性。生产建表时通常会为手机号建立唯一索引,例如 UNIQUE KEY uk_ap_user_phone (phone);给已有表补索引前,应先检查是否存在重复数据。
这个接口只有查询,没有跨表写操作,因此不存在复杂事务边界。当前 Service 类虽然统一标了 @Transactional,但单纯登录查询并不依赖写事务。
5. 先定义登录接口契约
5.1 外部路径与服务内路径
| 项目 | 内容 |
|---|---|
| 请求方法 | POST |
| 客户端访问路径 | /user/api/v1/login/login_auth |
| 用户服务内部路径 | /api/v1/login/login_auth |
| 请求体 | JSON 格式的 LoginDto |
| 登录请求是否需要 token | 不需要 |
| 游客登录成功 | 返回游客 JWT |
| 正常登录成功 | 返回 JWT 和脱敏用户信息 |
| 凭据只填一项 | 返回业务参数错误 |
| 用户不存在或密码错误 | 返回对应业务错误 |
外部路径多出的 /user 是 Gateway 路由前缀。StripPrefix=1 会在转发前移除这一段,所以 Controller 只需要声明 /api/v1/login/login_auth。
5.2 三种业务分支
先写清楚输入真值表,Service 才不容易把错误请求误当成游客:
| phone | password | 业务含义 | 处理方式 |
|---|---|---|---|
| 空 | 空 | 游客登录 | 使用用户 ID 0 签发 JWT |
| 有值 | 空 | 参数不完整 | 返回参数错误 |
| 空 | 有值 | 参数不完整 | 返回参数错误 |
| 有值 | 有值 | 正常登录 | 查询用户、校验密码、签发 JWT |
游客登录不是“任意一项为空”,而是“两项同时为空”。代码中分别使用 && 和 ||,正是为了表达这两个不同条件。
6. 第一步:创建 LoginDto
DTO 只负责承载接口输入,不负责查询数据库,也不应该在其中编写登录判断。
@Datapublic class LoginDto {
@ApiModelProperty(value = "手机号", required = true) private String phone;
@ApiModelProperty(value = "密码", required = true) private String password;}@Data 由 Lombok 生成 Getter、Setter 等方法;@RequestBody 会依赖这些访问方法把 JSON 绑定到对象。
这里有一个容易忽略的契约细节:Swagger 注解把两个字段标成了 required = true,但当前业务允许两个字段同时为空以进入游客模式。也就是说,接口文档元数据和真实业务规则并不完全一致。更严谨的做法是:
- 去掉 DTO 上统一的必填声明,并在接口说明中写出三种分支;或
- 把游客登录拆成单独接口,让正常登录 DTO 的两个字段都真正必填。
本文继续按现有单接口设计讲解。
7. 第二步:Controller 接收请求
Controller 的职责很薄:声明路径、把 JSON 转成 DTO,然后调用 Service。
@RestController@RequestMapping("/api/v1/login")@Api(value = "app端用户登录", tags = "ap_user", description = "app端用户登录API")public class ApUserLoginController {
@Autowired private ApUserService apUserService;
@PostMapping("/login_auth") @ApiOperation("用户登录") public ResponseResult login(@RequestBody LoginDto loginDto) { return apUserService.login(loginDto); }}几个注解分别解决不同问题:
@RestController:把方法返回值序列化成 JSON。@RequestMapping:声明类级公共路径。@PostMapping:声明登录动作使用 POST,避免凭据出现在 URL 查询字符串中。@RequestBody:读取 JSON 请求体并转换为LoginDto。
密码校验不写在 Controller 中,因为它属于业务规则。这样做便于测试、复用,也能避免 Controller 随业务增长变得臃肿。
8. 第三步:完整实现登录 Service
下面是当前 ApUserServiceImpl#login 的完整核心逻辑。为便于阅读,只去掉了原文件中的长篇教学注释,业务语句保持一致。
@Overridepublic ResponseResult login(LoginDto loginDto) {
if (loginDto == null) { return ResponseResult.errorResult(AppHttpCodeEnum.PARAM_INVALID); }
String phone = loginDto.getPhone(); String password = loginDto.getPassword();
boolean phoneBlank = StringUtils.isBlank(phone); boolean passwordBlank = StringUtils.isBlank(password);
if (phoneBlank && passwordBlank) { HashMap<Object, Object> hashMap = new HashMap<>(); hashMap.put("token", AppJwtUtil.getToken(0L)); return ResponseResult.okResult(hashMap); }
if (phoneBlank || passwordBlank) { return ResponseResult.errorResult( AppHttpCodeEnum.PARAM_INVALID, "手机号和密码必须同时填写" ); }
LambdaQueryWrapper<ApUser> lambdaQueryWrapper = new LambdaQueryWrapper<>(); lambdaQueryWrapper.eq(ApUser::getPhone, phone); ApUser apUser = getOne(lambdaQueryWrapper);
if (apUser == null) { return ResponseResult.errorResult( AppHttpCodeEnum.DATA_NOT_EXIST, "用户不存在" ); }
String salt = apUser.getSalt(); String userPassword = apUser.getPassword(); String pswd = DigestUtils.md5DigestAsHex((password + salt).getBytes());
if (!userPassword.equals(pswd)) { return ResponseResult.errorResult( AppHttpCodeEnum.LOGIN_PASSWORD_ERROR, "密码错误" ); }
String token = AppJwtUtil.getToken(apUser.getId().longValue());
HashMap<Object, Object> hashMap = new HashMap<>(); hashMap.put("token", token);
apUser.setPassword(null); apUser.setSalt(null); hashMap.put("user", apUser);
return ResponseResult.okResult(hashMap);}把这段代码拆开看,它依次完成了七件事:
- 防御
loginDto == null,避免空指针异常。 - 用
StringUtils.isBlank同时识别null、空串和纯空格。 - 两项都为空时签发游客 token。
- 只缺一项时立即返回,不继续查询数据库。
- 使用方法引用
ApUser::getPhone构造类型安全的查询条件。 - 用户存在时计算密码摘要并比较。
- 校验成功后签发 JWT,并在返回用户前清除
password和salt。
当前代码直接把查询出的实体放进响应,并把敏感字段设为 null。这比直接返回密码安全,但更推荐的长期做法是定义专门的登录响应 VO,只复制允许暴露的字段。白名单式响应比“先放入完整实体,再清除若干字段”更不容易因新增敏感字段而泄漏。
9. 加盐 MD5 到底怎样校验密码
9.1 当前项目的计算过程
注册或初始化用户数据时,数据库保存的是:
password_digest = MD5(明文密码 + salt)登录时执行同样的计算:
用户输入密码 + 数据库中的 salt ↓DigestUtils.md5DigestAsHex(...) ↓与数据库 password 字段比较盐值的作用是让相同明文密码产生不同摘要,降低通用彩虹表直接命中的风险。盐值不需要像密码一样保密,但每个用户都应该使用独立、随机的盐。
拼接顺序必须与生成数据库密码时一致。数据库若保存的是 MD5(password + salt),登录却计算 MD5(salt + password),结果一定不同。
9.2 为什么返回前还要清除密码和盐
即使数据库保存的是摘要,也不能把它返回客户端。攻击者取得摘要后仍可以离线猜测密码;盐值一并泄漏会让猜测过程更直接。因此响应中只保留客户端真正需要的用户资料。
9.3 生产系统为什么不应继续使用 MD5
MD5 的主要问题不是“有没有加盐”,而是它计算太快。攻击者可以用 GPU 高速尝试大量候选密码。生产环境应优先使用专门为密码设计的慢哈希算法,例如:
- BCrypt;
- Argon2id;
- PBKDF2。
同时还要配合:
- HTTPS,避免明文凭据在网络中传输;
- 登录失败限流与风控,抑制暴力破解;
- 每个用户独立随机盐;
- 不在日志中输出密码、摘要或完整 token;
- 使用明确字符集,例如 UTF-8,避免
getBytes()随运行环境变化; - 对锁定账号、注销账号增加状态检查;
- 对“用户不存在”和“密码错误”谨慎设计外部提示,避免账号枚举。
所以,加盐 MD5 在本文中是“理解现有课程代码”,不是生产安全方案推荐。
10. 第四步:签发 JWT
10.1 JWT 中保存什么
当前工具类会写入以下信息:
| Claim / 属性 | 含义 |
|---|---|
jti | token 的唯一编号 |
iat | 签发时间 |
sub | 主题 |
iss | 签发者 |
aud | 接收方 |
exp | 过期时间 |
id | 当前用户 ID,游客为 0 |
下面保留了真实构造流程,但签名材料只使用配置占位符,不能把真实密钥、完整 token 或本机配置写入文章:
private static final int TOKEN_TIME_OUT = 3_600;
public static String getToken(Long id) { Map<String, Object> claims = new HashMap<>(); claims.put("id", id);
long currentTime = System.currentTimeMillis();
return Jwts.builder() .setId(UUID.randomUUID().toString()) .setIssuedAt(new Date(currentTime)) .setSubject("system") .setIssuer("heima") .setAudience("app") .compressWith(CompressionCodecs.GZIP) .signWith(SignatureAlgorithm.HS512, loadSigningKeyFromSecureConfig()) .setExpiration(new Date(currentTime + TOKEN_TIME_OUT * 1000L)) .addClaims(claims) .compact();}loadSigningKeyFromSecureConfig() 是脱敏表达,重点是“签发端和验签端必须取得兼容的签名密钥”,而不是把密钥硬编码在教程里。
10.2 JWT 是签名,不是加密
JWT 的 Payload 通常可以被客户端解码。签名保证的是“内容没有被篡改,并且来自持有密钥的一方”,不代表 Payload 对客户端不可见。
因此 JWT 中适合放用户 ID、角色标识等最小身份信息,不适合放密码、手机号、密钥或其他敏感资料。
10.3 签发端与验签端必须一致
当前项目在公共 utils 和 APP Gateway 中各有一份 AppJwtUtil。两份工具必须在以下方面保持一致:
- 签名算法;
- 签名密钥;
- Claims 名称,例如都使用
id; - 过期时间和校验语义。
重复工具类容易出现一边改了、另一边没改的维护风险。课程阶段可以先跑通链路,后续可以把认证配置集中管理,或抽成双方共同依赖的安全组件。
11. 第五步:配置 Nacos 与 Gateway 路由
11.1 Gateway 连接 Nacos
仓库中的 bootstrap.yml 负责声明 Gateway 的端口、应用名和 Nacos 入口。公开文章应使用环境变量或占位地址:
server: port: 51601
spring: application: name: leadnews-app-gateway cloud: nacos: discovery: server-addr: ${NACOS_ADDR} config: server-addr: ${NACOS_ADDR} file-extension: ymldiscovery 用于服务发现,config 用于读取配置中心内容。课程环境中的真实 Nacos 地址不应写进博客。
11.2 在 Nacos 中配置用户服务路由
下面是课程环境可使用的路由示例:
spring: cloud: gateway: routes: - id: user uri: lb://leadnews-user predicates: - Path=/user/** filters: - StripPrefix=1逐项理解:
id: user:路由的唯一名称。lb://leadnews-user:通过注册中心按服务名负载均衡,而不是写死某个实例地址。Path=/user/**:只有带/user/前缀的请求进入这条路由。StripPrefix=1:转发前删除第一段/user。
路径变化如下:
客户端请求:/user/api/v1/login/login_auth
Gateway 删除一段前缀后:/api/v1/login/login_auth
Controller 匹配:@RequestMapping("/api/v1/login")@PostMapping("/login_auth")如果客户端直接访问 Gateway 的 /api/v1/login/login_auth,它不会命中 Path=/user/**;如果忘记 StripPrefix=1,用户服务收到的路径仍带 /user,也会因匹配不到 Controller 而返回 404。
12. 第六步:完整实现 Gateway 全局鉴权
下面是当前 AuthorizeFilter 的完整核心逻辑。它包含登录白名单、缺失 token、解析失败、过期、Claim 缺失、身份头覆盖和正常放行。
@Component@Slf4jpublic class AuthorizeFilter implements Ordered, GlobalFilter {
private static final String APP_LOGIN_PATH = "/user/api/v1/login/login_auth";
@Override public int getOrder() { return 0; }
@Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest(); ServerHttpResponse response = exchange.getResponse(); String requestPath = request.getURI().getPath();
if (APP_LOGIN_PATH.equals(requestPath)) { return chain.filter(exchange); }
String token = request.getHeaders().getFirst("token");
if (StringUtils.isBlank(token)) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return response.setComplete(); }
try { Claims claimsBody = AppJwtUtil.getClaimsBody(token); int result = AppJwtUtil.verifyToken(claimsBody);
if (result == 1 || result == 2) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return response.setComplete(); }
Object userId = claimsBody.get("id");
if (userId == null) { response.setStatusCode(HttpStatus.UNAUTHORIZED); return response.setComplete(); }
ServerHttpRequest mutatedRequest = request.mutate() .headers(headers -> headers.set("userId", String.valueOf(userId)) ) .build();
ServerWebExchange mutatedExchange = exchange.mutate() .request(mutatedRequest) .build();
return chain.filter(mutatedExchange);
} catch (Exception e) { log.warn("token 校验失败", e); response.setStatusCode(HttpStatus.UNAUTHORIZED); return response.setComplete(); } }}12.1 为什么白名单使用精确路径
如果写成:
request.getURI().getPath().contains("/login")那么任何路径中只要出现 /login 都可能绕过鉴权。路径命名不是权限规则,公开接口应该以明确、可审查的白名单管理。
当前过滤器看到的是 Gateway 入口路径,所以常量包含 /user。Controller 看到的是路由过滤器移除前缀后的路径,两者不要混淆。
12.2 为什么失败直接返回 HTTP 401
response.setComplete() 会结束响应式链路,不再访问下游服务。这里返回的是 HTTP 层未认证,而不是用户服务返回的业务 JSON 错误:
- 登录凭据不完整:请求已经到达用户服务,通常是 HTTP 200 + 业务错误码。
- token 缺失或无效:请求在 Gateway 被拒绝,返回 HTTP 401。
客户端必须同时判断 HTTP 状态码和响应体中的业务码。
12.3 为什么必须覆盖 userId
客户端可以随意伪造请求头:
userId: 999999如果 Gateway 使用客户端值,下游就可能把攻击者当成其他用户。正确做法是:
- 先验证 JWT。
- 从可信 Claims 读取
id。 - 使用
headers.set覆盖同名头,而不是add一个并存值。 - 把真正构造出的
mutatedExchange传给过滤器链。
这一步把“JWT 已验签”真正转化成“下游可以使用的可信身份上下文”。
12.4 当前过期校验的语义
JWT 解析器在解析签名时会处理 exp;过期 token 会被转换为不可用 Claims。当前 verifyToken 还根据剩余时间返回状态:
1:Claims 为空或已过期;2:校验异常;-1或0:过滤器继续放行,其中0表示临近刷新窗口。
目前过滤器没有真正签发刷新 token,所以“接近过期”仍只是放行。生产系统需要明确设计续期方案,而不能仅有一个状态码。
13. 请求与响应示例
下面的 token、账号和用户信息都使用占位值,不包含真实凭据。
13.1 游客登录
请求:
POST http://localhost:51601/user/api/v1/login/login_auth HTTP/1.1Content-Type: application/json
{ "phone": "", "password": ""}期望响应结构:
{ "code": 200, "errorMessage": "操作成功", "data": { "token": "<JWT 已省略>" }}游客 token 的 id 为 0。这只表示允许游客访问开放给游客的受保护资源,不等于游客拥有普通用户的全部权限;下游服务仍需要根据业务操作判断授权。
13.2 凭据只填写一项
请求:
POST http://localhost:51601/user/api/v1/login/login_auth HTTP/1.1Content-Type: application/json
{ "phone": "<测试手机号>", "password": ""}期望响应结构:
{ "code": 501, "errorMessage": "手机号和密码必须同时填写"}注意 HTTP 请求可能仍然成功到达用户服务,因此 HTTP 状态可能是 200;真正的失败原因在业务 code 中。
13.3 正常账号登录
请求:
POST http://localhost:51601/user/api/v1/login/login_auth HTTP/1.1Content-Type: application/json
{ "phone": "<测试手机号>", "password": "<测试密码>"}期望响应结构:
{ "code": 200, "errorMessage": "操作成功", "data": { "token": "<JWT 已省略>", "user": { "id": 10001, "name": "<测试用户>", "phone": "<脱敏手机号>" } }}示例中的手机号是为了公开展示而脱敏的。当前代码只清除了 password 和 salt,phone 等其他实体字段仍会按数据库值序列化;是否向客户端返回完整手机号应根据真实产品契约决定,生产实现更适合使用响应 VO 明确控制字段。无论采用哪种契约,响应中都不应出现 password 和 salt。
13.4 不带 token 访问受保护接口
请求:
POST http://localhost:51601/article/api/v1/article/load HTTP/1.1Content-Type: application/json
{}期望结果:
HTTP/1.1 401 Unauthorized13.5 携带合法 token
POST http://localhost:51601/article/api/v1/article/load HTTP/1.1Content-Type: application/jsontoken: <刚刚获得的完整 JWT>
{}合法 JWT 应被放行;把 token 任意改动一个字符后再次请求,应返回 HTTP 401。
14. 联调验证步骤
在课程环境中,可以按以下顺序验证,避免一开始就面对跨层问题:
- 确认 MySQL 中存在
ap_user表和可用测试数据。 - 确认 Nacos 已启动,用户服务和 Gateway 使用的配置 Data ID 正确。
- 启动用户服务,确认它注册为
leadnews-user。 - 启动 APP Gateway,确认用户路由加载成功。
- 先请求游客登录,验证白名单、路由、Controller 和 JWT 签发。
- 分别测试只填手机号、只填密码,确认都返回参数错误。
- 使用专门的测试账号验证正常登录,检查响应不含密码和盐。
- 不带 token 请求受保护接口,确认 Gateway 返回 HTTP 401。
- 使用合法 token 请求,确认能进入下游。
- 篡改 token 后重试,确认签名校验失败。
- 使用测试 token 检查
id缺失与过期分支。 - 选择一个确实读取
userId请求头的下游测试接口,确认客户端伪造的同名头会被 Gateway 覆盖。
最后一步不能只看文章列表响应来判断,因为当前文章首页接口不会回显 userId。要验证身份透传,应观察真正消费该请求头的下游逻辑或编写针对过滤器的集成测试。
15. 常见错误与排查
| 现象 | 优先检查 | 原因与处理 |
|---|---|---|
| 登录请求返回 404 | Gateway 路由和 StripPrefix | 外部路径必须带 /user;转发到用户服务前要去掉这一段 |
| 登录请求返回 401 | 白名单常量与实际入口路径 | 过滤器匹配的是 Gateway 入口路径,不是 Controller 内部路径 |
| 游客登录成功,但只填手机号也拿到 token | Service 分支条件 | 游客必须使用 phoneBlank && passwordBlank;不完整参数使用 `phoneBlank |
| HTTP 200,但页面提示登录失败 | ResponseResult.code | HTTP 成功只代表请求到达业务服务,还要判断业务码 |
| 明明密码正确却提示错误 | 盐值、拼接顺序、字符集、测试数据 | 确认数据库摘要的生成规则与登录端完全一致 |
getOne() 报多结果异常 | phone 是否重复 | 清理重复数据,并在确认数据后建立唯一约束 |
| 用户服务生成的 token 到 Gateway 立即失效 | 两份 JWT 工具配置 | 对齐签名算法、签名密钥、Claim 名称和系统时钟 |
| token 复制后总是解析失败 | 请求头内容 | 只传 token 本体,不要附带引号、换行或错误前缀 |
| 合法 token 仍没有下游用户 ID | Claims 和 exchange 变更 | 确认 JWT 含 id,使用 headers.set,并把 mutatedExchange 传给 chain.filter |
| 锁定用户仍能登录 | Service 未使用 status | 当前代码尚未检查账号状态,完善时应在签发 token 前拒绝锁定账号 |
| 网关日志被大量非法 token 淹没 | 异常日志策略 | 生产环境应限制重复告警、避免记录完整 token,并配合限流和监控 |
| 响应意外出现敏感字段 | 直接返回实体 | 优先改用登录响应 VO,只显式返回允许字段 |
16. 面试高频问题
16.1 请完整讲一下 APP 登录与鉴权流程
参考回答:
客户端先请求 APP Gateway 的登录路径,Gateway 对精确登录白名单直接放行,并通过 Nacos 路由到用户服务。Controller 把 JSON 转成
LoginDto后交给 Service。Service 区分游客、不完整参数和正常登录;正常登录按手机号查询ap_user,用数据库盐值计算密码摘要并比对,成功后把用户 ID 写入 JWT。后续请求携带 token 经过 Gateway,全局过滤器完成验签和过期判断,从 Claims 取出用户 ID,覆盖客户端可能伪造的userId请求头,再转发给下游服务。
常见追问:为什么不让每个微服务自己验 token?
统一在 Gateway 做基础认证能减少重复代码并保持入口规则一致;下游仍要负责具体业务授权,不能把“已登录”误当成“有权执行所有操作”。
16.2 为什么游客登录和参数不完整必须分开
参考回答:
游客登录是手机号和密码同时为空,参数不完整是只有一项为空。前者使用
&&,后者使用||。如果把“任意一项为空”都当成游客,用户输错或漏填密码时也会获得游客 token,接口语义会变得模糊。
常见追问:怎样防止这类组合条件出错?
先写四行真值表,再为全空、全有、只缺手机号、只缺密码分别写测试。
16.3 为什么 Controller 不直接查询数据库和校验密码
参考回答:
Controller 负责 HTTP 协议适配,Service 负责登录业务,Mapper 负责数据访问。把密码校验放在 Service 后,规则可以被其他入口复用,也更容易单独测试;Controller 不会随着业务分支增加而失控。
常见追问:DTO、Entity、VO 分别负责什么?
DTO 承载请求输入,Entity 映射数据库表,VO 定义允许返回给客户端的字段。生产项目不建议直接返回 Entity。
16.4 加盐 MD5 比明文好在哪里,为什么生产仍不推荐
参考回答:
加盐后,相同密码不会得到完全相同的摘要,能降低通用彩虹表攻击效果,也避免直接存储明文。但 MD5 计算速度太快,攻击者仍可高速暴力枚举。生产应该使用 BCrypt、Argon2id 或 PBKDF2,并配合独立随机盐、HTTPS、限流和安全日志。
常见追问:盐值需要加密保存吗?
盐值通常可以与摘要一起保存,关键是每个用户独立随机;真正不能泄漏的是密码、完整凭据和服务端密钥。
16.5 JWT 和传统 Session 有什么区别
参考回答:
Session 通常把登录状态保存在服务端,客户端只持有会话标识;JWT 把最小 Claims 放在签名 token 中,服务端可以无会话地验证,适合微服务横向扩展。但 JWT 主动失效更困难、Payload 可见、token 泄漏后在过期前可能被利用,因此要控制有效期并设计撤销或刷新机制。
常见追问:JWT 是不是绝对无状态?
只有完全不做撤销、黑名单和服务端会话时才接近无状态;一旦需要强制下线或刷新 token,通常仍会引入一定服务端状态。
16.6 JWT 签名和加密有什么区别
参考回答:
签名用于验证来源和完整性,不能保证 Payload 保密;加密才用于隐藏内容。普通签名 JWT 可以被解码,所以只放最小身份信息,不能放密码、手机号或密钥。
常见追问:为什么篡改一个字符会失败?
Payload 或 Header 变化后,原签名与新内容不匹配,Gateway 使用服务端密钥验签时会拒绝。
16.7 为什么登录白名单不能使用 contains("/login")
参考回答:
contains把路径命名当成权限规则,可能意外放行任何带/login的接口。鉴权白名单应该使用明确路径或经过审查的路由匹配规则,默认拒绝未登记的公开接口。
常见追问:精确字符串就一定没有问题吗?
还要统一处理尾斜杠、路径规范化、上下文路径和新增公开接口;项目变大后可使用集中配置的白名单并配套测试。
16.8 为什么 Gateway 要覆盖 userId,而不是直接相信请求头
参考回答:
客户端请求头属于不可信输入,攻击者可以随意伪造。Gateway 先验签,再从 Claims 取
id,使用set覆盖同名头,才能让下游拿到可信身份。使用add可能留下多个值,业务端取哪个值会产生歧义。
常见追问:下游拿到 userId 后还要做什么?
还要做资源级授权,例如只能修改自己的资料。认证确认“你是谁”,授权判断“你能做什么”。
16.9 Nacos 路由中的 lb:// 和 StripPrefix=1 有什么作用
参考回答:
lb://leadnews-user表示从 Nacos 按服务名选择用户服务实例,实现服务发现和负载均衡;StripPrefix=1会删除外部路径第一段/user,让剩余路径匹配用户服务 Controller。
常见追问:忘记去前缀通常是什么现象?
Gateway 能找到服务,但用户服务收到 /user/api/...,与 Controller 的 /api/... 不匹配,通常返回 404。
16.10 HTTP 401 和业务错误码有什么区别
参考回答:
token 缺失、过期或无效时,请求在 Gateway 被拒绝,适合返回 HTTP 401。手机号缺失、用户不存在、密码错误等请求已经进入用户服务,当前项目通过
ResponseResult返回业务码。客户端要先判断 HTTP 状态,再解析业务码。
常见追问:401 和 403 又有什么区别?
401 表示尚未通过身份认证;403 表示身份已经确认,但没有执行当前操作的权限。
16.11 JWT 过期与刷新应该怎样设计
参考回答:
当前 token 带
exp,Gateway 解析时会拒绝过期 token;临近过期状态目前没有真正刷新。完整方案通常使用短期 access token 和更严格保存的 refresh token,刷新时校验会话状态,并支持撤销、轮换和异常检测。
常见追问:为什么不把 access token 设置得特别长?
有效期越长,token 泄漏后的可利用窗口越大。有效期需要在安全性和用户体验之间权衡。
16.12 当前实现还可以怎样演进
参考回答:
我会优先把 MD5 升级为 BCrypt 或 Argon2id,用登录响应 VO 替代直接返回 Entity,补账号状态检查和登录限流;再统一签发端、验签端的 JWT 配置,改用标准
Authorization: Bearer,并补充真实账号、过期、刷新、身份头覆盖和权限校验的自动化测试。
常见追问:为什么不是先做复杂的统一认证中心?
演进应先解决当前明确的安全和一致性问题。课程项目规模较小时,先把边界、配置和测试做稳,再根据多端登录、单点登录等真实需求决定是否引入认证中心。
17. 本章总结
这一条登录鉴权链路可以浓缩成六步:
- Gateway 精确放行登录入口,并通过 Nacos 把
/user/**路由到用户服务。 - Controller 接收
LoginDto,Service 用真值表区分游客、错误参数和正常登录。 - 正常登录按手机号查询
ap_user,使用盐值计算密码摘要并比对。 - 校验成功后把用户 ID 写入带过期时间的 JWT,响应前移除敏感字段。
- 后续请求由 Gateway 统一验签,失败立即返回 HTTP 401。
- 验签成功后从 Claims 获取用户 ID,并覆盖请求头中的客户端输入,再交给下游做业务授权。
真正值得掌握的不是背诵某个工具类,而是理解三条边界:密码校验属于用户服务,基础身份认证属于 Gateway,具体资源权限仍属于下游业务服务。把这三条边界讲清楚,登录、JWT 和微服务鉴权的整体设计就串起来了。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!