第4章 JWT 登录认证与 Gateway 统一鉴权
本章目标
前面几章我们已经知道,KnowHub 是一个企业级 AI 知识库/RAG 平台。既然是知识库平台,就一定绕不开一个基础问题:
系统怎么知道当前是谁在访问?
如果系统不知道当前用户是谁,就无法判断他能不能创建知识库、能不能上传文档、能不能访问某个知识库、能不能进入后台管理端。
很多初学者做接口时,会习惯在请求参数里传一个 userId:
text
GET /kb/list?userId=1
这种方式在单体 Demo 阶段可以帮助我们快速验证功能,但它不能用于真正的企业系统。因为前端传来的 userId 是不可信的。用户完全可以把 userId=1 改成 userId=2,然后尝试访问别人的知识库。
所以,在 KnowHub 的微服务版本中,我们采用更标准的方式:

本章要讲清楚这条链路。
学完本章,你应该能回答下面几个问题:
- 为什么不能相信前端传入的
userId。 - JWT 登录认证的基本流程是什么。
- auth-service 在项目中负责什么。
- Gateway 为什么适合做统一鉴权。
- 用户信息如何从 Gateway 传到 knowledge-service、task-service。
- 普通用户和管理员角色如何区分。
4.1 为什么企业知识库必须先做认证
企业知识库和普通公开网站不一样。
公开网站里的很多内容可以匿名访问,但企业知识库里的文档往往有明确归属。
比如:
- A 用户创建了一个"公司制度"知识库。
- B 用户创建了一个"项目接口文档"知识库。
- 管理员可以查看系统任务和问答日志。
- 普通用户只能查看自己的知识库和文档。
如果没有认证和权限隔离,就会出现严重问题。
4.1.1 用户可能访问别人的知识库
假设接口是这样设计的:
text
GET /kb/100?userId=1
用户只要修改参数:
text
GET /kb/100?userId=2
就可能伪装成另一个用户。
这就是为什么真实系统不能依赖前端传入的用户 ID。
用户身份应该来自登录后签发的 token,而不是来自用户自己填写的参数。
4.1.2 普通用户不能访问管理端
KnowHub 有用户端和管理端。
用户端面向普通用户,主要使用知识库和问答功能。
管理端面向管理员,能查看用户、知识库、文档、索引任务和问答日志。
如果普通用户也能访问管理端接口,就可能看到不属于自己的全局数据。
所以 Gateway 必须对 /admin/** 这类路径做角色校验。只有 ADMIN 用户才能访问。
4.1.3 鉴权和资源校验不是一回事
这里要区分两个概念。
鉴权解决的是:
text
你有没有登录?
你是不是管理员?
资源校验解决的是:
text
这个知识库是不是你的?
这个文档是不是属于你的知识库?
你能不能对这个任务执行重试?
Gateway 适合做统一登录校验和角色粗粒度校验,但具体资源归属必须在业务服务中判断。
也就是说,Gateway 和业务服务都要参与安全控制,只靠其中一个是不够的。
4.2 JWT 登录认证的基本流程
JWT 是 JSON Web Token 的缩写。
你可以先把它理解成一张"登录后的身份证明"。用户登录成功后,后端给他一段 Token。之后用户访问需要登录的接口时,把 Token 带上,服务端就能识别他是谁。
4.2.0 什么是 JWT------三段式结构
JWT 本质上是一段用点号分成三段的字符串。它看起来像这样:
text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInVzZXJuYW1lIjoibHVvYmluIiwicm9sZSI6IlVTRVIiLCJleHAiOjE3MjAwMDAwMDB9.K4t9nJqG0vF6xQkQ9Zl4rVw7xYz8aBcDeFgHiJkLmNo
这段字符串可以拆成三部分:
text
Header.Payload.Signature
第一段是 Header,用于说明这个 Token 使用什么类型和什么签名算法。上面示例的第一段经过 Base64URL 解码后,大致是:
json
{
"alg": "HS256",
"typ": "JWT"
}
alg 表示签名算法,typ 表示这是一个 JWT。
第二段是 Payload,用于存放声明数据,也就是服务端希望写进 Token 的用户身份信息。示例中第二段解码后大致是:
json
{
"userId": 1,
"username": "luobin",
"role": "USER",
"exp": 1720000000
}
这里的 userId 用来识别用户,username 方便日志和展示,role 用来区分普通用户和管理员,exp 表示过期时间。
第三段是 Signature,也就是签名。签名不是随便生成的字符串,而是服务端把前两段内容和自己的密钥一起计算出来的结果。Gateway 校验 Token 时,会重新计算签名。如果攻击者改了 Payload,比如把 role 从 USER 改成 ADMIN,前两段内容变了,但攻击者不知道服务端密钥,就无法生成正确签名,校验自然会失败。
这里有一个初学者必须记住的安全点:Payload 只是 Base64URL 编码,不是加密。任何人拿到 Token,都可以把第二段解码出来看到里面的内容。所以 JWT 里绝对不能放密码、手机号、身份证号、密保答案这类敏感信息。JWT 的安全性不在于"别人看不见 Payload",而在于"别人改不了 Payload"。Payload 是透明的,但经过签名校验后是可信的。
签名算法常见有两类。第一类是对称算法,比如 HS256。它只有一个密钥,签发 Token 和校验 Token 都用同一个密钥。优点是简单,适合学习项目和服务数量不多的系统;缺点是所有需要校验 Token 的服务都必须保存同一个密钥,一旦某个服务泄露密钥,攻击者就可以伪造 Token。第二类是非对称算法,比如 RS256。它用私钥签发 Token,用公钥校验 Token。私钥只放在认证服务,其他服务只拿公钥校验,这样更适合大型微服务系统。
KnowHub 当前阶段为了降低学习成本,采用 HS256 实现 JWT 签发和校验,把认证链路先跑通。等系统演进到更多服务都需要独立校验 Token 时,可以升级为 RS256:auth-service 持有私钥签发 Token,Gateway 和其他服务只保存公钥做校验。理解了这三段结构后,下面再看注册流程和登录流程,就会清楚:注册负责创建用户,登录负责校验密码并签发这张"带签名的身份证明"。
4.2.1 注册流程
注册流程大致如下:

这里最重要的是:密码不能明文保存。
数据库里应该保存密码哈希,而不是用户原始密码。KnowHub 使用 BCrypt 进行密码加密。
4.2.2 登录流程
登录流程大致如下:

登录成功后,前端保存 Token。后续请求都带上:
text
Authorization: Bearer eyJhbGciOi...
注意,这里必须有 Bearer 前缀。Gateway 会根据这个格式解析 Token。
4.2.3 访问业务接口
用户访问知识库接口时,链路变成:

这样下游服务不再相信前端传入的 userId,而是使用 Gateway 透传的身份。
4.2.4 认证与鉴权架构总览

4.3 auth-service 的职责
在 KnowHub 中,用户认证相关能力放在 rag-auth-service。
这个服务的职责可以概括为:
text
负责用户是谁
负责密码怎么校验
负责 Token 怎么签发
负责用户角色和状态
4.3.1 用户表 user_account
用户表通常包含这些字段:
text
id
username
password_hash
nickname
status
role
created_at
updated_at
其中 password_hash 保存加密后的密码,role 保存用户角色。
KnowHub 中基础角色包括:
text
USER
ADMIN
USER 是普通用户,ADMIN 是管理员。
4.3.2 AuthController
AuthController 负责暴露认证接口。
典型接口包括:
text
POST /auth/register
POST /auth/login
GET /auth/me
注册和登录属于白名单接口,不需要 Token。/auth/me 用于查询当前登录用户,需要通过 Gateway 鉴权。
下面给出 AuthController 的完整写法。控制器层只负责接收请求、触发参数校验、调用业务服务和组装统一返回,不在控制器里直接操作数据库。
java
package com.luo.ragauth.auth.controller;
import com.luo.ragauth.auth.dto.AuthUserResponse;
import com.luo.ragauth.auth.dto.LoginRequest;
import com.luo.ragauth.auth.dto.LoginResponse;
import com.luo.ragauth.auth.dto.RegisterRequest;
import com.luo.ragauth.auth.service.AuthService;
import com.luo.ragcommon.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.HttpHeaders;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 认证接口控制器。
* register/login 是白名单接口,不需要携带 Token。
* me 用于验证当前 Token 是否有效,需要 Gateway 先完成 JWT 校验。
*/
@Tag(name = "认证接口", description = "用户注册、登录和当前用户查询")
@RestController
@RequestMapping("/auth")
@Validated
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@Operation(summary = "用户注册")
@PostMapping("/register")
public ApiResponse<AuthUserResponse> register(@Valid @RequestBody RegisterRequest request) {
return ApiResponse.success(authService.register(request));
}
@Operation(summary = "用户登录")
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.success(authService.login(request));
}
@Operation(summary = "查询当前登录用户")
@GetMapping("/me")
public ApiResponse<AuthUserResponse> currentUser(HttpServletRequest request) {
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
return ApiResponse.success(authService.currentUser(authorization));
}
}
请求参数 DTO 可以这样写:
java
package com.luo.ragauth.auth.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class RegisterRequest {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 32, message = "用户名长度必须在 3 到 32 个字符之间")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 64, message = "密码长度必须在 6 到 64 个字符之间")
private String password;
@Size(max = 50, message = "昵称不能超过 50 个字符")
private String nickname;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
public String getNickname() { return nickname; }
public void setNickname(String nickname) { this.nickname = nickname; }
}
java
package com.luo.ragauth.auth.dto;
import jakarta.validation.constraints.NotBlank;
public class LoginRequest {
@NotBlank(message = "用户名不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPassword() { return password; }
public void setPassword(String password) { this.password = password; }
}
异常处理不建议写在每个接口里重复 try-catch,而是交给全局异常处理器。
java
package com.luo.ragauth.common.exception;
import com.luo.ragcommon.api.ApiResponse;
import com.luo.ragcommon.exception.BusinessException;
import com.luo.ragcommon.exception.ErrorCode;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ApiResponse<Void> handleBusinessException(BusinessException ex) {
return ApiResponse.fail(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleValidException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining("; "));
return ApiResponse.fail(ErrorCode.PARAM_ERROR.getCode(), message);
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception ex) {
// 系统异常只记录在后端日志中,不直接暴露给前端。
return ApiResponse.fail(ErrorCode.BUSINESS_ERROR.getCode(), "认证服务处理失败");
}
}
关键依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
4.3.3 AuthServiceImpl
AuthServiceImpl 负责具体业务逻辑。
注册时,它要做:
text
校验参数
检查用户名是否存在
加密密码
保存用户
设置默认角色 USER
登录时,它要做:
text
查询用户
校验用户状态
校验密码
构造 JWT 用户声明
生成 Token
返回登录结果
如果用户不存在、密码错误、用户被禁用,都应该返回明确的业务错误,而不是暴露系统异常堆栈。
下面是完整的 AuthServiceImpl。这段代码把"参数整理、用户名查重、密码加密、用户状态校验、Token 签发"都放在业务层完成。
java
package com.luo.ragauth.auth.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.luo.ragauth.auth.dto.AuthUserResponse;
import com.luo.ragauth.auth.dto.LoginRequest;
import com.luo.ragauth.auth.dto.LoginResponse;
import com.luo.ragauth.auth.dto.RegisterRequest;
import com.luo.ragauth.auth.service.AuthService;
import com.luo.ragauth.user.entity.UserAccount;
import com.luo.ragauth.user.enums.UserAccountStatus;
import com.luo.ragauth.user.mapper.UserAccountMapper;
import com.luo.ragcommon.exception.BusinessException;
import com.luo.ragcommon.exception.ErrorCode;
import com.luo.ragcommon.security.JwtToken;
import com.luo.ragcommon.security.JwtTokenService;
import com.luo.ragcommon.security.UserClaims;
import com.luo.ragcommon.security.UserRole;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
/**
* 认证业务实现。
* 这里不返回系统异常,而是把所有可预期失败转换为明确的业务错误。
*/
@Service
public class AuthServiceImpl implements AuthService {
private final UserAccountMapper userAccountMapper;
private final PasswordEncoder passwordEncoder;
private final JwtTokenService jwtTokenService;
public AuthServiceImpl(UserAccountMapper userAccountMapper,
PasswordEncoder passwordEncoder,
JwtTokenService jwtTokenService) {
this.userAccountMapper = userAccountMapper;
this.passwordEncoder = passwordEncoder;
this.jwtTokenService = jwtTokenService;
}
@Override
@Transactional(rollbackFor = Exception.class)
public AuthUserResponse register(RegisterRequest request) {
String username = normalizeUsername(request.getUsername());
String nickname = normalizeNickname(request.getNickname());
Long duplicateCount = userAccountMapper.selectCount(new LambdaQueryWrapper<UserAccount>()
.eq(UserAccount::getUsername, username));
if (duplicateCount != null && duplicateCount > 0) {
throw new BusinessException(ErrorCode.BUSINESS_ERROR, "用户名已存在");
}
UserAccount user = new UserAccount();
user.setUsername(username);
user.setPasswordHash(passwordEncoder.encode(request.getPassword()));
user.setNickname(nickname);
user.setRole(UserRole.USER.name());
user.setStatus(UserAccountStatus.ACTIVE.getCode());
userAccountMapper.insert(user);
return toAuthUserResponse(user);
}
@Override
public LoginResponse login(LoginRequest request) {
String username = normalizeUsername(request.getUsername());
UserAccount user = userAccountMapper.selectOne(new LambdaQueryWrapper<UserAccount>()
.eq(UserAccount::getUsername, username)
.last("LIMIT 1"));
if (user == null) {
throw new BusinessException(ErrorCode.BUSINESS_ERROR, "用户名或密码错误");
}
validateActiveUser(user);
if (!passwordEncoder.matches(request.getPassword(), user.getPasswordHash())) {
throw new BusinessException(ErrorCode.BUSINESS_ERROR, "用户名或密码错误");
}
UserClaims claims = new UserClaims(user.getId(), user.getUsername(), UserRole.normalize(user.getRole()));
JwtToken token = jwtTokenService.generateToken(claims);
return toLoginResponse(user, token);
}
@Override
public AuthUserResponse currentUser(String authorization) {
UserClaims claims = jwtTokenService.parseAuthorization(authorization);
UserAccount user = userAccountMapper.selectById(claims.getUserId());
if (user == null) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "当前登录用户不存在,请重新登录");
}
validateActiveUser(user);
return toAuthUserResponse(user);
}
private void validateActiveUser(UserAccount user) {
if (!Integer.valueOf(UserAccountStatus.ACTIVE.getCode()).equals(user.getStatus())) {
throw new BusinessException(ErrorCode.FORBIDDEN, "用户已被禁用");
}
}
private String normalizeUsername(String username) {
return username == null ? null : username.trim();
}
private String normalizeNickname(String nickname) {
return StringUtils.hasText(nickname) ? nickname.trim() : null;
}
private AuthUserResponse toAuthUserResponse(UserAccount user) {
AuthUserResponse response = new AuthUserResponse();
response.setUserId(user.getId());
response.setUsername(user.getUsername());
response.setNickname(user.getNickname());
response.setRole(UserRole.normalize(user.getRole()));
response.setStatus(user.getStatus());
response.setCreatedAt(user.getCreatedAt());
return response;
}
private LoginResponse toLoginResponse(UserAccount user, JwtToken token) {
LoginResponse response = new LoginResponse();
response.setUserId(user.getId());
response.setUsername(user.getUsername());
response.setNickname(user.getNickname());
response.setRole(UserRole.normalize(user.getRole()));
response.setStatus(user.getStatus());
response.setTokenType(token.getTokenType());
response.setToken(token.getToken());
response.setExpiresAt(token.getExpiresAt());
return response;
}
}
关键依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>
4.3.4 PasswordEncoderConfig
密码加密通常通过 BCryptPasswordEncoder 完成。
BCrypt 的特点是同一个密码每次加密结果可能不同,但仍然可以通过 matches 方法校验。
所以登录时不要拿明文密码加密后和数据库字符串直接比较,而应该使用:
java
passwordEncoder.matches(rawPassword, passwordHash)
这对初学者很重要。
4.3.5 JwtTokenService
JwtTokenService 是认证链路的核心工具类。它负责生成 Token、校验 Token、从 Token 中解析 userId、username 和 role。
java
package com.luo.ragcommon.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
@ConfigurationProperties(prefix = "rag.jwt")
public class JwtProperties {
/** HS256 签名密钥。生产环境应通过环境变量或配置中心注入。 */
private String secret;
/** Access Token 有效期。 */
private Duration ttl = Duration.ofHours(2);
public String getSecret() { return secret; }
public void setSecret(String secret) { this.secret = secret; }
public Duration getTtl() { return ttl; }
public void setTtl(Duration ttl) { this.ttl = ttl; }
}
java
package com.luo.ragcommon.security;
import java.time.Instant;
public class JwtToken {
private final String token;
private final String tokenType;
private final Instant expiresAt;
public JwtToken(String token, String tokenType, Instant expiresAt) {
this.token = token;
this.tokenType = tokenType;
this.expiresAt = expiresAt;
}
public String getToken() { return token; }
public String getTokenType() { return tokenType; }
public Instant getExpiresAt() { return expiresAt; }
}
java
package com.luo.ragcommon.security;
public class UserClaims {
private final Long userId;
private final String username;
private final String role;
public UserClaims(Long userId, String username, String role) {
this.userId = userId;
this.username = username;
this.role = role;
}
public Long getUserId() { return userId; }
public String getUsername() { return username; }
public String getRole() { return role; }
}
完整服务实现如下:
java
package com.luo.ragcommon.security;
import com.luo.ragcommon.exception.BusinessException;
import com.luo.ragcommon.exception.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.util.StringUtils;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.UUID;
/**
* JWT 生成与解析服务。
* 当前阶段使用 HS256。secret 长度必须足够,否则 jjwt 会拒绝创建密钥。
*/
public class JwtTokenService {
private static final String BEARER_PREFIX = "Bearer ";
private final JwtProperties properties;
private final SecretKey secretKey;
public JwtTokenService(JwtProperties properties) {
this.properties = properties;
this.secretKey = Keys.hmacShaKeyFor(properties.getSecret().getBytes(StandardCharsets.UTF_8));
}
public JwtToken generateToken(UserClaims userClaims) {
Instant now = Instant.now();
Instant expiresAt = now.plus(properties.getTtl());
String token = Jwts.builder()
// jti 暂时不做黑名单,但先写入,方便后续扩展主动失效能力。
.id(UUID.randomUUID().toString())
.subject(String.valueOf(userClaims.getUserId()))
.claim("username", userClaims.getUsername())
.claim("role", userClaims.getRole())
.issuedAt(Date.from(now))
.expiration(Date.from(expiresAt))
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
return new JwtToken(token, "Bearer", expiresAt);
}
public UserClaims parseAuthorization(String authorization) {
if (!StringUtils.hasText(authorization) || !authorization.startsWith(BEARER_PREFIX)) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "请求头 Authorization 格式错误");
}
return parseToken(authorization.substring(BEARER_PREFIX.length()));
}
public UserClaims parseToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
Long userId = Long.valueOf(claims.getSubject());
String username = claims.get("username", String.class);
String role = claims.get("role", String.class);
if (userId == null || !StringUtils.hasText(role)) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "Token 中缺少用户身份信息");
}
return new UserClaims(userId, username, role);
} catch (ExpiredJwtException ex) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录");
} catch (JwtException | IllegalArgumentException ex) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "Token 无效");
}
}
}
配置示例:
yaml
rag:
jwt:
secret: "knowhub-rag-platform-demo-secret-key-at-least-32-bytes"
ttl: 2h
关键依赖:
xml
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
4.4 JWT 中应该放什么
JWT 中不应该放太多信息。
它适合放少量、稳定、用于识别身份的信息。
KnowHub 中的用户声明可以包含:
text
userId
username
role
issuedAt
expiresAt
4.4.1 userId
userId 是最重要的信息。
业务服务会根据它查询当前用户的知识库、文档、问答记录。
4.4.2 username
username 主要用于日志、展示和排查。
比如 Gateway 日志里可以看到是哪个用户访问了接口。
4.4.3 role
role 用于区分普通用户和管理员。
Gateway 对 /admin/** 做校验时,需要读取 Token 中的角色。
如果不是 ADMIN,就直接返回无权限。
4.4.4 过期时间与 Token 失效机制
Token 必须有过期时间。原因很简单:JWT 一旦签发,只要签名合法、没有过期,服务端通常就会认为它有效。如果 Token 永不过期,一旦被浏览器插件、日志、代理工具或他人电脑截获,攻击者就可以长期冒充这个用户访问系统。过期时间越长,泄露后的风险窗口越大。
JWT 的先天缺陷是:它本身没有真正的"注销"能力。传统 Session 存在服务端,用户退出登录时服务端可以删除 Session;但 JWT 是无状态的,服务端不保存每个 Token 的状态。也就是说,即使用户修改了密码,或者管理员禁用了账号,只要旧 Token 还没过期,并且 Gateway 只校验签名和过期时间,它仍然可能继续通过校验。这是读者必须知道的安全风险。
业界常见补偿方案有两类。
第一类是短过期时间加刷新令牌。Access Token 有效期设置短一些,比如 15 到 30 分钟;Refresh Token 有效期设置长一些,比如 7 天。Access Token 过期后,前端用 Refresh Token 换新的 Access Token;Refresh Token 过期后,用户必须重新登录。Refresh Token 一般存储在服务端,比如 Redis,因此可以被主动撤销。用户退出登录、管理员禁用账号、发现异常登录时,都可以删除服务端保存的 Refresh Token。
第二类是 Redis 黑名单。签发 JWT 时写入 jti,也就是 JWT ID。用户退出登录或管理员禁用用户时,把这个 jti 加入 Redis 黑名单,过期时间设置为 Token 剩余有效期。Gateway 校验 Token 时,除了校验签名和过期时间,还要查一次 Redis,判断当前 jti 是否已经被拉黑。这个方案能实现主动失效,但代价是每次请求都会多一次 Redis 查询,系统复杂度也会提高。
KnowHub 当前阶段采用的是"带过期时间的 Access Token"方案,没有在第 4 章直接实现 Refresh Token 和 Redis 黑名单。这样做是为了控制学习复杂度:本章重点是让读者先跑通注册、登录、JWT 签发、Gateway 校验和用户信息透传。等后续学习 Redis、缓存和幂等锁时,再扩展 Token 黑名单或刷新令牌更自然。
但是,即使暂时不实现主动失效,也应该在架构上预留扩展点。比如在 JwtTokenService 生成 Token 时写入 jti 字段,后续要做退出登录或管理员强制下线时,就可以把 jti 放入 Redis 黑名单,而不需要推翻原来的 Token 结构。
4.5 Gateway 统一鉴权
在微服务项目中,不建议每个服务都重复写一套 Token 校验。
更合理的方式是让 Gateway 做统一入口。
4.5.1 白名单
不是所有接口都需要登录。
比如:
text
/auth/register
/auth/login
/doc.html
/swagger-ui/**
/v3/api-docs/**
这些接口应该放行。
如果登录接口也要求 Token,用户就永远无法登录了。
4.5.2 解析 Authorization
前端请求头一般长这样:
text
Authorization: Bearer xxx.yyy.zzz
Gateway 要先判断:
- 请求是否在白名单。
- 是否存在 Authorization 请求头。
- 是否以
Bearer开头。 - Token 是否合法。
- Token 是否过期。
任何一步失败,都应该返回统一错误结构。
4.5.3 用户信息透传
Token 校验成功后,Gateway 会从 Token 中取出用户信息,然后写入下游请求头:
text
X-User-Id
X-Username
X-User-Role
这样下游服务不需要再次解析 JWT。
为了防止用户伪造这些请求头,Gateway 在转发前应该移除外部传入的同名请求头,再写入自己解析出的值。
也就是说,下游服务信任的是 Gateway 写入的头,而不是浏览器原始传来的头。
4.5.4 管理端路径校验
管理端接口通常以 /admin/** 开头。
Gateway 可以在过滤器里判断:
text
如果路径以 /admin/ 开头
-> 检查 role 是否 ADMIN
-> 不是 ADMIN,返回 403
这样普通用户即使知道管理端接口地址,也无法访问。
4.5.5 Gateway 全局过滤器完整实现
下面给出 Gateway 全局过滤器的完整实现。它做四件事:放行白名单、校验 JWT、拦截 /admin/**、向下游服务透传可信用户信息。
java
package com.luo.raggateway.security;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.luo.ragcommon.api.ApiResponse;
import com.luo.ragcommon.exception.BusinessException;
import com.luo.ragcommon.exception.ErrorCode;
import com.luo.ragcommon.security.JwtTokenService;
import com.luo.ragcommon.security.UserClaims;
import com.luo.ragcommon.security.UserRole;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.AntPathMatcher;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* Gateway JWT 全局鉴权过滤器。
* 外部请求不能直接信任 X-User-Id 等身份头,必须由 Gateway 解析 Token 后重新写入。
*/
@Component
public class JwtAuthGlobalFilter implements GlobalFilter, Ordered {
private static final String USER_ID_HEADER = "X-User-Id";
private static final String USERNAME_HEADER = "X-Username";
private static final String USER_ROLE_HEADER = "X-User-Role";
private static final String ADMIN_PATH_PREFIX = "/admin/";
private final JwtTokenService jwtTokenService;
private final AuthWhitelistProperties whitelistProperties;
private final ObjectMapper objectMapper;
private final AntPathMatcher pathMatcher = new AntPathMatcher();
public JwtAuthGlobalFilter(JwtTokenService jwtTokenService,
AuthWhitelistProperties whitelistProperties,
ObjectMapper objectMapper) {
this.jwtTokenService = jwtTokenService;
this.whitelistProperties = whitelistProperties;
this.objectMapper = objectMapper;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
if (isPreflightRequest(exchange) || isWhitelisted(path)) {
return chain.filter(exchange);
}
try {
String authorization = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
UserClaims claims = jwtTokenService.parseAuthorization(authorization);
if (isAdminPath(path) && !UserRole.isAdmin(claims.getRole())) {
throw new BusinessException(ErrorCode.FORBIDDEN, "只有管理员可以访问后台接口");
}
ServerWebExchange mutatedExchange = exchange.mutate()
.request(builder -> builder.headers(headers -> {
// 先移除外部伪造的身份头,再写入 Gateway 解析出的可信身份。
headers.remove(USER_ID_HEADER);
headers.remove(USERNAME_HEADER);
headers.remove(USER_ROLE_HEADER);
headers.set(USER_ID_HEADER, String.valueOf(claims.getUserId()));
headers.set(USERNAME_HEADER, claims.getUsername());
headers.set(USER_ROLE_HEADER, claims.getRole());
}))
.build();
return chain.filter(mutatedExchange);
} catch (BusinessException ex) {
HttpStatus status = ex.getCode() == ErrorCode.FORBIDDEN.getCode()
? HttpStatus.FORBIDDEN
: HttpStatus.UNAUTHORIZED;
return writeError(exchange, ex.getCode(), ex.getMessage(), status);
} catch (Exception ex) {
return writeError(exchange, ErrorCode.UNAUTHORIZED.getCode(),
ErrorCode.UNAUTHORIZED.getMessage(), HttpStatus.UNAUTHORIZED);
}
}
@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
private boolean isPreflightRequest(ServerWebExchange exchange) {
return HttpMethod.OPTIONS.equals(exchange.getRequest().getMethod());
}
private boolean isAdminPath(String path) {
return path != null && path.startsWith(ADMIN_PATH_PREFIX);
}
private boolean isWhitelisted(String path) {
List<String> whitelist = whitelistProperties.getWhitelist();
if (whitelist == null || whitelist.isEmpty()) {
return false;
}
return whitelist.stream().anyMatch(pattern -> pathMatcher.match(pattern, path));
}
private Mono<Void> writeError(ServerWebExchange exchange, int code, String message, HttpStatus status) {
exchange.getResponse().setStatusCode(status);
exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON);
byte[] bytes = toJsonBytes(ApiResponse.fail(code, message));
DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(bytes);
return exchange.getResponse().writeWith(Mono.just(buffer));
}
private byte[] toJsonBytes(ApiResponse<Void> response) {
try {
return objectMapper.writeValueAsBytes(response);
} catch (JsonProcessingException ex) {
return "{\"code\":40100,\"message\":\"未登录或登录已失效\",\"data\":null}"
.getBytes(StandardCharsets.UTF_8);
}
}
}
白名单配置类:
java
package com.luo.raggateway.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.List;
@ConfigurationProperties(prefix = "rag.auth")
public class AuthWhitelistProperties {
private List<String> whitelist = new ArrayList<>();
public List<String> getWhitelist() { return whitelist; }
public void setWhitelist(List<String> whitelist) { this.whitelist = whitelist; }
}
配置示例:
yaml
rag:
auth:
whitelist:
- /auth/register
- /auth/login
- /doc.html
- /swagger-ui/**
- /v3/api-docs/**
关键依赖:
xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
4.6 下游服务如何获取用户信息
Gateway 只是第一步。
请求转发到 knowledge-service 后,业务服务还需要方便地拿到当前用户。
4.6.1 UserContext
可以设计一个 UserContext,用于保存当前请求的用户信息:
text
userId
username
role
业务代码就不用每次从请求头里手动解析。
4.6.2 UserContextInterceptor
下游服务可以通过拦截器读取请求头:
text
X-User-Id
X-Username
X-User-Role
然后写入 UserContext。
请求处理结束后,再清理上下文,避免线程复用导致用户信息串号。
这点很重要。因为 Web 容器线程会复用,如果不清理 ThreadLocal,可能出现上一个请求的用户信息残留到下一个请求中。
4.6.3 业务服务使用用户上下文
knowledge-service 查询知识库时,不应该让前端传 userId,而应该使用当前上下文:
text
当前 userId = UserContext.getUserId()
查询当前用户自己的知识库
删除知识库、上传文档、发起问答时,也都要校验资源归属。
这就是用户隔离的基础。
下面用查询知识库列表演示资源隔离的具体写法。控制器不接收 userId,而是从 UserContext 获取当前用户。
java
@RestController
@RequestMapping("/kb")
public class KnowledgeBaseController {
private final KnowledgeBaseService knowledgeBaseService;
public KnowledgeBaseController(KnowledgeBaseService knowledgeBaseService) {
this.knowledgeBaseService = knowledgeBaseService;
}
@GetMapping("/list")
public ApiResponse<List<KnowledgeBaseResponse>> list() {
// userId 来自 Gateway 透传后的 UserContext,不来自前端参数。
Long userId = UserContext.requireUserId();
return ApiResponse.success(knowledgeBaseService.listCurrentUserKnowledgeBases(userId));
}
}
业务服务只查询当前用户自己的知识库。
java
@Service
public class KnowledgeBaseServiceImpl implements KnowledgeBaseService {
private final KnowledgeBaseMapper knowledgeBaseMapper;
public KnowledgeBaseServiceImpl(KnowledgeBaseMapper knowledgeBaseMapper) {
this.knowledgeBaseMapper = knowledgeBaseMapper;
}
@Override
public List<KnowledgeBaseResponse> listCurrentUserKnowledgeBases(Long userId) {
if (userId == null || userId <= 0) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "当前用户身份无效");
}
List<KnowledgeBase> list = knowledgeBaseMapper.selectList(
new LambdaQueryWrapper<KnowledgeBase>()
.eq(KnowledgeBase::getUserId, userId)
.orderByDesc(KnowledgeBase::getUpdatedAt));
return list.stream().map(KnowledgeBaseResponse::from).toList();
}
}
这段代码的重点是 .eq(KnowledgeBase::getUserId, userId)。如果少了这个条件,用户 A 就可能看到用户 B 的知识库。
4.6.4 UserContext 和拦截器完整实现
UserContext 使用 ThreadLocal 保存当前请求的用户身份。它只在一次请求生命周期内有效。
java
package com.luo.ragknowledge.common.security;
import com.luo.ragcommon.exception.BusinessException;
import com.luo.ragcommon.exception.ErrorCode;
import org.springframework.util.StringUtils;
/** 当前请求用户上下文。Web 容器线程会复用,所以请求结束后必须 clear。 */
public final class UserContext {
private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();
private static final ThreadLocal<String> USERNAME = new ThreadLocal<>();
private static final ThreadLocal<String> ROLE = new ThreadLocal<>();
private UserContext() {}
public static void setUserId(Long userId) { USER_ID.set(userId); }
public static Long getUserId() { return USER_ID.get(); }
public static Long requireUserId() {
Long userId = getUserId();
if (userId == null) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "当前请求缺少用户身份");
}
return userId;
}
public static void setUsername(String username) { USERNAME.set(username); }
public static String getUsername() { return USERNAME.get(); }
public static void setRole(String role) { ROLE.set(role); }
public static String getRole() { return ROLE.get(); }
public static boolean isAdmin() {
return StringUtils.hasText(getRole()) && "ADMIN".equalsIgnoreCase(getRole());
}
public static void clear() {
USER_ID.remove();
USERNAME.remove();
ROLE.remove();
}
}
java
package com.luo.ragknowledge.common.security;
import com.luo.ragcommon.exception.BusinessException;
import com.luo.ragcommon.exception.ErrorCode;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.HandlerInterceptor;
/** 下游服务用户上下文拦截器。它不解析 JWT,只读取 Gateway 透传的可信请求头。 */
@Component
public class UserContextInterceptor implements HandlerInterceptor {
private static final String USER_ID_HEADER = "X-User-Id";
private static final String USERNAME_HEADER = "X-Username";
private static final String USER_ROLE_HEADER = "X-User-Role";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String userIdValue = request.getHeader(USER_ID_HEADER);
if (!StringUtils.hasText(userIdValue)) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "请求缺少 X-User-Id");
}
try {
UserContext.setUserId(Long.valueOf(userIdValue));
} catch (NumberFormatException ex) {
throw new BusinessException(ErrorCode.UNAUTHORIZED, "X-User-Id 格式错误");
}
UserContext.setUsername(request.getHeader(USERNAME_HEADER));
UserContext.setRole(request.getHeader(USER_ROLE_HEADER));
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 必须清理 ThreadLocal,避免线程复用导致用户串号。
UserContext.clear();
}
}
最后把拦截器注册到 Spring MVC。
java
package com.luo.ragknowledge.common.config;
import com.luo.ragknowledge.common.security.UserContextInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final UserContextInterceptor userContextInterceptor;
public WebMvcConfig(UserContextInterceptor userContextInterceptor) {
this.userContextInterceptor = userContextInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(userContextInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/actuator/**", "/doc.html", "/swagger-ui/**", "/v3/api-docs/**");
}
}
关键依赖:
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
4.7 用户端和管理端如何配合
认证不是只有后端的事情,前端也要配合。
4.7.1 用户端
用户端登录后保存 Token 。
Axios 请求拦截器会自动加上:
text
Authorization: Bearer token
如果后端返回 401 ,前端应该清理 Token 并跳转登录页。
用户端只展示普通用户需要的页面,比如知识库、文档、问答。
4.7.2 管理端
管理端登录后,不仅要拿到 Token,还要检查用户角色。
如果登录用户不是 ADMIN,前端应该提示无权限。
但是要记住:前端检查只是用户体验,不是安全边界。
真正的安全边界仍然在后端:Gateway 必须拦截 /admin/**,业务服务也要避免普通用户访问全局数据。
4.8 完整访问链路示例
下面用一次"用户查看自己的知识库列表"来串起完整链路。
text
1. 用户在前端输入账号密码
2. 前端请求 POST /auth/login
3. Gateway 发现 /auth/login 在白名单中,直接放行
4. auth-service 校验账号密码
5. auth-service 签发 JWT
6. 前端保存 Token
7. 用户访问知识库列表
8. 前端请求 GET /kb/list,并携带 Authorization
9. Gateway 校验 JWT
10. Gateway 解析 userId、username、role
11. Gateway 写入 X-User-Id、X-Username、X-User-Role
12. Gateway 转发到 knowledge-service
13. knowledge-service 拦截器读取请求头,写入 UserContext
14. KnowledgeBaseService 查询当前 userId 的知识库
15. 返回当前用户自己的知识库列表
再看一次管理员访问后台任务列表:
text
1. 管理员登录
2. Token 中携带 role=ADMIN
3. 前端访问 /admin/index-tasks
4. Gateway 校验 Token
5. Gateway 判断路径为 /admin/**
6. Gateway 检查 role 是否 ADMIN
7. 校验通过,转发到 task-service
8. task-service 返回任务列表
如果普通用户访问同一个接口,Gateway 会在第 6 步直接拒绝。
4.9 常见问题排查
4.9.1 请求没有带 Authorization
现象:访问知识库接口返回未登录。
排查:
- 前端是否保存了 Token。
- Axios 拦截器是否正确添加请求头。
- 请求是不是绕过了封装的 request 实例。
4.9.2 Bearer 格式错误
正确格式是:
text
Authorization: Bearer xxx
常见错误:
text
Authorization: xxx
Authorization: Bearerxxx
Authorization: token xxx
Gateway 解析时通常会严格判断前缀。
4.9.3 Token 过期
现象:之前能访问,过一段时间后突然返回未登录。
排查:
- JWT 过期时间是否符合预期。
- 前端是否在 401 后清理 Token。
- 是否需要重新登录。
4.9.4 白名单漏配
现象:登录接口也提示未登录。
排查:
/auth/login是否在 Gateway 白名单。/auth/register是否在 Gateway 白名单。- 路径是否和实际请求一致。
4.9.5 下游服务拿不到用户信息
现象:Gateway 校验通过,但 knowledge-service 提示用户上下文不存在。
排查:
- Gateway 是否写入
X-User-Id。 - 下游服务是否配置了拦截器。
- 请求是否绕过 Gateway 直接访问了 knowledge-service。
- 请求头名称是否大小写或拼写不一致。
4.9.6 普通用户访问管理端
现象:普通用户访问 /admin/** 返回无权限。
这是正确现象。
如果普通用户能访问管理接口,才是问题。
排查时要确认:
- Token 中 role 是否正确。
- Gateway 是否判断
/admin/路径。 - 管理端前端是否也做了角色提示。
4.9.7 跨域问题
现象:浏览器控制台报 CORS 错误。
排查:
- Gateway 是否配置 CORS。
- 前端请求地址是否走 Gateway。
- 是否携带了非简单请求头,比如 Authorization。
- OPTIONS 预检请求是否被 Gateway 放行。
本章小结
这一章我们讲清楚了 KnowHub 的认证和统一鉴权设计。
企业知识库必须先知道当前用户是谁,才能判断他能访问哪些资源。前端传入的 userId 不可信,真正可信的身份应该来自登录后签发的 JWT。
rag-auth-service 负责注册、登录、密码加密和 Token 签发。rag-gateway-service 负责统一校验 Token、放行白名单、透传用户信息,并对 /admin/** 做管理员角色校验。下游服务通过 X-User-Id、X-Username、X-User-Role 构造用户上下文,再完成具体资源归属校验。
你需要记住一句话:
Gateway 负责判断"有没有登录、是不是管理员",业务服务负责判断"这个资源是不是你的"。
下一章,我们会在这个基础上继续讲用户资源隔离与基础后台管理,重点看知识库 owner 校验、Redis owner 缓存、用户端和管理端如何区分职责。
动手验证:从注册到查询知识库
学完本章后,建议用 Postman 按下面的顺序验证。这里假设所有请求都通过 Gateway 访问,网关地址是:
text
http://localhost:8080
第一步,注册一个普通用户。
text
请求方法:POST
URL:http://localhost:8080/auth/register
请求头:Content-Type: application/json
请求体:
{
"username": "user01",
"password": "123456",
"nickname": "普通用户01"
}
预期返回:
json
{
"code": 0,
"message": "success",
"data": {
"userId": 1,
"username": "user01",
"role": "USER",
"status": 1
}
}
第二步,用这个用户登录。
text
请求方法:POST
URL:http://localhost:8080/auth/login
请求头:Content-Type: application/json
请求体:
{
"username": "user01",
"password": "123456"
}
预期返回中应该包含 token、tokenType 和 expiresAt。
第三步,不带 Token 访问知识库列表。
text
请求方法:GET
URL:http://localhost:8080/kb/list
请求头:无 Authorization
预期返回 401 或业务错误码 40100,表示未登录或登录已失效。
第四步,带上 Token 访问知识库列表。
text
请求方法:GET
URL:http://localhost:8080/kb/list
请求头:
Authorization: Bearer 上一步登录返回的token
如果还没有创建知识库,预期返回空列表。
json
{
"code": 0,
"message": "success",
"data": []
}
第五步,用普通用户 Token 访问管理端接口。
text
请求方法:GET
URL:http://localhost:8080/admin/index-tasks
请求头:
Authorization: Bearer 普通用户token
预期返回 403 或业务错误码 40300,表示普通用户没有权限访问后台接口。
第六步,使用管理员账号访问管理端接口。
如果注册接口不支持直接创建管理员账号,可以在数据库中把某个用户的角色改成 ADMIN:
sql
UPDATE user_account
SET role = 'ADMIN'
WHERE username = 'admin01';
然后使用管理员账号登录,拿到管理员 Token,再访问:
text
请求方法:GET
URL:http://localhost:8080/admin/index-tasks
请求头:
Authorization: Bearer 管理员token
预期返回 200 和索引任务列表。如果当前还没有任务,列表可以为空,但不能返回 401 或 403。
如果某一步不符合预期,不要盲目改代码。先对照本章 4.9 的常见问题排查清单:确认请求是否经过 Gateway、白名单是否正确、Authorization 是否带 Bearer 前缀、Token 是否过期、Gateway 是否写入 X-User-Id、下游拦截器是否注册、普通用户是否误访问了 /admin/**。
思考题
思考题
-
为什么不能让前端直接传
userId来访问知识库?答案: 前端传入的
userId是不可信的。用户可以通过修改请求参数(如将userId=1改为userId=2)来伪装成其他用户,从而访问不属于自己的资源。在真实的企业系统中,用户身份必须来自服务端签发的、经过加密签名的凭证(如 JWT),以确保其真实性和不可篡改性。Gateway 统一校验 Token 后,向下游服务透传可信的用户身份信息,业务服务基于此进行资源归属校验,这才是安全的做法。 -
JWT 中适合保存哪些信息,不适合保存哪些信息?
答案:
- 适合保存: 少量、稳定、用于识别身份和权限的信息。例如:
userId:用于唯一标识用户,是业务查询的核心依据。username:用于日志记录和前端展示。role:用于区分用户角色(如USER、ADMIN),进行粗粒度权限控制。exp(过期时间):确保 Token 的有效期。
- 不适合保存: 任何敏感信息。因为 JWT 的 Payload 部分仅是 Base64URL 编码,并非加密,任何人拿到 Token 都可以解码查看其内容。绝对禁止放入密码、手机号、身份证号、密保答案等敏感数据。JWT 的安全性在于签名的不可伪造性,而非内容的保密性。
- 适合保存: 少量、稳定、用于识别身份和权限的信息。例如:
-
Gateway 统一鉴权相比每个服务重复鉴权有什么好处?
答案:
- 安全一致性: 所有服务的鉴权逻辑和规则由 Gateway 统一维护,避免了各个服务实现不一致导致的安全漏洞。
- 代码复用与维护性: 鉴权逻辑只需在 Gateway 中编写和维护一次,降低了代码重复度和后续升级成本。
- 性能优化: Token 解析和校验在 Gateway 层一次性完成,下游业务服务无需重复进行 JWT 解析和签名验证,减少了计算开销。
- 职责分离: Gateway 专注于"身份认证"和"粗粒度权限"(如是否登录、是否为管理员),业务服务则专注于"细粒度资源权限"(如资源归属),架构更清晰。
-
为什么 Gateway 透传用户信息前要移除外部传入的同名请求头?
答案: 这是为了防止请求头伪造攻击。如果 Gateway 不先移除外部传入的
X-User-Id、X-Username、X-User-Role等头信息,恶意用户就可以在请求中自行添加这些头,从而绕过 Gateway 的 JWT 校验,直接伪装成任何用户身份访问下游服务。Gateway 在验证 JWT 有效后,移除 外部传入的这些头,再重新写入自己从可信 Token 中解析出的值,确保了传递给下游服务的用户身份信息是绝对可信的。 -
/admin/**为什么应该在 Gateway 层做角色校验?答案: 在 Gateway 层进行管理员路径的角色校验,属于安全边界前置 和统一入口控制。
- 效率与安全: 非法请求(普通用户访问管理接口)在进入系统内部网络之前就被 Gateway 拦截并返回 403,避免了请求穿透到内部业务服务所带来的不必要的网络开销和潜在风险。
- 统一管控: 所有管理端接口的访问策略在 Gateway 一处定义和维护,规则清晰,修改方便,避免了在每个业务服务的
@PreAuthorize或拦截器中重复配置。 - 职责清晰: Gateway 负责"是不是管理员"这类系统级、粗粒度的权限控制;业务服务则负责"这个管理数据你能不能操作"这类业务级、细粒度的权限控制。
-
业务服务为什么还要做资源归属校验?
答案: Gateway 的鉴权解决了"你是谁"(身份)和"你是什么角色"(粗粒度权限)的问题,但无法解决"这个资源是不是你的"(细粒度数据权限)问题。例如,即使都是普通用户(
USER),用户 A 也绝不能访问或操作用户 B 创建的知识库。因此,业务服务在执行业务逻辑(如查询、更新、删除)时,必须 基于 Gateway 透传的可信userId,在数据库查询条件中显式添加WHERE user_id = ?之类的归属过滤条件。这是实现用户数据隔离、保障数据安全的最后一道也是最重要的一道防线。 -
如果 knowledge-service 拿不到
X-User-Id,应该按什么顺序排查?答案: 建议按以下链路从外到内、从大到小进行排查:
- 请求是否经过 Gateway? 确认前端请求的地址是 Gateway 的地址(如
http://localhost:8080),而不是直接访问了 knowledge-service 的地址。 - Gateway 是否成功校验并写入了请求头? 检查 Gateway 日志,确认 JWT 校验通过,并且
JwtAuthGlobalFilter成功执行了headers.set(USER_ID_HEADER, ...)。 - 请求头名称是否正确? 确认 knowledge-service 拦截器(
UserContextInterceptor)中读取的请求头名称(如X-User-Id)与 Gateway 写入的名称完全一致(注意大小写)。 - 下游服务拦截器是否生效? 确认
UserContextInterceptor已被正确注册到 Spring MVC,并且请求路径没有被excludePathPatterns排除。 - 是否有其他过滤器/拦截器移除了请求头? 检查 knowledge-service 中是否有其他全局过滤器或组件在处理请求时,意外地移除了
X-User-Id等头信息。
- 请求是否经过 Gateway? 确认前端请求的地址是 Gateway 的地址(如