Skip to content

Spring Security 商业场景训练营 ​

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

训练目标 ​

学完这页,你要能做到:

  1. 画出从 HTTP 请求到 Controller 的完整认证授权链路。
  2. 解释 SecurityFilterChain、Authentication、AuthenticationManager、AuthenticationProvider、SecurityContext 的关系。
  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,但它不知道这条资产属于哪个租户、医院或部门。真实商业系统必须在业务层或数据访问层做数据权限校验,否则用户可能有查看资产接口权限,却越权查看其他医院或其他租户的数据。

关联知识点 ​