mirror of
https://gitee.com/nicefold/seewis-cloud-plus.git
synced 2026-10-14 03:52:27 +08:00
feat(auth): 小程序登录支持配置化与手机号一键登录自动注册 / Add configurable xcx login with phone auto-registration
- 小程序 appid/密钥支持参数管理 sys.account.xcxApps(JSON 数组,多小程序)优先,yml security.xcx.apps 兜底 - 登录新增 phoneCode 字段,调用微信 getuserphonenumber 解析真实手机号,stable_token 按小程序缓存至 redis - 首次登录自动注册:以授权手机号为账号(已有同手机号账号则直接绑定),写入 sys_social 绑定 openid - 自动注册默认昵称/初始密码(sys.user.initPassword)/头像/性别,用户类型 app_user
This commit is contained in:
+3
-2
@@ -51,12 +51,13 @@ public interface RemoteUserService {
|
||||
LoginUser getUserInfoByEmail(String email) throws UserException;
|
||||
|
||||
/**
|
||||
* 通过openid查询用户信息
|
||||
* 通过openid查询小程序用户信息,用户不存在时自动注册
|
||||
*
|
||||
* @param openid openid
|
||||
* @param phone 手机号一键登录获取的真实手机号,首次登录自动注册时作为账号,可为空
|
||||
* @return 结果
|
||||
*/
|
||||
XcxLoginUser getUserInfoByOpenid(String openid) throws UserException;
|
||||
XcxLoginUser getUserInfoByOpenid(String openid, String phone) throws UserException;
|
||||
|
||||
/**
|
||||
* 注册用户信息
|
||||
|
||||
@@ -25,4 +25,9 @@ public class XcxLoginBody extends LoginBody {
|
||||
@NotBlank(message = "{xcx.code.not.blank}")
|
||||
private String xcxCode;
|
||||
|
||||
/**
|
||||
* 手机号一键登录 code(button open-type="getPhoneNumber" 获取),首次登录自动注册时必传
|
||||
*/
|
||||
private String phoneCode;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package org.dromara.auth.properties;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.cloud.context.config.annotation.RefreshScope;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 小程序登录配置
|
||||
*
|
||||
* @author seewis
|
||||
*/
|
||||
@Data
|
||||
@Configuration
|
||||
@RefreshScope
|
||||
@ConfigurationProperties(prefix = "security.xcx")
|
||||
public class XcxProperties {
|
||||
|
||||
/**
|
||||
* 小程序 appid 与密钥映射,key 为 appid,value 为 appSecret,支持配置多个小程序
|
||||
*/
|
||||
private Map<String, String> apps = new LinkedHashMap<>();
|
||||
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package org.dromara.auth.service.impl;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.dev33.satoken.stp.parameter.SaLoginParameter;
|
||||
import cn.hutool.http.HttpUtil;
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import me.zhyd.oauth.config.AuthConfig;
|
||||
@@ -14,17 +18,27 @@ import me.zhyd.oauth.request.AuthWechatMiniProgramRequest;
|
||||
import org.apache.dubbo.config.annotation.DubboReference;
|
||||
import org.dromara.auth.domain.vo.LoginVo;
|
||||
import org.dromara.auth.form.XcxLoginBody;
|
||||
import org.dromara.auth.properties.XcxProperties;
|
||||
import org.dromara.auth.service.IAuthStrategy;
|
||||
import org.dromara.auth.service.SysLoginService;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.constant.GlobalConstants;
|
||||
import org.dromara.common.core.utils.ValidatorUtils;
|
||||
import org.dromara.common.core.utils.StringUtils;
|
||||
import org.dromara.common.json.utils.JsonUtils;
|
||||
import org.dromara.common.redis.utils.RedisUtils;
|
||||
import org.dromara.common.satoken.utils.LoginHelper;
|
||||
import org.dromara.system.api.RemoteConfigService;
|
||||
import org.dromara.system.api.RemoteUserService;
|
||||
import org.dromara.system.api.domain.vo.RemoteClientVo;
|
||||
import org.dromara.system.api.model.XcxLoginUser;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 邮件认证策略
|
||||
*
|
||||
@@ -36,6 +50,10 @@ import org.springframework.stereotype.Service;
|
||||
public class XcxAuthStrategy implements IAuthStrategy {
|
||||
|
||||
private final SysLoginService loginService;
|
||||
private final XcxProperties xcxProperties;
|
||||
|
||||
@DubboReference
|
||||
private final RemoteConfigService remoteConfigService;
|
||||
|
||||
@DubboReference
|
||||
private RemoteUserService remoteUserService;
|
||||
@@ -48,10 +66,16 @@ public class XcxAuthStrategy implements IAuthStrategy {
|
||||
String xcxCode = loginBody.getXcxCode();
|
||||
// 多个小程序识别使用
|
||||
String appid = loginBody.getAppid();
|
||||
// 手机号一键登录 code(button open-type="getPhoneNumber" 获取),首次登录自动注册时需要
|
||||
String phoneCode = loginBody.getPhoneCode();
|
||||
|
||||
Map<String, String> apps = loadApps();
|
||||
String useAppid = resolveAppid(apps, appid);
|
||||
String appSecret = resolveAppSecret(apps, useAppid);
|
||||
|
||||
// 校验 appid + appsrcret + xcxCode 调用登录凭证校验接口 获取 session_key 与 openid
|
||||
AuthRequest authRequest = new AuthWechatMiniProgramRequest(AuthConfig.builder()
|
||||
.clientId(appid).clientSecret("自行填写密钥 可根据不同appid填入不同密钥")
|
||||
.clientId(useAppid).clientSecret(appSecret)
|
||||
.ignoreCheckRedirectUri(true).ignoreCheckState(true).build());
|
||||
AuthCallback authCallback = new AuthCallback();
|
||||
authCallback.setCode(xcxCode);
|
||||
@@ -65,8 +89,13 @@ public class XcxAuthStrategy implements IAuthStrategy {
|
||||
} else {
|
||||
throw new ServiceException(resp.getMsg());
|
||||
}
|
||||
// 手机号一键登录:获取用户授权的真实手机号,首次登录自动注册时作为账号
|
||||
String phone = null;
|
||||
if (StringUtils.isNotBlank(phoneCode)) {
|
||||
phone = getPhoneNumber(useAppid, appSecret, phoneCode);
|
||||
}
|
||||
// todo getUserInfoByOpenid 方法内部查询逻辑需要自行根据业务实现
|
||||
XcxLoginUser loginUser = remoteUserService.getUserInfoByOpenid(openid);
|
||||
XcxLoginUser loginUser = remoteUserService.getUserInfoByOpenid(openid, phone);
|
||||
loginUser.setClientKey(client.getClientKey());
|
||||
loginUser.setDeviceType(client.getDeviceType());
|
||||
|
||||
@@ -82,4 +111,103 @@ public class XcxAuthStrategy implements IAuthStrategy {
|
||||
return loginVo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载小程序配置:优先取参数管理中的 sys.account.xcxApps(JSON 数组),未配置时回退 yml 配置
|
||||
*/
|
||||
private Map<String, String> loadApps() {
|
||||
String configValue = remoteConfigService.getConfigValue("sys.account.xcxApps");
|
||||
if (StringUtils.isNotBlank(configValue)) {
|
||||
try {
|
||||
Map<String, String> apps = new LinkedHashMap<>();
|
||||
for (XcxApp app : JsonUtils.parseArray(configValue, XcxApp.class)) {
|
||||
if (StringUtils.isNotBlank(app.getAppid()) && StringUtils.isNotBlank(app.getAppSecret())) {
|
||||
apps.put(app.getAppid(), app.getAppSecret());
|
||||
}
|
||||
}
|
||||
if (!apps.isEmpty()) {
|
||||
return apps;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("解析参数 sys.account.xcxApps 失败,回退使用 yml 配置 security.xcx.apps", e);
|
||||
}
|
||||
}
|
||||
return xcxProperties.getApps();
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析实际使用的 appid:未传且仅配置了一个小程序时自动使用唯一配置
|
||||
*/
|
||||
private String resolveAppid(Map<String, String> apps, String appid) {
|
||||
if (StringUtils.isNotBlank(appid)) {
|
||||
return appid;
|
||||
}
|
||||
if (apps.size() == 1) {
|
||||
return apps.keySet().iterator().next();
|
||||
}
|
||||
throw new ServiceException("未配置小程序 appid,请在参数 sys.account.xcxApps 或 security.xcx.apps 中配置");
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析小程序密钥
|
||||
*/
|
||||
private String resolveAppSecret(Map<String, String> apps, String appid) {
|
||||
String secret = apps.get(appid);
|
||||
if (StringUtils.isBlank(secret)) {
|
||||
throw new ServiceException("未配置小程序 " + appid + " 对应的密钥,请在参数 sys.account.xcxApps 中配置");
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号一键登录:调用微信 getuserphonenumber 接口获取用户授权的真实手机号
|
||||
*/
|
||||
private String getPhoneNumber(String appid, String appSecret, String phoneCode) {
|
||||
String accessToken = getAccessToken(appid, appSecret);
|
||||
JSONObject body = new JSONObject();
|
||||
body.set("code", phoneCode);
|
||||
String response = HttpUtil.post(
|
||||
"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=" + accessToken,
|
||||
body.toString());
|
||||
JSONObject json = JSONUtil.parseObj(response);
|
||||
if (json.getInt("errcode", 0) != 0) {
|
||||
log.warn("微信手机号获取失败: {}", response);
|
||||
throw new ServiceException("手机号获取失败: " + json.getStr("errmsg"));
|
||||
}
|
||||
return json.getJSONObject("phone_info").getStr("purePhoneNumber");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取微信接口调用凭证 stable_token,缓存至 redis,过期前 5 分钟刷新
|
||||
*/
|
||||
private String getAccessToken(String appid, String appSecret) {
|
||||
String cacheKey = GlobalConstants.GLOBAL_REDIS_KEY + "xcx:access_token:" + appid;
|
||||
String accessToken = RedisUtils.getCacheObject(cacheKey);
|
||||
if (StringUtils.isNotBlank(accessToken)) {
|
||||
return accessToken;
|
||||
}
|
||||
JSONObject body = new JSONObject();
|
||||
body.set("grant_type", "client_credential");
|
||||
body.set("appid", appid);
|
||||
body.set("secret", appSecret);
|
||||
String response = HttpUtil.post("https://api.weixin.qq.com/cgi-bin/stable_token", body.toString());
|
||||
JSONObject json = JSONUtil.parseObj(response);
|
||||
if (json.getInt("errcode", 0) != 0) {
|
||||
log.warn("微信 access_token 获取失败: {}", response);
|
||||
throw new ServiceException("微信凭证获取失败: " + json.getStr("errmsg"));
|
||||
}
|
||||
accessToken = json.getStr("access_token");
|
||||
RedisUtils.setCacheObject(cacheKey, accessToken,
|
||||
Duration.ofSeconds(json.getInt("expires_in", 7200) - 300));
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 参数 sys.account.xcxApps 的 JSON 元素结构
|
||||
*/
|
||||
@Data
|
||||
public static class XcxApp {
|
||||
private String appid;
|
||||
private String appSecret;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+68
-7
@@ -4,10 +4,14 @@ import cn.hutool.core.bean.BeanUtil;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.lang.Opt;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import cn.hutool.crypto.digest.BCrypt;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.dubbo.config.annotation.DubboService;
|
||||
import org.dromara.common.core.constant.SystemConstants;
|
||||
import org.dromara.common.core.enums.UserStatus;
|
||||
import org.dromara.common.core.enums.UserType;
|
||||
import org.dromara.common.core.exception.ServiceException;
|
||||
import org.dromara.common.core.exception.user.UserException;
|
||||
import org.dromara.common.core.utils.MapstructUtils;
|
||||
@@ -23,6 +27,7 @@ import org.dromara.system.api.model.PostDTO;
|
||||
import org.dromara.system.api.model.RoleDTO;
|
||||
import org.dromara.system.api.model.XcxLoginUser;
|
||||
import org.dromara.system.domain.SysUser;
|
||||
import org.dromara.system.domain.SysSocial;
|
||||
import org.dromara.system.domain.SysUserPost;
|
||||
import org.dromara.system.domain.SysUserRole;
|
||||
import org.dromara.system.domain.bo.SysUserBo;
|
||||
@@ -33,8 +38,10 @@ import org.dromara.system.domain.vo.SysUserVo;
|
||||
import org.dromara.system.mapper.SysUserMapper;
|
||||
import org.dromara.system.mapper.SysUserPostMapper;
|
||||
import org.dromara.system.mapper.SysUserRoleMapper;
|
||||
import org.dromara.system.mapper.SysSocialMapper;
|
||||
import org.dromara.system.service.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
@@ -58,6 +65,7 @@ public class RemoteUserServiceImpl implements RemoteUserService {
|
||||
private final SysUserMapper userMapper;
|
||||
private final SysUserRoleMapper userRoleMapper;
|
||||
private final SysUserPostMapper userPostMapper;
|
||||
private final SysSocialMapper socialMapper;
|
||||
|
||||
/**
|
||||
* 通过用户名查询用户信息
|
||||
@@ -138,23 +146,34 @@ public class RemoteUserServiceImpl implements RemoteUserService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过openid查询用户信息
|
||||
* 通过 openid 查询小程序用户,首次登录自动以授权手机号注册并绑定 openid
|
||||
*
|
||||
* @param openid openid
|
||||
* @param phone 手机号一键登录获取的真实手机号,首次登录自动注册时作为账号,可为空
|
||||
* @return 结果
|
||||
*/
|
||||
@Override
|
||||
public XcxLoginUser getUserInfoByOpenid(String openid) throws UserException {
|
||||
// todo 自行实现 userService.selectUserByOpenid(openid);
|
||||
SysUser sysUser = new SysUser();
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public XcxLoginUser getUserInfoByOpenid(String openid, String phone) throws UserException {
|
||||
// 通过 sys_social 绑定关系查询小程序用户(source 固定为 xcx)
|
||||
SysSocial social = socialMapper.selectOne(new LambdaQueryWrapper<SysSocial>()
|
||||
.eq(SysSocial::getSource, "xcx")
|
||||
.eq(SysSocial::getOpenId, openid)
|
||||
.last("limit 1"));
|
||||
SysUser sysUser;
|
||||
if (ObjectUtil.isNull(social)) {
|
||||
// 首次登录,以授权手机号自动注册
|
||||
sysUser = registerXcxUser(openid, phone);
|
||||
} else {
|
||||
sysUser = userMapper.selectById(social.getUserId());
|
||||
}
|
||||
if (ObjectUtil.isNull(sysUser)) {
|
||||
// todo 用户不存在 业务逻辑自行实现
|
||||
throw new UserException("user.not.exists");
|
||||
}
|
||||
if (UserStatus.DISABLE.getCode().equals(sysUser.getStatus())) {
|
||||
// todo 用户已被停用 业务逻辑自行实现
|
||||
throw new UserException("user.blocked", sysUser.getUserName());
|
||||
}
|
||||
// 框架登录不限制从什么表查询 只要最终构建出 LoginUser 即可
|
||||
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
|
||||
XcxLoginUser loginUser = new XcxLoginUser();
|
||||
loginUser.setUserId(sysUser.getUserId());
|
||||
loginUser.setUsername(sysUser.getUserName());
|
||||
@@ -164,6 +183,48 @@ public class RemoteUserServiceImpl implements RemoteUserService {
|
||||
return loginUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序首次登录自动注册:以微信手机号一键登录授权的真实手机号作为账号,昵称默认"微信用户+随机数",
|
||||
* 密码取 sys.user.initPassword 参数(默认 123456),头像留空由前端展示默认头像,性别默认未知。
|
||||
* 若已存在同手机号账号(如之前用手机号注册过的用户),直接绑定该账号。
|
||||
*/
|
||||
private SysUser registerXcxUser(String openid, String phone) {
|
||||
if (StringUtils.isBlank(phone)) {
|
||||
throw new ServiceException("首次登录需要手机号授权,请重新点击手机号授权后登录");
|
||||
}
|
||||
if (!phone.matches("^1[3-9]\\d{9}$")) {
|
||||
throw new ServiceException("手机号格式不正确: " + phone);
|
||||
}
|
||||
SysUser sysUser = userMapper.selectOne(new LambdaQueryWrapper<SysUser>()
|
||||
.eq(SysUser::getUserName, phone)
|
||||
.last("limit 1"));
|
||||
if (ObjectUtil.isNull(sysUser)) {
|
||||
String initPassword = StringUtils.blankToDefault(
|
||||
configService.selectConfigByKey("sys.user.initPassword"), "123456");
|
||||
SysUserBo userBo = new SysUserBo();
|
||||
userBo.setUserName(phone);
|
||||
userBo.setNickName("微信用户" + RandomUtil.randomNumbers(4));
|
||||
userBo.setPassword(BCrypt.hashpw(initPassword));
|
||||
userBo.setGender("2");
|
||||
userBo.setUserType(UserType.APP_USER.getUserType());
|
||||
userBo.setStatus(UserStatus.OK.getCode());
|
||||
userBo.setRemark("小程序首次登录自动注册");
|
||||
sysUser = MapstructUtils.convert(userBo, SysUser.class);
|
||||
userMapper.insert(sysUser);
|
||||
}
|
||||
|
||||
// 绑定 openid 与用户关系
|
||||
SysSocial social = new SysSocial();
|
||||
social.setUserId(sysUser.getUserId());
|
||||
social.setAuthId(openid);
|
||||
social.setSource("xcx");
|
||||
social.setOpenId(openid);
|
||||
social.setUserName(sysUser.getUserName());
|
||||
social.setNickName(sysUser.getNickName());
|
||||
socialMapper.insert(social);
|
||||
return sysUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册用户信息
|
||||
*
|
||||
|
||||
@@ -10,6 +10,11 @@ security:
|
||||
numberLength: 1
|
||||
# 字符验证码长度
|
||||
charLength: 4
|
||||
# 小程序登录配置(grantType=xcx)
|
||||
# 优先使用参数管理中的 sys.account.xcxApps(JSON 数组,支持多个小程序);未配置该参数时回退使用此处的 yml 映射
|
||||
xcx:
|
||||
apps:
|
||||
# 你的小程序appid: 你的小程序appSecret
|
||||
|
||||
# 用户配置
|
||||
user:
|
||||
|
||||
Reference in New Issue
Block a user