管理主页分类

拖动分类调整顺序,并选择是否在主页显示。设置仅保存在当前浏览器。

  • 微服务课件 31
  • Java 基础 28
  • Java Web 开发 25
  • 多来买 18
  • LeetCode 题解 8
  • 开发工具 8
  • 网络工具 2
  • 黑马头条 2
  • Java 记忆恢复 1

黑马头条:APP 登录与 Gateway JWT 鉴权实战

7679 字
38 分钟
黑马头条:APP 登录与 Gateway JWT 鉴权实战

黑马头条:APP 登录与 Gateway JWT 鉴权实战#

登录看起来只是“手机号 + 密码换一个 token”,但放到微服务项目中,它至少要解决四个问题:

  1. 用户服务怎样区分正常登录、游客登录和错误参数。
  2. 密码怎样保存与校验,才能避免直接存储明文。
  3. 登录成功后怎样签发 JWT,让客户端后续能够证明身份。
  4. Gateway 怎样统一校验 JWT,并把可信用户身份传给下游服务。

本文以黑马头条手写项目中的真实实现为基础,从数据库、DTO、Controller、Service、JWT 到 Gateway 逐层搭建完整链路。读完后,你不仅能照着实现接口,也能解释每一层为什么存在、出错时该从哪里排查。

1. 项目背景与模块职责#

黑马头条是一个基于 Spring Cloud 的微服务学习项目。APP 端不会直接访问用户微服务,而是先访问统一入口 Gateway,再由 Gateway 根据路由规则把请求转发给用户服务。

登录与鉴权涉及以下模块:

模块主要职责
heima-leadnews-model保存 LoginDtoApUserResponseResult 等公共模型
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使用 ServiceImplLambdaQueryWrapper 按手机号查询 ap_user
MySQL持久化用户、密码摘要、盐值和账号状态
Spring DigestUtils按课程实现计算加盐 MD5 摘要
JJWT生成、解析并验证 JWT
Spring Cloud Gateway统一路由与全局鉴权
Spring WebFlux / ReactorGateway 过滤器使用 Mono<Void> 异步结束或放行请求
Nacos Discovery让 Gateway 通过服务名发现用户微服务实例
Nacos Config在课程环境中保存 Gateway 路由配置

需要特别说明:本文会忠实讲解课程项目中的“加盐 MD5”,但 MD5 不适合作为生产系统的新密码方案。生产建议会在密码章节单独说明。

3. 端到端请求链路#

3.1 登录并取得 JWT#

sequenceDiagram participant APP as "APP 客户端" participant GW as "APP Gateway" participant C as "ApUserLoginController" participant S as "ApUserServiceImpl" participant DB as "MySQL ap_user" participant JWT as "AppJwtUtil" APP->>GW: POST /user/api/v1/login/login_auth GW->>GW: 精确命中登录白名单 GW->>C: StripPrefix 后转发 /api/v1/login/login_auth C->>S: login(LoginDto) alt 手机号和密码都为空 S->>JWT: getToken(0L) JWT-->>S: 游客 JWT else 只填写一项 S-->>C: 参数错误 else 手机号和密码都填写 S->>DB: 按 phone 查询用户 DB-->>S: ApUser S->>S: MD5(输入密码 + salt) 并比对 S->>JWT: getToken(apUser.id) JWT-->>S: 用户 JWT end C-->>APP: ResponseResult

3.2 携带 JWT 访问受保护接口#

sequenceDiagram participant APP as "APP 客户端" participant F as "AuthorizeFilter" participant JWT as "Gateway AppJwtUtil" participant API as "下游微服务" APP->>F: 请求头 token: <JWT> F->>JWT: 解析签名、Claims 和过期时间 alt token 缺失、被篡改或已过期 F-->>APP: HTTP 401 Unauthorized else token 合法 JWT-->>F: Claims.id F->>F: 覆盖请求头 userId F->>API: 转发携带可信 userId 的请求 API-->>APP: 业务响应 end

登录接口本身必须先放行,否则用户会陷入“没有 token 就不能登录,没有登录又拿不到 token”的死循环。除此以外的 APP 请求统一经过鉴权。

4. 数据库设计:ap_user 保存了什么#

登录只查询 ap_user,不需要联表。它保存的是 APP 用户的身份与基础资料。

字段作用登录流程是否直接使用
id用户主键,也是 JWT 中的身份值
phone登录账号
password加盐后的密码摘要,不应存明文
salt每个用户的盐值
nameimagesex用户基础资料登录成功时可返回
status账号是否正常或锁定当前代码尚未判断
flag普通用户、自媒体人或大 V 等标记
is_certificationis_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_user
WHERE 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 才不容易把错误请求误当成游客:

phonepassword业务含义处理方式
游客登录使用用户 ID 0 签发 JWT
有值参数不完整返回参数错误
有值参数不完整返回参数错误
有值有值正常登录查询用户、校验密码、签发 JWT

游客登录不是“任意一项为空”,而是“两项同时为空”。代码中分别使用 &&||,正是为了表达这两个不同条件。

6. 第一步:创建 LoginDto#

DTO 只负责承载接口输入,不负责查询数据库,也不应该在其中编写登录判断。

@Data
public 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 的完整核心逻辑。为便于阅读,只去掉了原文件中的长篇教学注释,业务语句保持一致。

@Override
public 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);
}

把这段代码拆开看,它依次完成了七件事:

  1. 防御 loginDto == null,避免空指针异常。
  2. StringUtils.isBlank 同时识别 null、空串和纯空格。
  3. 两项都为空时签发游客 token。
  4. 只缺一项时立即返回,不继续查询数据库。
  5. 使用方法引用 ApUser::getPhone 构造类型安全的查询条件。
  6. 用户存在时计算密码摘要并比较。
  7. 校验成功后签发 JWT,并在返回用户前清除 passwordsalt

当前代码直接把查询出的实体放进响应,并把敏感字段设为 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 / 属性含义
jtitoken 的唯一编号
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: yml

discovery 用于服务发现,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
@Slf4j
public 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 使用客户端值,下游就可能把攻击者当成其他用户。正确做法是:

  1. 先验证 JWT。
  2. 从可信 Claims 读取 id
  3. 使用 headers.set 覆盖同名头,而不是 add 一个并存值。
  4. 把真正构造出的 mutatedExchange 传给过滤器链。

这一步把“JWT 已验签”真正转化成“下游可以使用的可信身份上下文”。

12.4 当前过期校验的语义#

JWT 解析器在解析签名时会处理 exp;过期 token 会被转换为不可用 Claims。当前 verifyToken 还根据剩余时间返回状态:

  • 1:Claims 为空或已过期;
  • 2:校验异常;
  • -10:过滤器继续放行,其中 0 表示临近刷新窗口。

目前过滤器没有真正签发刷新 token,所以“接近过期”仍只是放行。生产系统需要明确设计续期方案,而不能仅有一个状态码。

13. 请求与响应示例#

下面的 token、账号和用户信息都使用占位值,不包含真实凭据。

13.1 游客登录#

请求:

POST http://localhost:51601/user/api/v1/login/login_auth HTTP/1.1
Content-Type: application/json
{
"phone": "",
"password": ""
}

期望响应结构:

{
"code": 200,
"errorMessage": "操作成功",
"data": {
"token": "<JWT 已省略>"
}
}

游客 token 的 id0。这只表示允许游客访问开放给游客的受保护资源,不等于游客拥有普通用户的全部权限;下游服务仍需要根据业务操作判断授权。

13.2 凭据只填写一项#

请求:

POST http://localhost:51601/user/api/v1/login/login_auth HTTP/1.1
Content-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.1
Content-Type: application/json
{
"phone": "<测试手机号>",
"password": "<测试密码>"
}

期望响应结构:

{
"code": 200,
"errorMessage": "操作成功",
"data": {
"token": "<JWT 已省略>",
"user": {
"id": 10001,
"name": "<测试用户>",
"phone": "<脱敏手机号>"
}
}
}

示例中的手机号是为了公开展示而脱敏的。当前代码只清除了 passwordsaltphone 等其他实体字段仍会按数据库值序列化;是否向客户端返回完整手机号应根据真实产品契约决定,生产实现更适合使用响应 VO 明确控制字段。无论采用哪种契约,响应中都不应出现 passwordsalt

13.4 不带 token 访问受保护接口#

请求:

POST http://localhost:51601/article/api/v1/article/load HTTP/1.1
Content-Type: application/json
{}

期望结果:

HTTP/1.1 401 Unauthorized

13.5 携带合法 token#

POST http://localhost:51601/article/api/v1/article/load HTTP/1.1
Content-Type: application/json
token: <刚刚获得的完整 JWT>
{}

合法 JWT 应被放行;把 token 任意改动一个字符后再次请求,应返回 HTTP 401。

14. 联调验证步骤#

在课程环境中,可以按以下顺序验证,避免一开始就面对跨层问题:

  1. 确认 MySQL 中存在 ap_user 表和可用测试数据。
  2. 确认 Nacos 已启动,用户服务和 Gateway 使用的配置 Data ID 正确。
  3. 启动用户服务,确认它注册为 leadnews-user
  4. 启动 APP Gateway,确认用户路由加载成功。
  5. 先请求游客登录,验证白名单、路由、Controller 和 JWT 签发。
  6. 分别测试只填手机号、只填密码,确认都返回参数错误。
  7. 使用专门的测试账号验证正常登录,检查响应不含密码和盐。
  8. 不带 token 请求受保护接口,确认 Gateway 返回 HTTP 401。
  9. 使用合法 token 请求,确认能进入下游。
  10. 篡改 token 后重试,确认签名校验失败。
  11. 使用测试 token 检查 id 缺失与过期分支。
  12. 选择一个确实读取 userId 请求头的下游测试接口,确认客户端伪造的同名头会被 Gateway 覆盖。

最后一步不能只看文章列表响应来判断,因为当前文章首页接口不会回显 userId。要验证身份透传,应观察真正消费该请求头的下游逻辑或编写针对过滤器的集成测试。

15. 常见错误与排查#

现象优先检查原因与处理
登录请求返回 404Gateway 路由和 StripPrefix外部路径必须带 /user;转发到用户服务前要去掉这一段
登录请求返回 401白名单常量与实际入口路径过滤器匹配的是 Gateway 入口路径,不是 Controller 内部路径
游客登录成功,但只填手机号也拿到 tokenService 分支条件游客必须使用 phoneBlank && passwordBlank;不完整参数使用 `phoneBlank
HTTP 200,但页面提示登录失败ResponseResult.codeHTTP 成功只代表请求到达业务服务,还要判断业务码
明明密码正确却提示错误盐值、拼接顺序、字符集、测试数据确认数据库摘要的生成规则与登录端完全一致
getOne() 报多结果异常phone 是否重复清理重复数据,并在确认数据后建立唯一约束
用户服务生成的 token 到 Gateway 立即失效两份 JWT 工具配置对齐签名算法、签名密钥、Claim 名称和系统时钟
token 复制后总是解析失败请求头内容只传 token 本体,不要附带引号、换行或错误前缀
合法 token 仍没有下游用户 IDClaims 和 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. 本章总结#

这一条登录鉴权链路可以浓缩成六步:

  1. Gateway 精确放行登录入口,并通过 Nacos 把 /user/** 路由到用户服务。
  2. Controller 接收 LoginDto,Service 用真值表区分游客、错误参数和正常登录。
  3. 正常登录按手机号查询 ap_user,使用盐值计算密码摘要并比对。
  4. 校验成功后把用户 ID 写入带过期时间的 JWT,响应前移除敏感字段。
  5. 后续请求由 Gateway 统一验签,失败立即返回 HTTP 401。
  6. 验签成功后从 Claims 获取用户 ID,并覆盖请求头中的客户端输入,再交给下游做业务授权。

真正值得掌握的不是背诵某个工具类,而是理解三条边界:密码校验属于用户服务,基础身份认证属于 Gateway,具体资源权限仍属于下游业务服务。把这三条边界讲清楚,登录、JWT 和微服务鉴权的整体设计就串起来了。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

黑马头条:APP 登录与 Gateway JWT 鉴权实战
https://firefly-mu-weld.vercel.app/posts/heima-leadnews-app-login-gateway-jwt-auth/
作者
Daisy
发布于
2026-07-30
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
Daisy
Hello, I'm Daisy.
公告
欢迎来到我的博客!这是一则示例公告。
分类
标签

文章目录