Cookie、Session、JWT、SSO,网站与 APP 登录持久化与缓存

Cookie、Session、JWT、SSO,网站与 APP 登录持久化与缓存

Cookie 和 Session ,jwt,sso这些到底是什么,有什么区别或关系,他们的使用场景有哪些(给出具体实现代码)

1.Cookie、Session、JWT、SSO:概念、区别、关系与实战代码

在 Java 后端开发中,Cookie、Session、JWT、SSO 是认证与授权领域的核心技术,但很多开发者容易混淆它们的定位和用法。本文将从核心定义、区别与关系、适用场景三个维度层层拆解,结合实战代码,帮你彻底理清这四类技术的本质,掌握在不同场景下的选型与落地。

一、核心概念:一句话讲清每个技术的定位

技术 核心定义 本质作用
Cookie 客户端(浏览器)存储的小型文本数据,由服务器通过Set-Cookie头下发,请求时自动携带 存储少量状态(如 SessionID、Token)
Session 服务器端存储的用户会话状态,通过 Cookie 中的 SessionID 关联客户端与服务器 维护用户登录状态(如登录信息、权限)
JWT 无状态的 JSON 格式令牌,包含用户身份信息,通过签名保证完整性,客户端存储 分布式场景下的身份凭证(替代 Session)
SSO 单点登录系统,用户登录一次即可访问所有信任系统,整合认证流程 解决多系统重复登录问题

二、区别与关系:从技术维度对比

1. 存储位置与状态性

这是四类技术最核心的区别,直接决定了它们的分布式适配能力:

技术 存储位置 状态性 分布式适配能力
Cookie 客户端(浏览器) 客户端状态 天然支持(无需服务器共享)
Session 服务器端(内存 / Redis) 服务器状态 需共享存储(如 Redis),否则 Session 丢失
JWT 客户端(localStorage/Cookie) 无状态 天然支持(服务器无需存储状态)
SSO 认证中心存储用户状态 中心状态 需统一认证中心,子系统无状态

关系说明

  • Cookie 是 Session 的 "载体":Session 通过 Cookie 中的 SessionID 关联客户端;
  • JWT 是 Session 的 "无状态替代方案":分布式场景下,用 JWT 避免 Session 共享的复杂度;
  • SSO 是 "认证流程的整合":可基于 Session 或 JWT 实现(如认证中心生成 JWT,子系统验证)。

2. 安全性对比

不同技术的安全风险和防护手段差异显著:

技术 安全风险 防护手段
Cookie 被窃取(XSS)、被伪造(CSRF) 设置HttpOnly(防 XSS)、SameSite(防 CSRF)、Secure(仅 HTTPS)
Session SessionID 泄露(CSRF)、服务器存储风险 定期刷新 SessionID、限制 Session 有效期、Redis 存储防丢失
JWT Payload 可解码(非加密)、无法主动吊销 不存敏感信息、短期 Token + 刷新 Token、维护黑名单(Redis)
SSO 认证中心被攻击、Token 泄露 HTTPS 加密、Token 短期有效、多因素认证(MFA)

3. 核心区别总结

对比维度 Cookie Session JWT SSO
数据大小 最大 4KB 无限制(服务器资源决定) 建议不超过 1KB 无限制(取决于存储)
有效期 可设置(Persistent) 默认会话级(关闭浏览器失效) 固定有效期(Payload 中) 取决于 Token 有效期
服务器开销 高(存储状态) 低(仅验证签名) 中(认证中心维护状态)
适用架构 所有架构 单体应用 微服务 / 前后端分离 多系统集群

三、适用场景与实战代码

1. Cookie:客户端状态存储(如 SessionID、语言偏好)

适用场景

  • 存储 SessionID(关联 Session);
  • 存储用户偏好(如语言、主题);
  • 记住登录状态("记住我" 功能)。

实战代码:Spring Boot 下发与读取 Cookie

java 复制代码
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@RestController
public class CookieController {
    // 1. 下发Cookie(如存储用户语言偏好)
    @GetMapping("/set-cookie")
    public String setCookie(HttpServletResponse response) {
        // 创建Cookie(name=language, value=zh-CN)
        Cookie languageCookie = new Cookie("language", "zh-CN");
        languageCookie.setMaxAge(30 * 24 * 60 * 60); // 有效期30天
        languageCookie.setPath("/"); // 所有路径可见
        languageCookie.setHttpOnly(false); // 允许前端读取(用于语言切换)
        languageCookie.setSecure(true); // 仅HTTPS传递(生产环境必须)
        languageCookie.setSameSite("Lax"); // 防CSRF

        // 下发Cookie
        response.addCookie(languageCookie);
        return "Cookie已下发:language=zh-CN";
    }

    // 2. 读取Cookie(获取用户语言偏好)
    @GetMapping("/get-cookie")
    public String getCookie(HttpServletRequest request) {
        Cookie[] cookies = request.getCookies();
        if (cookies == null) {
            return "未获取到Cookie";
        }

        // 遍历Cookie,找到language
        for (Cookie cookie : cookies) {
            if ("language".equals(cookie.getName())) {
                return "当前语言偏好:" + cookie.getValue();
            }
        }
        return "未找到language Cookie";
    }
}

2. Session:单体应用的用户状态管理

适用场景

  • 单体应用的用户登录状态维护(如管理系统);
  • 存储用户临时数据(如购物车、表单临时数据);
  • 无需分布式部署的小型应用。

实战代码:Spring Boot 使用 Session

java 复制代码
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpSession;

@RestController
public class SessionController {
    // 1. 登录:创建Session
    @PostMapping("/login")
    public String login(
            @RequestParam String username,
            @RequestParam String password,
            HttpSession session) {
        // 模拟数据库校验(实际需查DB)
        if ("admin".equals(username) && "123456".equals(password)) {
            // 存储用户信息到Session
            session.setAttribute("userId", 1L);
            session.setAttribute("username", username);
            session.setAttribute("role", "ADMIN");
            session.setMaxInactiveInterval(30 * 60); // 有效期30分钟
            return "登录成功,SessionID:" + session.getId();
        }
        throw new RuntimeException("账号或密码错误");
    }

    // 2. 业务接口:验证Session
    @GetMapping("/admin/order")
    public String getAdminOrder(HttpSession session) {
        // 检查Session是否有效
        Long userId = (Long) session.getAttribute("userId");
        String role = (String) session.getAttribute("role");
        if (userId == null) {
            return "请先登录";
        }
        // 检查权限(仅ADMIN可访问)
        if (!"ADMIN".equals(role)) {
            return "无权限访问管理员订单";
        }
        return "管理员订单列表:...";
    }

    // 3. 登出:销毁Session
    @GetMapping("/logout")
    public String logout(HttpSession session) {
        session.invalidate(); // 销毁Session
        return "登出成功";
    }
}

分布式适配:若单体应用扩展为多节点,需用 Spring Session+Redis 共享 Session:

xml 复制代码
<!-- 引入依赖 -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
yaml 复制代码
# 配置Redis存储Session
spring:
  session:
    store-type: redis
    redis:
      namespace: spring:session
      max-inactive-interval: 1800 # 30分钟
  redis:
    host: localhost
    port: 6379

3. JWT:分布式 / 前后端分离的无状态认证

适用场景

  • 微服务架构(无状态,无需 Session 共享);
  • 前后端分离项目(Vue/React + Spring Boot);
  • 第三方 API 接口(如开放平台的身份凭证)。

实战代码:Spring Boot 实现 JWT 认证

3.1 JWT 工具类(生成、验证、解析)
java 复制代码
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class JwtUtils {
    // 密钥(生产环境存配置中心,32字节用于HS256)
    private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor("your-32-byte-secret-key-12345678".getBytes());
    // 有效期2小时
    private static final long EXPIRATION = 2 * 60 * 60 * 1000;

    // 生成JWT
    public static String generateToken(Long userId, String username, String role) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", userId);
        claims.put("username", username);
        claims.put("role", role);

        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
                .signWith(SECRET_KEY, SignatureAlgorithm.HS256)
                .compact();
    }

    // 验证JWT并解析用户信息
    public static Map<String, Object> validateToken(String token) {
        try {
            Jws<Claims> jws = Jwts.parserBuilder()
                    .setSigningKey(SECRET_KEY)
                    .build()
                    .parseClaimsJws(token);

            Claims claims = jws.getBody();
            Map<String, Object> userInfo = new HashMap<>();
            userInfo.put("userId", claims.get("userId"));
            userInfo.put("username", claims.get("username"));
            userInfo.put("role", claims.get("role"));
            return userInfo;
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("JWT已过期");
        } catch (MalformedJwtException | SignatureException e) {
            throw new RuntimeException("JWT无效或被篡改");
        }
    }
}
3.2 登录接口(生成 JWT)
java 复制代码
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class JwtLoginController {
    @PostMapping("/jwt/login")
    public String login(
            @RequestParam String username,
            @RequestParam String password) {
        // 模拟校验
        if ("admin".equals(username) && "123456".equals(password)) {
            // 生成JWT
            return JwtUtils.generateToken(1L, username, "ADMIN");
        }
        throw new RuntimeException("账号或密码错误");
    }
}
3.3 拦截器(统一验证 JWT)
java 复制代码
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;

public class JwtInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 从Authorization头获取JWT
        String authHeader = request.getHeader("Authorization");
        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            response.setStatus(401);
            response.getWriter().write("未携带JWT");
            return false;
        }

        String token = authHeader.substring(7);
        Map<String, Object> userInfo;
        try {
            userInfo = JwtUtils.validateToken(token);
        } catch (Exception e) {
            response.setStatus(401);
            response.getWriter().write(e.getMessage());
            return false;
        }

        // 传递用户信息到业务接口
        request.setAttribute("userId", userInfo.get("userId"));
        request.setAttribute("role", userInfo.get("role"));
        return true;
    }
}
3.4 配置拦截器
java 复制代码
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) {
        registry.addInterceptor(new JwtInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/jwt/login"); // 登录接口无需拦截
    }
}

4. SSO:多系统单点登录(整合认证流程)

适用场景

  • 企业多系统(如 OA、CRM、ERP);
  • 互联网产品矩阵(如京东金融、京东超市、京东国际);
  • 第三方平台接入(如微信开放平台、支付宝开放平台)。

实战代码:基于 JWT 的 SSO 认证中心(简化版)

4.1 认证中心核心接口
java 复制代码
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@RestController
@RequestMapping("/sso")
public class SsoAuthController {
    // 1. 登录页面(子系统重定向到这里)
    @GetMapping("/login-page")
    public String loginPage(@RequestParam String redirectUri) {
        // 实际返回登录HTML页面,这里简化为字符串
        return "请登录:<form action='/sso/login' method='post'>" +
                "<input name='username' placeholder='用户名'><br>" +
                "<input name='password' type='password' placeholder='密码'><br>" +
                "<input type='hidden' name='redirectUri' value='" + redirectUri + "'>" +
                "<button type='submit'>登录</button></form>";
    }

    // 2. 登录接口(生成JWT,重定向回子系统)
    @PostMapping("/login")
    public void login(
            @RequestParam String username,
            @RequestParam String password,
            @RequestParam String redirectUri,
            HttpServletResponse response) throws IOException {
        // 模拟校验
        if ("admin".equals(username) && "123456".equals(password)) {
            String jwt = JwtUtils.generateToken(1L, username, "ADMIN");
            // 重定向回子系统,携带JWT
            response.sendRedirect(redirectUri + "?token=" + jwt);
            return;
        }
        // 登录失败,重定向回登录页
        response.sendRedirect("/sso/login-page?redirectUri=" + redirectUri);
    }

    // 3. 验证JWT(子系统调用此接口验证Token)
    @GetMapping("/validate-token")
    public Map<String, Object> validateToken(@RequestParam String token) {
        return JwtUtils.validateToken(token);
    }
}
4.2 子系统整合 SSO(简化版)
java 复制代码
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;

@RestController
@RequestMapping("/subsystem")
public class SubsystemController {
    // SSO认证中心地址
    private static final String SSO_AUTH_URL = "http://localhost:8080/sso";
    // 子系统回调地址
    private static final String SUB_CALLBACK_URL = "http://localhost:8081/subsystem/callback";

    // 1. 子系统业务接口(需SSO认证)
    @GetMapping("/order")
    public void getOrder(HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 检查是否已获取JWT
        String token = (String) request.getAttribute("token");
        if (token == null) {
            // 未认证,重定向到SSO登录页
            response.sendRedirect(SSO_AUTH_URL + "/login-page?redirectUri=" + SUB_CALLBACK_URL);
            return;
        }
        // 已认证,返回业务数据
        response.getWriter().write("子系统订单列表(已认证)");
    }

    // 2. 回调接口(接收SSO返回的JWT)
    @GetMapping("/callback")
    public void callback(@RequestParam String token, HttpServletRequest request, HttpServletResponse response) throws IOException {
        // 调用SSO接口验证JWT
        Map<String, Object> userInfo = restTemplate.getForObject(
                SSO_AUTH_URL + "/validate-token?token=" + token,
                Map.class
        );
        if (userInfo == null) {
            // 验证失败,重定向到登录页
            response.sendRedirect(SSO_AUTH_URL + "/login-page?redirectUri=" + SUB_CALLBACK_URL);
            return;
        }
        // 验证成功,存储JWT并跳转到业务接口
        request.setAttribute("token", token);
        response.sendRedirect("/subsystem/order");
    }
}

四、选型指南:不同场景下的技术选择

项目架构 推荐技术组合 理由
单体应用(如管理系统) Session + Cookie 开发简单,无需分布式适配,Session 维护状态便捷
前后端分离(Vue+Spring Boot) JWT + localStorage 无状态,适配前端独立部署,避免 Cookie 的 CSRF 风险
微服务架构 JWT + API 网关 网关统一验证 JWT,微服务无状态,扩展性强
企业多系统 SSO(基于 JWT)+ 子系统验证 统一认证入口,避免多系统重复登录,JWT 简化子系统验证流程
开放平台(第三方 API) JWT + 刷新 Token 无状态便于第三方接入,刷新 Token 避免频繁登录

五、总结

Cookie、Session、JWT、SSO 并非互斥关系,而是不同层级的技术:

  1. Cookie 是基础载体:用于传递 SessionID 或 JWT,是客户端状态存储的最小单位;
  2. Session 是服务器状态:单体应用的首选,需 Cookie 配合,分布式需共享存储;
  3. JWT 是无状态凭证:替代 Session 的分布式方案,客户端存储,服务器仅验证;
  4. SSO 是流程整合:基于 Session 或 JWT,解决多系统认证统一问题,是更高层的架构设计。

掌握它们的核心区别和适用场景,才能在实际项目中选择最合适的技术组合,既保证系统安全,又兼顾开发效率和扩展性。

有些网站登陆过期时间是几小时,而有些却是好几天,手机应用登陆如b站则是长久保持,即使关机也不用重新登陆。这些是使用什么方案实现的?

2.网站与 APP 登录持久化方案:从几小时到永久登录的实现揭秘

在日常使用软件时,我们常会发现登录过期策略差异巨大:网站登录可能几小时失效,B 站等 APP 却能 "永久登录"(即使关机也无需重新登录)。这些差异并非简单的 "过期时间设置",而是基于用户体验、安全风险、设备特性设计的不同技术方案。本文将拆解从 "短期登录" 到 "永久登录" 的实现原理,结合实战代码,解析背后的技术选型逻辑。

一、核心问题:登录持久化的本质是什么?

登录持久化的核心是如何安全地存储用户身份凭证,并在凭证有效期内免密恢复登录状态。无论过期时间是几小时还是几年,技术方案都围绕以下 3 个目标设计:

  1. 安全性:防止凭证被窃取、篡改,避免账号被盗;
  2. 可用性:凭证能跨会话(如浏览器重启、APP 重装)恢复,无需频繁登录;
  3. 灵活性:支持按需失效(如用户登出、账号异常时强制失效)。

不同软件的过期策略差异,本质是安全与用户体验的权衡

  • 短期登录(几小时):金融、支付类软件(如网银、支付宝),优先保障安全;
  • 长期登录(几天到永久):内容、社交类 APP(如 B 站、微信),优先提升用户体验。

二、短期登录(几小时):网站的主流方案

网站(尤其是 PC 端)登录过期时间通常较短(1-24 小时),核心方案是Session-Cookie短期 JWT+Cookie,兼顾安全性和临时会话需求。

1. 方案 1:Session-Cookie(单体网站首选)

原理
  • 服务器存储用户会话(Session),通过 Cookie 将SessionID下发到客户端;
  • 客户端请求时自动携带SessionID,服务器验证 Session 有效性;
  • 过期策略:Session 设置短期有效期(如 2 小时),超时后服务器销毁 Session,用户需重新登录。
实战代码(Spring Boot)
java 复制代码
import javax.servlet.http.HttpSession;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ShortSessionLoginController {
    @PostMapping("/login")
    public String login(
            @RequestParam String username,
            @RequestParam String password,
            HttpSession session) {
        // 1. 模拟账号密码校验(实际需查数据库)
        if ("user123".equals(username) && "pwd123".equals(password)) {
            // 2. 存储用户信息到Session
            session.setAttribute("userId", 1001L);
            session.setAttribute("username", username);
            // 3. 设置Session过期时间:2小时(单位:秒)
            session.setMaxInactiveInterval(2 * 60 * 60);
            return "登录成功,SessionID:" + session.getId();
        }
        throw new RuntimeException("账号或密码错误");
    }

    // 业务接口:验证Session有效性
    @PostMapping("/user/info")
    public String getUserInfo(HttpSession session) {
        Long userId = (Long) session.getAttribute("userId");
        if (userId == null) {
            throw new RuntimeException("登录已过期,请重新登录");
        }
        return "用户ID:" + userId + ",登录状态有效";
    }
}
安全增强:Cookie 配置(防窃取、防篡改)
java 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import javax.servlet.ServletContext;

@Configuration
public class CookieConfig {
    @Bean
    public ServletContextInitializer servletContextInitializer() {
        return servletContext -> {
            // 配置Session对应的Cookie属性
            ServletContext.SessionCookieConfig cookieConfig = servletContext.getSessionCookieConfig();
            cookieConfig.setName("JSESSIONID");
            cookieConfig.setHttpOnly(true); // 禁止JS访问,防XSS窃取
            cookieConfig.setSecure(true);   // 仅HTTPS传递,防中间人攻击
            cookieConfig.setSameSite("Lax");// 限制跨站携带,防CSRF
            cookieConfig.setMaxAge(2 * 60 * 60); // 与Session过期时间一致
        };
    }
}
适用场景
  • 单体网站(如企业管理系统、论坛);
  • 对安全性要求较高,且用户无需长期登录的场景(如电商 PC 端、网银)。

2. 方案 2:短期 JWT+Cookie(分布式网站)

原理
  • 服务器验证用户身份后生成短期 JWT(如 2 小时),通过 Cookie 下发;
  • 客户端请求时携带 Cookie 中的 JWT,服务器验证签名和过期时间;
  • 过期策略:JWT 自身携带exp(过期时间)字段,超时后客户端需重新登录。
实战代码(JWT 工具类 + 登录接口)
java 复制代码
// JWT工具类(核心方法)
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;

public class ShortJwtUtils {
    // 密钥(生产环境存配置中心)
    private static final SecretKey SECRET_KEY = Keys.hmacShaKeyFor("your-32-byte-secret-key-here".getBytes());
    // 过期时间:2小时
    private static final long EXPIRATION = 2 * 60 * 60 * 1000;

    // 生成短期JWT
    public static String generateShortToken(Long userId, String username) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("userId", userId);
        claims.put("username", username);
        
        return Jwts.builder()
                .setClaims(claims)
                .setExpiration(new Date(System.currentTimeMillis() + EXPIRATION))
                .signWith(SECRET_KEY, SignatureAlgorithm.HS256)
                .compact();
    }

    // 验证JWT
    public static Map<String, Object> validateToken(String token) {
        try {
            Jws<Claims> jws = Jwts.parserBuilder()
                    .setSigningKey(SECRET_KEY)
                    .build()
                    .parseClaimsJws(token);
            Claims claims = jws.getBody();
            Map<String, Object> userInfo = new HashMap<>();
            userInfo.put("userId", claims.get("userId"));
            userInfo.put("username", claims.get("username"));
            return userInfo;
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("登录已过期");
        } catch (SignatureException | MalformedJwtException e) {
            throw new RuntimeException("凭证无效");
        }
    }
}

// 登录接口(下发短期JWT到Cookie)
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletResponse;

@RestController
public class ShortJwtLoginController {
    @PostMapping("/jwt/login")
    public String login(
            @RequestParam String username,
            @RequestParam String password,
            HttpServletResponse response) {
        // 模拟校验
        if ("user123".equals(username) && "pwd123".equals(password)) {
            String jwt = ShortJwtUtils.generateShortToken(1001L, username);
            // 下发JWT到Cookie
            Cookie jwtCookie = new Cookie("short_jwt", jwt);
            jwtCookie.setMaxAge(2 * 60 * 60); // 2小时过期
            jwtCookie.setPath("/");
            jwtCookie.setHttpOnly(true);
            jwtCookie.setSecure(true);
            response.addCookie(jwtCookie);
            return "登录成功,短期JWT已下发";
        }
        throw new RuntimeException("账号或密码错误");
    }
}
适用场景
  • 分布式网站(微服务架构);
  • 无需长期登录,但需跨服务共享身份的场景(如多节点部署的电商平台)。

三、中长期登录(几天到几周):兼顾体验与安全的平衡方案

电商 APP、社交软件(如淘宝、微博)常设置几天到几周的登录过期时间,核心方案是 "短期访问凭证 + 长期刷新凭证",既避免频繁登录,又降低长期凭证泄露的风险。

核心原理:双凭证机制

  1. 访问凭证(Access Token):短期有效(如 2 小时),用于接口调用,泄露风险低;
  2. 刷新凭证(Refresh Token):长期有效(如 7 天),仅用于获取新的 Access Token,泄露后可通过 "设备绑定" 限制风险;
  3. 流程:
    • 用户登录时,服务器返回 Access Token 和 Refresh Token;
    • Access Token 过期后,客户端用 Refresh Token 向服务器申请新的 Access Token;
    • 若 Refresh Token 也过期,用户需重新登录。
实战代码(双凭证登录 + 刷新接口)
java 复制代码
// 1. 凭证实体类
import lombok.Data;

@Data
public class TokenPair {
    private String accessToken;   // 访问凭证(2小时)
    private String refreshToken;  // 刷新凭证(7天)
    private long accessExpire;    // 访问凭证过期时间(时间戳)
    private long refreshExpire;   // 刷新凭证过期时间(时间戳)
}

// 2. 双凭证工具类
public class DoubleTokenUtils {
    // 访问凭证密钥
    private static final SecretKey ACCESS_KEY = Keys.hmacShaKeyFor("access-key-32-byte-secret".getBytes());
    // 刷新凭证密钥(与访问凭证不同,提升安全性)
    private static final SecretKey REFRESH_KEY = Keys.hmacShaKeyFor("refresh-key-32-byte-secret".getBytes());
    // 访问凭证过期时间:2小时
    private static final long ACCESS_EXP = 2 * 60 * 60 * 1000;
    // 刷新凭证过期时间:7天
    private static final long REFRESH_EXP = 7 * 24 * 60 * 60 * 1000;

    // 生成双凭证
    public static TokenPair generateTokenPair(Long userId, String deviceId) {
        long now = System.currentTimeMillis();
        // 生成Access Token(包含用户ID)
        String accessToken = Jwts.builder()
                .claim("userId", userId)
                .setExpiration(new Date(now + ACCESS_EXP))
                .signWith(ACCESS_KEY, SignatureAlgorithm.HS256)
                .compact();

        // 生成Refresh Token(包含用户ID+设备ID,绑定设备防泄露)
        String refreshToken = Jwts.builder()
                .claim("userId", userId)
                .claim("deviceId", deviceId) // 绑定设备,仅该设备可使用
                .setExpiration(new Date(now + REFRESH_EXP))
                .signWith(REFRESH_KEY, SignatureAlgorithm.HS256)
                .compact();

        TokenPair pair = new TokenPair();
        pair.setAccessToken(accessToken);
        pair.setRefreshToken(refreshToken);
        pair.setAccessExpire(now + ACCESS_EXP);
        pair.setRefreshExpire(now + REFRESH_EXP);
        return pair;
    }

    // 验证Access Token
    public static Map<String, Object> validateAccessToken(String token) {
        try {
            Jws<Claims> jws = Jwts.parserBuilder()
                    .setSigningKey(ACCESS_KEY)
                    .build()
                    .parseClaimsJws(token);
            Claims claims = jws.getBody();
            Map<String, Object> info = new HashMap<>();
            info.put("userId", claims.get("userId"));
            return info;
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("访问凭证已过期,请刷新");
        }
    }

    // 用Refresh Token获取新的Access Token
    public static String refreshAccessToken(String refreshToken, String deviceId) {
        try {
            Jws<Claims> jws = Jwts.parserBuilder()
                    .setSigningKey(REFRESH_KEY)
                    .build()
                    .parseClaimsJws(refreshToken);
            Claims claims = jws.getBody();

            // 验证设备ID(防止Refresh Token泄露到其他设备)
            String storedDeviceId = claims.get("deviceId").toString();
            if (!deviceId.equals(storedDeviceId)) {
                throw new RuntimeException("设备不匹配,刷新失败");
            }

            // 生成新的Access Token
            Long userId = Long.valueOf(claims.get("userId").toString());
            return generateTokenPair(userId, deviceId).getAccessToken();
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("刷新凭证已过期,请重新登录");
        }
    }
}

// 3. 登录接口(返回双凭证)
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class DoubleTokenLoginController {
    @PostMapping("/double/login")
    public TokenPair login(
            @RequestParam String username,
            @RequestParam String password,
            @RequestParam String deviceId) { // 客户端传递设备唯一标识(如手机IMEI)
        // 模拟校验
        if ("user123".equals(username) && "pwd123".equals(password)) {
            return DoubleTokenUtils.generateTokenPair(1001L, deviceId);
        }
        throw new RuntimeException("账号或密码错误");
    }

    // 刷新Access Token接口
    @PostMapping("/token/refresh")
    public String refreshToken(
            @RequestParam String refreshToken,
            @RequestParam String deviceId) {
        return DoubleTokenUtils.refreshAccessToken(refreshToken, deviceId);
    }
}
关键安全设计
  • 设备绑定:Refresh Token 包含设备 ID,仅绑定的设备可使用,即使泄露也无法在其他设备生效;
  • 密钥分离:Access Token 和 Refresh Token 使用不同密钥,降低单一密钥泄露的风险;
  • 刷新限制:可对 Refresh Token 设置 "最大刷新次数"(如 7 天内最多刷新 3 次),异常刷新时强制失效。
适用场景
  • 移动 APP(如电商、社交软件);
  • 需中长期登录,但又需控制安全风险的场景(如淘宝、微博,过期时间 7-30 天)。

四、永久登录(如 B 站 APP):极致用户体验的方案

B 站、微信等 APP 的 "永久登录" 并非真的 "永久",而是通过 "长期凭证 + 设备绑定 + 静默刷新" 实现 "用户无感知的持久登录",核心是在安全可控的前提下最大化用户体验。

核心原理:三层保障的持久化方案

  1. 长期 Refresh Token:设置超长有效期(如 1 年),存储在 APP 的 "安全存储区"(如 Android 的 Keystore、iOS 的 Keychain),避免被窃取;
  2. 设备指纹绑定:生成设备唯一指纹(如手机 IMEI + 系统版本 + APP 版本),与 Refresh Token 绑定,仅该设备可使用;
  3. 静默刷新机制:APP 启动时检查 Access Token 是否过期,若过期则自动用 Refresh Token 刷新,用户无感知;
  4. 强制失效机制:服务器端维护 "黑名单",若用户登出、账号异常(如异地登录),立即将 Refresh Token 加入黑名单,强制失效。
实战代码(永久登录核心逻辑)
java 复制代码
// 1. 设备指纹工具类(生成唯一设备标识)
import java.security.MessageDigest;
import java.util.UUID;

public class DeviceFingerprintUtils {
    // 生成设备指纹(结合设备硬件信息+系统信息)
    public static String generateFingerprint(String imei, String osVersion, String appVersion) {
        try {
            // 拼接设备信息
            String raw = imei + "_" + osVersion + "_" + appVersion + "_" + UUID.randomUUID().toString();
            // MD5哈希生成唯一指纹
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] digest = md.digest(raw.getBytes());
            StringBuilder sb = new StringBuilder();
            for (byte b : digest) {
                sb.append(String.format("%02x", b));
            }
            return sb.toString();
        } catch (Exception e) {
            // 异常时返回UUID(降级方案)
            return UUID.randomUUID().toString();
        }
    }
}

// 2. 永久登录工具类(核心逻辑)
public class PermanentLoginUtils {
    // 刷新凭证过期时间:1年
    private static final long PERMANENT_REFRESH_EXP = 365 * 24 * 60 * 60 * 1000;
    // 服务器端黑名单(Redis存储,key=refreshToken,value=失效时间)
    private static final RedisTemplate<String, Long> redisTemplate = new RedisTemplate<>();

    // 生成永久登录凭证(Access Token+长期Refresh Token)
    public static TokenPair generatePermanentToken(Long userId, String deviceFingerprint) {
        long now = System.currentTimeMillis();
        // Access Token(2小时,同前)
        String accessToken = generateAccessToken(userId);
        // 长期Refresh Token(1年,绑定设备指纹)
        String refreshToken = Jwts.builder()
                .claim("userId", userId)
                .claim("fingerprint", deviceFingerprint)
                .setExpiration(new Date(now + PERMANENT_REFRESH_EXP))
                .signWith(REFRESH_KEY, SignatureAlgorithm.HS256)
                .compact();
        // 存储Refresh Token到Redis(便于后续强制失效)
        redisTemplate.opsForValue().set(
                "refresh_token:" + refreshToken,
                userId,
                PERMANENT_REFRESH_EXP,
                TimeUnit.MILLISECONDS
        );

        TokenPair pair = new TokenPair();
        pair.setAccessToken(accessToken);
        pair.setRefreshToken(refreshToken);
        pair.setAccessExpire(now + 2 * 60 * 60 * 1000);
        pair.setRefreshExpire(now + PERMANENT_REFRESH_EXP);
        return pair;
    }

    // 验证Refresh Token(含黑名单校验)
    public static String refreshPermanentToken(String refreshToken, String deviceFingerprint) {
        // 1. 先检查是否在黑名单
        if (redisTemplate.hasKey("blacklist:" + refreshToken)) {
            throw new RuntimeException("登录已失效,请重新登录");
        }

        try {
            Jws<Claims> jws = Jwts.parserBuilder()
                    .setSigningKey(REFRESH_KEY)
                    .build()
                    .parseClaimsJws(refreshToken);
            Claims claims = jws.getBody();

            // 2. 验证设备指纹
            String storedFingerprint = claims.get("fingerprint").toString();
            if (!deviceFingerprint.equals(storedFingerprint)) {
                // 设备不匹配,加入黑名单
                redisTemplate.opsForValue().set(
                        "blacklist:" + refreshToken,
                        1L,
                        365 * 24 * 60 * 60 * 1000,
                        TimeUnit.MILLISECONDS
                );
                throw new RuntimeException("设备异常,登录已失效");
            }

            // 3. 生成新的Access Token
            Long userId = Long.valueOf(claims.get("userId").toString());
            return generateAccessToken(userId);
        } catch (ExpiredJwtException e) {
            throw new RuntimeException("登录已过期,请重新登录");
        }
    }

    // 强制失效(登出、账号异常时调用)
    public static void invalidateToken(String refreshToken) {
        // 加入黑名单
        redisTemplate.opsForValue().set(
                "blacklist:" + refreshToken,
                1L,
                365 * 24 * 60 * 60 * 1000,
                TimeUnit.MILLISECONDS
        );
        // 删除Redis中的有效凭证
        redisTemplate.delete("refresh_token:" + refreshToken);
    }

    // 生成Access Token(复用前序逻辑)
    private static String generateAccessToken(Long userId) {
        return Jwts.builder()
                .claim("userId", userId)
                .setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000))
                .signWith(ACCESS_KEY, SignatureAlgorithm.HS256)
                .compact();
    }
}

// 3. APP客户端静默刷新逻辑(伪代码)
public class AppLoginManager {
    private String refreshToken;
    private String deviceFingerprint;
    private String accessToken;

    // APP启动时调用:检查并刷新凭证
    public void checkAndRefreshToken() {
        if (isAccessTokenExpired()) {
            // 静默刷新Access Token
            try {
                accessToken = PermanentLoginUtils.refreshPermanentToken(refreshToken, deviceFingerprint);
                saveTokenToSecureStorage(); // 保存到安全存储区(如Keystore)
            } catch (Exception e) {
                // 刷新失败,跳转登录页
                jumpToLoginPage();
            }
        }
    }

    // 检查Access Token是否过期
    private boolean isAccessTokenExpired() {
        // 从安全存储区读取accessToken的过期时间,判断是否过期
        return System.currentTimeMillis() > getAccessTokenExpireTime();
    }

    // 保存凭证到安全存储区(避免被窃取)
    private void saveTokenToSecureStorage() {
        // Android:使用Keystore存储;iOS:使用Keychain存储
    }
}
关键技术细节
  • 安全存储 :凭证不存储在SharedPreferencesNSUserDefaults(易被 root / 越狱设备读取),而是存储在系统级安全区域(Keystore/Keychain);
  • 设备指纹稳定性:结合硬件信息(IMEI、MAC)和软件信息(系统版本、APP 版本),确保设备指纹在 "系统升级、APP 重装" 后仍能匹配;
  • 异常检测:服务器监控 Refresh Token 的使用频率(如短时间内多 IP 调用),异常时强制失效,防止账号被盗。
适用场景
  • 高频使用的 APP(如社交、内容类软件);
  • 对用户体验要求极高,且能通过设备绑定、异常检测控制安全风险的场景(如 B 站、微信、QQ)。

五、总结:不同登录时长的技术选型对比

登录时长 核心方案 安全措施 适用场景
几小时(短期) Session-Cookie / 短期 JWT+Cookie HttpOnly、Secure、SameSite、Session 超时 网站、网银、支付类软件
几天到几周 双凭证(Access Token+Refresh Token) 设备绑定、密钥分离、刷新限制 电商 APP、微博、淘宝
长期 / 永久 长期 Refresh Token + 安全存储 + 静默刷新 安全存储(Keystore/Keychain)、设备指纹、黑名单 社交 APP、内容 APP(B 站、微信、QQ)

选型核心逻辑

  1. 安全优先:金融、支付类软件优先选择短期登录,避免凭证泄露风险;
  2. 体验优先:高频使用的 APP 选择长期 / 永久登录,通过设备绑定、安全存储平衡风险;
  3. 分布式适配:多服务架构优先选择 JWT(无状态),单体架构可选择 Session-Cookie(开发简单)。

无论选择哪种方案,核心都是 "在安全与体验之间找到平衡点"------ 短期登录并非 "不安全",长期登录也并非 "无风险",关键在于通过技术手段将风险控制在可接受范围,同时最大化用户体验。

那么以上这些不用到缓存吗,缓存可以持久化吗

3.登录持久化与缓存:缓存的必要性、持久化方案与实战代码

在之前讨论的登录持久化方案中,缓存(如 Redis)其实是核心支撑组件 ------ 无论是短期 Session 共享、双凭证的黑名单管理,还是长期登录的状态维护,都离不开缓存的参与。同时,缓存能否持久化直接决定了登录状态在服务重启后的可用性。本文将系统解答 "登录场景中为什么需要缓存""缓存如何持久化",并结合实战代码展示缓存的落地细节。

一、登录持久化为什么离不开缓存?

在单体应用中,短期 Session 可以存储在 JVM 内存中,但分布式场景、长期登录场景必须依赖缓存。缓存的核心价值体现在以下 4 个方面:

1. 分布式 Session 共享(解决 "多服务 Session 不一致")

单体应用中,Session 存储在单个服务器内存,若扩展为多节点(如 2 台 Tomcat),用户请求可能被负载均衡到不同节点,导致 Session 丢失(用户需重复登录)。

缓存的作用:将 Session 集中存储在 Redis 等缓存中,所有服务节点通过缓存读写 Session,实现 "一处存储,多处共享"。

2. 双凭证的状态管理(控制 Refresh Token 的生命周期)

中长期登录的 "双凭证机制" 中,需要维护 3 类关键状态:

  • Refresh Token 的有效性(是否已过期);

  • 黑名单(登出、账号异常的 Token 需立即失效);

  • 设备绑定关系(防止 Token 在其他设备使用)。

    缓存的作用:用 Redis 存储这些状态,支持快速查询(如判断 Token 是否在黑名单)和过期自动清理(无需手动维护过期逻辑)。

3. 减轻数据库压力(避免高频查询)

登录场景中,"验证 Token 有效性""查询用户权限" 是高频操作(如每一次接口调用都需验证 Token)。若直接查询数据库,会导致数据库压力激增。

缓存的作用:将高频访问的 Token、用户权限等数据缓存到 Redis,查询耗时从 "毫秒级(数据库)" 降至 "微秒级(缓存)",大幅提升性能。

4. 长期登录的状态恢复(服务重启后不丢失登录状态)

若登录状态存储在 JVM 内存,服务重启后状态会全部丢失,用户需重新登录。

缓存的作用:缓存(如 Redis)支持持久化,服务重启后可从缓存恢复登录状态,避免用户感知服务重启。

二、缓存的持久化:什么是持久化?有哪些方案?

缓存的 "持久化" 是指将缓存中的数据(如 Session、Token、黑名单)写入磁盘,确保服务重启、缓存实例宕机后数据不丢失。不同缓存组件的持久化方案不同,以主流的Redis为例,核心持久化方案有两种:RDB 和 AOF。

1. Redis 持久化方案对比

方案 核心原理 优点 缺点 适用场景
RDB(快照) 按配置的时间间隔(如 5 分钟)生成内存数据的快照文件(.rdb),写入磁盘 1. 文件体积小,恢复速度快;2. 对 Redis 性能影响小 1. 可能丢失 "快照间隔内" 的数据;2. 大内存场景下生成快照耗时 对数据一致性要求不高的场景(如 Session、非核心 Token)
AOF(日志) 记录每一条写操作命令(如 SET、DEL)到日志文件(.aof),重启时重新执行命令恢复数据 1. 数据一致性高(可配置 "每写必刷盘");2. 无数据丢失风险 1. 日志文件体积大;2. 恢复速度慢 对数据一致性要求高的场景(如黑名单、长期 Refresh Token)

2. 生产环境推荐方案:RDB+AOF 混合持久化

Redis 4.0 + 支持 "RDB+AOF 混合持久化",结合两种方案的优点:

  • 持久化时:先生成 RDB 快照,再将后续的写命令追加到 AOF 日志;
  • 恢复时:先加载 RDB 快照(快速恢复大部分数据),再执行 AOF 日志中的增量命令(补全快照后的新数据);
  • 优势:兼顾 "恢复速度" 和 "数据一致性",是登录持久化场景的首选。

三、缓存持久化的实战配置(Redis)

以 Redis 6.0 为例,通过配置文件开启混合持久化,确保登录相关数据不丢失。

1. Redis 配置文件(redis.conf)关键配置

ini

ini 复制代码
# -------------------------- RDB持久化配置 --------------------------
# 900秒内有1个key变化则生成快照
save 900 1
# 300秒内有10个key变化则生成快照
save 300 10
# 60秒内有10000个key变化则生成快照
save 60 10000
# 快照文件存储路径(默认当前目录)
dir /var/lib/redis
# 快照文件名
dbfilename dump.rdb
# 生成快照失败时,是否停止Redis写操作(防止数据不一致)
stop-writes-on-bgsave-error yes

# -------------------------- AOF持久化配置 --------------------------
# 开启AOF持久化
appendonly yes
# AOF文件名
appendfilename "appendonly.aof"
# AOF刷盘策略(everysec:每秒刷盘,平衡性能和一致性)
appendfsync everysec
# 开启混合持久化(Redis 4.0+支持)
aof-use-rdb-preamble yes
# AOF日志重写触发条件(避免日志文件过大)
auto-aof-rewrite-percentage 100  # 当前AOF文件是上次重写的2倍时触发
auto-aof-rewrite-min-size 64mb  # AOF文件超过64MB时触发重写

2. 配置生效与验证

  1. 重启 Redissystemctl restart redis(Linux)或 redis-server redis.conf(Windows);
  2. 验证持久化开启:
    • 执行redis-cli config get appendonly,返回1表示 AOF 已开启;
    • 执行redis-cli config get aof-use-rdb-preamble,返回yes表示混合持久化已开启;
  3. 验证数据持久化:
    • 执行set user:1001 "admin"写入数据;
    • 重启 Redis 后执行get user:1001,若返回"admin",说明数据已持久化。

四、缓存在登录持久化中的实战代码

以下结合之前的登录方案,展示缓存(Redis)的具体应用,包括分布式 Session、双凭证管理、长期登录的黑名单控制。

1. 实战 1:分布式 Session(基于 Redis 缓存)

核心需求

多服务节点共享 Session,服务重启后 Session 不丢失,Session 过期时间 2 小时。

实战代码(Spring Boot + Spring Session + Redis)
1.1 引入依赖
xml 复制代码
<!-- Spring Session(整合Redis) -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
<!-- Redis客户端 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
1.2 配置 Redis 与 Session
yaml 复制代码
spring:
  # Redis配置(连接信息、序列化方式)
  redis:
    host: localhost
    port: 6379
    password: 123456
    lettuce:
      pool:
        max-active: 8  # 最大连接数
  # Spring Session配置(Redis存储)
  session:
    store-type: redis  # Session存储到Redis
    redis:
      namespace: spring:session  # Redis中Session的key前缀
      max-inactive-interval: 7200  # Session过期时间:2小时(秒)
    cookie:
      http-only: true  # 防XSS
      secure: true     # 仅HTTPS传递
      same-site: Lax   # 防CSRF
1.3 启用分布式 Session
java 复制代码
import org.springframework.session.data.redis.config.annotation.web.http.EnableRedisHttpSession;
import org.springframework.stereotype.Component;

// 启用Redis存储Session,过期时间与配置一致
@Component
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 7200)
public class RedisSessionConfig {
}
1.4 登录接口(Session 自动存储到 Redis)
java 复制代码
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpSession;

@RestController
public class DistributedSessionLoginController {
    @PostMapping("/distributed/login")
    public String login(
            @RequestParam String username,
            @RequestParam String password,
            HttpSession session) {
        // 1. 模拟账号密码校验
        if ("admin".equals(username) && "123456".equals(password)) {
            // 2. 存储用户信息到Session(自动同步到Redis)
            session.setAttribute("userId", 1001L);
            session.setAttribute("username", username);
            // 3. 返回SessionID(Redis中的key前缀为spring:session:sessions:xxx)
            return "分布式登录成功,SessionID:" + session.getId();
        }
        throw new RuntimeException("账号或密码错误");
    }

    // 业务接口(从Redis读取Session)
    @PostMapping("/distributed/user/info")
    public String getUserInfo(HttpSession session) {
        Long userId = (Long) session.getAttribute("userId");
        if (userId == null) {
            throw new RuntimeException("登录已过期,请重新登录");
        }
        return "用户ID:" + userId + ",Session存储在Redis,多服务共享";
    }
}
1.5 缓存持久化效果
  • 服务重启后,用户无需重新登录:Session 存储在 Redis,Redis 开启 RDB+AOF 持久化,服务重启后从 Redis 恢复 Session;
  • 多服务节点共享:2 台 Tomcat 节点均从 Redis 读写 Session,用户请求到任意节点都能识别登录状态。

2. 实战 2:双凭证的黑名单管理(Redis 缓存)

核心需求
  • Refresh Token 过期时间 7 天,存储在 Redis;
  • 用户登出或账号异常时,将 Refresh Token 加入黑名单,立即失效;
  • 黑名单数据需持久化,服务重启后仍能识别失效 Token。
实战代码
2.1 Redis 工具类(封装常用操作)
java 复制代码
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;

@Component
public class RedisUtils {
    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    // 存储数据(带过期时间)
    public void set(String key, Object value, long timeout, TimeUnit unit) {
        redisTemplate.opsForValue().set(key, value, timeout, unit);
    }

    // 获取数据
    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    // 判断key是否存在
    public boolean hasKey(String key) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    // 删除数据
    public void delete(String key) {
        redisTemplate.delete(key);
    }
}
2.2 双凭证管理(结合 Redis)
java 复制代码
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Component
public class DoubleTokenManager {
    // 密钥(生产环境存配置中心)
    private static final SecretKey ACCESS_KEY = Keys.hmacShaKeyFor("access-key-32-byte-secret-123456".getBytes());
    private static final SecretKey REFRESH_KEY = Keys.hmacShaKeyFor("refresh-key-32-byte-secret-654321".getBytes());
    // 过期时间:Access Token 2小时,Refresh Token 7天
    private static final long ACCESS_EXP = 2 * 60 * 60 * 1000;
    private static final long REFRESH_EXP = 7 * 24 * 60 * 60 * 1000;
    // Redis key前缀
    private static final String REFRESH_TOKEN_KEY = "refresh_token:";
    private static final String BLACKLIST_KEY = "blacklist:";

    @Resource
    private RedisUtils redisUtils;

    // 生成双凭证(存储Refresh Token到Redis)
    public TokenPair generateTokenPair(Long userId, String deviceId) {
        long now = System.currentTimeMillis();
        // 1. 生成Access Token
        String accessToken = Jwts.builder()
                .claim("userId", userId)
                .setExpiration(new Date(now + ACCESS_EXP))
                .signWith(ACCESS_KEY, SignatureAlgorithm.HS256)
                .compact();

        // 2. 生成Refresh Token(绑定设备ID)
        String refreshToken = Jwts.builder()
                .claim("userId", userId)
                .claim("deviceId", deviceId)
                .setExpiration(new Date(now + REFRESH_EXP))
                .signWith(REFRESH_KEY, SignatureAlgorithm.HS256)
                .compact();

        // 3. 存储Refresh Token到Redis(7天过期,与Token过期时间一致)
        redisUtils.set(
                REFRESH_TOKEN_KEY + refreshToken,
                userId,
                REFRESH_EXP,
                TimeUnit.MILLISECONDS
        );

        // 4. 封装返回
        TokenPair pair = new TokenPair();
        pair.setAccessToken(accessToken);
        pair.setRefreshToken(refreshToken);
        pair.setAccessExpire(now + ACCESS_EXP);
        pair.setRefreshExpire(now + REFRESH_EXP);
        return pair;
    }

    // 验证Refresh Token(检查是否在黑名单、设备是否匹配)
    public String refreshAccessToken(String refreshToken, String deviceId) {
        // 1. 先检查是否在黑名单
        if (redisUtils.hasKey(BLACKLIST_KEY + refreshToken)) {
            throw new RuntimeException("Token已失效,请重新登录");
        }

        // 2. 检查Redis中是否存在该Refresh Token(已过期会自动删除)
        Object userIdObj = redisUtils.get(REFRESH_TOKEN_KEY + refreshToken);
        if (userIdObj == null) {
            throw new RuntimeException("Refresh Token已过期");
        }
        Long userId = Long.valueOf(userIdObj.toString());

        try {
            // 3. 验证Token签名和设备ID
            Jws<Claims> jws = Jwts.parserBuilder()
                    .setSignWith(REFRESH_KEY)
                    .build()
                    .parseClaimsJws(refreshToken);
            Claims claims = jws.getBody();
            String storedDeviceId = claims.get("deviceId").toString();
            if (!deviceId.equals(storedDeviceId)) {
                // 设备不匹配,加入黑名单
                addToBlacklist(refreshToken);
                throw new RuntimeException("设备异常,Token失效");
            }

            // 4. 生成新的Access Token
            return Jwts.builder()
                    .claim("userId", userId)
                    .setExpiration(new Date(System.currentTimeMillis() + ACCESS_EXP))
                    .signWith(ACCESS_KEY, SignatureAlgorithm.HS256)
                    .compact();
        } catch (Exception e) {
            addToBlacklist(refreshToken);
            throw new RuntimeException("Token无效");
        }
    }

    // 登出:将Token加入黑名单
    public void logout(String refreshToken) {
        addToBlacklist(refreshToken);
        // 删除Redis中的有效Token
        redisUtils.delete(REFRESH_TOKEN_KEY + refreshToken);
    }

    // 加入黑名单(过期时间与Refresh Token一致,避免永久占用内存)
    private void addToBlacklist(String refreshToken) {
        redisUtils.set(
                BLACKLIST_KEY + refreshToken,
                "invalid",
                REFRESH_EXP,
                TimeUnit.MILLISECONDS
        );
    }
}
2.3 缓存持久化效果
  • 服务重启后,黑名单仍有效:Redis 开启 AOF 持久化,黑名单数据写入 AOF 日志,服务重启后恢复;
  • Token 过期自动清理:Redis 自动删除过期的 Refresh Token 和黑名单数据,无需手动维护。

3. 实战 3:长期登录的状态维护(Redis 缓存)

核心需求

B 站式 "永久登录":

  • Refresh Token 过期时间 1 年,存储在 Redis 并持久化;
  • 服务重启后,用户无需重新登录;
  • 账号异常时,可通过 Redis 黑名单强制失效 Token。
关键代码(基于实战 2 扩展)
java 复制代码
// 长期登录的Token生成(过期时间1年)
public TokenPair generatePermanentToken(Long userId, String deviceFingerprint) {
    long now = System.currentTimeMillis();
    long permanentExp = 365 * 24 * 60 * 60 * 1000; // 1年过期

    // 1. 生成长期Refresh Token(绑定设备指纹)
    String refreshToken = Jwts.builder()
            .claim("userId", userId)
            .claim("fingerprint", deviceFingerprint)
            .setExpiration(new Date(now + permanentExp))
            .signWith(REFRESH_KEY, SignatureAlgorithm.HS256)
            .compact();

    // 2. 存储到Redis(1年过期,开启RDB+AOF持久化)
    redisUtils.set(
            REFRESH_TOKEN_KEY + refreshToken,
            userId + "_" + deviceFingerprint, // 存储设备指纹,避免后续查询JWT
            permanentExp,
            TimeUnit.MILLISECONDS
    );

    // 3. 生成Access Token(2小时,同前)
    String accessToken = generateAccessToken(userId);

    TokenPair pair = new TokenPair();
    pair.setAccessToken(accessToken);
    pair.setRefreshToken(refreshToken);
    pair.setAccessExpire(now + 2 * 60 * 60 * 1000);
    pair.setRefreshExpire(now + permanentExp);
    return pair;
}

// 验证长期Token(直接从Redis获取设备指纹,避免解析JWT,提升性能)
public String refreshPermanentToken(String refreshToken, String deviceFingerprint) {
    // 1. 检查黑名单
    if (redisUtils.hasKey(BLACKLIST_KEY + refreshToken)) {
        throw new RuntimeException("登录已失效");
    }

    // 2. 从Redis获取用户ID和设备指纹(避免解析JWT)
    String storedValue = (String) redisUtils.get(REFRESH_TOKEN_KEY + refreshToken);
    if (storedValue == null) {
        throw new RuntimeException("登录已过期");
    }
    String[] parts = storedValue.split("_");
    Long userId = Long.valueOf(parts[0]);
    String storedFingerprint = parts[1];

    // 3. 验证设备指纹
    if (!deviceFingerprint.equals(storedFingerprint)) {
        addToBlacklist(refreshToken);
        throw new RuntimeException("设备异常,登录失效");
    }

    // 4. 生成新的Access Token
    return generateAccessToken(userId);
}
持久化关键:
  • Redis 开启混合持久化,1 年有效期的 Refresh Token 在服务重启后仍能从 Redis 恢复;
  • 设备指纹存储在 Redis,避免每次验证都解析 JWT,兼顾性能和持久化。

五、总结:缓存与登录持久化的核心结论

1. 缓存是登录持久化的 "基础设施"

  • 短期登录(分布式 Session):依赖 Redis 实现多服务共享;
  • 中长期登录(双凭证):依赖 Redis 管理 Token 状态、黑名单;
  • 长期登录(永久登录):依赖 Redis 持久化登录状态,服务重启不丢失。

2. 缓存必须持久化,否则登录状态不可靠

  • 非持久化缓存(如内存缓存):服务重启、缓存宕机后登录状态全部丢失,用户需重新登录;
  • 持久化方案:Redis 推荐 "RDB+AOF 混合持久化",兼顾恢复速度和数据一致性;
  • 注意点:缓存持久化需配置合理的刷盘策略(如 AOF 的everysec),避免影响 Redis 性能。

3. 缓存的选型建议

  • 登录场景首选 Redis:支持持久化、过期自动清理、高并发读写,生态成熟;
  • 避免使用本地缓存(如 Caffeine):仅适合单体、短期场景,不支持分布式共享和持久化。

通过缓存的合理使用和持久化配置,才能实现 "分布式共享、服务重启不丢失、高性能" 的登录持久化方案,这也是企业级应用的标准实践。

4.Java 后端核心技术体系总结:从并发编程到登录持久化与缓存

结合之前讨论的 AQS、JVM、设计模式、认证授权、登录持久化及缓存等核心技术,本文将从技术模块划分、核心原理、实战场景、关联关系四个维度进行系统总结,帮你构建完整的 Java 后端技术知识体系,明确各技术的定位与协同逻辑。

一、并发编程核心:AQS 与线程安全工具

并发编程是高并发系统的基础,核心围绕 "线程同步" 和 "线程安全" 展开,AQS 是整个体系的 "基础设施"。

1. AQS(抽象队列同步器)

  • 核心定位 :JUC 包的底层同步框架,通过 "状态变量state+CLH 变体队列" 实现线程排队与唤醒,支撑各类同步工具的实现。
  • 核心组件:
    • state:volatile 修饰的状态变量,自定义语义(如 ReentrantLock 的 "重入次数"、Semaphore 的 "许可证数量");
    • CLH 队列:双向链表存储竞争失败的线程,支持自旋 + 阻塞的高效等待。
  • 核心模式:
    • 独占模式(ReentrantLock):同一时间仅一个线程获取资源,适合互斥场景;
    • 共享模式(CountDownLatch、Semaphore):多个线程可同时获取资源,适合协作 / 限流场景。
  • 子类实现:
    • ReentrantLock:独占锁,支持公平 / 非公平模式,解决 synchronized 灵活性不足问题;
    • CountDownLatch:倒计时器,主线程等待 N 个任务完成,适合任务汇总场景;
    • Semaphore:信号量,控制并发访问线程数,适合接口限流 / 资源池控制。

2. 线程安全集合与工具

  • ConcurrentHashMap:线程安全的 HashMap,JDK1.8 通过 "CAS + 节点级 synchronized" 替代 1.7 的 Segment 分段锁,锁粒度更细,并发效率更高,适合高并发缓存场景;
  • ThreadLocal:线程私有变量容器,通过 "Thread 的 ThreadLocalMap" 存储变量,避免线程安全问题,适合链路追踪 ID、请求上下文传递;
  • 线程池:通过 "核心线程 + 任务队列 + 非核心线程" 复用线程,控制并发强度,核心参数(corePoolSize、maximumPoolSize、workQueue)需结合任务类型(CPU 密集 / IO 密集)配置,避免线程爆炸或资源浪费。

二、JVM 核心:内存结构与垃圾回收

JVM 是 Java 程序的运行基石,核心解决 "内存管理" 和 "性能优化" 问题,直接影响系统稳定性与吞吐量。

1. 运行数据区(内存布局)

  • 线程共享区域:
    • 堆:存储对象实例 / 数组,分新生代(Eden+S0+S1,8:1:1)和老年代(2:1),垃圾回收的主要场所;
    • 方法区(元空间):存储类信息、静态变量、运行时常量池,JDK8 用本地内存实现,避免永久代 OOM。
  • 线程私有区域:
    • 程序计数器:存储当前线程执行的字节码地址,唯一不抛 OOM 的区域;
    • 虚拟机栈:存储方法栈帧(局部变量表、操作数栈),栈帧过多 / 过大导致 StackOverflowError;
    • 本地方法栈:为 Native 方法服务,结构与虚拟机栈类似。

2. 类加载与垃圾回收

  • 类加载机制:
    • 流程:加载→验证→准备→解析→初始化→使用→卸载,核心是 "双亲委派模型"(父加载器优先加载,避免类重复与核心 API 篡改);
    • 类加载器:启动类加载器(加载 JRE 核心类)→扩展类加载器(加载 ext 目录)→应用类加载器(加载 ClassPath)→自定义类加载器(灵活加载非标准路径类)。
  • 垃圾回收(GC):
    • 垃圾判断:可达性分析算法(以 GC Roots 为起点,不可达对象标记为垃圾);
    • 回收算法:标记 - 清除(老年代,有碎片)、复制(新生代,无碎片)、标记 - 整理(老年代,无碎片);
    • 回收器:G1(JDK9 + 默认,分区回收,兼顾吞吐与响应)、CMS(并发标记清除,低延迟)、Parallel(吞吐量优先)。

三、设计模式:代码设计的 "最佳实践"

设计模式是解决共性业务场景的成熟方案,核心目标是 "解耦、复用、可扩展",高频模式集中在创建型和行为型。

1. 工厂模式(创建型)

  • 核心定位:解耦对象创建与使用,避免硬编码 new 导致的耦合。
  • 三种实现:
    • 简单工厂:一个工厂创建所有产品,适合产品少、变化少场景(如工具类);
    • 工厂方法:一个产品对应一个工厂,符合开闭原则,适合产品多、变化频繁场景(如支付方式创建);
    • 抽象工厂:创建多维度产品族(如 "美式风味" 包含咖啡 + 甜点),适合多产品配套场景。

2. 策略模式(行为型)

  • 核心定位:封装不同算法 / 行为,消除冗长 if-else,支持动态切换。
  • 核心组件:抽象策略(定义接口)→具体策略(实现算法)→环境类(持有策略引用,统一调用);
  • 实战场景:多方式登录(账号密码 / 短信 / 微信)、支付方式(支付宝 / 微信 / 银行卡)、优惠规则(满减 / 折扣)。

3. 责任链模式(行为型)

  • 核心定位:将请求处理者连成链,请求沿链传递,避免请求发送者与多处理者耦合;
  • 实战场景:订单流程(参数校验→数据填充→价格计算→落库)、过滤器(SpringMVC Filter)、审批流程(组长→主管→总裁)。

四、认证授权与登录持久化:系统安全的核心

认证授权是系统安全的入口,登录持久化是用户体验的关键,二者协同保障 "正确的人访问正确的资源"。

1. 认证与授权基础

  • 认证(Authentication):验证用户身份("你是谁"),核心是身份凭证(SessionID、JWT);
  • 授权(Authorization):分配资源访问权限("你能做什么"),核心是 RBAC 模型(用户→角色→权限);
  • 核心框架:Spring Security(与 Spring 生态无缝整合,支持 OAuth2.0、RBAC)、Shiro(轻量,适合中小型项目)。

2. 登录持久化方案

登录持久化的核心是 "安全存储身份凭证",不同场景对应不同方案,缓存是关键支撑:

  • 短期登录(几小时):
    • 方案:Session-Cookie(单体)、短期 JWT+Cookie(分布式);
    • 缓存作用:分布式场景用 Redis 存储 Session,解决多服务 Session 不一致。
  • 中长期登录(几天到几周):
    • 方案:双凭证机制(Access Token 短期 + Refresh Token 长期);
    • 缓存作用:Redis 存储 Refresh Token 有效性、黑名单、设备绑定关系,支持快速校验与过期自动清理。
  • 长期登录(永久,如 B 站):
    • 方案:长期 Refresh Token + 安全存储(Android Keystore/iOS Keychain)+ 静默刷新;
    • 缓存作用:Redis 持久化存储 Token 状态,服务重启后不丢失登录状态,支持黑名单强制失效。

五、缓存:高并发系统的 "性能加速器"

缓存是高并发系统的核心组件,支撑登录持久化、数据查询等高频场景,持久化是缓存可靠性的关键。

1. 缓存的核心价值

  • 性能提升:将高频访问数据(如用户信息、Token 状态)从数据库移到缓存(Redis),查询耗时从毫秒级降至微秒级;
  • 分布式协同:实现 Session 共享、Token 状态同步,支撑多服务架构;
  • 解耦数据库:减少数据库高频查询压力,避免数据库成为性能瓶颈。

2. 缓存持久化与选型

  • 主流缓存:Redis(支持持久化、高并发、丰富数据结构,是登录 / 业务缓存的首选);
  • 持久化方案:
    • RDB:按间隔生成内存快照,文件小、恢复快,适合非核心数据;
    • AOF:记录所有写操作,数据一致性高,适合核心数据(如黑名单);
    • 混合持久化(Redis 4.0+):RDB+AOF 结合,兼顾恢复速度与数据安全性;
  • 实战场景:
    • 分布式 Session:Redis 存储 Session,多服务共享;
    • Token 黑名单:Redis 存储登出 / 异常 Token,支持快速校验;
    • 业务缓存:存储热点数据(如商品详情),减少数据库访问。

六、技术关联关系:各模块如何协同工作

Java 后端技术并非孤立存在,而是相互协同支撑业务场景,以 "高并发电商订单系统" 为例:

  1. 并发控制:用 ReentrantLock 保证订单状态修改的原子性,用 Semaphore 控制下单接口并发量;
  2. 线程管理:用线程池处理订单异步任务(如消息推送、日志记录),避免线程频繁创建;
  3. 内存管理:JVM 调优(设置堆大小、选择 G1 回收器)避免 OOM,保证系统稳定;
  4. 代码设计:用工厂模式创建不同类型订单(普通订单 / 秒杀订单),用责任链处理订单流程(校验→计算→落库);
  5. 认证授权:用 JWT 实现用户登录,Spring Security 控制订单接口权限(仅登录用户可下单);
  6. 登录持久化:用 Redis 存储 Refresh Token,支持 7 天免登录,服务重启后状态不丢失;
  7. 性能优化:用 Redis 缓存商品库存、用户信息,减少数据库查询,提升下单响应速度。

七、总结:Java 后端技术学习路径与核心原则

1. 学习路径

  • 基础层:Java 语法→JVM(内存 / GC)→并发编程(AQS / 线程池);
  • 框架层:Spring/Spring Boot→Spring Security→MyBatis;
  • 架构层:设计模式→分布式(微服务 / 认证授权)→缓存(Redis);
  • 实战层:问题排查(日志 / 监控)→性能调优(JVM / 缓存)→安全防护(XSS/CSRF)。

2. 核心原则

  • 解耦优先:用设计模式、AQS、缓存等技术减少模块耦合,提升可扩展性;
  • 安全与体验平衡:认证授权保证安全,登录持久化提升体验,缓存平衡性能与可靠性;
  • 实战驱动:技术选型需结合业务场景(如金融用短期登录,社交 APP 用长期登录),避免过度设计;
  • 问题导向:排查问题从日志→监控→工具(Arthas/JVM 工具),定位根因而非表面现象。

掌握这些核心技术与协同逻辑,不仅能应对面试中的各类问题,更能在实际项目中设计稳定、高效、可扩展的 Java 后端系统,从 "代码实现者" 成长为 "技术解决方案提供者"。

相关推荐
计算机学姐2 分钟前
基于php的摄影网站系统
开发语言·vue.js·后端·mysql·php·phpstorm
月明长歌3 分钟前
【码道初阶】牛客TSINGK110:二叉树遍历(较难)如何根据“扩展先序遍历”构建二叉树?
java·数据结构·算法
全栈陈序员10 分钟前
【Python】基础语法入门(二十)——项目实战:从零构建命令行 To-Do List 应用
开发语言·人工智能·python·学习
我不是程序猿儿14 分钟前
【C#】ScottPlot的Refresh()
开发语言·c#
Neolnfra15 分钟前
渗透测试标准化流程
开发语言·安全·web安全·http·网络安全·https·系统安全
用户21903265273515 分钟前
Spring Boot + Redis 注解极简教程:5分钟搞定CRUD操作
java·后端
计算机学姐16 分钟前
基于php的旅游景点预约门票管理系统
开发语言·后端·mysql·php·phpstorm
AA陈超18 分钟前
枚举类 `ETriggerEvent`
开发语言·c++·笔记·学习·ue5
Alice19 分钟前
linux scripts
java·linux·服务器
Filotimo_22 分钟前
Spring Data JPA 方法名查询特性的使用
java·开发语言·windows