小明网站微信登录改造记——OAuth2完整指南(含续期逻辑)
一 、 故 事 背 景
小 明 运 营 着 一 个 电 商 网 站 , 用 户 需 要 登 录 才 能 购 物 。 之 前 他 用 Spring Security 实 现 了 账 号 密 码 登 录 , 但 随 着 竞 争 加 剧 , 用 户 嫌 注 册 麻 烦 流 失 严 重 。 为 了 提 升 用 户 体 验 , 小 明 决 定 引 入 微 信 登 录 功 能 , 让 用 户 一 键 授 权 即 可 登 录 。 这 就 涉 及 到 OAuth2 授 权 框 架 的 使 用 。
二 、 OAuth2 是 什 么
OAuth2 是 一 个 开 放 的 授 权 标 准 , 它 允 许 用 户 将 自 己 在 某 个 平 台 ( 如 微 信 ) 的 部 分 权 限 , 授 权 给 第 三 方 应 用 ( 如 小 明 的 网 站 ) 使 用 , 而 无 需 将 自 己 的 账 号 密 码 告 知 第 三 方 。
简 单 说 , OAuth2 解 决 的 是 “ 如 何 安 全 地 让 第 三 方 应 用 获 取 用 户 资 源 ” 的 问 题 。 比 如 微 信 登 录 时 , 小 明 的 网 站 并 不 会 获 取 用 户 的 微 信 账 号 密 码 , 而 是 通 过 微 信 授 权 服 务 器 获 取 一 个 临 时 令 牌 ( Token ) , 用 来 获 取 用 户 的 公 开 信 息 ( 如 昵 称 、 头 像 ) 。
三 、 OAuth2 使 用 整 体 流 程
OAuth2 有 四 种 授 权 模 式 , 微 信 登 录 采 用 ** 授 权 码 模 式 ** 。 我 们 用 “ 委 托 取 快 递 ” 的 故 事 来 形 象 理 解 :
3.1 委 托 取 快 递 故 事 版
-
** 委 托 申 请 ** : 你 ( 用 户 ) 在 小 明 网 站 点 击 “ 微 信 登 录 ” , 网 站 生 成 一 个 包 含 客 户 端 ID 、 回 调 地 址 、 随 机 State 参 数 的 授 权 申 请 单 , 去 找 丰 巢 授 权 中 心 ( 微 信 授 权 服 务 器 ) 。
-
** 授 权 确 认 ** : 丰 巢 给 你 手 机 推 送 消 息 : “ 小 明 网 站 想 代 您 取 快 递 , 允 许 吗 ? ” 你 点 击 “ 同 意 ” 。
-
** 获 取 临 时 取 件 码 ** : 丰 巢 给 小 明 网 站 一 个 短 期 有 效 的 临 时 取 件 码 ( 授 权 码 Code ) , 通 过 回 调 地 址 转 交 给 网 站 。
-
** 换 取 门 禁 卡 ** : 小 明 网 站 用 临 时 取 件 码 和 自 己 的 身 份 秘 密 ( Client Secret ) 换 取 一 张 短 期 有 效 的 门 禁 卡 ( Access Token ) 和 一 张 长 期 续 期 券 ( Refresh Token ) 。
-
** 取 快 递 ** : 网 站 用 门 禁 卡 打 开 丰 巢 柜 ( 微 信 资 源 服 务 器 ) , 取 出 你 的 快 递 ( 用 户 信 息 ) 。
-
** 完 成 登 录 ** : 网 站 根 据 快 递 信 息 创 建 或 查 找 本 地 用 户 , 生 成 网 站 通 行 证 ( JWT Token ) 返 回 给 你 。
-
** 续 期 逻 辑 ** : 当 门 禁 卡 过 期 时 , 网 站 用 续 期 券 ( Refresh Token ) 免 费 换 新 卡 , 无 需 你 重 新 授 权 。
3.2 技 术 流 程 图
四 、 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>
七 、 生 产 环 境 优 化 建 议
-
** 安 全 增 强 **
- 使 用 HTTPS 强 制 加 密 传 输
- 在 Redis 中 存 储 State 参 数 ( 分 布 式 系 统 )
- JWT 密 钥 定 期 轮 换 ( 每 90 天 )
- 使 用 RS256 非 对 称 加 密 替 代 HS256
-
** 健 壮 性 优 化 **
- 添 加 接 口 限 流 ( Redis 计 数 )
- 记 录 关 键 日 志 ( 授 权 流 程 、 Token 刷 新 )
- 实 现 错 误 重 试 机 制 ( 最 多 3 次 )
-
** 可 扩 展 性 **
- 抽 象 OAuth2 服 务 接 口 , 支 持 多 个 第 三 方 登 录
- 提 供 用 户 绑 定 账 号 功 能 ( 输 入 用 户 名 密 码 关 联 )
- 集 成 Redis 存 储 在 线 用 户 状 态
八 、 部 署 与 测 试
-
** 部 署 步 骤 **
- 准 备 服 务 器 ( 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
-
** 测 试 流 程 **
- 访 问 登 录 页 , 点 击 “ 微 信 登 录 ”
- 跳 转 微 信 授 权 页 , 确 认 授 权
- 微 信 重 定 向 回 回 调 地 址 , 后 端 生 成 JWT 并 返 回 前 端
- 前 端 存 储 JWT , 后 续 请 求 携 带 Authorization: Bearer {token} 头
通 过 以 上 步 骤 , 小 明 成 功 为 网 站 集 成 了 微 信 登 录 功 能 , 用 户 体 验 大 幅 提 升 , 网 站 注 册 转 化 率 提 高 了 30% 。 这 也 让 小 明 深 刻 认 识 到 , OAuth2 作 为 一 种 安 全 的 授 权 标 准 , 在 第 三 方 登 录 场 景 中 发 挥 着 不 可 或 缺 的 作 用 。
❤️ 如果你喜欢这篇文章,请点赞支持! 👍 同时欢迎关注我的博客,获取更多精彩内容!
本文来自博客园,作者:佛祖让我来巡山,转载请注明原文链接:https://www.cnblogs.com/sun-10387834/p/19271340

浙公网安备 33010602011771号