Spring Security 商业场景训练营
这页把 Spring Security 放到后台管理、医疗数据采集平台、资产平台这类商业系统里学习。目标不是“会写一个登录接口”,而是能解释一次请求为什么先经过过滤器链,认证对象怎么流转,JWT 为什么能识别用户,权限为什么不能只靠前端按钮,数据权限为什么必须落到后端业务层。
训练目标
学完这页,你要能做到:
- 画出从 HTTP 请求到 Controller 的完整认证授权链路。
- 解释
SecurityFilterChain、Authentication、AuthenticationManager、AuthenticationProvider、SecurityContext的关系。 - 解释 Session 登录、JWT 登录、OAuth2 登录分别适合什么场景。
- 设计用户、角色、权限、部门、租户、数据范围模型。
- 写出 JWT 前后端分离的最小可落地 Demo。
- 解释为什么接口权限、按钮权限、数据权限不是一回事。
- 排查登录失败、JWT 无效、403、跨域失败、过滤器顺序错误。
- 面试时能把认证、授权、数据权限、安全风险和项目落地串起来回答。
商业场景总览
以“医疗数据采集与资产平台”为例,后台用户进入系统后可能执行:
- 查看资产列表。
- 启动某医院采集任务。
- 查看采集异常日志。
- 修改医院接口配置。
- 导出敏感数据。
这些操作不能只判断“是否登录”,还要判断“是否有接口权限”“是否有数据范围”“是否能操作这家医院”“是否允许导出敏感字段”。
flowchart TD
A["用户请求"] --> B["Spring Security 过滤器链"]
B --> C["认证:你是谁"]
C --> D["接口授权:能不能访问这个接口"]
D --> E["业务数据权限:能不能操作这条数据"]
E --> F["字段和操作审计"]
F --> G["Controller / Service"]如果只做登录,不做数据权限,用户可能登录后访问别的医院、别的租户、别的部门的数据,这就是典型越权漏洞。
一次 JWT 请求完整链路
flowchart TD
A["前端请求携带 Authorization"] --> B["SecurityFilterChain"]
B --> C["JWT Filter 读取 Bearer Token"]
C --> D["校验签名、过期时间、issuer"]
D --> E{"Token 是否有效"}
E -- "无效" --> F["AuthenticationEntryPoint 返回 401"]
E -- "有效" --> G["解析 userId、tenantId、权限版本"]
G --> H["加载用户权限或校验权限缓存"]
H --> I["构造 Authentication"]
I --> J["写入 SecurityContext"]
J --> K["AuthorizationFilter 判断接口权限"]
K --> L{"是否有权限"}
L -- "否" --> M["AccessDeniedHandler 返回 403"]
L -- "是" --> N["进入 Controller"]
N --> O["Service 层继续做数据权限"]关键点:
- JWT Filter 的职责是恢复用户身份,不应该写复杂业务。
- 接口权限在 Spring Security 层判断。
- 数据权限必须在业务层结合资源归属判断。
- 401 是没登录或 Token 无效;403 是已登录但没权限。
SecurityContext通常绑定当前请求线程,异步线程要额外传递。
核心对象怎么配合
flowchart TD
A["请求中的凭证"] --> B["认证过滤器"]
B --> C["AuthenticationManager"]
C --> D["AuthenticationProvider"]
D --> E["UserDetailsService"]
E --> F["UserDetails"]
D --> G["PasswordEncoder 或 JWT 校验"]
G --> H["认证成功 Authentication"]
H --> I["SecurityContext"]
I --> J["授权判断"]| 对象 | 作用 | 商业项目里常见实现 |
|---|---|---|
SecurityFilterChain | 组织安全过滤器和授权规则 | 配置哪些接口放行、哪些接口登录 |
Authentication | 当前用户身份和权限 | 存 userId、tenantId、authorities |
AuthenticationManager | 认证入口 | 登录接口里调用 |
AuthenticationProvider | 具体认证逻辑 | 账号密码、短信、LDAP、自定义登录 |
UserDetailsService | 加载用户信息 | 查用户、角色、权限、状态 |
PasswordEncoder | 密码哈希校验 | BCrypt、Argon2 |
SecurityContext | 保存当前用户 | 当前请求线程中获取用户 |
Demo 一:用户、角色、权限、数据范围表设计
用户表
create table sys_user (
id bigint primary key,
username varchar(64) not null,
password_hash varchar(255) not null,
tenant_id bigint not null,
department_id bigint not null,
enabled tinyint not null,
locked tinyint not null,
token_version int not null default 0,
unique key uk_tenant_username (tenant_id, username)
);角色和权限
create table sys_role (
id bigint primary key,
tenant_id bigint not null,
role_code varchar(64) not null,
role_name varchar(128) not null,
unique key uk_tenant_role (tenant_id, role_code)
);
create table sys_permission (
id bigint primary key,
permission_code varchar(128) not null,
permission_name varchar(128) not null,
resource_type varchar(32) not null,
unique key uk_permission_code (permission_code)
);
create table sys_user_role (
user_id bigint not null,
role_id bigint not null,
primary key (user_id, role_id)
);
create table sys_role_permission (
role_id bigint not null,
permission_id bigint not null,
primary key (role_id, permission_id)
);数据范围
create table sys_role_data_scope (
role_id bigint not null,
scope_type varchar(32) not null,
scope_value varchar(128) not null,
primary key (role_id, scope_type, scope_value)
);权限码建议稳定:
asset:read
asset:create
asset:update
collect:task:start
collect:task:stop
hospital:config:update
data:export为什么权限码要稳定?因为它会出现在后端注解、前端菜单、角色配置、审计日志、接口文档里。如果随便改名,权限体系会大面积失效。
Demo 二:JWT 前后端分离骨架
Security 配置
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated())
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new JsonAuthenticationEntryPoint())
.accessDeniedHandler(new JsonAccessDeniedHandler()))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}登录接口
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtTokenService jwtTokenService;
public AuthController(AuthenticationManager authenticationManager,
JwtTokenService jwtTokenService) {
this.authenticationManager = authenticationManager;
this.jwtTokenService = jwtTokenService;
}
@PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.username(),
request.password()
)
);
LoginUser user = (LoginUser) authentication.getPrincipal();
String accessToken = jwtTokenService.createAccessToken(user);
String refreshToken = jwtTokenService.createRefreshToken(user);
return new LoginResponse(accessToken, refreshToken);
}
}JWT 过滤器
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenService jwtTokenService;
private final PermissionService permissionService;
public JwtAuthenticationFilter(JwtTokenService jwtTokenService,
PermissionService permissionService) {
this.jwtTokenService = jwtTokenService;
this.permissionService = permissionService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
filterChain.doFilter(request, response);
return;
}
String token = header.substring(7);
JwtClaims claims = jwtTokenService.parseAndVerify(token);
List<GrantedAuthority> authorities =
permissionService.loadAuthorities(claims.userId(), claims.tokenVersion());
LoginPrincipal principal = new LoginPrincipal(
claims.userId(),
claims.tenantId(),
claims.departmentId()
);
Authentication authentication =
new UsernamePasswordAuthenticationToken(principal, null, authorities);
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}注意:JWT 的 payload 不要放手机号、身份证、密码、详细权限列表等敏感或过大的内容。普通 JWT 是签名,不是加密,拿到 Token 的人可以解码 payload。
Demo 三:接口权限和数据权限
接口权限:
@PreAuthorize("hasAuthority('asset:read')")
@GetMapping("/api/assets/{assetId}")
public AssetVO getAsset(@PathVariable Long assetId) {
return assetService.getAsset(assetId);
}数据权限:
public AssetVO getAsset(Long assetId) {
LoginPrincipal user = SecurityUser.current();
Asset asset = assetRepository.findById(assetId);
if (!dataScopeService.canReadAsset(user, asset)) {
throw new AccessDeniedException("无权访问该资产");
}
return AssetVO.from(asset);
}为什么两层都要做?
| 权限 | 判断问题 | 例子 |
|---|---|---|
| 接口权限 | 能不能调用这个接口 | 是否有 asset:read |
| 数据权限 | 能不能看这条数据 | 是否属于当前租户、医院、部门 |
| 字段权限 | 能不能看这个字段 | 是否能看身份证、手机号 |
| 操作权限 | 能不能做高风险动作 | 是否能导出、删除、停用 |
只做接口权限会导致:用户有“查看资产”权限,但可能查看了其他医院资产。
Session、JWT、OAuth2 怎么选
| 方案 | 状态保存 | 优点 | 代价 | 适合场景 |
|---|---|---|---|---|
| Session | 服务端 | 易撤销、权限变更容易生效 | 多实例要共享 Session | 传统后台、同域系统 |
| JWT | 客户端 Token | 前后端分离友好、网关易识别 | 撤销和泄露处理复杂 | App、前后端分离、网关 |
| OAuth2/OIDC | 认证中心 | 统一身份、单点登录 | 接入复杂 | 企业统一登录、第三方登录 |
生产建议:
- 后台管理如果强控制会话,Session 很稳。
- 前后端分离可以用短 access token + refresh token。
- JWT 要有过期时间、刷新机制、黑名单或
tokenVersion。 - 企业内多系统统一登录,用 OAuth2/OIDC。
Token 泄露怎么办
flowchart TD
A["发现 Token 泄露"] --> B["提高用户 tokenVersion"]
B --> C["旧 Token 校验失败"]
C --> D["强制重新登录"]
D --> E["审计异常 IP 和设备"]
E --> F["修复泄露来源"]常见措施:
- 全站 HTTPS。
- access token 短有效期。
- refresh token 单独保存并可撤销。
- Redis 黑名单或用户
tokenVersion。 - 重要操作二次校验。
- 风险登录告警。
- 前端防 XSS,避免 Token 被脚本偷走。
CSRF、CORS、XSS 放到商业系统里看
CSRF
如果系统用 Cookie 保存登录态,浏览器会自动携带 Cookie。恶意页面可能诱导用户向系统发请求。
flowchart TD
A["用户登录后台"] --> B["浏览器保存 Cookie"]
C["恶意网站"] --> D["发起转账或修改请求"]
D --> E["浏览器自动带 Cookie"]
E --> F["系统误认为用户主动操作"]前后端分离如果 Token 放在 Authorization Header,CSRF 风险降低,但 XSS 风险仍然很高。
CORS
CORS 是浏览器跨域控制,不是服务端调用控制。危险配置是随便允许所有 Origin 又允许 Credentials。
XSS
XSS 会让攻击者脚本在用户浏览器执行,可能偷 Token、发请求、修改页面。安全系统不能只盯后端,前端输出转义、富文本白名单、CSP、HttpOnly Cookie 也很重要。
生产排查训练
登录失败
flowchart TD
A["登录失败"] --> B["用户名是否存在"]
B --> C["用户是否禁用或锁定"]
C --> D["密码 hash 是否 BCrypt matches"]
D --> E["AuthenticationProvider 是否匹配"]
E --> F["失败处理器是否返回正确错误码"]JWT 无效
flowchart TD
A["JWT 无效"] --> B["请求头是否是 Bearer"]
B --> C["签名密钥是否一致"]
C --> D["是否过期"]
D --> E["issuer 和 audience 是否匹配"]
E --> F["tokenVersion 是否已失效"]
F --> G["过滤器顺序是否正确"]明明有角色却 403
flowchart TD
A["出现 403"] --> B["是否认证成功"]
B --> C["SecurityContext 是否有 Authentication"]
C --> D["authorities 是否包含权限码"]
D --> E["hasRole 和 hasAuthority 是否混用"]
E --> F["方法权限是否开启"]
F --> G["是否被数据权限拒绝"]跨域失败
flowchart TD
A["跨域失败"] --> B["浏览器控制台看报错"]
B --> C["OPTIONS 预检是否放行"]
C --> D["Origin 是否在白名单"]
D --> E["Method 和 Header 是否允许"]
E --> F["Credentials 配置是否正确"]商业项目落地清单
| 检查项 | 为什么重要 |
|---|---|
| 密码使用 BCrypt/Argon2 | 防止数据库泄露后被快速撞库 |
| 区分 401 和 403 | 前端能正确跳登录或提示无权限 |
| JWT 短有效期 | 降低泄露风险 |
| Token 可撤销 | 用户退出、改密、封禁能生效 |
| 权限码稳定 | 防止前后端和角色配置混乱 |
| 数据权限后端校验 | 防止越权访问 |
| 高风险操作审计 | 能追踪谁做了什么 |
| CORS 白名单 | 防止跨站风险扩大 |
| 敏感字段脱敏 | 防止日志和页面泄露 |
| 异常统一 JSON | 前后端分离体验一致 |
面试标准回答
Spring Security 请求链路怎么答
请求进入应用后先经过 Spring Security Filter Chain。认证过滤器会解析 Session、JWT 或用户名密码等凭证,认证成功后构造 Authentication 并放入 SecurityContext。后续授权过滤器根据 URL 规则、方法注解或权限码判断是否允许访问。未认证返回 401,已认证但权限不足返回 403。商业系统还要在 Service 层结合租户、部门、资源归属做数据权限,因为接口权限只能判断能不能调用接口,不能判断能不能访问某条具体数据。JWT 和 Session 怎么选
Session 把登录状态保存在服务端,适合传统后台和需要强会话控制的系统;JWT 把身份声明放在签名 Token 中,适合前后端分离、移动端和网关统一认证。JWT 扩展性好,但撤销、权限变更实时生效和泄露处理更复杂,通常要短 access token、refresh token、黑名单或 tokenVersion。不能简单说 JWT 一定比 Session 好,要按业务场景选择。为什么权限注解不能解决数据权限
权限注解只能判断当前用户是否有某个接口或方法权限,例如 asset:read,但它不知道这条资产属于哪个租户、医院或部门。真实商业系统必须在业务层或数据访问层做数据权限校验,否则用户可能有查看资产接口权限,却越权查看其他医院或其他租户的数据。