UniApp 微信小程序开发实战:JWT令牌身份认证与安全策略

内容分享2小时前发布
0 0 0

在微信小程序开发中,身份认证是保障用户数据安全的核心环节。JWT(JSON Web Token)作为一种轻量级的身份验证机制,凭借其无状态、易扩展的特性,成为前后端分离架构的首选方案。本文将围绕 JWT 的生成、传递、验证和刷新等全生命周期管理,结合 UniApp+Java 技术栈,详解如何在微信小程序中实现安全可靠的身份认证系统。

一、JWT 基础:为什么选择 JWT?

JWT 的核心优势

  • 无状态:服务器无需存储会话信息,减轻服务器负担
  • 跨域支持:适合分布式系统和前后端分离架构
  • 自包含:令牌本身包含用户关键信息,减少数据库查询
  • 防篡改:使用密钥签名,确保传输过程中不被篡改

JWT 的结构组成

一个完整的 JWT 由三部分组成,用.分隔:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkiLCJuYW1lIjoiSm9obiBEb2UifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  1. Header(头部):指定算法和令牌类型
  2. Payload(载荷):存储用户 ID、过期时间等声明信息
  3. Signature(签名):用密钥对前两部分加密生成的签名

二、后端 JWT 工具类:生成与验证实现

基于前文微信授权登录的 Java 后端,我们先实现一个通用的 JWT 工具类,用于令牌的生成、验证和解析:

import io.jsonwebtoken.*;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

/**
 * JWT工具类:负责令牌的生成、验证和解析
 */
public class JwtUtils {

    // 令牌过期时间:7天(单位:毫秒)
    private static final long EXPIRATION_TIME = 7 * 24 * 60 * 60 * 1000;
    
    // 密钥(实际项目中应放在配置文件,且定期更换)
    private static final String SECRET_KEY = "your-256-bit-secret-key-should-be-very-long-and-complex";

    /**
     * 生成JWT令牌
     * @param userId 用户唯一标识
     * @return 生成的令牌
     */
    public static String generateToken(String userId) {
        // 自定义载荷信息
        Map<String, Object> claims = new HashMap<>();
        claims.put("role", "user"); // 示例:存储用户角色
        
        return Jwts.builder()
                .setClaims(claims) // 设置自定义载荷
                .setSubject(userId) // 设置主题(一般为用户ID)
                .setIssuedAt(new Date()) // 设置签发时间
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME)) // 设置过期时间
                .signWith(SignatureAlgorithm.HS256, SECRET_KEY) // 设置签名算法和密钥
                .compact();
    }

    /**
     * 从令牌中获取用户ID
     * @param token JWT令牌
     * @return 用户ID
     */
    public static String getUserIdFromToken(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    /**
     * 验证令牌是否有效
     * @param token JWT令牌
     * @param userId 预期的用户ID
     * @return 令牌是否有效
     */
    public static boolean validateToken(String token, String userId) {
        final String extractedUserId = getUserIdFromToken(token);
        return (extractedUserId.equals(userId) && !isTokenExpired(token));
    }

    /**
     * 检查令牌是否过期
     * @param token JWT令牌
     * @return 是否过期
     */
    public static boolean isTokenExpired(String token) {
        return extractExpiration(token).before(new Date());
    }

    /**
     * 从令牌中获取过期时间
     * @param token JWT令牌
     * @return 过期时间
     */
    private static Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    /**
     * 从令牌中提取指定的载荷信息
     * @param token JWT令牌
     * @param claimsResolver 解析函数
     * @return 提取的信息
     */
    private static <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    /**
     * 从令牌中提取所有载荷信息
     * @param token JWT令牌
     * @return 载荷信息
     */
    private static Claims extractAllClaims(String token) {
        return Jwts.parser().setSigningKey(SECRET_KEY).parseClaimsJws(token).getBody();
    }
}

三、Spring Boot 集成:拦截器实现令牌验证

通过 Spring Boot 的拦截器机制,实现对需要身份认证的接口进行自动验证:

import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * JWT拦截器:验证请求中的令牌
 */
public class JwtInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 从请求头中获取令牌
        String authHeader = request.getHeader("Authorization");
        
        // 2. 检查令牌是否存在
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("未授权访问:请提供有效的令牌");
            return false;
        }
        
        // 3. 提取令牌(去除"Bearer "前缀)
        String token = authHeader.substring(7);
        
        try {
            // 4. 验证令牌
            String userId = JwtUtils.getUserIdFromToken(token);
            
            // 5. 检查令牌是否过期
            if (JwtUtils.isTokenExpired(token)) {
                response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                response.getWriter().write("令牌已过期,请重新登录");
                return false;
            }
            
            // 6. 将用户ID存入请求属性,供后续处理使用
            request.setAttribute("userId", userId);
            return true;
            
        } catch (SignatureException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("令牌验证失败:签名无效");
            return false;
        } catch (Exception e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
            response.getWriter().write("令牌验证失败:" + e.getMessage());
            return false;
        }
    }
}

配置拦截器使其生效:

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 注册JWT拦截器
        registry.addInterceptor(new JwtInterceptor())
                .addPathPatterns("/api/user/**") // 需要验证的接口
                .excludePathPatterns("/api/login") // 登录接口不验证
                .excludePathPatterns("/api/register"); // 注册接口不验证
    }
}

四、UniApp 前端:JWT 存储与请求拦截

在 UniApp 中实现 JWT 的存储、自动添加到请求头以及过期处理:

1. 封装存储工具(utils/storage.js)

/**
 * 本地存储工具类:处理JWT令牌的存储与读取
 */
export default {
  // 存储令牌
  setToken(token) {
    uni.setStorageSync('token', token)
  },
  
  // 获取令牌
  getToken() {
    return uni.getStorageSync('token')
  },
  
  // 清除令牌
  removeToken() {
    uni.removeStorageSync('token')
  },
  
  // 检查是否有令牌
  hasToken() {
    return !!this.getToken()
  }
}

2. 配置请求拦截器(utils/request.js)

import storage from './storage'

// 创建请求实例
const request = (options) => {
  return new Promise((resolve, reject) => {
    // 1. 配置默认参数
    const baseUrl = 'https://api.yourdomain.com'
    const token = storage.getToken()
    
    // 2. 添加请求头(包含令牌)
    const header = {
      ...options.header,
      'Content-Type': options.contentType || 'application/json'
    }
    
    // 如果有令牌,添加到请求头
    if (token) {
      header.Authorization = `Bearer ${token}`
    }
    
    // 3. 发起请求
    uni.request({
      url: baseUrl + options.url,
      method: options.method || 'GET',
      data: options.data || {},
      header,
      success: (res) => {
        // 4. 处理响应
        if (res.statusCode === 200) {
          // 业务逻辑成功
          if (res.data.code === 0) {
            resolve(res.data)
          } else {
            reject(res.data)
          }
        } else if (res.statusCode === 401) {
          // 401未授权:令牌无效或过期
          storage.removeToken()
          uni.showToast({
            title: '登录已过期,请重新登录',
            icon: 'none'
          })
          // 跳转到登录页
          setTimeout(() => {
            uni.navigateTo({ url: '/pages/login/index' })
          }, 1500)
          reject(new Error('登录已过期'))
        } else {
          // 其他错误
          uni.showToast({
            title: `请求失败:${res.statusCode}`,
            icon: 'none'
          })
          reject(res)
        }
      },
      fail: (err) => {
        uni.showToast({
          title: '网络错误,请稍后重试',
          icon: 'none'
        })
        reject(err)
      }
    })
  })
}

// 导出常用请求方法
export const api = {
  get: (url, data, options = {}) => request({ url, data, method: 'GET', ...options }),
  post: (url, data, options = {}) => request({ url, data, method: 'POST', ...options }),
  put: (url, data, options = {}) => request({ url, data, method: 'PUT', ...options }),
  delete: (url, data, options = {}) => request({ url, data, method: 'DELETE', ...options })
}

五、令牌刷新机制:无缝续期实现

为避免用户在使用过程中因令牌过期而被迫重新登录,实现令牌自动刷新机制:

1. 后端刷新接口

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TokenController {

    /**
     * 刷新令牌接口
     * @param authHeader 包含旧令牌的请求头
     * @return 新令牌
     */
    @PostMapping("/api/token/refresh")
    public Result refreshToken(@RequestHeader("Authorization") String authHeader) {
        try {
            // 验证旧令牌格式
            if (authHeader == null || !authHeader.startsWith("Bearer ")) {
                return Result.error("无效的令牌格式");
            }
            
            String oldToken = authHeader.substring(7);
            
            // 检查旧令牌是否即将过期(还剩1小时内)
            Date expiration = JwtUtils.extractExpiration(oldToken);
            long timeLeft = expiration.getTime() - System.currentTimeMillis();
            if (timeLeft > 60 * 60 * 1000) {
                return Result.error("令牌无需刷新");
            }
            
            // 从旧令牌中获取用户ID
            String userId = JwtUtils.getUserIdFromToken(oldToken);
            
            // 生成新令牌
            String newToken = JwtUtils.generateToken(userId);
            
            return Result.success(newToken, "令牌刷新成功");
            
        } catch (Exception e) {
            return Result.error("令牌刷新失败:" + e.getMessage());
        }
    }
}

2. 前端自动刷新实现

修改请求拦截器,添加令牌自动刷新逻辑:

// 在request.js中添加以下逻辑
let isRefreshing = false; // 是否正在刷新令牌
let requests = []; // 等待刷新的请求队列

// 刷新令牌函数
const refreshToken = async () => {
  try {
    const oldToken = storage.getToken();
    const res = await uni.request({
      url: 'https://api.yourdomain.com/api/token/refresh',
      method: 'POST',
      header: {
        'Authorization': `Bearer ${oldToken}`
      }
    });
    
    if (res.data.code === 0) {
      // 刷新成功,更新令牌
      storage.setToken(res.data.data);
      return res.data.data;
    } else {
      // 刷新失败,需要重新登录
      storage.removeToken();
      throw new Error('令牌刷新失败');
    }
  } catch (err) {
    storage.removeToken();
    uni.navigateTo({ url: '/pages/login/index' });
    throw err;
  }
};

// 修改请求函数中的成功处理部分
success: async (res) => {
  if (res.statusCode === 401) {
    // 401错误处理
    const currentRequest = options;
    
    if (!isRefreshing) {
      isRefreshing = true;
      
      try {
        // 刷新令牌
        const newToken = await refreshToken();
        
        // 重新发起队列中的请求
        requests.forEach(cb => cb(newToken));
        requests = [];
        
        // 重新发起当前请求
        const retryRes = await request({
          ...currentRequest,
          header: {
            ...currentRequest.header,
            'Authorization': `Bearer ${newToken}`
          }
        });
        
        resolve(retryRes);
      } catch (err) {
        reject(err);
      } finally {
        isRefreshing = false;
      }
    } else {
      // 正在刷新令牌,将请求加入队列
      return new Promise(resolve => {
        requests.push((token) => {
          currentRequest.header = {
            ...currentRequest.header,
            'Authorization': `Bearer ${token}`
          };
          resolve(request(currentRequest));
        });
      });
    }
  }
  // ...其他状态处理
}

六、安全最佳实践与常见问题

安全加固措施

  1. 令牌存储:小程序中使用uni.setStorageSync存储,避免暴露在 URL 中生产环境提议使用 HTTPS 协议,防止中间人攻击
  2. 密钥管理:密钥长度至少 256 位,定期更换不同环境(开发 / 测试 / 生产)使用不同密钥密钥存储在安全的配置中心,而非代码中
  3. 令牌策略:合理设置过期时间(提议 1-7 天)实现令牌黑名单机制,处理注销和安全事件敏感操作(如支付、修改密码)提议重新验证身份

常见问题解决方案

  1. 令牌被盗用:缩短令牌有效期实现设备绑定,检查请求来源提供快速注销功能
  2. 刷新令牌失效:实现刷新令牌的有效期(长于访问令牌)限制刷新次数,防止滥用
  3. 性能优化:避免在 Payload 中存储过多信息对令牌验证过程进行缓存思考使用 Redis 存储黑名单,加速验证

通过本文的实现,可以掌握 JWT 在 UniApp 小程序中的完整应用方案,包括令牌的生成、验证、存储和自动刷新。这种身份认证机制既能保障用户数据安全,又能提供流畅的用户体验,是现代小程序开发的必备技能。

在实际项目中,可根据业务需求调整令牌有效期、载荷内容和安全策略,构建更贴合自身业务的身份认证系统。

#JWT 实战 #UniApp 开发 #微信小程序 #身份认证 #前后端分离

© 版权声明

相关文章

暂无评论

none
暂无评论...