1. 项目概述与核心价值

最近在重构一个内部管理系统,前端从Vue2升级到了Vue3,后端依然是SpringBoot。在这个过程中,我重新梳理了整个登录和权限体系,决定采用JWT(JSON Web Token)方案。这几乎是现代前后端分离项目的标配了,但网上很多教程要么只讲后端,要么只讲前端,要么就是版本老旧,用着过时的API。今天,我就把这次从零到一、前后端打通的完整实现过程,以及我踩过的那些坑,毫无保留地分享出来。

这个方案能解决什么问题?简单说,就是用户登录后,后端生成一个“令牌”(Token)给前端,前端后续的每次请求都带着这个令牌。后端验证令牌的合法性,并从中解析出用户身份和权限信息,从而决定是否允许访问某个接口或看到某个菜单。整个过程是无状态的,服务器不需要保存会话,非常适合分布式和微服务架构。对于刚接触前后端分离开发,或者想从Session/Cookie模式切换到Token模式的朋友来说,这是一个非常实用的实战案例。无论你是全栈开发者,还是专注于前端或后端,都能从中找到清晰的实现路径和避坑指南。

2. 技术选型与架构设计思路

2.1 为什么是JWT,而不是Session?

在项目初期,我们面临一个选择:沿用传统的Session-Cookie,还是拥抱JWT?我最终选择了JWT,主要基于以下几点考量:

  1. 无状态与扩展性 :Session需要服务器端存储(内存、Redis等),在集群环境下需要做Session共享,增加了复杂度和维护成本。JWT是无状态的,令牌本身包含了所有必要信息,任何一台持有密钥的服务都可以验证它,天然适合水平扩展。
  2. 跨域与多端支持 :现代应用往往是前后端分离部署,域名不同,Session-Cookie在跨域场景下处理起来比较麻烦(需要配置CORS和 withCredentials )。JWT通常放在HTTP请求的 Authorization 头里,不受同源策略限制,对移动端App、第三方调用也更友好。
  3. 灵活性 :JWT的Payload可以自定义存放用户ID、角色、权限等信息,一次解析即可获取,无需多次查询数据库。虽然这带来了令牌无法主动失效的问题(后面会讲解决方案),但其灵活性在很多场景下优势明显。

注意 :JWT并非银弹。如果你的应用有极强的“即时踢人下线”需求,或者令牌Payload信息量巨大,需要谨慎评估。对于后台管理系统这类场景,JWT的优势非常突出。

2.2 整体流程与组件职责

整个登录权限流程,我把它拆解为以下几个核心环节,并明确了前后端的职责:

  1. 登录认证
    • 前端 :收集用户账号密码,调用后端登录接口。
    • 后端 :校验账号密码,成功后,生成JWT令牌(包含用户ID、角色等),返回给前端。
  2. 令牌存储与携带
    • 前端 :收到令牌后,存储在 localStorage sessionStorage (根据安全需求选择),并在后续所有请求的 Authorization 头中携带(格式: Bearer <token> )。
  3. 请求拦截与验证
    • 后端 :配置一个拦截器(Interceptor)或过滤器(Filter),对需要保护的接口进行拦截。从 Authorization 头中取出令牌,进行签名验证、过期时间校验,并解析出用户信息,存入本次请求的上下文(如 SecurityContextHolder Request 属性中)。
  4. 权限控制
    • 后端 :在业务接口或更细的粒度上,通过注解(如 @PreAuthorize(“hasRole(‘ADMIN’)”) )或手动判断,校验当前用户是否有权限执行操作。
    • 前端 :根据用户角色或权限列表,动态渲染侧边栏菜单、页面按钮等,实现前端路由守卫和界面元素权限控制。

技术栈版本

  • 后端 :Spring Boot 2.7.x / 3.x, Java 8+, jjwt 库(处理JWT)
  • 前端 :Vue 3.x, Composition API ( <script setup> ), Vite, Axios, Vue Router 4, Pinia(状态管理)

3. 后端SpringBoot实现详解

3.1 引入依赖与基础配置

首先,创建一个SpringBoot项目,并引入必要的依赖。核心是 jjwt ,我们选用目前维护活跃的 jjwt-api , jjwt-impl , jjwt-jackson 组合。

<!-- pom.xml 关键依赖 -->
<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <!-- JWT 核心库 -->
    <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>
    <!-- 其他可能需要的:Lombok, Spring Security (如需更复杂权限), MyBatis-Plus等 -->
</dependencies>

接下来,在 application.yml 中配置JWT相关参数。这些参数最好放在配置文件中,方便不同环境(开发、生产)调整。

# application.yml
jwt:
  secret: your-256-bit-secret-key-here-must-be-very-long-and-secure # 密钥,务必足够长且复杂!
  expiration: 7200 # 令牌过期时间,单位:秒 (2小时)
  token-header: Authorization # 前端传递令牌的请求头名称
  token-prefix: Bearer # 令牌前缀,规范写法

这里有个 大坑 jwt.secret 。千万不要用简单的单词或短字符串。它用于签名,一旦泄露,攻击者可以伪造任意令牌。建议使用一个长度至少32位的随机字符串,可以通过命令行工具生成,并妥善保管,生产环境务必使用环境变量注入,而不是硬编码在配置文件中。

3.2 核心工具类:JwtUtils

创建一个 JwtUtils 工具类,封装JWT的生成、解析和验证逻辑。这是后端的核心。

import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.Map;

@Component
public class JwtUtils {
    // 从配置文件中注入
    @Value("${jwt.secret}")
    private String secret;
    @Value("${jwt.expiration}")
    private Long expiration;

    // 生成密钥
    private SecretKey getSigningKey() {
        // 将配置的字符串密钥转换为安全的SecretKey对象
        return Keys.hmacShaKeyFor(secret.getBytes());
    }

    /**
     * 生成JWT令牌
     * @param claims 自定义声明,通常放用户ID、用户名、角色等
     * @return JWT字符串
     */
    public String generateToken(Map<String, Object> claims) {
        Date now = new Date();
        Date expiryDate = new Date(now.getTime() + expiration * 1000);

        return Jwts.builder()
                .setClaims(claims) // 设置自定义负载
                .setIssuedAt(now) // 签发时间
                .setExpiration(expiryDate) // 过期时间
                .signWith(getSigningKey(), SignatureAlgorithm.HS256) // 使用HS256算法和密钥签名
                .compact();
    }

    /**
     * 从令牌中解析Claims(负载)
     */
    public Claims parseToken(String token) {
        // 移除可能携带的“Bearer ”前缀
        if (token != null && token.startsWith("Bearer ")) {
            token = token.substring(7);
        }
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey()) // 设置签名密钥
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * 验证令牌是否有效(未过期且签名正确)
     */
    public boolean validateToken(String token) {
        try {
            parseToken(token); // 如果能成功解析,说明签名有效且未过期(过期会抛出ExpiredJwtException)
            return true;
        } catch (JwtException | IllegalArgumentException e) {
            // 捕获签名无效、令牌过期、令牌格式错误等异常
            // 生产环境可以在这里记录日志
            return false;
        }
    }

    /**
     * 从令牌中获取指定Claim
     */
    public <T> T getClaimFromToken(String token, String claimName, Class<T> requiredType) {
        Claims claims = parseToken(token);
        return claims.get(claimName, requiredType);
    }
}

实操心得

  • signWith 方法使用的算法 HS256 是HMAC + SHA256,对称加密,需要同一个密钥。还有 RS256 (非对称,公私钥),更适合多服务场景,但配置稍复杂。对于单服务后台系统, HS256 足够。
  • parseToken 方法里处理了 Bearer 前缀,这是一个好习惯,让工具类更健壮。
  • validateToken 方法通过捕获异常来判断有效性,这是标准做法。千万不要自己手动去判断时间,因为签名验证是更根本的检查。

3.3 登录接口与令牌发放

创建一个 AuthController 来处理登录请求。

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

    @Autowired
    private UserService userService; // 假设你有一个用户服务
    @Autowired
    private JwtUtils jwtUtils;

    @PostMapping("/login")
    public Result login(@RequestBody LoginRequest loginRequest) {
        // 1. 验证用户名密码
        User user = userService.authenticate(loginRequest.getUsername(), loginRequest.getPassword());
        if (user == null) {
            return Result.error("用户名或密码错误");
        }

        // 2. 构造JWT的Claims(负载)
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", user.getId());
        claims.put("username", user.getUsername());
        claims.put("roles", user.getRoles()); // 假设用户有角色列表,如 ["ADMIN", "USER"]

        // 3. 生成令牌
        String token = jwtUtils.generateToken(claims);

        // 4. 构造返回体(通常包含令牌和用户基础信息)
        LoginResponse response = new LoginResponse();
        response.setToken(token);
        response.setUserInfo(new UserInfoVo(user)); // 用户信息视图对象

        return Result.success("登录成功", response);
    }
}

这里 Result LoginResponse 是自定义的通用返回对象和登录响应对象。注意, 敏感信息如密码哈希千万不要放在JWT的Payload里

3.4 请求拦截与身份认证

我们需要一个拦截器,在请求到达Controller之前,验证JWT令牌,并将用户信息设置到当前请求上下文中。这里使用Spring的 HandlerInterceptor

@Component
public class JwtAuthenticationInterceptor implements HandlerInterceptor {

    @Autowired
    private JwtUtils jwtUtils;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 检查是否是公开接口(如登录、注册),可以通过注解或配置排除路径
        String requestURI = request.getRequestURI();
        if (“/api/auth/login”.equals(requestURI)) {
            return true; // 放行登录接口
        }

        // 2. 从请求头获取令牌
        String authHeader = request.getHeader(“Authorization”);
        if (authHeader == null || !authHeader.startsWith(“Bearer ”)) {
            // 没有令牌或格式错误,返回401未授权
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType(“application/json;charset=UTF-8”);
            response.getWriter().write(JSON.toJSONString(Result.error(“未提供有效的认证令牌”)));
            return false;
        }

        String token = authHeader.substring(7); // 去掉“Bearer ”前缀

        // 3. 验证令牌
        if (!jwtUtils.validateToken(token)) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.setContentType(“application/json;charset=UTF-8”);
            response.getWriter().write(JSON.toJSONString(Result.error(“令牌已过期或无效”)));
            return false;
        }

        // 4. 解析令牌,获取用户信息
        Claims claims = jwtUtils.parseToken(token);
        String userId = claims.get(“userId”, String.class);
        String username = claims.get(“username”, String.class);
        List<String> roles = claims.get(“roles”, List.class);

        // 5. 将用户信息存入请求上下文,供后续Controller使用
        // 方式一:存入Request Attribute
        request.setAttribute(“currentUserId”, userId);
        request.setAttribute(“currentUsername”, username);
        request.setAttribute(“currentUserRoles”, roles);

        // 方式二:使用Spring Security的SecurityContextHolder(如果集成了Spring Security)
        // UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(username, null, authorities);
        // SecurityContextHolder.getContext().setAuthentication(authentication);

        return true; // 验证通过,放行
    }
}

然后,需要将这个拦截器注册到Spring MVC中。

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private JwtAuthenticationInterceptor jwtAuthenticationInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(jwtAuthenticationInterceptor)
                .addPathPatterns(“/api/**”) // 拦截所有/api开头的请求
                .excludePathPatterns(“/api/auth/login”, “/api/auth/register”); // 排除登录注册等公开接口
    }
}

3.5 细粒度权限控制

身份认证(Authentication)解决了“你是谁”的问题,权限控制(Authorization)则要解决“你能干什么”。我们可以在拦截器里做简单的角色判断,但更优雅的方式是使用注解。

方案一:自定义注解 + 拦截器增强

  1. 定义一个权限注解:
    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface RequiresPermissions {
        String[] value(); // 需要的权限标识符,如 “user:add”, “sys:config:view”
    }
    
  2. 在拦截器的 preHandle 方法中,不仅验证JWT,在验证通过后,获取当前请求对应的方法上的 @RequiresPermissions 注解。
  3. 将注解中定义的权限标识符与当前用户拥有的权限(可以从JWT的 claims 或根据 userId 实时查询数据库获得)进行比对。如果用户不具备所需权限,则返回403 Forbidden。

方案二:集成Spring Security(推荐用于复杂系统) 对于权限模型复杂(RBAC、ABAC等)的系统,强烈建议直接集成Spring Security。它可以与JWT完美结合。

  1. 引入 spring-boot-starter-security 依赖。
  2. 配置一个继承 WebSecurityConfigurerAdapter (Spring Boot 2.x)或使用 SecurityFilterChain Bean(Spring Boot 3.x)的配置类。
  3. 实现一个 JwtAuthenticationFilter ,继承 OncePerRequestFilter ,在其中完成JWT验证,并构建 Authentication 对象设置到 SecurityContextHolder
  4. 使用 @PreAuthorize , @PostAuthorize , @Secured 等注解在Controller方法上进行声明式权限控制,例如 @PreAuthorize(“hasRole(‘ADMIN’) or hasAuthority(‘user:delete’)”)

实操心得

  • 对于中小型后台管理系统, 方案一(自定义拦截器) 足够轻量、直观,耦合度低,容易理解和定制。
  • 如果你的系统权限模型复杂,未来可能涉及方法级、数据级权限,或者需要OAuth2等高级特性,那么 方案二(Spring Security) 是更专业、更强大的选择,虽然学习曲线稍陡。我当前的项目权限比较简单,所以选择了方案一,在拦截器里根据角色判断,清晰可控。

4. 前端Vue3实现详解

4.1 项目初始化与依赖安装

使用Vite快速创建一个Vue3项目,并安装必要的依赖。

npm create vue@latest my-vue-app # 按提示选择需要的特性(Router, Pinia)
cd my-vue-app
npm install
npm install axios pinia # 如果创建时没选,手动安装

4.2 状态管理:用户信息与令牌存储

我们使用Pinia来全局管理用户登录状态和令牌。创建一个 auth store。

// stores/auth.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import axios from 'axios'

export const useAuthStore = defineStore('auth', () => {
  // 状态
  const token = ref(localStorage.getItem('token') || '')
  const userInfo = ref(JSON.parse(localStorage.getItem('userInfo') || 'null'))

  // Getter
  const isAuthenticated = computed(() => !!token.value)

  // Actions
  function login(loginData) {
    return new Promise((resolve, reject) => {
      axios.post('/api/auth/login', loginData)
        .then(response => {
          const { token: newToken, userInfo: newUserInfo } = response.data.data
          // 更新状态
          token.value = newToken
          userInfo.value = newUserInfo
          // 持久化到本地存储
          localStorage.setItem('token', newToken)
          localStorage.setItem('userInfo', JSON.stringify(newUserInfo))
          // 设置axios默认请求头
          axios.defaults.headers.common['Authorization'] = `Bearer ${newToken}`
          resolve(response)
        })
        .catch(error => {
          // 清除可能存在的旧状态
          logout()
          reject(error)
        })
    })
  }

  function logout() {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
    localStorage.removeItem('userInfo')
    delete axios.defaults.headers.common['Authorization']
    // 跳转到登录页
    router.push('/login')
  }

  // 初始化时,如果本地有token,则设置到axios头部
  if (token.value) {
    axios.defaults.headers.common['Authorization'] = `Bearer ${token.value}`
  }

  return { token, userInfo, isAuthenticated, login, logout }
})

关键点

  • localStorage vs sessionStorage localStorage 关闭浏览器后依然存在,用户体验好,但有一定安全风险(XSS攻击可能窃取)。 sessionStorage 关闭标签页即清除,更安全。根据项目安全要求选择。对于内部管理系统,我通常用 localStorage ,并确保做好XSS防护。
  • 令牌存储位置 :除了Web Storage,还可以考虑 Vuex/Pinia 内存、Cookie(需设置 httpOnly secure 防XSS,但前端JS无法直接读取,适用于纯后端Session场景,与JWT无状态理念稍悖)。这里是最常见的 localStorage 方案。
  • 设置axios默认头 :登录成功后,立即将令牌设置到axios的公共请求头中,这样后续所有请求都会自动携带。

4.3 请求拦截器与响应拦截器

我们需要在axios的拦截器中统一处理请求头的添加和响应的错误(如401令牌过期)。

// utils/request.js
import axios from 'axios'
import { useAuthStore } from '@/stores/auth'
import router from '@/router'

const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL, // 从环境变量读取API基础地址
  timeout: 10000
})

// 请求拦截器
service.interceptors.request.use(
  config => {
    // 从store获取token(如果store已初始化)
    const authStore = useAuthStore()
    if (authStore.token) {
      config.headers['Authorization'] = `Bearer ${authStore.token}`
    }
    return config
  },
  error => {
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  response => {
    // 对响应数据做点什么
    const res = response.data
    // 假设后端统一返回格式为 { code: 200, message: ‘success’, data: ... }
    if (res.code === 200) {
      return res.data // 直接返回业务数据
    } else {
      // 业务逻辑错误,例如密码错误
      ElMessage.error(res.message || ‘Error’)
      return Promise.reject(new Error(res.message || ‘Error’))
    }
  },
  error => {
    // 对响应错误做点什么
    if (error.response) {
      const { status } = error.response
      const authStore = useAuthStore()
      switch (status) {
        case 401:
          ElMessage.error(‘用户未认证或令牌已过期’)
          // 清除用户状态,跳转到登录页
          authStore.logout()
          router.push(‘/login’)
          break
        case 403:
          ElMessage.error(‘用户没有权限访问此资源’)
          break
        case 500:
          ElMessage.error(‘服务器内部错误’)
          break
        default:
          ElMessage.error(error.response.data?.message || `请求错误 ${status}`)
      }
    } else if (error.request) {
      // 请求发送了但没有收到响应
      ElMessage.error(‘网络错误,请检查您的网络连接’)
    } else {
      // 请求配置出错
      ElMessage.error(‘请求配置错误:’ + error.message)
    }
    return Promise.reject(error)
  }
)

export default service

然后在项目的 main.js 或入口文件中导入这个 service ,并挂载到全局,或者在各处直接导入使用。

4.4 路由守卫与页面权限

使用Vue Router的导航守卫,在用户进入需要认证的页面之前进行检查。

// router/index.js
import { createRouter, createWebHistory } from ‘vue-router’
import { useAuthStore } from ‘@/stores/auth’

const routes = [
  {
    path: ‘/login’,
    name: ‘Login’,
    component: () => import(‘@/views/Login.vue’),
    meta: { requiresAuth: false } // 明确标记不需要认证
  },
  {
    path: ‘/dashboard’,
    name: ‘Dashboard’,
    component: () => import(‘@/views/Dashboard.vue’),
    meta: { requiresAuth: true, roles: [‘ADMIN’, ‘USER’] } // 需要认证,且角色为ADMIN或USER
  },
  {
    path: ‘/admin’,
    name: ‘Admin’,
    component: () => import(‘@/views/Admin.vue’),
    meta: { requiresAuth: true, roles: [‘ADMIN’] } // 需要认证,且角色必须为ADMIN
  },
  // … 其他路由
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

// 全局前置守卫
router.beforeEach((to, from, next) => {
  const authStore = useAuthStore()
  const isAuthenticated = authStore.isAuthenticated

  // 判断该路由是否需要认证
  if (to.meta.requiresAuth !== false) { // 默认需要认证,除非显式设置为false
    if (!isAuthenticated) {
      // 未登录,重定向到登录页,并携带目标路径,以便登录后回跳
      next({ path: ‘/login’, query: { redirect: to.fullPath } })
      return
    }

    // 已登录,检查角色权限
    const requiredRoles = to.meta.roles
    if (requiredRoles && requiredRoles.length > 0) {
      const userRoles = authStore.userInfo?.roles || [] // 从store中获取用户角色
      const hasRole = requiredRoles.some(role => userRoles.includes(role))
      if (!hasRole) {
        // 角色不符,跳转到无权限页面或首页
        ElMessage.warning(‘您没有权限访问该页面’)
        next(‘/403’) // 假设有一个403页面
        return
      }
    }
  }

  // 如果要去登录页且已登录,则跳转到首页
  if (to.path === ‘/login’ && isAuthenticated) {
    next(‘/’)
    return
  }

  next() // 放行
})

export default router

实操心得

  • meta 字段是路由元信息,非常适合用来存放权限相关的数据。
  • 角色检查的逻辑可以更复杂,比如支持权限字符串数组。这里为了演示,用了简单的角色数组包含判断。
  • 对于更细粒度的 按钮级权限 ,可以写一个全局的指令或一个工具函数,在组件内根据用户权限 v-if 控制按钮的显示隐藏。例如:
    <template>
      <button v-if=“hasPermission(‘user:delete’)” @click=“handleDelete”>删除用户</button>
    </template>
    <script setup>
    import { useAuthStore } from ‘@/stores/auth’
    const authStore = useAuthStore()
    const hasPermission = (permission) => {
      return authStore.userInfo?.permissions?.includes(permission)
    }
    </script>
    

4.5 登录页面与状态联动

最后,实现一个简单的登录页面,将上述所有部分串联起来。

<!-- views/Login.vue -->
<template>
  <div class=“login-container”>
    <el-form :model=“loginForm” :rules=“rules” ref=“formRef”>
      <el-form-item prop=“username”>
        <el-input v-model=“loginForm.username” placeholder=“请输入用户名” />
      </el-form-item>
      <el-form-item prop=“password”>
        <el-input v-model=“loginForm.password” type=“password” placeholder=“请输入密码” />
      </el-form-item>
      <el-form-item>
        <el-button type=“primary” @click=“handleLogin” :loading=“loading”>登录</el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script setup>
import { ref, reactive } from ‘vue’
import { useRouter, useRoute } from ‘vue-router’
import { useAuthStore } from ‘@/stores/auth’
import { ElMessage } from ‘element-plus’ // 假设使用Element Plus

const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()

const formRef = ref()
const loading = ref(false)
const loginForm = reactive({
  username: ‘’,
  password: ‘’
})

const rules = {
  username: [{ required: true, message: ‘请输入用户名’, trigger: ‘blur’ }],
  password: [{ required: true, message: ‘请输入密码’, trigger: ‘blur’ }]
}

const handleLogin = () => {
  formRef.value.validate((valid) => {
    if (!valid) return
    loading.value = true
    authStore.login(loginForm)
      .then(() => {
        ElMessage.success(‘登录成功’)
        // 登录成功后,跳转到之前想访问的页面或首页
        const redirectPath = route.query.redirect || ‘/’
        router.push(redirectPath)
      })
      .catch(err => {
        // 错误信息已在axios拦截器或login action中统一处理,这里可以不再显示
        console.error(‘登录失败:’, err)
      })
      .finally(() => {
        loading.value = false
      })
  })
}
</script>

至此,一个完整的、前后端分离的JWT登录权限流程就实现了。用户登录后,令牌被存储并自动附加到请求中,后端验证令牌并控制权限,前端根据权限动态展示界面和路由。

5. 进阶优化与安全考量

基础功能跑通后,我们还需要考虑一些进阶问题和安全加固措施。

5.1 令牌刷新机制

JWT的一个固有缺点是过期后无法主动失效。通常我们设置一个较短的过期时间(如2小时)来降低风险。但用户体验会变差,用户每2小时就要重新登录。为此,需要引入 刷新令牌(Refresh Token) 机制。

流程

  1. 登录成功后,后端返回两个令牌:
    • Access Token (AT) : 短期有效(如2小时),用于访问业务接口。
    • Refresh Token (RT) : 长期有效(如7天),仅用于获取新的AT,不能访问业务接口。
  2. RT需要安全存储(后端数据库或Redis,关联用户ID),AT可以放在内存或 localStorage
  3. 前端在请求接口时,如果收到401错误(AT过期),则自动发起一个使用RT换取新AT的请求。
  4. 后端验证RT的有效性和关联性,如果有效,则颁发新的AT和RT(可选,可以RT不变),并返回给前端。
  5. 前端用新的AT重试刚才失败的请求。

实现要点

  • RT的存储和验证必须在服务端,这是安全的关键。
  • 前端需要封装一个“带自动刷新重试”的请求函数,或在axios响应拦截器中实现刷新逻辑,注意处理多个并发请求同时触发刷新的问题(需要锁机制)。
  • 用户主动登出或管理员禁用用户时,后端应立即使该用户的RT失效。

5.2 防止CSRF与XSS

  • CSRF(跨站请求伪造) :由于我们使用 Authorization 头携带Token,而浏览器不会自动在跨站请求中附加自定义头,因此 我们的JWT方案天然对CSRF免疫 。这是相比Cookie方案的一大优势。
  • XSS(跨站脚本攻击) :这是Token存储在 localStorage 时的主要威胁。如果网站存在XSS漏洞,攻击者可以窃取Token。防御措施包括:
    1. 对输出进行编码/转义 ,防止恶意脚本注入。
    2. 设置Cookie的 HttpOnly 属性(但我们的Token不存Cookie)。
    3. 使用 内容安全策略(CSP) 头部,限制页面可以加载和执行哪些资源。
    4. 考虑将Token存储在 sessionStorage 中,关闭标签页即失效,但牺牲了用户体验。
    5. 为敏感操作(如修改密码、支付)增加二次验证。

5.3 分布式部署与令牌黑名单

在单服务下,JWT无状态是优点。但在分布式或微服务下,如果你想实现“即时踢人下线”功能,就需要引入一个中心化的状态存储(如Redis),这与无状态理念相悖,但有时是业务刚需。

方案 :维护一个 令牌黑名单 令牌白名单版本号

  • 黑名单 :用户登出或管理员禁用用户时,将该用户的AT(虽然快过期)或RT的 jti (JWT ID)存入Redis,并设置一个稍长于AT有效期的TTL。在所有服务的拦截器中,除了验证JWT本身,还要查询这个黑名单。这会增加一次Redis查询,但实现了主动失效。
  • 版本号 :在用户表中增加一个 tokenVersion 字段。JWT的Payload中包含这个版本号。当用户登出或修改密码时,递增这个版本号。拦截器验证JWT时,不仅验证签名和过期时间,还要从数据库或缓存中取出用户最新的 tokenVersion 进行比对,不一致则拒绝。这比黑名单查询压力小,但每次验证都需要查用户信息。

实操心得 :对于大多数后台管理系统,“即时踢人”并非强需求。可以折中:设置较短的AT过期时间(如30分钟),并提示用户“长时间无操作,请重新登录”。如果必须实现,我倾向于使用“版本号”方案,因为它更优雅,对缓存友好。

6. 常见问题排查与调试技巧

在实际开发中,你肯定会遇到各种问题。这里记录几个我踩过的坑和解决方法。

6.1 前端常见问题

问题1:登录成功,但后续请求返回401。

  • 排查 :打开浏览器开发者工具的 Network 选项卡。
    • 检查登录请求的响应中是否确实包含了 token 字段。
    • 检查后续请求的 Request Headers 中, Authorization 头是否存在且格式正确( Bearer <token> )。
    • 检查 token 是否被正确存入 localStorage 和Pinia store。
  • 可能原因
    • axios默认头未设置 :确保在登录成功的action里,设置了 axios.defaults.headers.common[‘Authorization’]
    • 请求拦截器逻辑错误 :检查请求拦截器是否从store正确获取了token。注意Pinia store在拦截器中的使用方式,可能需要 pinia 实例已创建。
    • 跨域问题(CORS) :后端需要正确配置CORS,允许前端域名的请求,并暴露 Authorization 头。Spring Boot中需要配置 CorsFilter @CrossOrigin

问题2:页面刷新后,用户登录状态丢失。

  • 排查 :检查 auth store 的初始化逻辑。在 created onMounted 中,是否从 localStorage 读取了token和userInfo并还原到store状态中?Pinia store通常是响应式的,但刷新页面会重置,所以 持久化是关键

问题3:路由守卫无限循环。

  • 现象 :页面在登录页和首页之间疯狂跳转。
  • 排查 :检查 router.beforeEach 逻辑。
    • 确保对 /login 这类公开路由,正确设置了 meta: { requiresAuth: false } 或在守卫中做了排除。
    • 检查 isAuthenticated 的计算逻辑是否正确。有时store还未初始化完成,判断可能出错。可以加一个 loading 状态,或者确保store初始化在路由守卫之前。

6.2 后端常见问题

问题1:签名验证失败(SignatureException)。

  • 排查
    • 密钥不一致 :这是最常见的原因。确保生成Token和验证Token使用的是 完全相同的 jwt.secret 字符串。检查生产环境和开发环境的配置文件。
    • 算法不匹配 :生成Token时用的 HS256 ,验证时也必须用 HS256 。检查 JwtUtils 中的 SignatureAlgorithm
    • Token被篡改 :如果密钥没问题,那可能就是Token在传输过程中被修改了。对比前端发送的Token和后端收到的Token是否完全一致(注意空格、换行符)。

问题2:令牌过期(ExpiredJwtException)。

  • 现象 :接口返回401,后端日志打印Token过期。
  • 解决 :这就是引入刷新令牌机制的场景。短期内,可以先在前端拦截401响应,提示用户“登录已过期,请重新登录”并跳转登录页。

问题3:拦截器放行了OPTIONS预检请求。

  • 现象 :前端POST请求失败,但Network里看到先发了一个 OPTIONS 请求,这个请求没有带 Authorization 头,被拦截器拦截返回401,导致真正的POST请求发不出去。
  • 解决 :在拦截器的 preHandle 方法中,对 OPTIONS 请求方法直接放行。
    if (HttpMethod.OPTIONS.toString().equals(request.getMethod())) {
        return true;
    }
    

问题4:获取不到请求头中的Token。

  • 排查
    • 前端是否正确设置了头?名称是否是 Authorization
    • 后端是否因为CORS配置,没有接收到这个头?检查CORS配置是否包含了 Authorization 头。
      // Spring Boot CORS配置示例
      @Configuration
      public class CorsConfig implements WebMvcConfigurer {
          @Override
          public void addCorsMappings(CorsRegistry registry) {
              registry.addMapping(“/**“)
                      .allowedOriginPatterns(“*“) // 生产环境应指定具体域名
                      .allowedMethods(“GET”, “POST”, “PUT”, “DELETE”, “OPTIONS”)
                      .allowedHeaders(“*“)
                      .exposedHeaders(“Authorization”) // 暴露Authorization头,某些浏览器需要
                      .allowCredentials(true); // 如果前端需要传Cookie,设为true
          }
      }
      

6.3 联调工具与技巧

  1. 使用Postman或ApiFox测试后端接口 :先绕过前端,直接用工具测试登录接口和受保护的接口,确保后端逻辑正确。在工具的 Authorization 选项卡中选择 Bearer Token 类型填入Token。
  2. 查看浏览器Application面板 :查看 Local Storage 里存储的Token和用户信息是否正确。
  3. 查看浏览器Network面板 :这是最重要的调试工具,查看每一个请求的请求头、响应状态码和响应体。
  4. 后端开启详细日志 :在 application.yml 中设置 logging.level.com.yourpackage: DEBUG ,查看拦截器、JWT解析的详细日志。
  5. 手动构造和解析JWT :遇到JWT格式问题时,可以到 jwt.io 这个网站,粘贴你的Token和Secret,手动解码和验证,非常直观。

整个实现过程就像搭积木,前后端各司其职,通过一个简单的字符串(Token)和一套约定好的规则进行通信。关键在于理解每个环节的目的和可能出现的问题点。希望这篇超详细的总结,能帮你一次性打通任督二脉,少走弯路。在实际项目中,根据你的具体业务需求,再对权限模型、令牌管理策略进行微调即可。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐