跳转到内容
123xiao | 无名键客

《Spring Boot 中基于 JWT 与 Spring Security 的前后端分离登录鉴权实战与权限设计》

字数: 0 阅读时长: 1 分钟

背景与问题

前后端分离之后,登录鉴权这件事看起来简单,真正落地时却很容易“半通不通”:

  • 前端拿什么保存登录态?
  • 后端还要不要用 Session?
  • JWT 放哪儿最合适?
  • Spring Security 默认那套表单登录,怎么改造成前后端接口风格?
  • 角色、权限、接口访问控制到底怎么设计才不至于后期失控?

我见过不少项目,一开始只是想“做个登录”,最后却演变成:

  • 登录接口能通,但所有接口都 401
  • Token 明明没过期,后端却解析失败
  • 角色权限写死在代码里,需求一变就得全项目搜索替换
  • 刷新 Token、退出登录、黑名单一个都没想清楚

这篇文章就从架构视角 + 可运行代码来讲清楚:如何在 Spring Boot 中,基于 JWT + Spring Security 构建一套适合前后端分离的登录鉴权方案,并且把权限设计做得更可维护。


为什么很多团队会选 JWT + Spring Security

在前后端分离场景里,服务端常见有两种登录态思路:

  1. Session/Cookie
  2. JWT Token

先说结论:
如果你的系统是典型的前后端分离、接口服务化、可能还有网关或多端接入,JWT + Spring Security 往往更灵活。

方案对比与取舍

方案优点缺点适用场景
Session + Cookie简单、成熟、服务端可控分布式共享 Session 成本高;跨域麻烦传统单体后台
JWT无状态、适合分布式、跨服务传递方便失效控制更复杂;泄露风险更高前后端分离、多端接入
OAuth2/OIDC标准化、适合开放平台学习和接入成本高大型统一认证平台

这篇文章聚焦的是第二种:JWT 负责携带身份信息,Spring Security 负责接管认证与授权流程。


核心原理

先别急着写代码,先把职责边界理清。很多坑,都是因为没搞清“谁负责什么”。

1. JWT 负责“携带身份声明”

JWT(JSON Web Token)本质上是一个字符串,通常分为三段:

  • Header:签名算法等信息
  • Payload:用户 ID、用户名、角色、过期时间等声明
  • Signature:签名,防止内容被篡改

它的特点是:

  • 服务端签发
  • 客户端保存
  • 每次请求都带上
  • 服务端校验签名与有效期后,恢复出用户身份

2. Spring Security 负责“认证与授权”

Spring Security 不是只会“表单登录”。它更像一套安全框架:

  • 认证 Authentication:你是谁?
  • 授权 Authorization:你能访问什么?

在 JWT 方案中,我们通常这样改造:

  • /api/auth/login:用户名密码登录,校验成功后签发 JWT
  • 其他接口:前端在请求头带上 Authorization: Bearer xxx
  • 自定义过滤器解析 JWT
  • 解析成功后把用户信息放进 SecurityContext
  • Spring Security 再根据权限规则决定是否放行

3. 一条请求的完整流转

flowchart LR
    A[前端登录请求<br>/api/auth/login] --> B[认证接口校验用户名密码]
    B --> C[生成 JWT 返回前端]
    C --> D[前端保存 Token]
    D --> E[请求受保护接口<br>Authorization: Bearer token]
    E --> F[JWT 过滤器解析 Token]
    F --> G[写入 SecurityContext]
    G --> H[Spring Security 鉴权]
    H --> I[业务接口执行]

4. 认证与授权的边界

这一点特别重要,很多文章会混着讲。

  • 认证:登录时用户名密码是否正确;JWT 是否合法、是否过期
  • 授权:当前用户是否拥有 ADMIN 角色或 user:read 权限

我的建议是:

  • JWT 里尽量放必要且稳定的信息,比如 userIdusername
  • 角色权限是否放进 JWT,要看系统复杂度
    • 小系统:可以放角色,减少查库
    • 中大型系统:建议只放用户标识,权限走缓存/数据库加载,便于动态变更

权限设计:别一上来就只用角色

很多项目刚开始就两个角色:ADMINUSER
三个月后需求变成:

  • 某些管理员只能看报表,不能删用户
  • 某些运营可以审核,但不能导出
  • 同一个角色在不同租户下权限不同

这时候,纯角色模型就不够了。

推荐的权限模型:RBAC 的简化落地

我更推荐的做法是:

  • 用户(User)
  • 角色(Role)
  • 权限(Permission)

关系如下:

  • 用户绑定多个角色
  • 角色绑定多个权限
  • 接口控制尽量落到“权限”层,而不是直接写死角色

例如:

  • 角色:ROLE_ADMINROLE_AUDITOR
  • 权限:user:adduser:deletereport:view

这样后续扩展时,只需要改角色和权限关系,不需要到处改代码。

classDiagram
    class User {
      +Long id
      +String username
      +String password
    }

    class Role {
      +Long id
      +String code
      +String name
    }

    class Permission {
      +Long id
      +String code
      +String name
    }

    User --> Role : many-to-many
    Role --> Permission : many-to-many

设计建议

角色适合做“职责归类”

比如:

  • 系统管理员
  • 审核员
  • 普通用户

权限适合做“接口或操作控制”

比如:

  • sys:user:list
  • sys:user:create
  • sys:user:delete

代码层面建议

  • 接口注解优先使用权限,如 hasAuthority('sys:user:list')
  • 少用过于粗粒度的 hasRole('ADMIN')
  • 保留少量超级管理员兜底逻辑,但别把它当常态

实战代码(可运行)

下面给出一个可运行的最小示例。为了把重点放在 JWT 与 Security 流程上,我这里先用内存用户演示。如果你接数据库,只需要把 UserDetailsService 替换掉即可。

项目依赖

pom.xml

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>com.example</groupId>
    <artifactId>jwt-security-demo</artifactId>
    <version>1.0.0</version>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
    </parent>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
</project>

配置文件

application.yml

server:
  port: 8080

jwt:
  secret: 12345678901234567890123456789012
  expire: 3600000

这里的 secret 至少要足够长,别图省事写个 abc。我当时就踩过这个坑,JJWT 直接给我报密钥强度不够。


启动类

JwtSecurityDemoApplication.java

package com.example.demo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class JwtSecurityDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(JwtSecurityDemoApplication.class, args);
    }
}

JWT 工具类

JwtTokenUtil.java

package com.example.demo.security;

import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;

@Component
public class JwtTokenUtil {

    @Value("${jwt.secret}")
    private String secret;

    @Value("${jwt.expire}")
    private long expire;

    private SecretKey getSecretKey() {
        return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    public String generateToken(String username, Map<String, Object> claims) {
        Date now = new Date();
        Date expireDate = new Date(now.getTime() + expire);

        return Jwts.builder()
                .setSubject(username)
                .addClaims(claims)
                .setIssuedAt(now)
                .setExpiration(expireDate)
                .signWith(getSecretKey(), SignatureAlgorithm.HS256)
                .compact();
    }

    public Claims parseToken(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSecretKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    public String getUsernameFromToken(String token) {
        return parseToken(token).getSubject();
    }

    public boolean isTokenExpired(String token) {
        Date expiration = parseToken(token).getExpiration();
        return expiration.before(new Date());
    }

    public boolean validateToken(String token, String username) {
        String tokenUsername = getUsernameFromToken(token);
        return tokenUsername.equals(username) && !isTokenExpired(token);
    }
}

自定义用户服务

CustomUserDetailsService.java

package com.example.demo.security;

import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.*;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        if ("admin".equals(username)) {
            return new User(
                    "admin",
                    "$2a$10$7EqJtq98hPqEX7fNZaFWoOHi6M9G5J0xYwcmBHQYaWV7k/akdUSH2", // 123456
                    List.of(
                            new SimpleGrantedAuthority("ROLE_ADMIN"),
                            new SimpleGrantedAuthority("sys:user:list"),
                            new SimpleGrantedAuthority("sys:user:add")
                    )
            );
        }

        if ("user".equals(username)) {
            return new User(
                    "user",
                    "$2a$10$7EqJtq98hPqEX7fNZaFWoOHi6M9G5J0xYwcmBHQYaWV7k/akdUSH2", // 123456
                    List.of(
                            new SimpleGrantedAuthority("ROLE_USER"),
                            new SimpleGrantedAuthority("sys:user:list")
                    )
            );
        }

        throw new UsernameNotFoundException("用户不存在");
    }
}

JWT 过滤器

JwtAuthenticationFilter.java

package com.example.demo.security;

import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenUtil jwtTokenUtil;
    private final CustomUserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtTokenUtil jwtTokenUtil, CustomUserDetailsService userDetailsService) {
        this.jwtTokenUtil = jwtTokenUtil;
        this.userDetailsService = userDetailsService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");
        String token = null;
        String username = null;

        if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
            token = authHeader.substring(7);
            try {
                Claims claims = jwtTokenUtil.parseToken(token);
                username = claims.getSubject();
            } catch (Exception e) {
                // 这里不直接抛出,让后续认证入口统一返回 401
            }
        }

        if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
            if (jwtTokenUtil.validateToken(token, userDetails.getUsername())) {
                UsernamePasswordAuthenticationToken authentication =
                        new UsernamePasswordAuthenticationToken(
                                userDetails,
                                null,
                                userDetails.getAuthorities()
                        );
                authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }
}

认证接口 DTO

LoginRequest.java

package com.example.demo.dto;

public class LoginRequest {
    private String username;
    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;
    }
}

Security 配置

SecurityConfig.java

package com.example.demo.config;

import com.example.demo.security.JwtAuthenticationFilter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@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())
                .cors(Customizer.withDefaults())
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/login").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .exceptionHandling(ex -> ex
                        .authenticationEntryPoint((request, response, authException) -> {
                            response.setContentType("application/json;charset=UTF-8");
                            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                            response.getWriter().write("{\"code\":401,\"message\":\"未认证或Token无效\"}");
                        })
                        .accessDeniedHandler((request, response, accessDeniedException) -> {
                            response.setContentType("application/json;charset=UTF-8");
                            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
                            response.getWriter().write("{\"code\":403,\"message\":\"无权限访问\"}");
                        })
                );

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
        return configuration.getAuthenticationManager();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

上面用到了 HttpServletResponse,别忘了导包:

import jakarta.servlet.http.HttpServletResponse;

登录接口

AuthController.java

package com.example.demo.controller;

import com.example.demo.dto.LoginRequest;
import com.example.demo.security.JwtTokenUtil;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final AuthenticationManager authenticationManager;
    private final JwtTokenUtil jwtTokenUtil;

    public AuthController(AuthenticationManager authenticationManager, JwtTokenUtil jwtTokenUtil) {
        this.authenticationManager = authenticationManager;
        this.jwtTokenUtil = jwtTokenUtil;
    }

    @PostMapping("/login")
    public Map<String, Object> login(@RequestBody LoginRequest request) {
        Authentication authentication = authenticationManager.authenticate(
                new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())
        );

        List<String> authorities = authentication.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList();

        Map<String, Object> claims = new HashMap<>();
        claims.put("authorities", authorities);

        String token = jwtTokenUtil.generateToken(request.getUsername(), claims);

        Map<String, Object> result = new HashMap<>();
        result.put("token", token);
        result.put("tokenType", "Bearer");
        result.put("username", request.getUsername());
        result.put("authorities", authorities);
        return result;
    }
}

业务接口

UserController.java

package com.example.demo.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/users")
public class UserController {

    @GetMapping("/me")
    public Map<String, Object> me() {
        return Map.of("message", "你已通过认证");
    }

    @GetMapping
    @PreAuthorize("hasAuthority('sys:user:list')")
    public Map<String, Object> list() {
        return Map.of("message", "用户列表查询成功");
    }

    @PostMapping
    @PreAuthorize("hasAuthority('sys:user:add')")
    public Map<String, Object> add() {
        return Map.of("message", "新增用户成功");
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN')")
    public Map<String, Object> delete(@PathVariable Long id) {
        return Map.of("message", "删除用户成功", "id", id);
    }
}

请求验证流程演示

sequenceDiagram
    participant FE as 前端
    participant API as Spring Boot API
    participant Filter as JWT Filter
    participant Sec as Spring Security
    participant Biz as Controller

    FE->>API: POST /api/auth/login 用户名密码
    API->>Sec: AuthenticationManager.authenticate
    Sec-->>API: 认证成功
    API-->>FE: 返回 JWT

    FE->>API: GET /api/users Authorization: Bearer token
    API->>Filter: 进入 JWT 过滤器
    Filter->>Filter: 解析并校验 Token
    Filter->>Sec: 写入 Authentication 到 SecurityContext
    Sec->>Biz: 检查权限 sys:user:list
    Biz-->>FE: 返回业务结果

如何运行与测试

启动项目后,可以直接用 curl 测试。

1. 登录

curl -X POST http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"123456"}'

返回示例:

{
  "token": "eyJhbGciOiJIUzI1NiJ9....",
  "tokenType": "Bearer",
  "username": "admin",
  "authorities": [
    "ROLE_ADMIN",
    "sys:user:list",
    "sys:user:add"
  ]
}

2. 访问受保护接口

curl http://localhost:8080/api/users/me \
  -H "Authorization: Bearer 你的token"

3. 访问需要列表权限的接口

curl http://localhost:8080/api/users \
  -H "Authorization: Bearer 你的token"

4. 访问新增接口

curl -X POST http://localhost:8080/api/users \
  -H "Authorization: Bearer 你的token"

如果你登录的是 user / 123456,查询接口能过,但新增接口会返回 403。


常见坑与排查

这一节我尽量讲得“接地气”一点,因为这些问题在实战里真的出现频率很高。

1. 明明带了 Token,还是 401

常见原因

  • 请求头没带 Bearer 前缀
  • 前端把 token 放错位置,比如放成了自定义头
  • Token 已过期
  • JWT 密钥不一致
  • 过滤器没有加入 Spring Security 过滤链

排查顺序

  1. 浏览器开发者工具看请求头
  2. 后端日志打印 Authorization
  3. 单独验证 jwtTokenUtil.parseToken(token) 是否正常
  4. 确认 addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) 是否生效

2. 登录成功,但接口全是 403

这通常不是认证问题,而是授权问题

重点检查

  • @PreAuthorize("hasRole('ADMIN')") 时,权限是否真的有 ROLE_ADMIN
  • hasAuthority('sys:user:add') 时,字符串是否完全一致
  • Spring Security 里角色默认前缀就是 ROLE_

很多人会把:

new SimpleGrantedAuthority("ADMIN")

和:

@PreAuthorize("hasRole('ADMIN')")

配在一起,然后纳闷为什么不行。
因为 hasRole('ADMIN') 底层会匹配 ROLE_ADMIN

3. 密码明明对,登录却失败

常见原因

  • 数据库存的是 BCrypt 密码,但你用了明文比较
  • PasswordEncoder 没注册
  • 复制的密文不完整

建议统一使用:

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

并且数据库里只存加密后的密码。

4. 跨域导致前端请求失败

如果前后端分离部署,跨域经常会被误认为鉴权问题。

症状

  • 浏览器控制台报 CORS 错误
  • Postman 正常,浏览器不正常

解决思路

明确配置 CORS,而不是只在某个 Controller 上临时加注解。

例如:

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.*;

import java.util.List;

@Configuration
public class CorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOriginPatterns(List.of("*"));
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(List.of("*"));
        configuration.setAllowCredentials(false);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

5. Token 中带了权限,但权限改了以后不生效

这是 JWT 常见的“无状态副作用”。

如果你把角色权限直接塞进 JWT,那么:

  • 用户登录后拿到旧权限
  • 管理员后台改了权限
  • 旧 Token 在过期前仍可能继续生效

解决思路

  • 短 Token 生命周期
  • 配合刷新 Token
  • 关键权限实时查库或走缓存
  • 极端情况下引入黑名单机制

安全/性能最佳实践

这部分是架构落地时真正拉开差距的地方。代码能跑不难,能稳、能控、能维护才难。

1. 不要把敏感信息放进 JWT

JWT 的 Payload 只是 Base64 编码,不是加密。
所以不要放:

  • 明文密码
  • 手机号、身份证号等高敏感信息
  • 过多业务字段

建议只放:

  • userId
  • username
  • jti
  • 少量权限标识(视场景决定)

2. Token 过期时间别贪长

很多系统为了“用户省心”,把 Token 有效期设成 7 天、30 天。
安全上这非常危险,一旦泄露,攻击窗口太大。

推荐经验值:

  • 访问 Token:30 分钟 ~ 2 小时
  • 刷新 Token:7 天 ~ 14 天

如果系统安全等级更高,就再缩短。

3. 引入 Refresh Token,而不是无限续命

比较合理的双 Token 模型如下:

stateDiagram-v2
    [*] --> 未登录
    未登录 --> 已登录: 用户名密码校验成功
    已登录 --> 访问中: Access Token 有效
    访问中 --> Access过期: 到达过期时间
    Access过期 --> 访问中: 使用 Refresh Token 刷新
    Access过期 --> 未登录: Refresh Token 失效或撤销

建议

  • Access Token 短效
  • Refresh Token 存数据库或 Redis
  • 支持主动注销和失效控制
  • 刷新时轮换 Refresh Token,减少重放风险

4. 退出登录不能只让前端删 Token

前端本地删掉 Token,只是“看起来退出了”。
如果 Token 还有效,被截获后仍可能使用。

更稳妥的做法:

  • 给 JWT 加 jti
  • 退出登录时把 jti 加入 Redis 黑名单
  • 黑名单有效期与 Token 剩余寿命一致

5. 权限加载要考虑性能

如果每次请求都查数据库加载权限,高并发下开销不小。

常见优化策略

  • 用户权限缓存到 Redis
  • 用户登录时把权限快照缓存
  • 权限变更时主动清理缓存

取舍建议

  • 小系统:JWT 带权限即可
  • 中型系统:JWT 带用户标识,权限走缓存
  • 大型系统:认证中心统一签发,资源服务独立校验

6. 接口鉴权粒度要稳定

不要今天这个接口写 hasRole('ADMIN'),明天那个接口写 hasAuthority('xxx'),后天又在代码里手动 if 判断。这样项目越做越乱。

建议统一规范:

  • 页面菜单控制:前端根据权限码渲染
  • 接口控制:后端以 @PreAuthorize("hasAuthority('xxx')") 为主
  • 数据权限:在 Service/SQL 层补充,不要误以为接口权限能替代数据权限

7. HTTPS 是底线

JWT 再“无状态”,只要走明文传输,就等于裸奔。
线上环境必须启用 HTTPS,否则中间人截获 Authorization 请求头后,就可以直接重放请求。


一个更贴近生产的落地建议

如果你现在要做一个中型后台系统,我会建议你这样分层:

认证层

  • 用户名密码登录
  • Access Token + Refresh Token
  • 退出登录黑名单机制

鉴权层

  • Spring Security 统一接管
  • JWT Filter 恢复用户身份
  • @PreAuthorize 做接口权限控制

权限数据层

  • 用户、角色、权限三表模型
  • 权限码统一命名规则,如 module:resource:action
  • 权限缓存 + 变更后失效

审计层

  • 记录登录成功/失败
  • 记录关键操作日志
  • 对异常 IP、频繁失败登录做限流或告警

这样设计的好处是:
不是只解决“能不能登录”,而是给后面的权限扩展、审计追踪、性能优化留了空间。


边界条件与适用范围

这套方案不是银弹,也有它的边界。

更适合的场景

  • 前后端分离后台系统
  • App / H5 / 多端接入
  • 微服务或分布式接口服务

不一定最优的场景

  • 纯传统 SSR Web 项目
  • 非常简单的内部工具系统
  • 已经有统一认证中心,应该接 OAuth2/OIDC 而不是重复造轮子

如果你的系统后续会接第三方登录、单点登录、开放授权,那么从一开始就要评估是否直接上 Spring Authorization Server / OAuth2,别在 JWT 自建方案上越堆越重。


总结

在 Spring Boot 中做前后端分离登录鉴权,JWT + Spring Security 是一套非常常见、也足够实用的组合:

  • JWT:解决无状态身份传递
  • Spring Security:解决认证流程接管和权限控制
  • RBAC 权限模型:解决角色与权限的可扩展设计

如果你准备在项目里真正落地,我建议按这个顺序推进:

  1. 先打通登录、签发 Token、过滤器解析 Token
  2. 再接入 Spring Security 的接口鉴权
  3. 然后把权限模型从“纯角色”升级为“角色 + 权限”
  4. 最后补上刷新 Token、黑名单、缓存、审计日志这些生产级能力

一句话概括:
先把认证链路跑通,再把授权模型做稳,最后补安全与性能细节。

这样做,系统不会一开始就过度设计,但也不会在需求增长后迅速失控。


分享到:

上一篇
《Java 中基于 CompletableFuture 的异步编排实战:并行调用、超时控制与异常兜底》
下一篇
《Web3 中级实战:用 Solidity + Ethers.js 构建并部署一个支持 MetaMask 登录与代币支付的 DApp》