1. Token、Session、Cookie

引入

在当代,软件开发以Web应用为主,而提到 Web,我们就会想到 HTTP。众所周知,HTTP是无状态协议,那假如我们已经登录了一个网站,要用什么手段让浏览器知道当前登录的状态是我呢?这个时候,就需要一个特定的标识,来进行对应的匹配与确定。

传统的方案是让服务端保持会话状态,但是这样会导致服务端必须保存大量会话数据,并且在多实例部署时还要额外处理 Session 共享、过期、清理、故障转移等问题。

Cookie

概念

Cookie 是由服务器通过Set-Cookie响应头发送给浏览器,浏览器自动在后续请求中携带

特点

  • 存储在客户端(浏览器)
  • 自动随请求发送(同域下)
  • 可设置过期时间、HttpOnly、Secure、SameSite

用途

常用于存储Session-ID

举例

用户登录后,服务器生成一个session_id = "abc123",存入内存/缓存(Redis等),并通过Set-Cookie: JSESSIONID=abc123返回,浏览器下次的请求会自带Cookie: JSESSIONID=abc123,服务查session来确认身份

1.2 Session

Session

概念

服务端保存的用户会话数据(如userId、权限等)

特点

依赖Cookie:通过Cookie中的Session ID关联

缺点

  • 服务端需要存储用户状态 → 不利于水平扩展(需要共享Session存储,如Redis)
  • 跨域、跨平台支持差(Android/iOS不自动管理Cookie)

1.3 Token

Token

概念

Token是一个泛指的概念,指的是一段字符串,代表用户身份或者权限,由客户端主动携带(如放在Authorization Header)

特点

  • 无状态,即服务端无需存储
  • 客户端负责存储和发送(LocalStorage、SharedPreferences等)
  • 适用于前后端分离、移动端、微服务

对比上述三种方式:

特性CookieSessionToken
存储位置客户端(浏览器)服务端客户端(任意)
是否自动发送是(同域)否(依赖Cookie)否(需要手动)
服务端状态无(仅存id)有无
跨域支持差(需要跨域配置)差良好
移动端友好否否是
安全性易受CSRF攻击依赖Cookie安全易受XSS攻击

2. Json Web Token(JWT)详解

2.1 基本内容

什么是JWT?

概念

JWT是一种基于 Json 的开放标准(RFC 7519),用于在各方之间安全地传递信息

特点

  • 紧凑(Compact):可通过URL、POST参数或HTTP头传输
  • 自包含(Self-contained):携带了用户的基本身份信息和声明
  • 可验证(Verifiable):签名保证数据未被篡改

典型场景

用户登录后,服务器会发一个JWT,客户端每次请求都会携带这个Token,无需再次查询数据库做Session验证

JWT的组成

一个典型的JWT结构如下: xxxxxx.yyyyyy.zzzzzz 各部分讲解:

  • Header(头部):用于指定算法和类型
  • Payload(负载):存放声明(Claims),如用户ID,过期时间等
  • Signature(签名):保证前两部分不被篡改

需要注意:Header 和 Payload 都需要做 Base64Url 编码 JWT = Base64Url(Header) + "." + Base64Url(Payload) + "." + Base64Url(Signature)

2.2 JWT密钥与签名算法

JWT Secret(密钥)

作用

对称签名(HS256/HS384/HS512)中,Secret用于签发和验证Token

形式&长度

高强度随机字节,建议 ≥ 256bits(32bytes) 常见的编码:

  • URL-Safe Base64:9d6bXFMmZ3RV8Ytp9rz8QpKBuGV9zZ4T5vHSuJEjw8M
  • Hex:e75ab5c53266774557c62da7dacfc429281b8695f736784f9bc74ae24923c3c30

签名算法(alg)

  • HS256:HMAC + SHA-256,对称加密(Shared Secret)
  • HS384:HMAC + SHA-384
  • HS512:HMAC + SHA-512
  • RS256:RSA + SHA-256,非对称加密(公钥/私钥对)
  • ES256:ECDSA + SHA-256,椭圆曲线数字签名算法

小项目常用 HS256;安全需求高可选 RS256(私钥签发、公钥验签)

2.3 工作流程(重要)

JWT的工作流程

  1. 用户登录(提供用户名和密码)
  2. 服务器验证成功后,签发JWT
  3. 客户端保存(LocalStorage/Cookie)
  4. 后续请求携带JWT
  5. 推荐:请求头Authorization: Bearer <token>
  6. 服务器验证签名&检查声明(是否过期,是否有权限等)
  7. 验证通过,返回对应的内容,反之则会报401 Unauthorized
graph TD
   subgraph 登录流程
       A[用户登录<br/>提供用户名/密码] --> B[服务器验证成功]
       B --> C[签发 JWT]
   end
   
   subgraph 客户端处理
       C --> D[客户端保存<br/>LocalStorage / Cookie]
       D --> E[后续请求携带 JWT]
       E --> F[请求头: Authorization: Bearer <token>]
   end
   
   subgraph 验证流程
       F --> G[服务器验证签名<br/>检查声明]
       G --> H{验证通过?}
       H -->|是| I[返回数据]
       H -->|否| J[返回 401 Unauthorized]
   end
   
   style A fill:#e1f5fe
   style I fill:#e8f5e8
   style J fill:#ffebee
   style C fill:#fff3e0

签名与认证(以HS256为例)

签名生成

令:

  • H = Base64Url(Header)
  • P = Base64Url(Payload)
  • Secret = 服务器持有的密钥

LaTex公式:Signature = HMACSHA256(H + "." + P,Secret)

验证流程

  • 拆分为Header.Payload.Signature
  • 重新计算HMACSHA256(H + "." + P,Secret)
  • 对比结果:
    • 相同 ⇒ 数据未被篡改
    • 不同 ⇒ 拒绝访问

2.4 优缺点概览

优点缺点
无状态,水平扩展无法即时“撤销”已签发的 Token
携带信息自包含,无需多次查询数据库Token 泄露风险大,需要妥善存储
支持跨域认证Payload 明文可读,敏感信息请勿存放

2.5 最佳实践方案—双 Token 模式

回顾

在了解 Token 登录后,我们马上会遇到一个新的问题:如果 Token 有效期很长,泄露后风险很大;如果有效期很短,用户又会频繁登录。

因此生产项目常用 Access Token + Refresh Token 的双 Token 模式。

双 Token 的职责划分

  • Access Token:短有效期,用于访问业务接口,例如 15 分钟
  • Refresh Token:长有效期,只用于刷新 Access Token,例如 7 天或 30 天
  • 服务端存储 Refresh Token 状态:可存在 Redis / DB,用来支持退出登录、踢下线、设备管理
  • Access Token 尽量无状态:接口请求只验证签名和声明,不做频繁数据库查询

双 Token 架构图

flowchart TD
    A[用户登录] --> B[认证服务验证账号密码]
    B --> C[签发 Access Token<br/>短期有效]
    B --> D[签发 Refresh Token<br/>长期有效]
    D --> E[(Redis/DB 保存 Refresh Token 状态)]
    C --> F[客户端保存 Access Token]
    D --> G[客户端安全保存 Refresh Token]

    F --> H[访问业务接口]
    H --> I{Access Token 是否有效?}
    I -->|有效| J[返回业务数据]
    I -->|过期| K[调用刷新接口]
    G --> K
    K --> L{Refresh Token 是否有效?}
    L -->|有效| M[签发新的 Access Token]
    L -->|无效| N[重新登录]
    M --> F

安全注意点

  • Payload 不要放密码、手机号、身份证等敏感信息
  • Access Token 有效期要短
  • Refresh Token 必须能撤销
  • 移动端应优先存入 Keychain / Keystore 等安全存储
  • Web 端如果放 Cookie,需要设置 HttpOnly、Secure、SameSite
  • 服务端要校验 iss、aud、exp、nbf 等声明

配置文件编写

jwt {
  issuer = "med-collab-system"
  audience = "med-collab-api"
  realm = "med-collab-system"
  secret = "your-super-secret-key-must-be-at-least-256-bits-long"
  accessExpirationSeconds = 900
  refreshExpirationSeconds = 2592000
}
jwt:
  issuer: "med-collab-system"
  audience: "med-collab-api"
  realm: "med-collab-system"
  secret: "your-super-secret-key-must-be-at-least-256-bits-long"
  access-expiration-seconds: 900
  refresh-expiration-seconds: 2592000

3. 实战案例

3.1 SpringBoot 示例

依赖配置

// build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-security")
    implementation("com.auth0:java-jwt:4.4.0")
}

JWT 工具类

// JwtUtil.java
package com.example.auth;
 
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
 
import java.time.Instant;
import java.util.Date;
 
public class JwtUtil {
    private final String issuer;
    private final String audience;
    private final Algorithm algorithm;
    private final JWTVerifier verifier;
 
    public JwtUtil(String issuer, String audience, String secret) {
        this.issuer = issuer;
        this.audience = audience;
        this.algorithm = Algorithm.HMAC256(secret);
        this.verifier = JWT.require(algorithm)
                .withIssuer(issuer)
                .withAudience(audience)
                .build();
    }
 
    public String createAccessToken(Long userId, String role, long expireSeconds) {
        Instant now = Instant.now();
        return JWT.create()
                .withIssuer(issuer)
                .withAudience(audience)
                // subject 通常放用户唯一标识
                .withSubject(String.valueOf(userId))
                // 自定义声明,放权限、租户等非敏感信息
                .withClaim("role", role)
                .withIssuedAt(Date.from(now))
                .withExpiresAt(Date.from(now.plusSeconds(expireSeconds)))
                .sign(algorithm);
    }
 
    public DecodedJWT verify(String token) {
        // 校验签名、issuer、audience、过期时间
        return verifier.verify(token);
    }
}
// JwtBeanConfig.java
package com.example.auth;
 
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class JwtBeanConfig {
    @Bean
    JwtUtil jwtUtil(
            @Value("${jwt.issuer}") String issuer,
            @Value("${jwt.audience}") String audience,
            @Value("${jwt.secret}") String secret
    ) {
        return new JwtUtil(issuer, audience, secret);
    }
}

Spring Security 过滤器

// JwtAuthenticationFilter.java
package com.example.auth;
 
import com.auth0.jwt.interfaces.DecodedJWT;
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.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
 
import java.io.IOException;
import java.util.List;
 
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
 
    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }
 
    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {
        String header = request.getHeader("Authorization");
 
        if (header != null && header.startsWith("Bearer ")) {
            String token = header.substring("Bearer ".length());
            DecodedJWT jwt = jwtUtil.verify(token);
 
            Long userId = Long.valueOf(jwt.getSubject());
            String role = jwt.getClaim("role").asString();
 
            // 将用户身份放入 Spring Security 上下文,后续 Controller 可直接获取认证信息
            var authentication = new UsernamePasswordAuthenticationToken(
                    userId,
                    null,
                    List.of(new SimpleGrantedAuthority("ROLE_" + role))
            );
            SecurityContextHolder.getContext().setAuthentication(authentication);
        }
 
        filterChain.doFilter(request, response);
    }
}
// SecurityConfig.java
package com.example.auth;
 
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
 
@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http, JwtUtil jwtUtil) throws Exception {
        return http
                .csrf(csrf -> csrf.disable())
                // JWT 是无状态认证,不需要服务端 Session
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/login", "/auth/refresh").permitAll()
                        .anyRequest().authenticated()
                )
                .addFilterBefore(new JwtAuthenticationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class)
                .build();
    }
}

3.2 Ktor 示例

依赖配置

// build.gradle.kts
dependencies {
    implementation("io.ktor:ktor-server-auth")
    implementation("io.ktor:ktor-server-auth-jwt")
    implementation("com.auth0:java-jwt:4.4.0")
}

JWT 配置与路由

// Security.kt
package com.example.auth
 
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import java.util.Date
 
data class JwtConfig(
    val issuer: String,
    val audience: String,
    val realm: String,
    val secret: String,
    val accessExpirationSeconds: Long
)
 
fun JwtConfig.createAccessToken(userId: Long, role: String): String {
    val now = System.currentTimeMillis()
    return JWT.create()
        .withIssuer(issuer)
        .withAudience(audience)
        .withSubject(userId.toString())
        // 只放非敏感声明,敏感信息应从服务端查询
        .withClaim("role", role)
        .withIssuedAt(Date(now))
        .withExpiresAt(Date(now + accessExpirationSeconds * 1000))
        .sign(Algorithm.HMAC256(secret))
}
 
fun Application.configureJwt(config: JwtConfig) {
    install(Authentication) {
        jwt("auth-jwt") {
            realm = config.realm
            verifier(
                JWT.require(Algorithm.HMAC256(config.secret))
                    .withIssuer(config.issuer)
                    .withAudience(config.audience)
                    .build()
            )
            validate { credential ->
                // subject 是用户 ID,缺失时拒绝认证
                if (credential.payload.subject != null) {
                    JWTPrincipal(credential.payload)
                } else {
                    null
                }
            }
            challenge { _, _ ->
                call.respond(HttpStatusCode.Unauthorized, "Token is invalid or expired")
            }
        }
    }
}
 
fun Application.authRoutes(config: JwtConfig) {
    routing {
        post("/auth/login") {
            // 示例中省略账号密码校验,真实项目需要查询数据库并校验密码哈希
            val accessToken = config.createAccessToken(userId = 10001L, role = "USER")
            call.respond(mapOf("accessToken" to accessToken))
        }
 
        authenticate("auth-jwt") {
            get("/profile") {
                val principal = call.principal<JWTPrincipal>()!!
                val userId = principal.payload.subject
                val role = principal.payload.getClaim("role").asString()
                call.respond(mapOf("userId" to userId, "role" to role))
            }
        }
    }
}

3.3 Refresh Token 刷新流程

为什么 Refresh Token 要存服务端?

Access Token 适合无状态校验,但 Refresh Token 必须支持撤销。 例如用户退出登录、修改密码、设备丢失时,服务端需要让旧 Refresh Token 失效。

sequenceDiagram
    participant C as 客户端
    participant A as 认证服务
    participant R as Redis/DB

    C->>A: POST /auth/login
    A->>A: 校验账号密码
    A->>R: 保存 refreshTokenId 与用户/设备关系
    A-->>C: accessToken + refreshToken

    C->>A: POST /auth/refresh
    A->>R: 查询 refreshToken 是否有效
    alt 有效
        A->>A: 签发新的 accessToken
        A-->>C: new accessToken
    else 无效或过期
        A-->>C: 401,要求重新登录
    end

实战建议

  • Refresh Token 可以使用随机 UUID,也可以是 JWT,但都需要服务端记录状态
  • 每次刷新后可以轮换 Refresh Token,降低泄露风险
  • Redis Key 示例:refresh:user:{userId}:device:{deviceId}
  • 如果要实现“单设备登录”,刷新时删除同一用户其他设备的 Refresh Token 即可