Skip to content

Spring Security 商业场景训练营

这页把 Spring Security 放到后台管理、医疗数据采集平台、资产平台这类商业系统里学习。目标不是“会写一个登录接口”,而是能解释一次请求为什么先经过过滤器链,认证对象怎么流转,JWT 为什么能识别用户,权限为什么不能只靠前端按钮,数据权限为什么必须落到后端业务层。

训练目标

学完这页,你要能做到:

  1. 画出从 HTTP 请求到 Controller 的完整认证授权链路。
  2. 解释 SecurityFilterChainAuthenticationAuthenticationManagerAuthenticationProviderSecurityContext 的关系。
  3. 解释 Session 登录、JWT 登录、OAuth2 登录分别适合什么场景。
  4. 设计用户、角色、权限、部门、租户、数据范围模型。
  5. 写出 JWT 前后端分离的最小可落地 Demo。
  6. 解释为什么接口权限、按钮权限、数据权限不是一回事。
  7. 排查登录失败、JWT 无效、403、跨域失败、过滤器顺序错误。
  8. 面试时能把认证、授权、数据权限、安全风险和项目落地串起来回答。

商业场景总览

以“医疗数据采集与资产平台”为例,后台用户进入系统后可能执行:

  1. 查看资产列表。
  2. 启动某医院采集任务。
  3. 查看采集异常日志。
  4. 修改医院接口配置。
  5. 导出敏感数据。

这些操作不能只判断“是否登录”,还要判断“是否有接口权限”“是否有数据范围”“是否能操作这家医院”“是否允许导出敏感字段”。

mermaid
flowchart TD
    A["用户请求"] --> B["Spring Security 过滤器链"]
    B --> C["认证:你是谁"]
    C --> D["接口授权:能不能访问这个接口"]
    D --> E["业务数据权限:能不能操作这条数据"]
    E --> F["字段和操作审计"]
    F --> G["Controller / Service"]

如果只做登录,不做数据权限,用户可能登录后访问别的医院、别的租户、别的部门的数据,这就是典型越权漏洞。

一次 JWT 请求完整链路

mermaid
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 层继续做数据权限"]

关键点:

  1. JWT Filter 的职责是恢复用户身份,不应该写复杂业务。
  2. 接口权限在 Spring Security 层判断。
  3. 数据权限必须在业务层结合资源归属判断。
  4. 401 是没登录或 Token 无效;403 是已登录但没权限。
  5. SecurityContext 通常绑定当前请求线程,异步线程要额外传递。

核心对象怎么配合

mermaid
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 一:用户、角色、权限、数据范围表设计

用户表

sql
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)
);

角色和权限

sql
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)
);

数据范围

sql
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)
);

权限码建议稳定:

text
asset:read
asset:create
asset:update
collect:task:start
collect:task:stop
hospital:config:update
data:export

为什么权限码要稳定?因为它会出现在后端注解、前端菜单、角色配置、审计日志、接口文档里。如果随便改名,权限体系会大面积失效。

Demo 二:JWT 前后端分离骨架

Security 配置

java
@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();
    }
}

登录接口

java
@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 过滤器

java
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 三:接口权限和数据权限

接口权限:

java
@PreAuthorize("hasAuthority('asset:read')")
@GetMapping("/api/assets/{assetId}")
public AssetVO getAsset(@PathVariable Long assetId) {
    return assetService.getAsset(assetId);
}

数据权限:

java
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认证中心统一身份、单点登录接入复杂企业统一登录、第三方登录

生产建议:

  1. 后台管理如果强控制会话,Session 很稳。
  2. 前后端分离可以用短 access token + refresh token。
  3. JWT 要有过期时间、刷新机制、黑名单或 tokenVersion
  4. 企业内多系统统一登录,用 OAuth2/OIDC。

Token 泄露怎么办

mermaid
flowchart TD
    A["发现 Token 泄露"] --> B["提高用户 tokenVersion"]
    B --> C["旧 Token 校验失败"]
    C --> D["强制重新登录"]
    D --> E["审计异常 IP 和设备"]
    E --> F["修复泄露来源"]

常见措施:

  1. 全站 HTTPS。
  2. access token 短有效期。
  3. refresh token 单独保存并可撤销。
  4. Redis 黑名单或用户 tokenVersion
  5. 重要操作二次校验。
  6. 风险登录告警。
  7. 前端防 XSS,避免 Token 被脚本偷走。

CSRF、CORS、XSS 放到商业系统里看

CSRF

如果系统用 Cookie 保存登录态,浏览器会自动携带 Cookie。恶意页面可能诱导用户向系统发请求。

mermaid
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 也很重要。

生产排查训练

登录失败

mermaid
flowchart TD
    A["登录失败"] --> B["用户名是否存在"]
    B --> C["用户是否禁用或锁定"]
    C --> D["密码 hash 是否 BCrypt matches"]
    D --> E["AuthenticationProvider 是否匹配"]
    E --> F["失败处理器是否返回正确错误码"]

JWT 无效

mermaid
flowchart TD
    A["JWT 无效"] --> B["请求头是否是 Bearer"]
    B --> C["签名密钥是否一致"]
    C --> D["是否过期"]
    D --> E["issuer 和 audience 是否匹配"]
    E --> F["tokenVersion 是否已失效"]
    F --> G["过滤器顺序是否正确"]

明明有角色却 403

mermaid
flowchart TD
    A["出现 403"] --> B["是否认证成功"]
    B --> C["SecurityContext 是否有 Authentication"]
    C --> D["authorities 是否包含权限码"]
    D --> E["hasRole 和 hasAuthority 是否混用"]
    E --> F["方法权限是否开启"]
    F --> G["是否被数据权限拒绝"]

跨域失败

mermaid
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 请求链路怎么答

text
请求进入应用后先经过 Spring Security Filter Chain。认证过滤器会解析 Session、JWT 或用户名密码等凭证,认证成功后构造 Authentication 并放入 SecurityContext。后续授权过滤器根据 URL 规则、方法注解或权限码判断是否允许访问。未认证返回 401,已认证但权限不足返回 403。商业系统还要在 Service 层结合租户、部门、资源归属做数据权限,因为接口权限只能判断能不能调用接口,不能判断能不能访问某条具体数据。

JWT 和 Session 怎么选

text
Session 把登录状态保存在服务端,适合传统后台和需要强会话控制的系统;JWT 把身份声明放在签名 Token 中,适合前后端分离、移动端和网关统一认证。JWT 扩展性好,但撤销、权限变更实时生效和泄露处理更复杂,通常要短 access token、refresh token、黑名单或 tokenVersion。不能简单说 JWT 一定比 Session 好,要按业务场景选择。

为什么权限注解不能解决数据权限

text
权限注解只能判断当前用户是否有某个接口或方法权限,例如 asset:read,但它不知道这条资产属于哪个租户、医院或部门。真实商业系统必须在业务层或数据访问层做数据权限校验,否则用户可能有查看资产接口权限,却越权查看其他医院或其他租户的数据。

关联知识点