小明网站微信登录改造记——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 复制代码
<?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 表 创 建 )

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 )

yaml 复制代码
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 配 置 属 性 类

java 复制代码
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 有 效 期 ( 秒 )
}
java 复制代码
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 工 具 类

java 复制代码
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 加 密 工 具 类

java 复制代码
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 微 信 授 权 服 务

java 复制代码
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 用 户 服 务

java 复制代码
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 控 制 器

java 复制代码
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 配 置

java 复制代码
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 前 端 集 成 示 例

html 复制代码
<!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 作 为 一 种 安 全 的 授 权 标 准 , 在 第 三 方 登 录 场 景 中 发 挥 着 不 可 或 缺 的 作 用 。

相关推荐
曲幽1 天前
FastAPI + SQLite:从基础CRUD到安全并发的实战指南
python·sqlite·fastapi·web·jwt·form·sqlalchemy·oauth2
Tancenter1 天前
OAuth2协议
oauth2·授权行式
IT 行者17 天前
Spring Security 6.x 迁移到 7.0 的完整步骤
java·spring·oauth2
IT界的奇葩22 天前
OAuth2 单点登录流程图
java·流程图·oauth2·单点登录·sso
泽济天下1 个月前
【经验分享】基于Spring Boot 4.0快速实现最简版的OAuth2 Server和Client
spring boot·springboot·oauth2
佛祖让我来巡山1 个月前
小明网站双登录系统实现——微信授权登录+用户名密码登录完整指南
oauth2·springsecurity·微信授权登录
不会吃萝卜的兔子2 个月前
spring - 微服务授权 2 实战
spring·oauth2·authorization
陈果然DeepVersion2 个月前
Java大厂面试真题:从Spring Boot到AI微服务的三轮技术拷问(一)
java·spring boot·redis·微服务·kafka·面试题·oauth2
asom222 个月前
互联网大厂Java求职面试实战:Spring Boot到Kubernetes的技术问答
java·spring boot·kubernetes·oauth2·电商·microservices·面试技巧