小明网站微信登录改造记——OAuth2完整指南(含续期逻辑)

一 、 故 事 背 景

小 明 运 营 着 一 个 电 商 网 站 , 用 户 需 要 登 录 才 能 购 物 。 之 前 他 用 Spring Security 实 现 了 账 号 密 码 登 录 , 但 随 着 竞 争 加 剧 , 用 户 嫌 注 册 麻 烦 流 失 严 重 。 为 了 提 升 用 户 体 验 , 小 明 决 定 引 入 微 信 登 录 功 能 , 让 用 户 一 键 授 权 即 可 登 录 。 这 就 涉 及 到 OAuth2 授 权 框 架 的 使 用 。

二 、 OAuth2 是 什 么

OAuth2 是 一 个 开 放 的 授 权 标 准 , 它 允 许 用 户 将 自 己 在 某 个 平 台 ( 如 微 信 ) 的 部 分 权 限 , 授 权 给 第 三 方 应 用 ( 如 小 明 的 网 站 ) 使 用 , 而 无 需 将 自 己 的 账 号 密 码 告 知 第 三 方 。

简 单 说 , OAuth2 解 决 的 是 “ 如 何 安 全 地 让 第 三 方 应 用 获 取 用 户 资 源 ” 的 问 题 。 比 如 微 信 登 录 时 , 小 明 的 网 站 并 不 会 获 取 用 户 的 微 信 账 号 密 码 , 而 是 通 过 微 信 授 权 服 务 器 获 取 一 个 临 时 令 牌 ( Token ) , 用 来 获 取 用 户 的 公 开 信 息 ( 如 昵 称 、 头 像 ) 。

三 、 OAuth2 使 用 整 体 流 程

OAuth2 有 四 种 授 权 模 式 , 微 信 登 录 采 用 ** 授 权 码 模 式 ** 。 我 们 用 “ 委 托 取 快 递 ” 的 故 事 来 形 象 理 解 :

3.1 委 托 取 快 递 故 事 版

  1. ** 委 托 申 请 ** : 你 ( 用 户 ) 在 小 明 网 站 点 击 “ 微 信 登 录 ” , 网 站 生 成 一 个 包 含 客 户 端 ID 、 回 调 地 址 、 随 机 State 参 数 的 授 权 申 请 单 , 去 找 丰 巢 授 权 中 心 ( 微 信 授 权 服 务 器 ) 。

  2. ** 授 权 确 认 ** : 丰 巢 给 你 手 机 推 送 消 息 : “ 小 明 网 站 想 代 您 取 快 递 , 允 许 吗 ? ” 你 点 击 “ 同 意 ” 。

  3. ** 获 取 临 时 取 件 码 ** : 丰 巢 给 小 明 网 站 一 个 短 期 有 效 的 临 时 取 件 码 ( 授 权 码 Code ) , 通 过 回 调 地 址 转 交 给 网 站 。

  4. ** 换 取 门 禁 卡 ** : 小 明 网 站 用 临 时 取 件 码 和 自 己 的 身 份 秘 密 ( Client Secret ) 换 取 一 张 短 期 有 效 的 门 禁 卡 ( Access Token ) 和 一 张 长 期 续 期 券 ( Refresh Token ) 。

  5. ** 取 快 递 ** : 网 站 用 门 禁 卡 打 开 丰 巢 柜 ( 微 信 资 源 服 务 器 ) , 取 出 你 的 快 递 ( 用 户 信 息 ) 。

  6. ** 完 成 登 录 ** : 网 站 根 据 快 递 信 息 创 建 或 查 找 本 地 用 户 , 生 成 网 站 通 行 证 ( JWT Token ) 返 回 给 你 。

  7. ** 续 期 逻 辑 ** : 当 门 禁 卡 过 期 时 , 网 站 用 续 期 券 ( Refresh Token ) 免 费 换 新 卡 , 无 需 你 重 新 授 权 。

3.2 技 术 流 程 图

sequenceDiagram participant 用 户 participant 网 站 participant 微 信 授 权 服 务 器 participant 微 信 资 源 服 务 器 用 户 ->> 网 站 : 点 击 微 信 登 录 网 站 ->> 微 信 授 权 服 务 器 : 重 定 向 授 权 ( 携 带 state ) 微 信 授 权 服 务 器 ->> 用 户 : 展 示 授 权 页 用 户 ->> 微 信 授 权 服 务 器 : 确 认 授 权 微 信 授 权 服 务 器 ->> 网 站 : 返 回 code + state 网 站 ->> 微 信 授 权 服 务 器 : 用 code 换 access_token + refresh_token 微 信 授 权 服 务 器 ->> 网 站 : 返 回 access_token + refresh_token + openid 网 站 ->> 微 信 资 源 服 务 器 : 用 access_token 获 取 用 户 信 息 微 信 资 源 服 务 器 ->> 网 站 : 返 回 用 户 信 息 网 站 ->> 网 站 : 存 储 refresh_token , 生 成 JWT Token 网 站 ->> 用 户 : 返 回 JWT Token Note over 用 户 , 网 站 : 后 续 请 求 中 ... 用 户 ->> 网 站 : 访 问 需 认 证 接 口 网 站 ->> 网 站 : 校 验 JWT Token 发 现 过 期 网 站 ->> 网 站 : 调 用 /refresh-token 接 口 网 站 ->> 微 信 授 权 服 务 器 : 用 refresh_token 刷 新 access_token 微 信 授 权 服 务 器 ->> 网 站 : 返 回 新 access_token + refresh_token 网 站 ->> 网 站 : 生 成 新 JWT Token 返 回 用 户

四 、 OAuth2 实 现 原 理

4.1 核 心 角 色

  • ** 资 源 所 有 者 ** : 用 户 本 人 , 拥 有 资 源 的 所 有 权 。
  • ** 客 户 端 ** : 小 明 的 网 站 , 想 获 取 用 户 资 源 。
  • ** 授 权 服 务 器 ** : 微 信 服 务 器 , 负 责 验 证 用 户 身 份 并 发 放 令 牌 。
  • ** 资 源 服 务 器 ** : 微 信 服 务 器 , 存 储 用 户 资 源 ( 如 个 人 信 息 ) 。

4.2 核 心 要 素

  • ** 授 权 码 ( Code ) ** : 短 期 有 效 的 临 时 凭 证 , 用 于 交 换 Access Token , 防 止 Token 泄 露 。
  • ** 访 问 令 牌 ( Access Token ) ** : 短 期 有 效 的 凭 证 , 用 于 访 问 资 源 服 务 器 。
  • ** 刷 新 令 牌 ( Refresh Token ) ** : 长 期 有 效 的 凭 证 , 用 于 刷 新 Access Token 。
  • ** 作 用 域 ( Scope ) ** : 授 权 的 权 限 范 围 , 如 微 信 登 录 的 snsapi_login 。

五 、 OAuth2 与 Spring Security 对 比

| ** 维 度 ** | ** OAuth2 ** | ** Spring Security ** |
| ** 定 位 ** | 授 权 框 架 , 解 决 第 三 方 授 权 问 题 | 综 合 安 全 框 架 , 解 决 认 证 + 授 权 全 流 程 |
| ** 核 心 目 标 ** | 安 全 授 权 ( 如 微 信 登 录 ) | 系 统 安 全 ( 登 录 、 权 限 校 验 、 CSRF 防 护 ) |
| ** 核 心 角 色 ** | 资 源 所 有 者 、 客 户 端 、 授 权 服 务 器 、 资 源 服 务 器 | 无 固 定 角 色 , 通 过 过 滤 器 链 实 现 安 全 控 制 |
| ** 典 型 场 景 ** | 第 三 方 登 录 、 API 接 口 授 权 | 系 统 登 录 、 接 口 权 限 校 验 、 会 话 管 理 |
| ** 关 系 ** | Spring Security 可 通 过 spring-security-oauth2 集 成 OAuth2 | 可 将 OAuth2 作 为 认 证 方 式 之 一 ( 如 社 交 登 录 ) |

六 、 完 整 保 姆 级 案 例 : 小 明 网 站 微 信 登 录

6.1 环 境 准 备

  • ** 开 发 者 账 号 ** : 在 微 信 开 放 平 台 注 册 账 号 , 创 建 网 站 应 用 , 获 取 AppID 和 AppSecret 。
  • ** 项 目 依 赖 ** : 使 用 Spring Boot 2.7.x , 引 入 相 关 依 赖 。

6.2 POM 导 入 ( 核 心 依 赖 )

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.15</version> <!-- 稳定版 -->
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>wechat-login-demo</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <java.version>1.8</java.version>
        <mybatis-plus.version>3.5.3.1</mybatis-plus.version>
        <hutool.version>5.8.20</hutool.version>
    </properties>

    <dependencies>
        <!-- Spring Boot Web -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- Spring Security(用于JWT认证) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

        <!-- MyBatis-Plus(数据库操作) -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>

        <!-- MySQL驱动 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </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>

        <!-- HTTP客户端 -->
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
            <version>4.5.14</version>
        </dependency>

        <!-- 工具类 -->
        <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-all</artifactId>
            <version>${hutool.version}</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

6.3 数 据 库 设 计 ( SQL 表 创 建 )

-- 用 户 表 ( 含 Refresh Token  存 储 )
CREATE TABLE `sys_user` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '用 户 ID',
  `username` varchar(50) NOT NULL COMMENT '用 户 名 ( 唯 一 )',
  `password` varchar(100) DEFAULT '' COMMENT '密 码 ( 本 地 登 录 用 ,  第 三 方 登 录 为 空 )',
  `real_name` varchar(50) DEFAULT '' COMMENT '真 实 姓 名',
  `avatar` varchar(255) DEFAULT '' COMMENT '头 像 URL',
  `provider` varchar(20) DEFAULT NULL COMMENT '第 三 方 登 录 提 供 商 ( wechat/qq/local )',
  `provider_id` varchar(100) DEFAULT NULL COMMENT '第 三 方 用 户 唯 一 标 识 ( openid )',
  `status` tinyint NOT NULL DEFAULT '1' COMMENT '状 态 ( 0 禁 用 ,  1 启 用 )',
  `last_login_time` datetime DEFAULT NULL COMMENT '最 后 登 录 时 间',
  `refresh_token` varchar(255) DEFAULT NULL COMMENT '微 信 刷 新 令 牌 ( AES  加 密 存 储 )',
  `refresh_token_expire_time` datetime DEFAULT NULL COMMENT '刷 新 令 牌 过 期 时 间',
  `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创 建 时 间',
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更 新 时 间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_username` (`username`),
  UNIQUE KEY `uk_provider_openid` (`provider`,`provider_id`) COMMENT '第 三 方 账 号 唯 一 索 引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用 户 表';

-- 创 建 索 引
CREATE INDEX idx_refresh_token_expire ON sys_user(refresh_token_expire_time);

6.4 配 置 文 件 ( application.yml )

server:
  port: 8080
  servlet:
    context-path: /api

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/wechat_login_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: root123
    driver-class-name: com.mysql.cj.jdbc.Driver

# 微 信 登 录 配 置
wechat:
  oauth:
    client-id: ${WECHAT_APP_ID:wx_your_app_id}  # 微 信 开 放 平 台 AppID
    client-secret: ${WECHAT_APP_SECRET:your_app_secret}  # 微 信 开 放 平 台 AppSecret
    redirect-uri: ${WECHAT_REDIRECT_URI:https://yourdomain.com/api/auth/wechat/callback}  # 授 权 回 调 地 址
    auth-uri: https://open.weixin.qq.com/connect/qrconnect  # 授 权 URL
    token-uri: https://api.weixin.qq.com/sns/oauth2/access_token  # 获 取 token URL
    user-info-uri: https://api.weixin.qq.com/sns/userinfo  # 获 取 用 户 信 息 URL
    scope: snsapi_login  # 授 权 范 围
    token-expiration: 7200  # access_token 有 效 期 ( 秒 )

# JWT 配 置
jwt:
  secret: ${JWT_SECRET:your_strong_secret_key_32_chars_min}  # 密 钥 ( 生 产 环 境 用 复 杂 随 机 字 符 串 )
  expiration: 86400000  # Token 有 效 期 ( 毫 秒 ,  24 小 时 )
  issuer: xiaoming-website  # 签 发 者

# 加 密 配 置 ( 用 于 Refresh Token  加 密 )
crypto:
  aes:
    key: ${AES_SECRET_KEY:your_aes_secret_key_16_bytes}  # AES 密 钥 ( 16 字 节 )

6.5 核 心 代 码 实 现

6.5.1 配 置 属 性 类

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * 微 信 OAuth2  配 置 属 性
 */
@Data
@Component
@ConfigurationProperties(prefix = "wechat.oauth")
public class WechatOAuthProperties {
    private String clientId;       // AppID
    private String clientSecret;   // AppSecret
    private String redirectUri;    // 回 调 地 址
    private String authUri;        // 授 权 URL
    private String tokenUri;       // 获 取 token URL
    private String userInfoUri;    // 获 取 用 户 信 息 URL
    private String scope;          // 授 权 范 围
    private int tokenExpiration;   // token 有 效 期 ( 秒 )
}
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * 加 密 配 置 属 性
 */
@Data
@Component
@ConfigurationProperties(prefix = "crypto.aes")
public class CryptoProperties {
    private String key;  // AES 密 钥
}

6.5.2 JWT 工 具 类

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

import javax.crypto.SecretKey;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Function;

/**
 * JWT  工 具 类 ( 生 产 级 实 现 )
 */
@Component
public class JwtUtils {

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

    @Value("${jwt.expiration}")
    private long expiration; // 毫 秒

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

    // 生 成 签 名 密 钥 ( HS512  需 至 少  512  位 密 钥 )
    private SecretKey getSigningKey() {
        return Keys.hmacShaKeyFor(secret.getBytes());
    }

    /**
     * 生 成 JWT  令 牌
     */
    public String generateToken(UserDetails userDetails) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("username", userDetails.getUsername());
        return Jwts.builder()
                .setClaims(claims)
                .setSubject(userDetails.getUsername())
                .setIssuer(issuer)
                .setIssuedAt(new Date())
                .setExpiration(new Date(System.currentTimeMillis() + expiration))
                .signWith(getSigningKey(), SignatureAlgorithm.HS512) // 生 产 环 境 用 HS512  更 安 全
                .compact();
    }

    /**
     * 从 令 牌 中 提 取 用 户 名
     */
    public String extractUsername(String token) {
        return extractClaim(token, Claims::getSubject);
    }

    /**
     * 提 取 过 期 时 间
     */
    public Date extractExpiration(String token) {
        return extractClaim(token, Claims::getExpiration);
    }

    /**
     * 提 取 声 明
     */
    private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
        final Claims claims = extractAllClaims(token);
        return claimsResolver.apply(claims);
    }

    /**
     * 解 析 所 有 声 明
     */
    private Claims extractAllClaims(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(getSigningKey())
                .build()
                .parseClaimsJws(token)
                .getBody();
    }

    /**
     * 验 证 令 牌 是 否 有 效
     */
    public boolean validateToken(String token, UserDetails userDetails) {
        final String username = extractUsername(token);
        return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
    }

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

6.5.3 加 密 工 具 类

import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.symmetric.AES;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

/**
 * AES  加 密 工 具 类 ( 用 于 Refresh Token  加 密 存 储 )
 */
@Component
public class CryptoUtils {

    private final AES aes;

    // 构 造 函 数 注 入 密 钥
    public CryptoUtils(@Value("${crypto.aes.key}") String aesKey) {
        // 确 保 密 钥 长 度 为  16  字 节 ( AES-128  )
        if (aesKey.length() < 16) {
            aesKey = String.format("%-16s", aesKey).substring(0, 16);
        } else if (aesKey.length() > 16) {
            aesKey = aesKey.substring(0, 16);
        }
        this.aes = SecureUtil.aes(aesKey.getBytes());
    }

    /**
     * 加 密 字 符 串
     */
    public String encrypt(String data) {
        return aes.encryptHex(data);
    }

    /**
     * 解 密 字 符 串
     */
    public String decrypt(String encryptedData) {
        return aes.decryptStr(encryptedData);
    }
}

6.5.4 微 信 授 权 服 务

import cn.hutool.http.HttpUtil;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.web.util.UriComponentsBuilder;

/**
 * 微 信 授 权 服 务 ( 含 续 期 逻 辑 )
 */
@Service
@RequiredArgsConstructor
public class WechatAuthService {

    private final WechatOAuthProperties wechatProps;

    /**
     * 构 建 微 信 授 权 URL ( 引 导 用 户 跳 转 )
     */
    public String buildAuthUrl(String state) {
        return UriComponentsBuilder.fromHttpUrl(wechatProps.getAuthUri())
                .queryParam("appid", wechatProps.getClientId())
                .queryParam("redirect_uri", wechatProps.getRedirectUri())
                .queryParam("response_type", "code")
                .queryParam("scope", wechatProps.getScope())
                .queryParam("state", state) // 防 CSRF  攻 击
                .fragment("wechat_redirect") // 微 信 要 求 的 锚 点
                .build().toUriString();
    }

    /**
     * 用 授 权 码 换 取 Token ( 含 Access Token  和 Refresh Token  )
     */
    public TokenDTO getAccessToken(String code) {
        String url = UriComponentsBuilder.fromHttpUrl(wechatProps.getTokenUri())
                .queryParam("appid", wechatProps.getClientId())
                .queryParam("secret", wechatProps.getClientSecret())
                .queryParam("code", code)
                .queryParam("grant_type", "authorization_code")
                .build().toUriString();

        String response = HttpUtil.get(url);
        JSONObject json = JSONUtil.parseObj(response);

        // 校 验 微 信 返 回 错 误 ( 如 code  无 效 )
        if (json.containsKey("errcode")) {
            throw new RuntimeException("微 信 授 权 失 败 : " + json.getStr("errmsg"));
        }

        // 封 装 Token  信 息
        TokenDTO tokenDTO = new TokenDTO();
        tokenDTO.setAccessToken(json.getStr("access_token"));
        tokenDTO.setRefreshToken(json.getStr("refresh_token")); // 获 取 刷 新 令 牌
        tokenDTO.setOpenid(json.getStr("openid"));
        tokenDTO.setExpiresIn(json.getInt("expires_in", 7200)); // 有 效 期 ( 秒 )
        tokenDTO.setScope(json.getStr("scope"));
        return tokenDTO;
    }

    /**
     * 用 Refresh Token  刷 新 Access Token
     */
    public TokenDTO refreshAccessToken(String refreshToken) {
        String url = UriComponentsBuilder.fromHttpUrl(wechatProps.getTokenUri())
                .queryParam("appid", wechatProps.getClientId())
                .queryParam("grant_type", "refresh_token")
                .queryParam("refresh_token", refreshToken)
                .build().toUriString();

        String response = HttpUtil.get(url);
        JSONObject json = JSONUtil.parseObj(response);

        if (json.containsKey("errcode")) {
            throw new RuntimeException("刷 新 Token  失 败 : " + json.getStr("errmsg"));
        }

        TokenDTO tokenDTO = new TokenDTO();
        tokenDTO.setAccessToken(json.getStr("access_token"));
        tokenDTO.setRefreshToken(json.getStr("refresh_token")); // 微 信 可 能 返 回 新 的 refresh_token
        tokenDTO.setOpenid(json.getStr("openid"));
        tokenDTO.setExpiresIn(json.getInt("expires_in", 7200));
        return tokenDTO;
    }

    /**
     * 用 Access Token  获 取 用 户 信 息
     */
    public SocialUserInfo getUserInfo(String accessToken, String openid) {
        String url = UriComponentsBuilder.fromHttpUrl(wechatProps.getUserInfoUri())
                .queryParam("access_token", accessToken)
                .queryParam("openid", openid)
                .queryParam("lang", "zh_CN")
                .build().toUriString();

        String response = HttpUtil.get(url);
        JSONObject json = JSONUtil.parseObj(response);

        if (json.getInt("errcode", 0) != 0) {
            throw new RuntimeException("微 信 API  错 误 : " + json.getStr("errmsg"));
        }

        SocialUserInfo info = new SocialUserInfo();
        info.setOpenid(json.getStr("openid"));
        info.setNickname(json.getStr("nickname"));
        info.setAvatar(json.getStr("headimgurl"));
        info.setGender(json.getInt("sex", 0) == 1 ? "男" : (json.getInt("sex", 0) == 2 ? "女" : "未知"));
        info.setProvider("wechat");
        return info;
    }

    /**
     * Token  信 息 封 装 类
     */
    @lombok.Data
    public static class TokenDTO {
        private String accessToken;
        private String refreshToken;
        private String openid;
        private int expiresIn; // 有 效 期 ( 秒 )
        private String scope;
    }
}

6.5.5 用 户 服 务

import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

/**
 * 用 户 服 务 ( 含 Refresh Token  存 储 与 刷 新 )
 */
@Service
@RequiredArgsConstructor
public class UserService extends ServiceImpl<SysUserMapper, SysUser> {

    private final WechatAuthService wechatAuthService;
    private final CryptoUtils cryptoUtils;
    private final PasswordEncoder passwordEncoder;

    /**
     * 根 据 第 三 方 信 息 查 找 / 创 建 用 户 ( 含 Token  存 储 )
     */
    @Transactional(rollbackFor = Exception.class)
    public SysUser findOrCreateBySocialInfo(SocialUserInfo socialInfo, WechatAuthService.TokenDTO tokenDTO) {
        // 1. 查 找 是 否 已 绑 定 该 第 三 方 账 号
        SysUser user = lambdaQuery()
                .eq(SysUser::getProvider, socialInfo.getProvider())
                .eq(SysUser::getProviderId, socialInfo.getProviderId())
                .one();

        // 2. 若 用 户 已 存 在  ,  更 新 Token  信 息 和 登 录 时 间
        if (user != null) {
            user.setRefreshToken(cryptoUtils.encrypt(tokenDTO.getRefreshToken())); // AES  加 密 存 储
            user.setRefreshTokenExpireTime(calculateExpireTime(tokenDTO.getExpiresIn()));
            user.setLastLoginTime(LocalDateTime.now());
            updateById(user);
            return user;
        }

        // 3. 新 用 户 : 生 成 账 号 并 存 储 Token
        user = new SysUser();
        user.setUsername(generateUniqueUsername(socialInfo.getNickname()));
        user.setPassword(""); // 第 三 方 登 录 无 密 码
        user.setRealName(socialInfo.getNickname());
        user.setAvatar(socialInfo.getAvatar());
        user.setProvider(socialInfo.getProvider());
        user.setProviderId(socialInfo.getProviderId());
        user.setStatus(1); // 启 用 状 态
        user.setRefreshToken(cryptoUtils.encrypt(tokenDTO.getRefreshToken())); // AES  加 密 存 储
        user.setRefreshTokenExpireTime(calculateExpireTime(tokenDTO.getExpiresIn()));
        user.setCreatedAt(LocalDateTime.now());
        user.setUpdatedAt(LocalDateTime.now());

        save(user);
        return user;
    }

    /**
     * 用 Refresh Token  刷 新 用 户 的 Access Token
     */
    @Transactional(rollbackFor = Exception.class)
    public SysUser refreshUserToken(Long userId) {
        SysUser user = getById(userId);
        if (user == null || StrUtil.isBlank(user.getRefreshToken())) {
            throw new RuntimeException("用 户 未 绑 定 微 信 或 Refresh Token  已 失 效 ");
        }

        // 1. 解 密 Refresh Token
        String refreshToken = cryptoUtils.decrypt(user.getRefreshToken());
        // 2. 调 用 微 信 接 口 刷 新 Token
        WechatAuthService.TokenDTO newToken = wechatAuthService.refreshAccessToken(refreshToken);
        // 3. 更 新 用 户 的 Token  信 息
        user.setRefreshToken(cryptoUtils.encrypt(newToken.getRefreshToken()));
        user.setRefreshTokenExpireTime(calculateExpireTime(newToken.getExpiresIn()));
        user.setUpdatedAt(LocalDateTime.now());
        updateById(user);

        return user;
    }

    /**
     * 生 成 唯 一 用 户 名 ( 避 免 重 复 )
     */
    private String generateUniqueUsername(String nickname) {
        String baseName = nickname.replaceAll("[^a-zA-Z0-9_]", "");
        if (baseName.length() < 3) {
            baseName = "user_" + System.currentTimeMillis();
        }
        String username = baseName;
        int suffix = 1;
        while (lambdaQuery().eq(SysUser::getUsername, username).count() > 0) {
            username = baseName + "_" + suffix++;
        }
        return username;
    }

    /**
     * 计 算 Token  过 期 时 间
     */
    private LocalDateTime calculateExpireTime(int expiresInSeconds) {
        return LocalDateTime.now().plusSeconds(expiresInSeconds);
    }
}

6.5.6 控 制 器

import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.util.Collections;
import java.util.UUID;

/**
 * 认 证 控 制 器 ( 含 续 期 接 口 )
 */
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {

    private final WechatAuthService wechatAuthService;
    private final UserService userService;
    private final JwtUtils jwtUtils;

    /**
     * 微 信 登 录 入 口 ( 重 定 向 到 微 信 授 权 页 )
     */
    @GetMapping("/wechat/login")
    public void wechatLogin(HttpServletResponse response, HttpSession session) throws IOException {
        // 生 成 随 机 state  参 数 ( 防 CSRF  攻 击 )
        String state = UUID.randomUUID().toString();
        session.setAttribute("wechat_oauth_state", state); // 存 储 到 Session

        // 构 建 授 权 URL  并 重 定 向
        String authUrl = wechatAuthService.buildAuthUrl(state);
        response.sendRedirect(authUrl);
    }

    /**
     * 微 信 授 权 回 调 ( 微 信 重 定 向 到 这 里 )
     */
    @GetMapping("/wechat/callback")
    public ModelAndView wechatCallback(
            @RequestParam String code,
            @RequestParam String state,
            HttpSession session,
            HttpServletRequest request
    ) {
        // 1. 验 证 state  参 数 ( 防 CSRF  攻 击 )
        String savedState = (String) session.getAttribute("wechat_oauth_state");
        if (savedState == null || !savedState.equals(state)) {
            return new ModelAndView("redirect:/login?error=invalid_state");
        }

        try {
            // 2. 用 code  换 取 Token ( 含 access_token  和 refresh_token  )
            WechatAuthService.TokenDTO tokenDTO = wechatAuthService.getAccessToken(code);
            String accessToken = tokenDTO.getAccessToken();
            String openid = tokenDTO.getOpenid();

            // 3. 获 取 用 户 信 息
            SocialUserInfo userInfo = wechatAuthService.getUserInfo(accessToken, openid);

            // 4. 查 找 / 创 建 本 地 用 户 ( 存 储 refresh_token  )
            SysUser sysUser = userService.findOrCreateBySocialInfo(userInfo, tokenDTO);

            // 5. 生 成 JWT  令 牌
            UserDetails userDetails = new User(
                    sysUser.getUsername(),
                    sysUser.getPassword(),
                    Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
            );
            String token = jwtUtils.generateToken(userDetails);

            // 6. 重 定 向 到 前 端 ( 携 带 token  )
            return new ModelAndView("redirect:https://yourfrontend.com/login/success?token=" + token);

        } catch (Exception e) {
            // 记 录 错 误 日 志 ( 生 产 环 境 用 日 志 框 架 )
            e.printStackTrace();
            return new ModelAndView("redirect:/login?error=" + e.getMessage());
        } finally {
            // 清 除 Session  中 的 state
            session.removeAttribute("wechat_oauth_state");
        }
    }

    /**
     * 刷 新 Token  接 口 ( 前 端 定 时 调 用 )
     */
    @PostMapping("/refresh-token")
    public Result<String> refreshToken(@RequestHeader("Authorization") String authHeader) {
        // 1. 从 Header  提 取 旧 JWT Token
        String oldToken = authHeader.replace("Bearer ", "");
        String username = jwtUtils.extractUsername(oldToken);

        // 2. 查 询 用 户
        SysUser user = userService.lambdaQuery()
                .eq(SysUser::getUsername, username)
                .one();
        if (user == null) {
            return Result.error("用 户 不 存 在 ");
        }

        // 3. 刷 新 用 户 Token
        SysUser updatedUser = userService.refreshUserToken(user.getId());

        // 4. 用 新 的 Refresh Token  获 取 用 户 信 息 ( 验 证 有 效 性 )
        String refreshToken = cryptoUtils.decrypt(updatedUser.getRefreshToken());
        WechatAuthService.TokenDTO newToken = wechatAuthService.refreshAccessToken(refreshToken);
        SocialUserInfo userInfo = wechatAuthService.getUserInfo(newToken.getAccessToken(), newToken.getOpenid());

        // 5. 生 成 新 的 JWT Token  返 回
        UserDetails userDetails = new User(
                updatedUser.getUsername(),
                "",
                Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER"))
        );
        String newJwtToken = jwtUtils.generateToken(userDetails);

        return Result.success(newJwtToken);
    }

    // 简 化 版 Result  响 应 类
    static class Result<T> {
        private int code;
        private String msg;
        private T data;

        public static <T> Result<T> success(T data) {
            Result<T> result = new Result<>();
            result.code = 200;
            result.msg = "成 功 ";
            result.data = data;
            return result;
        }

        public static <T> Result<T> error(String msg) {
            Result<T> result = new Result<>();
            result.code = 500;
            result.msg = msg;
            return result;
        }
    }
}

6.5.7 Spring Security 配 置

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.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // 密 码 编 码 器 ( 生 产 环 境 用 BCrypt  )
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    // 安 全 过 滤 链 配 置
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable()) // 前 后 端 分 离 禁 用 CSRF
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/auth/**").permitAll() // 登 录 相 关 接 口 放 行
                        .anyRequest().authenticated() // 其 他 接 口 需 认 证
                )
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 无 状 态 ( JWT  )
                );
        return http.build();
    }
}

6.6 前 端 集 成 示 例

<!DOCTYPE html>
<html>
<head>
    <title>微 信 登 录 示 例</title>
</head>
<body>
    <h1>第 三 方 登 录 演 示</h1>
    <button onclick="loginWithWechat()">微 信 登 录</button>

    <script>
        // 跳 转 到 微 信 登 录
        function loginWithWechat() {
            window.location.href = "/api/auth/wechat/login";
        }

        // 处 理 登 录 成 功 后 的 Token
        function handleLoginSuccess(token) {
            localStorage.setItem("jwt_token", token);
            alert("登 录 成 功 !");
            // 跳 转 到 首 页
            window.location.href = "/home";
        }

        // 解 析 URL  参 数 ( 接 收 后 端 重 定 向 的 token  )
        function getUrlParam(name) {
            const reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
            const r = window.location.search.substr(1).match(reg);
            return r ? decodeURIComponent(r[2]) : null;
        }

        // 页 面 加 载 时 检 查 是 否 有 token  参 数
        window.onload = function() {
            const token = getUrlParam("token");
            if (token) {
                handleLoginSuccess(token);
            }
        };

        // 定 时 续 期 逻 辑
        let refreshTimer;
        function startAutoRefresh(token) {
            const payload = JSON.parse(atob(token.split('.')[1]));
            const expTime = payload.exp * 1000;
            const refreshTime = expTime - 30 * 60 * 1000; // 提 前  30  分 钟 续 期

            refreshTimer = setTimeout(async () => {
                try {
                    const response = await fetch('/api/auth/refresh-token', {
                        method: 'POST',
                        headers: {
                            'Authorization': `Bearer ${token}`
                        }
                    });
                    const result = await response.json();
                    if (result.code === 200) {
                        const newToken = result.data;
                        localStorage.setItem('jwt_token', newToken);
                        startAutoRefresh(newToken);
                    } else {
                        throw new Error(result.msg);
                    }
                } catch (e) {
                    console.error('续 期 失 败 ,  需 重 新 登 录', e);
                    window.location.href = '/login';
                }
            }, refreshTime - Date.now());
        }

        // 登 录 成 功 后 启 动 续 期
        const token = localStorage.getItem('jwt_token');
        if (token) {
            startAutoRefresh(token);
        }
    </script>
</body>
</html>

七 、 生 产 环 境 优 化 建 议

  1. ** 安 全 增 强 **

    • 使 用 HTTPS 强 制 加 密 传 输
    • 在 Redis 中 存 储 State 参 数 ( 分 布 式 系 统 )
    • JWT 密 钥 定 期 轮 换 ( 每 90 天 )
    • 使 用 RS256 非 对 称 加 密 替 代 HS256
  2. ** 健 壮 性 优 化 **

    • 添 加 接 口 限 流 ( Redis 计 数 )
    • 记 录 关 键 日 志 ( 授 权 流 程 、 Token 刷 新 )
    • 实 现 错 误 重 试 机 制 ( 最 多 3 次 )
  3. ** 可 扩 展 性 **

    • 抽 象 OAuth2 服 务 接 口 , 支 持 多 个 第 三 方 登 录
    • 提 供 用 户 绑 定 账 号 功 能 ( 输 入 用 户 名 密 码 关 联 )
    • 集 成 Redis 存 储 在 线 用 户 状 态

八 、 部 署 与 测 试

  1. ** 部 署 步 骤 **

    • 准 备 服 务 器 ( CentOS/Ubuntu ), 安 装 JDK 1.8+ 、 MySQL 8.0+
    • 配 置 环 境 变 量 ( 注 入 WECHAT_APP_ID 、 WECHAT_APP_SECRET 、 JWT_SECRET 等 )
    • 打 包 项 目 : mvn clean package -DskipTests
    • 启 动 应 用 : java -jar target/wechat-login-demo-1.0-SNAPSHOT.jar
  2. ** 测 试 流 程 **

    • 访 问 登 录 页 , 点 击 “ 微 信 登 录 ”
    • 跳 转 微 信 授 权 页 , 确 认 授 权
    • 微 信 重 定 向 回 回 调 地 址 , 后 端 生 成 JWT 并 返 回 前 端
    • 前 端 存 储 JWT , 后 续 请 求 携 带 Authorization: Bearer {token} 头

通 过 以 上 步 骤 , 小 明 成 功 为 网 站 集 成 了 微 信 登 录 功 能 , 用 户 体 验 大 幅 提 升 , 网 站 注 册 转 化 率 提 高 了 30% 。 这 也 让 小 明 深 刻 认 识 到 , OAuth2 作 为 一 种 安 全 的 授 权 标 准 , 在 第 三 方 登 录 场 景 中 发 挥 着 不 可 或 缺 的 作 用 。

posted @ 2025-11-26 09:59  佛祖让我来巡山  阅读(435)  评论(4)    收藏  举报

佛祖让我来巡山博客站 - 创建于 2018-08-15

开发工程师个人站,内容主要是网站开发方面的技术文章,大部分来自学习或工作,部分来源于网络,希望对大家有所帮助。

Bootstrap中文网