keycloak接入微信登录
📋 项目概述
本项目是一个 Keycloak SPI 扩展,用于实现微信 OAuth 登录功能。用户可以通过微信账号登录 Keycloak 管理的应用系统。
核心功能
- ✅ 微信 OAuth 2.0 授权登录
- ✅ 自动创建/关联 Keycloak 用户
- ✅ 支持微信用户信息同步
- ✅ 支持手机号绑定与短信验证码
- ✅ 支持开发环境 Mock 模式
- ✅ 环境变量配置管理
- ✅ Redis 缓存支持(可选)
技术栈
- Java: 17
- Keycloak: 26.4
- 构建工具: Maven
- 部署方式: Docker
- 数据库: MySQL
- 缓存: Redis(可选)
🏗️ 架构设计
1. 项目结构
keycloak-wx-authenticator/
├── pom.xml # Maven 配置
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/vectorstory/keycloak/wechat/
│ │ │ ├── authenticator/ # 认证器实现
│ │ │ │ ├── WeChatAuthenticator.java
│ │ │ │ └── WeChatAuthenticatorFactory.java
│ │ │ ├── service/ # 微信 API 服务
│ │ │ │ ├── WeChatOAuthService.java
│ │ │ │ ├── WeChatOAuthServiceImpl.java
│ │ │ │ └── MockWeChatOAuthService.java
│ │ │ ├── model/ # 数据模型
│ │ │ │ ├── WeChatAccessToken.java
│ │ │ │ ├── WeChatUserInfo.java
│ │ │ │ └── VerificationCode.java
│ │ │ ├── cache/ # 缓存层
│ │ │ │ ├── CodeCache.java
│ │ │ │ ├── StateCache.java
│ │ │ │ ├── MemoryCodeCache.java
│ │ │ │ ├── MemoryStateCache.java
│ │ │ │ ├── RedisCodeCache.java
│ │ │ │ └── RedisStateCache.java
│ │ │ ├── sms/ # 短信服务
│ │ │ │ ├── SmsService.java
│ │ │ │ ├── MockSmsService.java
│ │ │ │ └── AliyunSmsService.java
│ │ │ └── config/ # 配置管理
│ │ │ └── WeChatConfig.java
│ │ └── resources/
│ │ ├── META-INF/
│ │ │ └── services/
│ │ │ └── org.keycloak.authentication.AuthenticatorFactory
│ │ └── theme-resources/ # 主题资源
│ │ ├── messages/
│ │ │ ├── messages_zh_CN.properties
│ │ │ └── messages_en.properties
│ │ └── templates/
│ │ ├── wechat-login-button.ftl
│ │ ├── wechat-bind-phone.ftl
│ │ └── wechat-bind-code.ftl
│ └── test/
│ └── java/
└── target/ # 构建输出
└── keycloak-wx-authenticator-1.0.0.jar2. 核心组件说明
2.1 WeChatAuthenticator(认证器)
- 实现
org.keycloak.authentication.Authenticator接口 - 处理微信 OAuth 授权流程
- 管理用户创建和关联逻辑
- 首次登录引导手机号绑定与短信校验
2.2 WeChatAuthenticatorFactory(工厂类)
- 实现
org.keycloak.authentication.AuthenticatorFactory接口 - 向 Keycloak 注册认证器
- 提供元数据和配置信息
2.3 WeChatOAuthService(微信 API 服务)
- 封装微信 OAuth API 调用
- 支持多种微信平台(开放平台/公众平台/企业微信)
- 提供 Mock 实现用于开发测试
2.4 StateCache(状态缓存)
- 存储 OAuth state 参数(防 CSRF)
- 提供内存和 Redis 两种实现
- 支持过期时间管理
2.5 CodeCache(短信验证码缓存)
- 存储短信验证码与发送频率
- 支持内存与 Redis 实现
- 支持验证码过期与重试次数限制
2.6 SmsService(短信服务)
- 发送手机号验证码
- 提供 Mock 与阿里云实现
🔧 开发环境准备
1. 前置要求
必需软件
# Java 17
java -version
# 输出应包含: openjdk version "17.x.x"
# Maven 3.6+
mvn -version
# Docker 和 Docker Compose
docker --version
docker compose --version外部服务
# MySQL 容器(需要先启动)
# 确保创建了 mysql_mysql-network 网络
docker network ls | grep mysql_mysql-network
# Redis 容器(可选,推荐用于生产环境)
# 确保创建了 redis_redis-network 网络
docker network ls | grep redis_redis-network2. 微信平台配置
选择微信平台类型
根据您的应用场景,选择合适的微信平台:
2.1 微信开放平台(推荐用于网页应用)
适用场景: 网站、H5 页面、移动 App
注册步骤:
- 访问 微信开放平台
- 注册开发者账号(需要企业认证,¥300/年)
- 创建网站应用
- 填写应用信息:
- 应用名称:您的应用名称
- 应用简介:应用描述
- 应用官网:https://your-domain.com
- 授权回调域:
your-domain.com(重要!)
- 等待审核(1-3 个工作日)
- 审核通过后获取:
- AppID:
wx1234567890abcdef - AppSecret:
your_app_secret_32_chars
- AppID:
回调 URL 格式:
https://your-domain.com/realms/{realm-name}/login-actions/authenticate2.2 微信公众平台(用于公众号内)
适用场景: 微信公众号内的网页
注册步骤:
- 访问 微信公众平台
- 注册公众号(服务号支持网页授权)
- 开发 → 接口权限 → 网页授权
- 设置网页授权域名:
your-domain.com - 获取:
- AppID: 在 开发 → 基本配置 中查看
- AppSecret: 在 开发 → 基本配置 中生成
2.3 企业微信(用于企业内部应用)
适用场景: 企业内部 OA 系统
注册步骤:
- 访问 企业微信
- 注册企业
- 应用管理 → 创建应用
- 获取:
- CorpID: 企业 ID
- AgentID: 应用 ID
- Secret: 应用密钥
2.4 开发环境配置(Mock 模式)
如果暂时没有微信平台账号,可以使用 Mock 模式进行开发:
WECHAT_SERVICE_MOCK=true
WECHAT_APP_ID=mock_app_id
WECHAT_APP_SECRET=mock_app_secret
SMS_SERVICE_MOCK=trueMock 模式下:
- 不会真实调用微信 API
- 会模拟返回固定的用户信息
- 首次登录进入手机号绑定,验证码会打印到日志
- 所有操作日志会打印到 Keycloak 日志中
3. 本地环境配置
3.1 创建环境变量文件
在项目根目录(keycloak-groups/)的 .env 文件中添加微信相关配置:
# ========================================
# 微信 OAuth 配置
# ========================================
# Mock 模式(开发环境:true,生产环境:false)
# true: 不调用真实微信 API,使用模拟数据
# false: 调用真实微信 OAuth API
WECHAT_SERVICE_MOCK=true
# 微信平台类型
# 可选值:open(开放平台), official(公众平台), work(企业微信)
WECHAT_PLATFORM_TYPE=open
# 微信应用凭证(从微信平台获取)
WECHAT_APP_ID=wx1234567890abcdef
WECHAT_APP_SECRET=your_app_secret_here
# 微信授权回调 URL
# 当前实现默认使用 Keycloak action URL
# 如需兼容旧版本或自定义逻辑可保留
WECHAT_REDIRECT_URI=http://localhost:8081/realms/vectorstory-test/login-actions/authenticate
# 微信 OAuth 作用域
# snsapi_login: 静默授权(开放平台)
# snsapi_userinfo: 获取用户信息(需用户同意)
WECHAT_SCOPE=snsapi_login
# State 参数过期时间(秒)
WECHAT_STATE_EXPIRE_TIME=300
# ========================================
# 短信服务配置
# ========================================
# true: Mock 短信(验证码打印到日志)
# false: 使用阿里云短信
SMS_SERVICE_MOCK=true
# 阿里云短信(SMS_SERVICE_MOCK=false 时必填)
ALIYUN_ACCESS_KEY_ID=your_key_id
ALIYUN_ACCESS_KEY_SECRET=your_key_secret
ALIYUN_SMS_SIGN_NAME=your_sign_name
ALIYUN_SMS_TEMPLATE_CODE=your_template_code
# 可选:手机号/验证码规则
PHONE_NUMBER_REGEX=^1[3-9]\d{9}$
VERIFICATION_CODE_LENGTH=6
# ========================================
# Redis 缓存配置(state + 短信验证码)
# ========================================
# USE_REDIS_CACHE=true 时启用
USE_REDIS_CACHE=false
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DATABASE=03.2 本地调试 HTTPS 回调(重要!)
问题: 微信 OAuth 要求回调地址必须是 HTTPS 协议,但本地开发环境通常使用 HTTP。
解决方案: 我们提供了多种本地调试方案,请参考详细指南:
📖 本地调试完整指南 (LOCAL_DEBUG_GUIDE.md)
快速方案选择:
| 开发阶段 | 推荐方案 | 说明 |
|---|---|---|
| 初期开发 | Mock 模式 | 无需真实微信账号,快速迭代 |
| OAuth 测试 | ngrok | 免费,真实微信 OAuth 流程 |
| 稳定开发 | Cloudflare Tunnel | 免费,固定域名,团队共享 |
快速开始 - ngrok 示例:
# 1. 安装 ngrok
brew install ngrok # macOS
# 2. 注册并配置(一次性)
ngrok config add-authtoken YOUR_AUTHTOKEN
# 3. 启动隧道
ngrok http 8081
# 4. 获得 HTTPS URL
# Forwarding https://abc123.ngrok-free.app -> http://localhost:8081
# 5. 更新环境变量(可选)
WECHAT_REDIRECT_URI=https://abc123.ngrok-free.app/realms/vectorstory-test/login-actions/authenticate
#
# 说明:回调地址默认由 Keycloak action URL 生成,WECHAT_REDIRECT_URI 仅用于兼容或自定义逻辑
# 6. 在微信平台配置授权回调域
# 填入: abc123.ngrok-free.app (不含 https://)详细步骤、其他方案和常见问题请查看: LOCAL_DEBUG_GUIDE.md
💻 开发步骤
步骤 1: 配置 pom.xml
更新 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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.vectorstory</groupId>
<artifactId>keycloak-wx-authenticator</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>Keycloak WeChat Authenticator</name>
<description>Keycloak SPI for WeChat OAuth authentication</description>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<keycloak.version>26.0.4</keycloak.version>
<jedis.version>5.1.0</jedis.version>
<slf4j.version>2.0.9</slf4j.version>
</properties>
<dependencies>
<!-- Keycloak SPI 依赖 -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>${keycloak.version}</version>
<scope>provided</scope>
</dependency>
<!-- HTTP 客户端(用于调用微信 API) -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- JSON 处理 -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<!-- Redis 客户端(可选,用于生产环境缓存) -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>${jedis.version}</version>
</dependency>
<!-- 日志 -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>${slf4j.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<finalName>${project.artifactId}-${project.version}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
<!-- Maven Shade Plugin - 打包依赖到 JAR -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<artifactSet>
<includes>
<include>com.squareup.okhttp3:*</include>
<include>com.squareup.okio:*</include>
<include>com.google.code.gson:*</include>
<include>redis.clients:*</include>
<include>org.apache.commons:*</include>
</includes>
</artifactSet>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>步骤 2: 创建配置管理类
创建 src/main/java/com/vectorstory/keycloak/wechat/config/WeChatConfig.java:
package com.vectorstory.keycloak.wechat.config;
/**
* 微信 OAuth 配置管理
* 从环境变量读取配置
*/
public class WeChatConfig {
// 微信平台类型
public enum PlatformType {
OPEN, // 开放平台
OFFICIAL, // 公众平台
WORK // 企业微信
}
private final boolean mock;
private final PlatformType platformType;
private final String appId;
private final String appSecret;
private final String redirectUri;
private final String scope;
private final int stateExpireTime;
public WeChatConfig() {
this.mock = "true".equalsIgnoreCase(getEnv("WECHAT_SERVICE_MOCK", "true"));
this.platformType = parsePlatformType(getEnv("WECHAT_PLATFORM_TYPE", "open"));
this.appId = getEnv("WECHAT_APP_ID", "");
this.appSecret = getEnv("WECHAT_APP_SECRET", "");
this.redirectUri = getEnv("WECHAT_REDIRECT_URI", "");
this.scope = getEnv("WECHAT_SCOPE", "snsapi_login");
this.stateExpireTime = Integer.parseInt(getEnv("WECHAT_STATE_EXPIRE_TIME", "300"));
}
private String getEnv(String key, String defaultValue) {
String value = System.getenv(key);
return (value != null && !value.isEmpty()) ? value : defaultValue;
}
private PlatformType parsePlatformType(String type) {
try {
return PlatformType.valueOf(type.toUpperCase());
} catch (IllegalArgumentException e) {
return PlatformType.OPEN;
}
}
// Getters
public boolean isMock() { return mock; }
public PlatformType getPlatformType() { return platformType; }
public String getAppId() { return appId; }
public String getAppSecret() { return appSecret; }
public String getRedirectUri() { return redirectUri; }
public String getScope() { return scope; }
public int getStateExpireTime() { return stateExpireTime; }
/**
* 获取微信授权 URL
*/
public String getAuthorizationUrl() {
switch (platformType) {
case OPEN:
return "https://open.weixin.qq.com/connect/qrconnect";
case OFFICIAL:
return "https://open.weixin.qq.com/connect/oauth2/authorize";
case WORK:
return "https://open.work.weixin.qq.com/wwopen/sso/qrConnect";
default:
return "https://open.weixin.qq.com/connect/qrconnect";
}
}
/**
* 获取 Access Token URL
*/
public String getAccessTokenUrl() {
switch (platformType) {
case WORK:
return "https://qyapi.weixin.qq.com/cgi-bin/gettoken";
default:
return "https://api.weixin.qq.com/sns/oauth2/access_token";
}
}
/**
* 获取用户信息 URL
*/
public String getUserInfoUrl() {
switch (platformType) {
case WORK:
return "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo";
default:
return "https://api.weixin.qq.com/sns/userinfo";
}
}
}步骤 3: 创建数据模型
3.1 创建 WeChatAccessToken.java
package com.vectorstory.keycloak.wechat.model;
/**
* 微信 Access Token 模型
*/
public class WeChatAccessToken {
private String accessToken;
private int expiresIn;
private String refreshToken;
private String openid;
private String scope;
private String unionid;
// Getters and Setters
public String getAccessToken() { return accessToken; }
public void setAccessToken(String accessToken) { this.accessToken = accessToken; }
public int getExpiresIn() { return expiresIn; }
public void setExpiresIn(int expiresIn) { this.expiresIn = expiresIn; }
public String getRefreshToken() { return refreshToken; }
public void setRefreshToken(String refreshToken) { this.refreshToken = refreshToken; }
public String getOpenid() { return openid; }
public void setOpenid(String openid) { this.openid = openid; }
public String getScope() { return scope; }
public void setScope(String scope) { this.scope = scope; }
public String getUnionid() { return unionid; }
public void setUnionid(String unionid) { this.unionid = unionid; }
}3.2 创建 WeChatUserInfo.java
package com.vectorstory.keycloak.wechat.model;
/**
* 微信用户信息模型
*/
public class WeChatUserInfo {
private String openid; // 用户唯一标识
private String unionid; // 开放平台唯一标识
private String nickname; // 用户昵称
private String headimgurl; // 头像 URL
private int sex; // 性别:0-未知,1-男,2-女
private String province; // 省份
private String city; // 城市
private String country; // 国家
// Getters and Setters
public String getOpenid() { return openid; }
public void setOpenid(String openid) { this.openid = openid; }
public String getUnionid() { return unionid; }
public void setUnionid(String unionid) { this.unionid = unionid; }
public String getNickname() { return nickname; }
public void setNickname(String nickname) { this.nickname = nickname; }
public String getHeadimgurl() { return headimgurl; }
public void setHeadimgurl(String headimgurl) { this.headimgurl = headimgurl; }
public int getSex() { return sex; }
public void setSex(int sex) { this.sex = sex; }
public String getProvince() { return province; }
public void setProvince(String province) { this.province = province; }
public String getCity() { return city; }
public void setCity(String city) { this.city = city; }
public String getCountry() { return country; }
public void setCountry(String country) { this.country = country; }
/**
* 获取性别文本
*/
public String getSexText() {
switch (sex) {
case 1: return "男";
case 2: return "女";
default: return "未知";
}
}
}3.3 创建 VerificationCode.java
package com.vectorstory.keycloak.wechat.model;
/**
* 短信验证码模型
*/
public class VerificationCode {
private final String code;
private final long timestamp;
private int attempts;
public VerificationCode(String code, long timestamp) {
this.code = code;
this.timestamp = timestamp;
this.attempts = 0;
}
public String getCode() {
return code;
}
public long getTimestamp() {
return timestamp;
}
public int getAttempts() {
return attempts;
}
public void incrementAttempts() {
this.attempts++;
}
public boolean isExpired(int expiryMinutes) {
long currentTime = System.currentTimeMillis();
long elapsedMinutes = (currentTime - timestamp) / (1000 * 60);
return elapsedMinutes >= expiryMinutes;
}
public boolean isMaxAttemptsExceeded(int maxAttempts) {
return attempts >= maxAttempts;
}
}步骤 4: 创建缓存层
4.1 创建缓存接口 StateCache.java
package com.vectorstory.keycloak.wechat.cache;
/**
* State 参数缓存接口
* 用于存储 OAuth state 参数,防止 CSRF 攻击
*/
public interface StateCache {
/**
* 保存 state
* @param state state 参数
* @param expireSeconds 过期时间(秒)
*/
void saveState(String state, int expireSeconds);
/**
* 验证并删除 state
* @param state state 参数
* @return true 如果 state 有效,false 如果 state 无效或已过期
*/
boolean validateAndRemoveState(String state);
}4.2 创建内存缓存实现 MemoryStateCache.java
package com.vectorstory.keycloak.wechat.cache;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 基于内存的 State 缓存实现
* 适用于单机开发环境
*/
public class MemoryStateCache implements StateCache {
private static final Map<String, Long> stateMap = new ConcurrentHashMap<>();
@Override
public void saveState(String state, int expireSeconds) {
long expireTime = System.currentTimeMillis() + (expireSeconds * 1000L);
stateMap.put(state, expireTime);
// 清理过期的 state
cleanExpiredStates();
}
@Override
public boolean validateAndRemoveState(String state) {
Long expireTime = stateMap.remove(state);
if (expireTime == null) {
return false;
}
return System.currentTimeMillis() <= expireTime;
}
private void cleanExpiredStates() {
long now = System.currentTimeMillis();
stateMap.entrySet().removeIf(entry -> entry.getValue() < now);
}
}4.3 创建 Redis 缓存实现 RedisStateCache.java
package com.vectorstory.keycloak.wechat.cache;
import org.jboss.logging.Logger;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
/**
* 基于 Redis 的 State 缓存实现
* 适用于生产环境(支持分布式部署)
*/
public class RedisStateCache implements StateCache {
private static final Logger logger = Logger.getLogger(RedisStateCache.class);
private static final String KEY_PREFIX = "wechat:state:";
private final JedisPool jedisPool;
public RedisStateCache() {
String host = System.getenv().getOrDefault("REDIS_HOST", "localhost");
int port = Integer.parseInt(System.getenv().getOrDefault("REDIS_PORT", "6379"));
String password = System.getenv("REDIS_PASSWORD");
int database = Integer.parseInt(System.getenv().getOrDefault("REDIS_DATABASE", "0"));
int timeout = Integer.parseInt(System.getenv().getOrDefault("REDIS_TIMEOUT", "3000"));
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(20);
poolConfig.setMaxIdle(10);
poolConfig.setMinIdle(5);
if (password != null && !password.isEmpty()) {
this.jedisPool = new JedisPool(poolConfig, host, port, timeout, password, database);
} else {
this.jedisPool = new JedisPool(poolConfig, host, port, timeout, null, database);
}
logger.infof("Redis State Cache 初始化: %s:%d (database=%d)", host, port, database);
}
@Override
public void saveState(String state, int expireSeconds) {
try (Jedis jedis = jedisPool.getResource()) {
String key = KEY_PREFIX + state;
jedis.setex(key, expireSeconds, "1");
logger.debugf("State 已保存: %s (过期时间: %d 秒)", state, expireSeconds);
} catch (Exception e) {
logger.errorf(e, "保存 State 失败: %s", state);
}
}
@Override
public boolean validateAndRemoveState(String state) {
try (Jedis jedis = jedisPool.getResource()) {
String key = KEY_PREFIX + state;
Long result = jedis.del(key);
boolean valid = result != null && result > 0;
logger.debugf("State 验证: %s -> %s", state, valid ? "有效" : "无效");
return valid;
} catch (Exception e) {
logger.errorf(e, "验证 State 失败: %s", state);
return false;
}
}
}4.4 创建短信验证码缓存接口 CodeCache.java
package com.vectorstory.keycloak.wechat.cache;
/**
* 短信验证码缓存接口
*/
public interface CodeCache {
void store(String phone, String code);
boolean verify(String phone, String inputCode);
boolean canSendSms(String phone);
void recordSmsSent(String phone);
void remove(String phone);
}4.5 创建内存验证码缓存 MemoryCodeCache.java
package com.vectorstory.keycloak.wechat.cache;
import com.vectorstory.keycloak.wechat.model.VerificationCode;
import org.jboss.logging.Logger;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 基于内存的短信验证码缓存
*/
public class MemoryCodeCache implements CodeCache {
private static final Logger logger = Logger.getLogger(MemoryCodeCache.class);
private static final int CODE_EXPIRY_MINUTES = 5;
private static final int MAX_ATTEMPTS = 5;
private static final int SEND_INTERVAL_SECONDS = 60;
private final Map<String, VerificationCode> codeMap = new ConcurrentHashMap<>();
private final Map<String, Long> sendTimestamps = new ConcurrentHashMap<>();
@Override
public void store(String phone, String code) {
VerificationCode verificationCode = new VerificationCode(code, System.currentTimeMillis());
codeMap.put(phone, verificationCode);
logger.infof("验证码已存储: phone=%s", phone);
}
@Override
public boolean verify(String phone, String inputCode) {
VerificationCode storedCode = codeMap.get(phone);
if (storedCode == null) {
logger.warnf("验证码不存在: phone=%s", phone);
return false;
}
if (storedCode.isExpired(CODE_EXPIRY_MINUTES)) {
logger.warnf("验证码已过期: phone=%s", phone);
codeMap.remove(phone);
return false;
}
if (storedCode.isMaxAttemptsExceeded(MAX_ATTEMPTS)) {
logger.warnf("超过最大尝试次数: phone=%s", phone);
return false;
}
storedCode.incrementAttempts();
boolean isValid = storedCode.getCode().equals(inputCode);
if (isValid) {
logger.infof("验证码验证成功: phone=%s", phone);
codeMap.remove(phone);
} else {
logger.warnf("验证码验证失败: phone=%s, attempts=%d", phone, storedCode.getAttempts());
}
return isValid;
}
@Override
public boolean canSendSms(String phone) {
Long lastSendTime = sendTimestamps.get(phone);
if (lastSendTime == null) {
return true;
}
long elapsedSeconds = (System.currentTimeMillis() - lastSendTime) / 1000;
boolean canSend = elapsedSeconds >= SEND_INTERVAL_SECONDS;
if (!canSend) {
logger.warnf("发送频率限制: phone=%s, 需等待 %d 秒", phone, SEND_INTERVAL_SECONDS - elapsedSeconds);
}
return canSend;
}
@Override
public void recordSmsSent(String phone) {
sendTimestamps.put(phone, System.currentTimeMillis());
logger.infof("记录短信发送时间: phone=%s", phone);
}
@Override
public void remove(String phone) {
codeMap.remove(phone);
logger.infof("验证码已删除: phone=%s", phone);
}
}4.6 创建 Redis 验证码缓存 RedisCodeCache.java
package com.vectorstory.keycloak.wechat.cache;
import org.jboss.logging.Logger;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
/**
* 基于 Redis 的短信验证码缓存
*/
public class RedisCodeCache implements CodeCache {
private static final Logger logger = Logger.getLogger(RedisCodeCache.class);
private static final int CODE_EXPIRY_SECONDS = 300;
private static final int SEND_INTERVAL_SECONDS = 60;
private static final int MAX_ATTEMPTS = 5;
private static final String CODE_PREFIX = "sms:code:";
private static final String SEND_PREFIX = "sms:send:";
private static final String ATTEMPTS_PREFIX = "sms:attempts:";
private final JedisPool jedisPool;
public RedisCodeCache() {
String host = getEnv("REDIS_HOST", "localhost");
int port = Integer.parseInt(getEnv("REDIS_PORT", "6379"));
String password = getEnv("REDIS_PASSWORD", null);
int database = Integer.parseInt(getEnv("REDIS_DATABASE", "0"));
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(20);
poolConfig.setMaxIdle(10);
poolConfig.setMinIdle(5);
poolConfig.setTestOnBorrow(true);
if (password != null && !password.isEmpty()) {
this.jedisPool = new JedisPool(poolConfig, host, port, 2000, password, database);
} else {
this.jedisPool = new JedisPool(poolConfig, host, port, 2000);
}
logger.infof("Redis 连接池已初始化: %s:%d, database=%d", host, port, database);
}
@Override
public void store(String phone, String code) {
try (Jedis jedis = jedisPool.getResource()) {
String key = CODE_PREFIX + phone;
jedis.setex(key, CODE_EXPIRY_SECONDS, code);
String attemptsKey = ATTEMPTS_PREFIX + phone;
jedis.setex(attemptsKey, CODE_EXPIRY_SECONDS, "0");
logger.infof("验证码已存储到 Redis: phone=%s", phone);
} catch (Exception e) {
logger.errorf(e, "存储验证码到 Redis 失败: phone=%s", phone);
throw new RuntimeException("Redis 操作失败", e);
}
}
@Override
public boolean verify(String phone, String inputCode) {
try (Jedis jedis = jedisPool.getResource()) {
String key = CODE_PREFIX + phone;
String storedCode = jedis.get(key);
if (storedCode == null) {
logger.warnf("验证码不存在或已过期: phone=%s", phone);
return false;
}
String attemptsKey = ATTEMPTS_PREFIX + phone;
String attemptsStr = jedis.get(attemptsKey);
int attempts = attemptsStr != null ? Integer.parseInt(attemptsStr) : 0;
if (attempts >= MAX_ATTEMPTS) {
logger.warnf("超过最大尝试次数: phone=%s, attempts=%d", phone, attempts);
return false;
}
jedis.incr(attemptsKey);
boolean isValid = storedCode.equals(inputCode);
if (isValid) {
logger.infof("验证码验证成功: phone=%s", phone);
jedis.del(key);
jedis.del(attemptsKey);
} else {
logger.warnf("验证码验证失败: phone=%s, attempts=%d", phone, attempts + 1);
}
return isValid;
} catch (Exception e) {
logger.errorf(e, "验证验证码失败: phone=%s", phone);
throw new RuntimeException("Redis 操作失败", e);
}
}
@Override
public boolean canSendSms(String phone) {
try (Jedis jedis = jedisPool.getResource()) {
String key = SEND_PREFIX + phone;
String lastSendTime = jedis.get(key);
if (lastSendTime == null) {
return true;
}
long lastSend = Long.parseLong(lastSendTime);
long elapsedSeconds = (System.currentTimeMillis() - lastSend) / 1000;
boolean canSend = elapsedSeconds >= SEND_INTERVAL_SECONDS;
if (!canSend) {
logger.warnf("发送频率限制: phone=%s, 需等待 %d 秒", phone, SEND_INTERVAL_SECONDS - elapsedSeconds);
}
return canSend;
} catch (Exception e) {
logger.errorf(e, "检查发送频率失败: phone=%s", phone);
return true;
}
}
@Override
public void recordSmsSent(String phone) {
try (Jedis jedis = jedisPool.getResource()) {
String key = SEND_PREFIX + phone;
jedis.setex(key, SEND_INTERVAL_SECONDS, String.valueOf(System.currentTimeMillis()));
logger.infof("记录短信发送时间到 Redis: phone=%s", phone);
} catch (Exception e) {
logger.errorf(e, "记录短信发送时间失败: phone=%s", phone);
}
}
@Override
public void remove(String phone) {
try (Jedis jedis = jedisPool.getResource()) {
jedis.del(CODE_PREFIX + phone);
jedis.del(ATTEMPTS_PREFIX + phone);
logger.infof("验证码已从 Redis 删除: phone=%s", phone);
} catch (Exception e) {
logger.errorf(e, "删除验证码失败: phone=%s", phone);
}
}
private String getEnv(String key, String defaultValue) {
String value = System.getenv(key);
return (value != null && !value.isEmpty()) ? value : defaultValue;
}
}步骤 5: 创建微信 OAuth 服务
5.1 创建服务接口 WeChatOAuthService.java
package com.vectorstory.keycloak.wechat.service;
import com.vectorstory.keycloak.wechat.model.WeChatAccessToken;
import com.vectorstory.keycloak.wechat.model.WeChatUserInfo;
/**
* 微信 OAuth 服务接口
*/
public interface WeChatOAuthService {
/**
* 构建微信授权 URL
* @param state CSRF 防护参数
* @param redirectUri 回调地址(可为空,使用配置默认值)
* @return 授权 URL
*/
String buildAuthorizationUrl(String state, String redirectUri);
/**
* 使用授权码获取 Access Token
* @param code 授权码
* @return Access Token 信息
*/
WeChatAccessToken getAccessToken(String code) throws Exception;
/**
* 获取微信用户信息
* @param accessToken Access Token
* @param openid 用户 OpenID
* @return 用户信息
*/
WeChatUserInfo getUserInfo(String accessToken, String openid) throws Exception;
}5.2 创建真实实现 WeChatOAuthServiceImpl.java
package com.vectorstory.keycloak.wechat.service;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.vectorstory.keycloak.wechat.config.WeChatConfig;
import com.vectorstory.keycloak.wechat.model.WeChatAccessToken;
import com.vectorstory.keycloak.wechat.model.WeChatUserInfo;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.jboss.logging.Logger;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.concurrent.TimeUnit;
/**
* 微信 OAuth 服务实现(真实 API 调用)
*/
public class WeChatOAuthServiceImpl implements WeChatOAuthService {
private static final Logger logger = Logger.getLogger(WeChatOAuthServiceImpl.class);
private final WeChatConfig config;
private final OkHttpClient httpClient;
private final Gson gson;
public WeChatOAuthServiceImpl() {
this.config = new WeChatConfig();
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build();
this.gson = new Gson();
logger.infof("微信 OAuth 服务初始化: 平台类型=%s, AppID=%s",
config.getPlatformType(), config.getAppId());
}
@Override
public String buildAuthorizationUrl(String state, String redirectUri) {
try {
String baseUrl = config.getAuthorizationUrl();
String resolvedRedirectUri = resolveRedirectUri(redirectUri);
String encodedRedirectUri = URLEncoder.encode(resolvedRedirectUri, "UTF-8");
return String.format("%s?appid=%s&redirect_uri=%s&response_type=code&scope=%s&state=%s#wechat_redirect",
baseUrl,
config.getAppId(),
encodedRedirectUri,
config.getScope(),
state);
} catch (UnsupportedEncodingException e) {
logger.error("构建授权 URL 失败", e);
throw new RuntimeException("构建授权 URL 失败", e);
}
}
private String resolveRedirectUri(String redirectUri) {
if (redirectUri != null && !redirectUri.isBlank()) {
return redirectUri;
}
return config.getRedirectUri();
}
@Override
public WeChatAccessToken getAccessToken(String code) throws Exception {
String url = String.format("%s?appid=%s&secret=%s&code=%s&grant_type=authorization_code",
config.getAccessTokenUrl(),
config.getAppId(),
config.getAppSecret(),
code);
logger.debugf("获取 Access Token: code=%s", code);
Request request = new Request.Builder()
.url(url)
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("获取 Access Token 失败: HTTP " + response.code());
}
String body = response.body().string();
logger.debugf("Access Token 响应: %s", body);
JsonObject json = gson.fromJson(body, JsonObject.class);
// 检查错误
if (json.has("errcode")) {
int errcode = json.get("errcode").getAsInt();
String errmsg = json.get("errmsg").getAsString();
throw new Exception(String.format("微信 API 错误: errcode=%d, errmsg=%s", errcode, errmsg));
}
WeChatAccessToken token = new WeChatAccessToken();
token.setAccessToken(json.get("access_token").getAsString());
token.setExpiresIn(json.get("expires_in").getAsInt());
token.setRefreshToken(json.get("refresh_token").getAsString());
token.setOpenid(json.get("openid").getAsString());
token.setScope(json.get("scope").getAsString());
if (json.has("unionid")) {
token.setUnionid(json.get("unionid").getAsString());
}
return token;
}
}
@Override
public WeChatUserInfo getUserInfo(String accessToken, String openid) throws Exception {
String url = String.format("%s?access_token=%s&openid=%s&lang=zh_CN",
config.getUserInfoUrl(),
accessToken,
openid);
logger.debugf("获取用户信息: openid=%s", openid);
Request request = new Request.Builder()
.url(url)
.get()
.build();
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new IOException("获取用户信息失败: HTTP " + response.code());
}
String body = response.body().string();
logger.debugf("用户信息响应: %s", body);
JsonObject json = gson.fromJson(body, JsonObject.class);
// 检查错误
if (json.has("errcode")) {
int errcode = json.get("errcode").getAsInt();
String errmsg = json.get("errmsg").getAsString();
throw new Exception(String.format("微信 API 错误: errcode=%d, errmsg=%s", errcode, errmsg));
}
return gson.fromJson(json, WeChatUserInfo.class);
}
}
}5.3 创建 Mock 实现 MockWeChatOAuthService.java
package com.vectorstory.keycloak.wechat.service;
import com.vectorstory.keycloak.wechat.config.WeChatConfig;
import com.vectorstory.keycloak.wechat.model.WeChatAccessToken;
import com.vectorstory.keycloak.wechat.model.WeChatUserInfo;
import org.jboss.logging.Logger;
import java.util.UUID;
/**
* 微信 OAuth 服务 Mock 实现(用于开发测试)
*/
public class MockWeChatOAuthService implements WeChatOAuthService {
private static final Logger logger = Logger.getLogger(MockWeChatOAuthService.class);
private final WeChatConfig config;
public MockWeChatOAuthService() {
this.config = new WeChatConfig();
logger.info("=== 微信 OAuth Mock 模式已启用 ===");
}
@Override
public String buildAuthorizationUrl(String state, String redirectUri) {
String mockCode = "mock_code_" + UUID.randomUUID().toString().replace("-", "");
String resolvedRedirectUri = resolveRedirectUri(redirectUri);
String separator = resolvedRedirectUri.contains("?") ? "&" : "?";
String url = String.format("%s%scode=%s&state=%s&mock=true",
resolvedRedirectUri,
separator,
mockCode,
state);
logger.infof("[Mock] 构建授权 URL: %s", url);
return url;
}
private String resolveRedirectUri(String redirectUri) {
if (redirectUri != null && !redirectUri.isBlank()) {
return redirectUri;
}
return config.getRedirectUri();
}
@Override
public WeChatAccessToken getAccessToken(String code) {
logger.infof("[Mock] 获取 Access Token: code=%s", code);
WeChatAccessToken token = new WeChatAccessToken();
token.setAccessToken("mock_access_token_" + UUID.randomUUID().toString());
token.setExpiresIn(7200);
token.setRefreshToken("mock_refresh_token_" + UUID.randomUUID().toString());
token.setOpenid("mock_openid_" + System.currentTimeMillis());
token.setScope("snsapi_login");
token.setUnionid("mock_unionid_" + System.currentTimeMillis());
logger.infof("[Mock] Access Token: %s, OpenID: %s", token.getAccessToken(), token.getOpenid());
return token;
}
@Override
public WeChatUserInfo getUserInfo(String accessToken, String openid) {
logger.infof("[Mock] 获取用户信息: openid=%s", openid);
WeChatUserInfo userInfo = new WeChatUserInfo();
userInfo.setOpenid(openid);
userInfo.setUnionid("mock_unionid_" + System.currentTimeMillis());
userInfo.setNickname("测试用户_" + openid.substring(openid.length() - 6));
userInfo.setHeadimgurl("https://thirdwx.qlogo.cn/mmopen/mock_avatar.jpg");
userInfo.setSex(1);
userInfo.setProvince("广东");
userInfo.setCity("深圳");
userInfo.setCountry("中国");
logger.infof("[Mock] 用户信息: nickname=%s, openid=%s", userInfo.getNickname(), userInfo.getOpenid());
return userInfo;
}
}5.4 创建短信服务接口 SmsService.java
package com.vectorstory.keycloak.wechat.sms;
/**
* 短信服务接口
*/
public interface SmsService {
boolean sendVerificationCode(String phoneNumber, String code);
default String generateCode(int length) {
StringBuilder code = new StringBuilder();
for (int i = 0; i < length; i++) {
code.append((int) (Math.random() * 10));
}
return code.toString();
}
}5.5 创建 Mock 短信服务 MockSmsService.java
package com.vectorstory.keycloak.wechat.sms;
import org.jboss.logging.Logger;
/**
* Mock 短信服务(验证码打印到日志)
*/
public class MockSmsService implements SmsService {
private static final Logger logger = Logger.getLogger(MockSmsService.class);
public MockSmsService() {
logger.warn("=".repeat(60));
logger.warn("警告:正在使用 Mock 短信服务,验证码将打印在日志中");
logger.warn("生产环境请设置 SMS_SERVICE_MOCK=false");
logger.warn("=".repeat(60));
}
@Override
public boolean sendVerificationCode(String phoneNumber, String code) {
logger.info("=".repeat(60));
logger.infof("【Mock 短信】手机号: %s", phoneNumber);
logger.infof("【Mock 短信】验证码: %s", code);
logger.infof("【Mock 短信】有效期: 5分钟");
logger.info("=".repeat(60));
return true;
}
}5.6 创建阿里云短信服务 AliyunSmsService.java
package com.vectorstory.keycloak.wechat.sms;
import com.aliyun.dysmsapi20170525.Client;
import com.aliyun.dysmsapi20170525.models.SendSmsRequest;
import com.aliyun.dysmsapi20170525.models.SendSmsResponse;
import com.aliyun.teaopenapi.models.Config;
import org.jboss.logging.Logger;
/**
* 阿里云短信服务实现
*/
public class AliyunSmsService implements SmsService {
private static final Logger logger = Logger.getLogger(AliyunSmsService.class);
private final Client client;
private final String signName;
private final String templateCode;
public AliyunSmsService() {
try {
String accessKeyId = getRequiredEnv("ALIYUN_ACCESS_KEY_ID");
String accessKeySecret = getRequiredEnv("ALIYUN_ACCESS_KEY_SECRET");
this.signName = getRequiredEnv("ALIYUN_SMS_SIGN_NAME");
this.templateCode = getRequiredEnv("ALIYUN_SMS_TEMPLATE_CODE");
Config config = new Config()
.setAccessKeyId(accessKeyId)
.setAccessKeySecret(accessKeySecret)
.setEndpoint("dysmsapi.aliyuncs.com");
this.client = new Client(config);
logger.infof("阿里云短信服务初始化成功: signName=%s, templateCode=%s", signName, templateCode);
} catch (Exception e) {
logger.error("阿里云短信服务初始化失败", e);
throw new RuntimeException("Failed to initialize Aliyun SMS service", e);
}
}
@Override
public boolean sendVerificationCode(String phoneNumber, String code) {
try {
SendSmsRequest request = new SendSmsRequest()
.setPhoneNumbers(phoneNumber)
.setSignName(signName)
.setTemplateCode(templateCode)
.setTemplateParam("{\"code\":\"" + code + "\"}");
SendSmsResponse response = client.sendSms(request);
String resultCode = response.getBody().getCode();
boolean success = "OK".equals(resultCode);
if (success) {
logger.infof("短信发送成功: phone=%s, code=%s, requestId=%s",
phoneNumber, code, response.getBody().getRequestId());
} else {
logger.errorf("短信发送失败: phone=%s, code=%s, message=%s",
phoneNumber, resultCode, response.getBody().getMessage());
}
return success;
} catch (Exception e) {
logger.errorf(e, "短信发送异常: phone=%s", phoneNumber);
return false;
}
}
private String getRequiredEnv(String key) {
String value = System.getenv(key);
if (value == null || value.trim().isEmpty()) {
throw new IllegalStateException("Required environment variable not set: " + key);
}
return value.trim();
}
}步骤 6: 创建认证器核心类
6.1 创建 WeChatAuthenticator.java
这是认证器的核心实现,处理完整的 OAuth 流程与手机号绑定逻辑:
package com.vectorstory.keycloak.wechat.authenticator;
import com.vectorstory.keycloak.wechat.cache.CodeCache;
import com.vectorstory.keycloak.wechat.cache.MemoryCodeCache;
import com.vectorstory.keycloak.wechat.cache.RedisCodeCache;
import com.vectorstory.keycloak.wechat.cache.StateCache;
import com.vectorstory.keycloak.wechat.cache.MemoryStateCache;
import com.vectorstory.keycloak.wechat.cache.RedisStateCache;
import com.vectorstory.keycloak.wechat.config.WeChatConfig;
import com.vectorstory.keycloak.wechat.model.WeChatAccessToken;
import com.vectorstory.keycloak.wechat.model.WeChatUserInfo;
import com.vectorstory.keycloak.wechat.service.MockWeChatOAuthService;
import com.vectorstory.keycloak.wechat.service.WeChatOAuthService;
import com.vectorstory.keycloak.wechat.service.WeChatOAuthServiceImpl;
import com.vectorstory.keycloak.wechat.sms.AliyunSmsService;
import com.vectorstory.keycloak.wechat.sms.MockSmsService;
import com.vectorstory.keycloak.wechat.sms.SmsService;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriBuilder;
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.sessions.AuthenticationSessionModel;
import java.util.UUID;
public class WeChatAuthenticator implements Authenticator {
private static final Logger logger = Logger.getLogger(WeChatAuthenticator.class);
private static final String TEMPLATE_LOGIN_BUTTON = "wechat-login-button.ftl";
private static final String TEMPLATE_PHONE_FORM = "wechat-bind-phone.ftl";
private static final String TEMPLATE_CODE_FORM = "wechat-bind-code.ftl";
private static final String FORM_ACTION = "wechatAction";
private static final String ACTION_LOGIN = "login";
private static final String ACTION_SEND_CODE = "sendCode";
private static final String ACTION_VERIFY_CODE = "verifyCode";
private static final String FORM_PHONE = "phone";
private static final String FORM_CODE = "code";
private static final String PARAM_CODE = "code";
private static final String PARAM_STATE = "state";
private static final String PARAM_SESSION_CODE = "session_code";
private static final String PARAM_EXECUTION = "execution";
private static final String PARAM_CLIENT_ID = "client_id";
private static final String PARAM_TAB_ID = "tab_id";
private static final String PARAM_CLIENT_DATA = "client_data";
private static final String NOTE_WECHAT_OPENID = "WECHAT_OPENID";
private static final String NOTE_WECHAT_UNIONID = "WECHAT_UNIONID";
private static final String NOTE_WECHAT_NICKNAME = "WECHAT_NICKNAME";
private static final String NOTE_WECHAT_AVATAR = "WECHAT_AVATAR";
private static final String NOTE_WECHAT_GENDER = "WECHAT_GENDER";
private static final String NOTE_BIND_PHONE = "WECHAT_BIND_PHONE";
private final WeChatOAuthService oauthService;
private final StateCache stateCache;
private final WeChatConfig config;
private final SmsService smsService;
private final CodeCache codeCache;
private final String phoneRegex;
private final int codeLength;
public WeChatAuthenticator() {
this.config = new WeChatConfig();
this.oauthService = config.isMock()
? new MockWeChatOAuthService()
: new WeChatOAuthServiceImpl();
boolean useRedis = "true".equalsIgnoreCase(System.getenv("USE_REDIS_CACHE"));
this.stateCache = useRedis ? new RedisStateCache() : new MemoryStateCache();
boolean useMockSms = "true".equalsIgnoreCase(System.getenv("SMS_SERVICE_MOCK"));
this.smsService = useMockSms ? new MockSmsService() : new AliyunSmsService();
this.codeCache = useRedis ? new RedisCodeCache() : new MemoryCodeCache();
this.phoneRegex = System.getenv().getOrDefault("PHONE_NUMBER_REGEX", "^1[3-9]\\d{9}$");
this.codeLength = parseIntEnv("VERIFICATION_CODE_LENGTH", 6);
logger.infof("微信认证器初始化: mock=%s, stateCache=%s, smsService=%s",
config.isMock(),
stateCache.getClass().getSimpleName(),
smsService.getClass().getSimpleName());
}
@Override
public void authenticate(AuthenticationFlowContext context) {
Response response = context.form().createForm(TEMPLATE_LOGIN_BUTTON);
context.challenge(response);
}
@Override
public void action(AuthenticationFlowContext context) {
MultivaluedMap<String, String> queryParams = context.getHttpRequest().getUri().getQueryParameters();
String code = queryParams.getFirst(PARAM_CODE);
String state = queryParams.getFirst(PARAM_STATE);
if (code != null || state != null) {
handleCallback(context, code, state);
return;
}
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String action = formData.getFirst(FORM_ACTION);
if (ACTION_LOGIN.equals(action)) {
if (config.isMock()) {
handleMockLogin(context);
} else {
redirectToWeChat(context);
}
return;
}
if (ACTION_SEND_CODE.equals(action)) {
handleSendCode(context, formData);
return;
}
if (ACTION_VERIFY_CODE.equals(action)) {
handleVerifyCode(context, formData);
return;
}
logger.warnf("未知的操作: %s", action);
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS,
context.form().setError("微信登录失败:请求无效").createForm(TEMPLATE_LOGIN_BUTTON));
}
private void redirectToWeChat(AuthenticationFlowContext context) {
String state = UUID.randomUUID().toString();
stateCache.saveState(state, config.getStateExpireTime());
// 使用 Keycloak 提供的 Action URL,它会自动生成正确的 session_code
// getActionUrl() 返回当前认证流程的 action URL
String callbackUrl = context.getActionUrl(context.generateAccessCode()).toString();
String authUrl = oauthService.buildAuthorizationUrl(state, callbackUrl);
logger.infof("重定向到微信授权页面: %s", authUrl);
logger.infof("回调地址: %s", callbackUrl);
Response response = Response.status(302)
.header("Location", authUrl)
.build();
context.challenge(response);
}
private String buildCallbackUrl(AuthenticationFlowContext context) {
return context.getUriInfo().getRequestUriBuilder()
.replaceQueryParam(PARAM_CODE)
.replaceQueryParam(PARAM_STATE)
.build()
.toString();
}
private String resolveRedirectUri(AuthenticationFlowContext context) {
String configuredRedirect = config.getRedirectUri();
if (configuredRedirect != null && !configuredRedirect.isBlank()) {
return mergeLoginParams(configuredRedirect, context.getHttpRequest().getUri().getQueryParameters());
}
return buildCallbackUrl(context);
}
private String mergeLoginParams(String baseRedirect, MultivaluedMap<String, String> params) {
UriBuilder builder = UriBuilder.fromUri(baseRedirect);
copyParam(builder, params, PARAM_SESSION_CODE);
copyParam(builder, params, PARAM_EXECUTION);
copyParam(builder, params, PARAM_CLIENT_ID);
copyParam(builder, params, PARAM_TAB_ID);
copyParam(builder, params, PARAM_CLIENT_DATA);
return builder.build().toString();
}
private void copyParam(UriBuilder builder, MultivaluedMap<String, String> params, String name) {
String value = params.getFirst(name);
if (value != null && !value.isBlank()) {
builder.replaceQueryParam(name, value);
}
}
private void handleMockLogin(AuthenticationFlowContext context) {
try {
WeChatAccessToken token = oauthService.getAccessToken("mock_code");
WeChatUserInfo userInfo = oauthService.getUserInfo(token.getAccessToken(), token.getOpenid());
processWeChatUser(context, userInfo);
} catch (Exception e) {
logger.error("Mock 微信登录失败", e);
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,
context.form().setError("微信登录失败:" + e.getMessage())
.createErrorPage(Response.Status.INTERNAL_SERVER_ERROR));
}
}
private void handleCallback(AuthenticationFlowContext context, String code, String state) {
logger.infof("微信回调: code=%s, state=%s", code, state);
if (state == null || !stateCache.validateAndRemoveState(state)) {
logger.error("State 参数验证失败");
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS,
context.form().setError("微信登录失败:state 参数无效")
.createErrorPage(Response.Status.BAD_REQUEST));
return;
}
if (code == null || code.isEmpty()) {
logger.error("授权码为空");
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS,
context.form().setError("微信登录失败:授权码无效")
.createErrorPage(Response.Status.BAD_REQUEST));
return;
}
try {
WeChatAccessToken token = oauthService.getAccessToken(code);
logger.infof("获取 Access Token 成功: openid=%s", token.getOpenid());
WeChatUserInfo userInfo = oauthService.getUserInfo(token.getAccessToken(), token.getOpenid());
logger.infof("获取用户信息成功: nickname=%s, openid=%s", userInfo.getNickname(), userInfo.getOpenid());
processWeChatUser(context, userInfo);
} catch (Exception e) {
logger.error("微信登录失败", e);
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,
context.form().setError("微信登录失败:" + e.getMessage())
.createErrorPage(Response.Status.INTERNAL_SERVER_ERROR));
}
}
private void processWeChatUser(AuthenticationFlowContext context, WeChatUserInfo userInfo) {
UserModel user = findUserByUnionId(context, userInfo.getUnionid());
if (user != null) {
bindWeChatToUser(user, userInfo);
context.setUser(user);
context.success();
clearWeChatNotes(context.getAuthenticationSession());
logger.infof("微信登录成功: username=%s, unionid=%s", user.getUsername(), userInfo.getUnionid());
return;
}
storeWeChatNotes(context.getAuthenticationSession(), userInfo);
Response response = context.form().createForm(TEMPLATE_PHONE_FORM);
context.challenge(response);
}
private void handleSendCode(AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {
if (!hasWeChatNotes(context.getAuthenticationSession())) {
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS,
context.form().setError("微信登录信息已失效,请重新登录")
.createForm(TEMPLATE_LOGIN_BUTTON));
return;
}
String phone = formData.getFirst(FORM_PHONE);
if (!isValidPhone(phone)) {
Response response = context.form()
.setError("手机号格式不正确,请输入正确的手机号")
.createForm(TEMPLATE_PHONE_FORM);
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response);
return;
}
if (!codeCache.canSendSms(phone)) {
Response response = context.form()
.setError("发送频率过快,请稍后再试")
.createForm(TEMPLATE_PHONE_FORM);
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response);
return;
}
String code = smsService.generateCode(codeLength);
boolean sent = smsService.sendVerificationCode(phone, code);
if (!sent) {
Response response = context.form()
.setError("验证码发送失败,请稍后重试")
.createForm(TEMPLATE_PHONE_FORM);
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR, response);
return;
}
codeCache.store(phone, code);
codeCache.recordSmsSent(phone);
context.getAuthenticationSession().setAuthNote(NOTE_BIND_PHONE, phone);
Response response = context.form()
.setAttribute(FORM_PHONE, phone)
.createForm(TEMPLATE_CODE_FORM);
context.challenge(response);
}
private void handleVerifyCode(AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {
AuthenticationSessionModel authSession = context.getAuthenticationSession();
if (!hasWeChatNotes(authSession)) {
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS,
context.form().setError("微信登录信息已失效,请重新登录")
.createForm(TEMPLATE_LOGIN_BUTTON));
return;
}
String phone = formData.getFirst(FORM_PHONE);
if (phone == null || phone.isBlank()) {
phone = authSession.getAuthNote(NOTE_BIND_PHONE);
}
String inputCode = formData.getFirst(FORM_CODE);
if (phone == null || phone.isBlank() || inputCode == null || inputCode.isBlank()) {
Response response = context.form()
.setAttribute(FORM_PHONE, phone)
.setError("验证码不能为空,请重新输入")
.createForm(TEMPLATE_CODE_FORM);
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response);
return;
}
if (!codeCache.verify(phone, inputCode)) {
Response response = context.form()
.setAttribute(FORM_PHONE, phone)
.setError("验证码错误或已过期,请重新获取")
.createForm(TEMPLATE_CODE_FORM);
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, response);
return;
}
WeChatUserInfo userInfo = loadWeChatInfoFromNotes(authSession);
if (userInfo == null) {
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS,
context.form().setError("微信登录信息已失效,请重新登录")
.createForm(TEMPLATE_LOGIN_BUTTON));
return;
}
UserModel user = findUserByPhone(context, phone);
if (user == null) {
user = createUserForPhone(context, phone);
}
if (user == null) {
context.failureChallenge(AuthenticationFlowError.INTERNAL_ERROR,
context.form().setError("系统错误,请联系管理员")
.createForm(TEMPLATE_PHONE_FORM));
return;
}
bindWeChatToUser(user, userInfo);
user.setSingleAttribute("phone", phone);
user.setSingleAttribute("phoneVerified", "true");
context.setUser(user);
context.success();
clearWeChatNotes(authSession);
logger.infof("手机号绑定成功: username=%s, phone=%s", user.getUsername(), phone);
}
private UserModel findUserByUnionId(AuthenticationFlowContext context, String unionid) {
if (unionid == null || unionid.isBlank()) {
return null;
}
KeycloakSession session = context.getSession();
RealmModel realm = context.getRealm();
return session.users().searchForUserByUserAttributeStream(realm, "wechatUnionId", unionid)
.findFirst()
.orElse(null);
}
private UserModel findUserByPhone(AuthenticationFlowContext context, String phone) {
KeycloakSession session = context.getSession();
RealmModel realm = context.getRealm();
UserModel user = session.users().searchForUserByUserAttributeStream(realm, "phone", phone)
.findFirst()
.orElse(null);
if (user != null) {
return user;
}
return session.users().getUserByUsername(realm, phone);
}
private UserModel createUserForPhone(AuthenticationFlowContext context, String phone) {
KeycloakSession session = context.getSession();
RealmModel realm = context.getRealm();
String username = buildUniqueUsername(session, realm, phone);
try {
UserModel user = session.users().addUser(realm, username);
user.setEnabled(true);
user.setSingleAttribute("phone", phone);
user.setSingleAttribute("phoneVerified", "true");
user.addRequiredAction(UserModel.RequiredAction.UPDATE_PROFILE);
user.addRequiredAction(UserModel.RequiredAction.UPDATE_PASSWORD);
logger.infof("创建新用户成功: username=%s, phone=%s", user.getUsername(), phone);
return user;
} catch (Exception e) {
logger.errorf(e, "创建用户失败: phone=%s", phone);
return null;
}
}
private void bindWeChatToUser(UserModel user, WeChatUserInfo userInfo) {
user.setSingleAttribute("wechatOpenId", userInfo.getOpenid());
if (userInfo.getUnionid() != null) {
user.setSingleAttribute("wechatUnionId", userInfo.getUnionid());
}
if (userInfo.getNickname() != null) {
user.setSingleAttribute("wechatNickname", userInfo.getNickname());
}
if (userInfo.getHeadimgurl() != null) {
user.setSingleAttribute("wechatAvatar", userInfo.getHeadimgurl());
}
user.setSingleAttribute("wechatGender", userInfo.getSexText());
if (user.getFirstName() == null || user.getFirstName().isBlank()) {
user.setFirstName(userInfo.getNickname());
}
}
private void storeWeChatNotes(AuthenticationSessionModel authSession, WeChatUserInfo userInfo) {
authSession.setAuthNote(NOTE_WECHAT_OPENID, userInfo.getOpenid());
if (userInfo.getUnionid() != null) {
authSession.setAuthNote(NOTE_WECHAT_UNIONID, userInfo.getUnionid());
}
if (userInfo.getNickname() != null) {
authSession.setAuthNote(NOTE_WECHAT_NICKNAME, userInfo.getNickname());
}
if (userInfo.getHeadimgurl() != null) {
authSession.setAuthNote(NOTE_WECHAT_AVATAR, userInfo.getHeadimgurl());
}
authSession.setAuthNote(NOTE_WECHAT_GENDER, String.valueOf(userInfo.getSex()));
}
private boolean hasWeChatNotes(AuthenticationSessionModel authSession) {
return authSession.getAuthNote(NOTE_WECHAT_OPENID) != null;
}
private WeChatUserInfo loadWeChatInfoFromNotes(AuthenticationSessionModel authSession) {
String openid = authSession.getAuthNote(NOTE_WECHAT_OPENID);
if (openid == null || openid.isBlank()) {
return null;
}
WeChatUserInfo userInfo = new WeChatUserInfo();
userInfo.setOpenid(openid);
userInfo.setUnionid(authSession.getAuthNote(NOTE_WECHAT_UNIONID));
userInfo.setNickname(authSession.getAuthNote(NOTE_WECHAT_NICKNAME));
userInfo.setHeadimgurl(authSession.getAuthNote(NOTE_WECHAT_AVATAR));
String gender = authSession.getAuthNote(NOTE_WECHAT_GENDER);
if (gender != null && !gender.isBlank()) {
try {
userInfo.setSex(Integer.parseInt(gender));
} catch (NumberFormatException ignored) {
userInfo.setSex(0);
}
}
return userInfo;
}
private void clearWeChatNotes(AuthenticationSessionModel authSession) {
authSession.removeAuthNote(NOTE_WECHAT_OPENID);
authSession.removeAuthNote(NOTE_WECHAT_UNIONID);
authSession.removeAuthNote(NOTE_WECHAT_NICKNAME);
authSession.removeAuthNote(NOTE_WECHAT_AVATAR);
authSession.removeAuthNote(NOTE_WECHAT_GENDER);
authSession.removeAuthNote(NOTE_BIND_PHONE);
}
private boolean isValidPhone(String phone) {
if (phone == null || phone.trim().isEmpty()) {
return false;
}
return phone.trim().matches(phoneRegex);
}
private int parseIntEnv(String key, int defaultValue) {
String value = System.getenv(key);
if (value == null || value.trim().isEmpty()) {
return defaultValue;
}
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
return defaultValue;
}
}
private String buildUniqueUsername(KeycloakSession session, RealmModel realm, String baseUsername) {
String username = baseUsername;
int suffix = 1;
while (session.users().getUserByUsername(realm, username) != null) {
username = baseUsername + "_" + suffix++;
}
return username;
}
@Override
public boolean requiresUser() {
return false;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return true;
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
}
@Override
public void close() {
}
}6.2 创建 WeChatAuthenticatorFactory.java
package com.vectorstory.keycloak.wechat.authenticator;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.AuthenticationExecutionModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderConfigProperty;
import java.util.Collections;
import java.util.List;
/**
* 微信登录认证器工厂
*/
public class WeChatAuthenticatorFactory implements AuthenticatorFactory {
public static final String PROVIDER_ID = "wechat-authenticator";
private static final WeChatAuthenticator SINGLETON = new WeChatAuthenticator();
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getDisplayType() {
return "wechat-authenticator-display-name";
}
@Override
public String getReferenceCategory() {
return "WeChat";
}
@Override
public String getHelpText() {
return "wechat-authenticator-help-text";
}
@Override
public boolean isConfigurable() {
return false;
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return new AuthenticationExecutionModel.Requirement[]{
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.DISABLED
};
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return Collections.emptyList();
}
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
@Override
public void init(Config.Scope config) {
// 初始化配置
}
@Override
public void postInit(KeycloakSessionFactory factory) {
// 初始化后处理
}
@Override
public void close() {
// 清理资源
}
}步骤 7: 创建 SPI 注册文件
创建 src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory:
com.vectorstory.keycloak.wechat.authenticator.WeChatAuthenticatorFactory步骤 8: 创建国际化资源文件
8.1 创建中文资源 src/main/resources/theme-resources/messages/messages_zh_CN.properties
# 微信登录认证器
wechat-authenticator-display-name=微信登录
wechat-authenticator-help-text=允许用户使用微信账号登录
# 按钮文本
wechat.button.login=微信登录
wechat.button.back=返回
# 绑定手机号
wechat.bind.phone.title=绑定手机号
wechat.bind.phone.label=手机号
wechat.bind.phone.placeholder=请输入手机号
wechat.bind.code.title=验证码
wechat.bind.code.label=验证码
wechat.bind.code.placeholder=请输入验证码
wechat.bind.code.sent=验证码已发送至手机号
wechat.bind.button.sendCode=发送验证码
wechat.bind.button.verify=验证并绑定
# 错误消息
wechat.error.invalid-state=State 参数无效,请重新登录
wechat.error.invalid-code=授权码无效,请重新登录
wechat.error.api-failed=微信服务暂时不可用,请稍后重试8.2 创建英文资源 src/main/resources/theme-resources/messages/messages_en.properties
# WeChat Authenticator
wechat-authenticator-display-name=WeChat Login
wechat-authenticator-help-text=Allows users to log in with WeChat account
# Button text
wechat.button.login=WeChat Login
wechat.button.back=Back
# Bind phone
wechat.bind.phone.title=Bind Phone
wechat.bind.phone.label=Phone Number
wechat.bind.phone.placeholder=Enter your phone number
wechat.bind.code.title=Verification Code
wechat.bind.code.label=Verification Code
wechat.bind.code.placeholder=Enter verification code
wechat.bind.code.sent=Verification code sent to
wechat.bind.button.sendCode=Send Code
wechat.bind.button.verify=Verify and Bind
# Error messages
wechat.error.invalid-state=Invalid state parameter, please try again
wechat.error.invalid-code=Invalid authorization code, please try again
wechat.error.api-failed=WeChat service is temporarily unavailable, please try again later8.3 创建模板文件
创建 src/main/resources/theme-resources/templates/wechat-login-button.ftl:
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=true; section>
<#if section = "header">
${msg("wechat-authenticator-display-name")}
<#elseif section = "form">
<div id="kc-form">
<div id="kc-form-wrapper">
<form id="kc-wechat-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<input type="hidden" name="wechatAction" value="login"/>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<button
type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="submit"
id="kc-wechat-login-button"
>
${msg("wechat.button.login")}
</button>
</div>
</div>
</form>
<div style="margin-top: 20px; display: flex; justify-content: space-between; align-items: center;">
<div>
<a href="${url.loginUrl}">${msg("wechat.button.back")}</a>
</div>
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<div>
<span>${msg("noAccount")} <a tabindex="6" href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</#if>
</div>
</div>
</div>
</#if>
</@layout.registrationLayout>创建 src/main/resources/theme-resources/templates/wechat-bind-phone.ftl:
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=true; section>
<#if section = "header">
${msg("wechat.bind.phone.title")}
<#elseif section = "form">
<div id="kc-form">
<div id="kc-form-wrapper">
<form id="kc-wechat-phone-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<input type="hidden" name="wechatAction" value="sendCode"/>
<div class="${properties.kcFormGroupClass!}">
<label for="phone" class="${properties.kcLabelClass!}">${msg("wechat.bind.phone.label")}</label>
<input
type="tel"
id="phone"
name="phone"
class="${properties.kcInputClass!}"
placeholder="${msg("wechat.bind.phone.placeholder")}"
pattern="[0-9]{11}"
maxlength="11"
autocomplete="tel"
required
autofocus
/>
</div>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<button
type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="submit"
id="kc-submit-button"
>
${msg("wechat.bind.button.sendCode")}
</button>
</div>
</div>
</form>
<div style="margin-top: 20px; display: flex; justify-content: space-between; align-items: center;">
<div>
<a href="${url.loginUrl}">${msg("wechat.button.back")}</a>
</div>
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<div>
<span>${msg("noAccount")} <a tabindex="6" href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</#if>
</div>
</div>
</div>
</#if>
</@layout.registrationLayout>创建 src/main/resources/theme-resources/templates/wechat-bind-code.ftl:
<#import "template.ftl" as layout>
<@layout.registrationLayout displayMessage=true; section>
<#if section = "header">
${msg("wechat.bind.code.title")}
<#elseif section = "form">
<div id="kc-form">
<div id="kc-form-wrapper">
<div class="${properties.kcFormGroupClass!}" style="margin-bottom: 20px;">
<div class="alert alert-info" style="padding: 10px; background-color: #d1ecf1; border: 1px solid #bee5eb; border-radius: 4px; color: #0c5460;">
<i class="pficon pficon-info" style="margin-right: 8px;"></i>
${msg("wechat.bind.code.sent")} ${phone!""}
</div>
</div>
<form id="kc-wechat-code-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<input type="hidden" name="wechatAction" value="verifyCode"/>
<input type="hidden" name="phone" value="${phone!""}"/>
<div class="${properties.kcFormGroupClass!}">
<label for="code" class="${properties.kcLabelClass!}">${msg("wechat.bind.code.label")}</label>
<input
type="text"
id="code"
name="code"
class="${properties.kcInputClass!}"
placeholder="${msg("wechat.bind.code.placeholder")}"
pattern="[0-9]{6}"
maxlength="6"
inputmode="numeric"
autocomplete="off"
required
autofocus
/>
</div>
<div class="${properties.kcFormGroupClass!}">
<div id="kc-form-buttons" class="${properties.kcFormButtonsClass!}">
<button
type="submit"
class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}"
name="submit"
id="kc-submit-button"
>
${msg("wechat.bind.button.verify")}
</button>
</div>
</div>
</form>
</div>
</div>
</#if>
</@layout.registrationLayout>🔨 构建和部署
1. 编译项目
# 进入项目目录
cd keycloak-wx-authenticator
# 清理并编译
mvn clean package
# 编译成功后,JAR 文件位于:
# target/keycloak-wx-authenticator-1.0.0.jar2. 更新 Docker Compose 配置
编辑父目录的 docker-compose.yml,添加微信认证器的挂载:
services:
keycloak:
# ... 其他配置 ...
environment:
# ... 其他环境变量 ...
# ========================================
# 微信 OAuth 配置
# ========================================
WECHAT_SERVICE_MOCK: ${WECHAT_SERVICE_MOCK:-true}
WECHAT_PLATFORM_TYPE: ${WECHAT_PLATFORM_TYPE:-open}
WECHAT_APP_ID: ${WECHAT_APP_ID:-}
WECHAT_APP_SECRET: ${WECHAT_APP_SECRET:-}
WECHAT_REDIRECT_URI: ${WECHAT_REDIRECT_URI:-}
WECHAT_SCOPE: ${WECHAT_SCOPE:-snsapi_login}
WECHAT_STATE_EXPIRE_TIME: ${WECHAT_STATE_EXPIRE_TIME:-300}
SMS_SERVICE_MOCK: ${SMS_SERVICE_MOCK:-true}
ALIYUN_ACCESS_KEY_ID: ${ALIYUN_ACCESS_KEY_ID:-}
ALIYUN_ACCESS_KEY_SECRET: ${ALIYUN_ACCESS_KEY_SECRET:-}
ALIYUN_SMS_SIGN_NAME: ${ALIYUN_SMS_SIGN_NAME:-}
ALIYUN_SMS_TEMPLATE_CODE: ${ALIYUN_SMS_TEMPLATE_CODE:-}
PHONE_NUMBER_REGEX: ${PHONE_NUMBER_REGEX:-^1[3-9]\d{9}$}
VERIFICATION_CODE_LENGTH: ${VERIFICATION_CODE_LENGTH:-6}
USE_REDIS_CACHE: ${USE_REDIS_CACHE:-false}
REDIS_HOST: ${REDIS_HOST:-localhost}
REDIS_PORT: ${REDIS_PORT:-6379}
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
REDIS_DATABASE: ${REDIS_DATABASE:-0}
volumes:
# ... 其他挂载 ...
# 4. 微信登录扩展
- ./keycloak-wx-authenticator/target/keycloak-wx-authenticator-1.0.0.jar:/opt/keycloak/providers/keycloak-wx-authenticator.jar:ro3. 启动 Keycloak
# 回到父目录
cd ..
# 重建 Keycloak 容器
docker compose up -d --force-recreate keycloak
# 验证环境变量(确保 .env 生效)
docker compose exec keycloak env | grep WECHAT
# 查看日志,确认扩展加载成功
docker compose logs -f keycloak | grep -i wechat
# 应该看到类似输出:
# keycloak | ... INFO [org.keycloak.services] KC-SERVICES0001: Loading providers
# keycloak | ... INFO [WeChatAuthenticator] 微信认证器初始化: mock=true, stateCache=MemoryStateCache, smsService=MockSmsService说明:docker compose restart 不会重新读取 .env。
⚙️ Keycloak 配置
1. 创建 Realm
- 访问 Keycloak 管理控制台:http://localhost:8081
- 登录(admin / 你的密码)
- 点击左上角下拉菜单 → Create Realm
- Realm name:
vectorstory-test - 点击 Create
2. 配置用户属性
在 Realm Settings → User Profile → Attributes:
添加以下属性:
| Attribute name | Display name | Required | Permissions: User can edit |
|---|---|---|---|
| phone | 手机号 | ❌ No | ❌ No |
| phoneVerified | 手机号已验证 | ❌ No | ❌ No |
| wechatOpenId | 微信 OpenID | ❌ No | ❌ No |
| wechatUnionId | 微信 UnionID | ❌ No | ❌ No |
| wechatNickname | 微信昵称 | ❌ No | ❌ No |
| wechatAvatar | 微信头像 | ❌ No | ❌ No |
| wechatGender | 性别 | ❌ No | ❌ No |
3. 配置认证流程
3.1 创建微信登录流程
- Authentication → Flows
- 点击右上角 Create flow
- Name:
Browser with WeChat - Flow type:
Basic flow - 点击 Create
3.2 添加认证步骤
在新创建的流程中:
- 点击 Add execution
- 搜索
WeChat Login(或微信登录) - 点击 Add
- 设置 Requirement 为 ALTERNATIVE
3.3 添加其他认证方式(可选)
为了支持多种登录方式:
- 添加 Username Password Form → 设置为 ALTERNATIVE
- 添加 Phone SMS Authenticator(如果已配置)→ 设置为 ALTERNATIVE
3.4 绑定认证流程
- Authentication → Bindings 选项卡
- Browser Flow 下拉选择
Browser with WeChat - 点击 Save
4. 测试登录
- 打开浏览器隐私模式/无痕模式
- 访问:http://localhost:8081/realms/vectorstory-test/account
- 应该会重定向到登录页面
- 开发模式(Mock): 首次登录进入手机号绑定,验证后完成登录
- 生产模式: 会重定向到微信授权页面,回调后进入手机号绑定
🧪 测试和调试
1. 查看日志
# 实时查看 Keycloak 日志
docker compose logs -f keycloak
# 过滤微信相关日志
docker compose logs -f keycloak | grep -i wechat
# 过滤认证相关日志
docker compose logs -f keycloak | grep -E "WeChatAuthenticator|OAuth|State"2. Mock 模式调试
开发环境设置:
WECHAT_SERVICE_MOCK=true
SMS_SERVICE_MOCK=trueMock 模式特点:
- ✅ 不需要真实微信平台账号
- ✅ 所有操作打印到日志
- ✅ 验证码会打印到日志(需手机号绑定)
- ✅ 快速迭代开发
查看 Mock 日志:
docker compose logs keycloak | grep "Mock"
# 示例输出:
# [Mock] 获取 Access Token: code=mock_code
# [Mock] Access Token: mock_access_token_..., OpenID: mock_openid_...
# [Mock] 获取用户信息: openid=mock_openid_...
# [Mock] 用户信息: nickname=测试用户_123456, openid=mock_openid_...
# 【Mock 短信】验证码: 1234563. 验证 SPI 加载
# 检查扩展是否加载
docker compose exec keycloak ls -la /opt/keycloak/providers/
# 应该看到:
# keycloak-wx-authenticator.jar
# 检查日志确认加载
docker compose logs keycloak | grep "Added provider.*wechat"
# 应该看到:
# Added provider 'singleton' (WeChatAuthenticatorFactory)4. 用户数据验证
登录后,在 Keycloak 管理控制台:
- Users → 搜索手机号(如
13800138000) - 查看用户属性,应该包含:
phonephoneVerifiedwechatOpenIdwechatNicknamewechatAvatar- 等
🚀 生产环境部署
1. 配置真实微信凭证
在 .env 文件中:
# 禁用 Mock 模式
WECHAT_SERVICE_MOCK=false
# 配置真实微信平台信息
WECHAT_PLATFORM_TYPE=open
WECHAT_APP_ID=wx1234567890abcdef
WECHAT_APP_SECRET=your_real_app_secret
# 回调地址默认由 Keycloak action URL 生成
# 如需兼容旧版本或自定义逻辑可保留
WECHAT_REDIRECT_URI=https://your-domain.com/realms/your-realm/login-actions/authenticate
# 短信服务(生产环境建议真实短信)
SMS_SERVICE_MOCK=false
ALIYUN_ACCESS_KEY_ID=your_key_id
ALIYUN_ACCESS_KEY_SECRET=your_key_secret
ALIYUN_SMS_SIGN_NAME=your_sign_name
ALIYUN_SMS_TEMPLATE_CODE=your_template_code
# 启用 Redis 缓存(推荐)
USE_REDIS_CACHE=true2. HTTPS 配置
微信 OAuth 要求回调 URL 必须是 HTTPS(本地测试除外)。
方案 1: 使用 Nginx 反向代理
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}方案 2: Keycloak 原生 HTTPS
修改 docker-compose.yml:
environment:
KC_HTTPS_ENABLED: true
KC_HTTPS_CERTIFICATE_FILE: /opt/keycloak/conf/cert.pem
KC_HTTPS_CERTIFICATE_KEY_FILE: /opt/keycloak/conf/key.pem
volumes:
- ./certs/cert.pem:/opt/keycloak/conf/cert.pem:ro
- ./certs/key.pem:/opt/keycloak/conf/key.pem:ro3. 微信平台回调域名配置
在微信开放平台/公众平台:
- 应用管理 → 你的应用
- 网站应用:设置授权回调域 =
your-domain.com - 公众号:设置网页授权域名 =
your-domain.com - 上传验证文件(微信会提供)
4. 安全检查清单
生产部署前确认:
🐛 常见问题
Q1: 扩展未加载,Keycloak 日志没有微信相关信息
排查步骤:
# 1. 检查 JAR 文件是否存在
ls -la keycloak-wx-authenticator/target/keycloak-wx-authenticator-1.0.0.jar
# 2. 检查容器内挂载
docker compose exec keycloak ls -la /opt/keycloak/providers/
# 3. 检查 SPI 注册文件
unzip -l target/keycloak-wx-authenticator-1.0.0.jar | grep AuthenticatorFactory
# 应该看到:
# META-INF/services/org.keycloak.authentication.AuthenticatorFactory解决方案:
- 确保
mvn clean package编译成功 - 确保
docker-compose.yml正确挂载了 JAR 文件 - 重建容器:
docker compose up -d --force-recreate keycloak
Q2: 微信授权后报错 "State 参数无效"
原因: State 缓存失效或不一致
解决方案:
开发环境(单机):
USE_REDIS_CACHE=false # 使用内存缓存生产环境(分布式):
USE_REDIS_CACHE=true # 必须使用 RedisQ3: 获取 Access Token 失败,errcode=40029
错误信息: invalid code
原因: 授权码已被使用或过期
解决方案:
- 授权码只能使用一次
- 不要刷新回调页面
- 检查是否有重复的请求
Q4: 获取用户信息失败,errcode=40001
错误信息: invalid credential, access_token is invalid or not latest
原因: Access Token 无效
解决方案:
- 检查 AppID 和 AppSecret 是否正确
- 确认微信平台应用状态正常
- 检查作用域
scope是否正确
Q5: 回调 URL 不匹配
错误信息: redirect_uri 参数错误
原因: 回调 URL 未在微信平台配置
解决方案:
- 确保域名与微信平台配置一致
- 检查 Keycloak
KC_HOSTNAME/KC_PROXY(action URL 生成的域名必须正确) - 如使用自定义回调逻辑,再检查
WECHAT_REDIRECT_URI - 微信平台只需要配置域名,不需要完整 URL
- ✅ 配置:
your-domain.com - ❌ 不要配置:
https://your-domain.com/realms/...
- ✅ 配置:
Q6: 本地开发无法测试真实微信登录
解决方案: 使用 ngrok 暴露本地端口
# 启动 ngrok
ngrok http 8081
# 获取公网 URL,例如:https://abc123.ngrok.io
# 如需兼容旧版本可保留
WECHAT_REDIRECT_URI=https://abc123.ngrok.io/realms/vectorstory-test/login-actions/authenticate
# 推荐:让 Keycloak action URL 使用公网域名
KC_HOSTNAME=abc123.ngrok.io
# 在微信平台配置授权回调域
# 域名: abc123.ngrok.io📚 参考资料
微信官方文档
Keycloak 文档
相关项目
- keycloak-sms-authenticator - 短信验证码登录
- keycloak-email-authenticator - 邮箱验证码登录
- keycloak-phone-registration - 手机号注册
📝 开发检查清单
在提交代码前,确认以下事项:
🤝 贡献指南
如果你想改进这个项目:
- 遵循现有代码风格
- 添加必要的注释
- 更新文档
- 测试你的改动
📄 许可证
本项目遵循相关开源许可证。
祝你开发顺利! 🎉
如有问题,请查看日志或参考常见问题部分。
版权所有
版权归属:matiastang
许可证:Copyright © 2022-present tangdaoyong
