增加验证码登录,验证码使用的是腾讯云的,验证码登录入口可以在系统配置里关闭
修改登录时使用算术改为滑块
@@ -48,6 +48,13 @@ public class RedisConstant {
|
||||
*/
|
||||
public static final String UCENTER_SMSCODE = PLATFORM_REDIS_PREFIX + "ucenter:smscode:";
|
||||
|
||||
/** 拼图挑战与凭证按匿名会话隔离,频率键单独存放。 */
|
||||
public static final String REDIS_SLIDER_CAPTCHA_KEY = PLATFORM_REDIS_PREFIX + "login:slider:";
|
||||
public static final String REDIS_SLIDER_RATE_KEY = PLATFORM_REDIS_PREFIX + "login:slider-rate:";
|
||||
|
||||
/** 登录短信专用缓存前缀,避免验证码与其他业务场景交叉使用。 */
|
||||
public static final String LOGIN_SMSCODE = PLATFORM_REDIS_PREFIX + "login:sms:";
|
||||
|
||||
/**
|
||||
* 接口重复提交前缀
|
||||
*/
|
||||
|
||||
@@ -13,6 +13,7 @@ public enum LoginType {
|
||||
QI_YE_WECHAT("企业微信"),
|
||||
WECHAT("微信"),
|
||||
PC_LOCAL("PC本地"),
|
||||
SMS_CODE("手机验证码"),
|
||||
MOBILE_LOCAL("手机本地"),
|
||||
CAS("CAS");
|
||||
|
||||
|
||||
@@ -7,10 +7,12 @@ import com.budwk.app.base.result.Result;
|
||||
import com.budwk.app.sys.models.Sys_config;
|
||||
import com.budwk.app.sys.services.SysConfigService;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.dao.Dao;
|
||||
import org.nutz.dao.Sqls;
|
||||
import org.nutz.integration.jedis.pubsub.PubSubService;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Strings;
|
||||
@@ -139,12 +141,15 @@ public class SysConfController {
|
||||
@At
|
||||
@Ok("json:full")
|
||||
@SaCheckPermission("sys.manager.conf")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Object data(@Param("pageNumber") int pageNumber,
|
||||
@Param("pageSize") int pageSize,
|
||||
@Param("pageOrderName") String pageOrderName,
|
||||
@Param("pageOrderBy") String pageOrderBy,
|
||||
@Param("configKey") String configKey) {
|
||||
try {
|
||||
// 由 service 补充默认关闭的入口参数,管理员可通过现有“是/否”控件修改。
|
||||
sysConfigService.ensureSmsLoginVisibleConfig(SecurityUtil.getUserId());
|
||||
ensureAppImageConfig("AppHomeImg", "PC首页轮播图");
|
||||
ensureAppImageConfig("H5AppHomeImg", "移动端首页轮播图");
|
||||
ensureAppImageConfig("AppFeaturedActivityImg", "精彩活动页顶部图片");
|
||||
|
||||
@@ -16,16 +16,20 @@ import com.budwk.app.base.utils.RsaUtils;
|
||||
import com.budwk.app.sys.models.Sys_log;
|
||||
import com.budwk.app.sys.models.Sys_user;
|
||||
import com.budwk.app.sys.services.SysUserService;
|
||||
import com.budwk.app.sys.services.SysConfigService;
|
||||
import com.budwk.app.web.commons.auth.service.ValidateService;
|
||||
import com.budwk.app.zhgh.dayofficework.message.service.SliderCaptchaService;
|
||||
import com.budwk.app.web.commons.auth.utils.SecurityUtil;
|
||||
import com.budwk.app.web.commons.base.Globals;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import org.jasig.cas.client.util.AbstractCasFilter;
|
||||
import org.jasig.cas.client.validation.Assertion;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.integration.jedis.RedisService;
|
||||
import org.nutz.ioc.impl.PropertiesProxy;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Lang;
|
||||
@@ -35,6 +39,7 @@ import org.nutz.mvc.annotation.At;
|
||||
import org.nutz.mvc.annotation.Filters;
|
||||
import org.nutz.mvc.annotation.Ok;
|
||||
import org.nutz.mvc.annotation.Param;
|
||||
import org.nutz.mvc.annotation.POST;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
@@ -55,19 +60,19 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
public class SysLoginController {
|
||||
private static final Log log = Logs.get();
|
||||
private static final LoginRsaKeyRing LOGIN_RSA_KEY_RING = new LoginRsaKeyRing();
|
||||
/**
|
||||
* TODO: 临时屏蔽回环地址及指定局域网 IP 的平台登录验证码,全部恢复时改为 false。
|
||||
*/
|
||||
private static final boolean DISABLE_LOGIN_CAPTCHA = true;
|
||||
@Inject
|
||||
private SysUserService sysUserService;
|
||||
@Inject
|
||||
private SysConfigService sysConfigService;
|
||||
@Inject
|
||||
private RedisService redisService;
|
||||
@Inject
|
||||
private SLogService sLogService;
|
||||
@Inject
|
||||
private ValidateService validateService;
|
||||
@Inject
|
||||
private SliderCaptchaService sliderCaptchaService;
|
||||
@Inject
|
||||
protected PropertiesProxy conf;
|
||||
|
||||
@At("")
|
||||
@@ -75,13 +80,17 @@ public class SysLoginController {
|
||||
@ApiOperation("用户本地登录页面")
|
||||
@Filters
|
||||
public String login(HttpServletRequest req, HttpSession session) {
|
||||
// 只向模板提供入口显示状态,短信发送及验证码登录接口继续使用原有规则。
|
||||
req.setAttribute("smsLoginVisible", sysConfigService.isSmsLoginVisible());
|
||||
return "beetl:/platform/sys/login.html";
|
||||
}
|
||||
|
||||
@At
|
||||
@Ok("re")
|
||||
@Filters
|
||||
public String h5() {
|
||||
public String h5(HttpServletRequest req) {
|
||||
// 手机端复用同一个登录模板,读取相同的系统参数。
|
||||
req.setAttribute("smsLoginVisible", sysConfigService.isSmsLoginVisible());
|
||||
return "beetl:/platform/sys/login.html";
|
||||
}
|
||||
|
||||
@@ -93,6 +102,13 @@ public class SysLoginController {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号密码登录:username 为账号,password 为 RSA 密文,keyId 为公钥编号;
|
||||
* platformKey/platformCaptcha 为当前会话、当前账号的滑块一次性凭证。
|
||||
* 返回 Result:code=0 表示登录成功,msg 为成功提示或失败原因。
|
||||
*/
|
||||
@POST
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
@At("/doLogin")
|
||||
@Ok("json")
|
||||
@ApiOperation("用户本地账号密码登录")
|
||||
@@ -124,14 +140,9 @@ public class SysLoginController {
|
||||
// return Result.error("登录失败次数过多,请5分钟后再试");
|
||||
// }
|
||||
try {
|
||||
// 验证码校验
|
||||
if (!isLoginCaptchaDisabled(req)) {
|
||||
try {
|
||||
validateService.checkCode(captchaKey, captchaCode);
|
||||
} catch (BaseException e) {
|
||||
return Result.error(e.getMessage());
|
||||
}
|
||||
}
|
||||
// 所有访问地址均需消费绑定当前账号的滑块凭证,旧图形码不能用于登录。
|
||||
sliderCaptchaService.consume(session.getId(), captchaKey, captchaCode,
|
||||
SliderCaptchaService.PASSWORD, username);
|
||||
|
||||
// 解密密码
|
||||
LoginRsaKey rsaKey = LOGIN_RSA_KEY_RING.getPrivateKey(keyId);
|
||||
@@ -162,19 +173,6 @@ public class SysLoginController {
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isLoginCaptchaDisabled(HttpServletRequest req) {
|
||||
if (!DISABLE_LOGIN_CAPTCHA || req == null) {
|
||||
return false;
|
||||
}
|
||||
String serverName = req.getServerName();
|
||||
// 临时允许该局域网地址免验证码;恢复 IP 验证码时删除此条件,并同步删除登录页中的该 IP。
|
||||
return "localhost".equalsIgnoreCase(serverName)
|
||||
|| "192.168.0.108".equals(serverName)
|
||||
|| "127.0.0.1".equals(serverName)
|
||||
|| "0:0:0:0:0:0:0:1".equals(serverName)
|
||||
|| "::1".equals(serverName);
|
||||
}
|
||||
|
||||
@At(value = "/platform/sso/login", top = true)
|
||||
@Ok("re")
|
||||
@ApiOperation("用户cas登录统一入口")
|
||||
@@ -277,6 +275,120 @@ public class SysLoginController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送系统绑定手机号的登录验证码,滑块凭证和账号可用性由 service 再次校验。
|
||||
*
|
||||
* @param mobile 已绑定系统账号的 11 位大陆手机号
|
||||
* @param captchaKey /checkSliderCaptcha 返回的 platformKey
|
||||
* @param captchaCode /checkSliderCaptcha 返回的一次性 platformCaptcha
|
||||
* @param request 当前请求,供后端按连接来源限流
|
||||
* @return Result,成功 data 包含 retryAfter 重发等待秒数、expireIn 验证码有效秒数;失败 msg 为原因
|
||||
*/
|
||||
@At("/mobileCaptcha")
|
||||
@POST
|
||||
@Ok("json")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
@ApiOperation("发送手机登录验证码")
|
||||
public Object mobileCaptcha(@Param("mobile") String mobile,
|
||||
@Param("platformKey") String captchaKey,
|
||||
@Param("platformCaptcha") String captchaCode,
|
||||
HttpServletRequest request) {
|
||||
if (StrUtil.isBlank(mobile) || !mobile.trim().matches("^1[3-9]\\d{9}$")) {
|
||||
return Result.error("请输入正确的手机号码");
|
||||
}
|
||||
if (StrUtil.isBlank(captchaKey) || StrUtil.isBlank(captchaCode)) {
|
||||
return Result.error("请先完成滑块验证");
|
||||
}
|
||||
try {
|
||||
return Result.success("短信验证码已发送", sysUserService.sendLoginSmsCode(mobile.trim(), captchaKey, captchaCode, request));
|
||||
} catch (BaseException e) {
|
||||
return Result.error(e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("发送登录短信验证码失败", e);
|
||||
return Result.error("短信发送失败,请稍后重试或联系管理员");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用与手机号绑定的一次性短信验证码登录,不接收密码或 RSA 公钥参数。
|
||||
*
|
||||
* @param mobile 获取短信时填写的 11 位大陆手机号
|
||||
* @param smsCode 用户收到的六位数字验证码
|
||||
* @param request 当前登录请求,供 service 创建原有 Sa-Token 会话和登录日志
|
||||
* @return Result,code 为 0 表示完成登录;失败 msg 为校验或登录失败原因
|
||||
*/
|
||||
@At("/doSmsLogin")
|
||||
@POST
|
||||
@Ok("json")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
@ApiOperation("手机验证码登录")
|
||||
public Object doSmsLogin(@Param("mobile") String mobile, @Param("smsCode") String smsCode,
|
||||
HttpServletRequest request) {
|
||||
if (StrUtil.isBlank(mobile) || !mobile.trim().matches("^1[3-9]\\d{9}$")) {
|
||||
return Result.error("请输入正确的手机号码");
|
||||
}
|
||||
if (StrUtil.isBlank(smsCode) || !smsCode.trim().matches("^\\d{6}$")) {
|
||||
return Result.error("请输入六位短信验证码");
|
||||
}
|
||||
try {
|
||||
sysUserService.loginBySmsCode(mobile.trim(), smsCode.trim(), request);
|
||||
return Result.success("login.success");
|
||||
} catch (BaseException e) {
|
||||
return Result.error(e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("手机验证码登录失败", e);
|
||||
return Result.error("登录失败,请稍后重试或联系管理员");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* scene 传 password(账号登录)或 sms(发送短信),subject 传对应账号或手机号。
|
||||
* 返回 Result.data:challengeId、background/piece 图片、width/height/pieceSize/pieceY 像素信息及 expiresIn 秒数。
|
||||
* 会话 ID 仅从服务器读取,业务校验、图片生成和频控均由 service 完成。
|
||||
*/
|
||||
@At("/sliderCaptcha")
|
||||
@POST
|
||||
@Ok("json")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Object sliderCaptcha(@Param("scene") String scene, @Param("subject") String subject,
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
try {
|
||||
return Result.success(sliderCaptchaService.create(request.getSession().getId(),
|
||||
request.getRemoteAddr(), scene, subject));
|
||||
} catch (BaseException e) {
|
||||
return Result.error(e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("生成滑块验证码失败", e);
|
||||
return Result.error("验证码加载失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* challengeId 传挑战编号,offset 传拼图左侧在原始 320px 图片中的横坐标。
|
||||
* 返回 Result.data:platformKey、platformCaptcha 为一次性凭证,expiresIn 为有效秒数。
|
||||
*/
|
||||
@At("/checkSliderCaptcha")
|
||||
@POST
|
||||
@Ok("json")
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public Object checkSliderCaptcha(@Param("challengeId") String challengeId, @Param("offset") String offset,
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
response.setHeader("Cache-Control", "no-store");
|
||||
if (offset == null || !offset.matches("[0-9]{1,3}(\\.[0-9]{1,3})?")) {
|
||||
return Result.error("滑块位置不正确,请重新验证");
|
||||
}
|
||||
try {
|
||||
return Result.success(sliderCaptchaService.verify(request.getSession().getId(),
|
||||
challengeId, Double.parseDouble(offset)));
|
||||
} catch (BaseException e) {
|
||||
return Result.error(e.getMessage());
|
||||
} catch (Exception e) {
|
||||
log.error("校验滑块验证码失败", e);
|
||||
return Result.error("验证失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
|
||||
@At("/captcha")
|
||||
@Ok("json")
|
||||
@ApiOperation("获取验证码")
|
||||
|
||||
@@ -10,6 +10,23 @@ import java.util.List;
|
||||
* Created by wizzer on 2016/12/23.
|
||||
*/
|
||||
public interface SysConfigService extends BaseService<Sys_config> {
|
||||
/** 仅控制登录页面短信入口的可见性,不作为后端短信接口的访问开关。 */
|
||||
String SMS_LOGIN_VISIBLE_KEY = "AppSmsLoginEnabled";
|
||||
|
||||
/**
|
||||
* 补充缺失的短信登录入口参数,初始值为 false,已有管理员配置保持不变。
|
||||
*
|
||||
* @param createdBy 当前打开系统参数列表的管理员 ID,用于记录参数创建人
|
||||
*/
|
||||
void ensureSmsLoginVisibleConfig(String createdBy);
|
||||
|
||||
/**
|
||||
* 读取短信登录入口显示配置,不依赖异步更新的全局缓存。
|
||||
*
|
||||
* @return 仅参数值为 true 时返回 true;参数缺失、为空或非法时返回 false
|
||||
*/
|
||||
boolean isSmsLoginVisible();
|
||||
|
||||
/**
|
||||
* 查询所有数据
|
||||
* @return
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.budwk.app.base.service.BaseService;
|
||||
import com.budwk.app.sys.models.Sys_menu;
|
||||
import com.budwk.app.sys.models.Sys_role;
|
||||
import com.budwk.app.sys.models.Sys_user;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.util.List;
|
||||
@@ -155,14 +156,37 @@ public interface SysUserService extends BaseService<Sys_user> {
|
||||
Sys_user loginByPassword(String loginname, String passowrd) throws BaseException;
|
||||
|
||||
/**
|
||||
* 通过短信验证码登录
|
||||
* 根据手机号获取唯一可登录用户,并加载所属单位及分工会。
|
||||
*
|
||||
* @param mobile 手机号
|
||||
* @return
|
||||
* @throws BaseException
|
||||
* @param mobile 已在系统人员资料中登记的 11 位大陆手机号,允许首尾空格
|
||||
* @return Sys_user,包含用户基本资料、unit 单位及 union 分工会关联
|
||||
* @throws BaseException 手机号格式不正确、未绑定用户、关联多条用户或账号已禁用
|
||||
*/
|
||||
Sys_user loginByMobile(String mobile) throws BaseException;
|
||||
|
||||
/**
|
||||
* 为已登记且唯一有效的用户发送登录短信验证码。
|
||||
*
|
||||
* @param mobile 系统人员资料中登记的 11 位大陆手机号
|
||||
* @param captchaKey 滑块验证接口返回的 platformKey
|
||||
* @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证
|
||||
* @param request 当前 HTTP 请求,用于取得服务端接收到的客户端 IP 并限制发送频率
|
||||
* @return NutMap,retryAfter 为再次发送前应等待的秒数,expireIn 为短信验证码有效秒数
|
||||
* @throws BaseException 用户不可登录、滑块凭证错误、请求过于频繁或短信发送失败
|
||||
*/
|
||||
NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, HttpServletRequest request);
|
||||
|
||||
/**
|
||||
* 校验登录短信验证码并建立与账号密码登录一致的 Sa-Token 会话。
|
||||
*
|
||||
* @param mobile 系统人员资料中登记的 11 位大陆手机号
|
||||
* @param smsCode 发送到该手机号的登录短信验证码
|
||||
* @param request 当前 HTTP 请求,供既有登录流程记录 IP 及取得跳转参数
|
||||
* @return String,登录成功后应访问的项目页面地址
|
||||
* @throws BaseException 用户不可登录、关联资料缺失或短信验证码校验失败
|
||||
*/
|
||||
String loginBySmsCode(String mobile, String smsCode, HttpServletRequest request);
|
||||
|
||||
/**
|
||||
* 通过用户名获取用户信息
|
||||
* @param loginname
|
||||
|
||||
@@ -6,8 +6,11 @@ import com.budwk.app.base.service.impl.BaseServiceImpl;
|
||||
import com.budwk.app.base.utils.PageUtil;
|
||||
import com.budwk.app.sys.models.Sys_config;
|
||||
import com.budwk.app.sys.services.SysConfigService;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.dao.Cnd;
|
||||
import org.nutz.dao.Dao;
|
||||
import org.nutz.dao.DaoException;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Lang;
|
||||
import org.nutz.lang.Strings;
|
||||
@@ -24,6 +27,35 @@ public class SysConfigServiceImpl extends BaseServiceImpl<Sys_config> implements
|
||||
super(dao);
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public synchronized void ensureSmsLoginVisibleConfig(String createdBy) {
|
||||
if (fetch(SMS_LOGIN_VISIBLE_KEY) != null) {
|
||||
return;
|
||||
}
|
||||
Sys_config config = new Sys_config();
|
||||
config.setConfigKey(SMS_LOGIN_VISIBLE_KEY);
|
||||
config.setConfigValue("false");
|
||||
config.setNote("是否开启短信登录(仅控制登录页入口显示)");
|
||||
config.setCreatedBy(createdBy);
|
||||
try {
|
||||
insert(config);
|
||||
} catch (DaoException e) {
|
||||
// 多实例同时首次打开列表时,另一个实例可能已插入;已有值不能被默认值覆盖。
|
||||
if (fetch(SMS_LOGIN_VISIBLE_KEY) == null) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public boolean isSmsLoginVisible() {
|
||||
Sys_config config = fetch(SMS_LOGIN_VISIBLE_KEY);
|
||||
return config != null && "true".equals(Strings.trim(config.getConfigValue()));
|
||||
}
|
||||
|
||||
public List<Sys_config> getAllList() {
|
||||
return this.query(Cnd.where("delFlag", "=", false));
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.budwk.app.sys.services.SysMenuService;
|
||||
import com.budwk.app.sys.services.SysRoleService;
|
||||
import com.budwk.app.sys.services.SysUserService;
|
||||
import com.budwk.app.sys.views.View_user;
|
||||
import com.budwk.app.web.commons.auth.service.ValidateService;
|
||||
import com.budwk.app.web.commons.base.Globals;
|
||||
import com.budwk.app.zhgh.staffmanage.specialstaff.model.SpecialStaff;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
@@ -34,6 +35,7 @@ import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Lang;
|
||||
import org.nutz.lang.Strings;
|
||||
import org.nutz.lang.random.R;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
import org.nutz.plugins.wkcache.annotation.CacheDefaults;
|
||||
import org.nutz.plugins.wkcache.annotation.CacheRemove;
|
||||
import org.nutz.plugins.wkcache.annotation.CacheRemoveAll;
|
||||
@@ -63,6 +65,8 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
|
||||
private SLogService sLogService;
|
||||
@Inject
|
||||
private RedisService redisService;
|
||||
@Inject
|
||||
private ValidateService validateService;
|
||||
|
||||
@Override
|
||||
@CacheResult(cacheKey = "${userId}_getPermissionList")
|
||||
@@ -368,16 +372,90 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按登记手机号确认唯一且未禁用的账号,补齐后续登录会话使用的组织关系。
|
||||
*
|
||||
* @param mobile 已登记的 11 位大陆手机号,查询前移除首尾空格
|
||||
* @return Sys_user,包含 unit 单位和按单位关联的 union 分工会信息
|
||||
* @throws BaseException 手机号无效、未登记、关联多条用户或账号已禁用
|
||||
*/
|
||||
@Override
|
||||
public Sys_user loginByMobile(String mobile) throws BaseException {
|
||||
Sys_user user = this.fetch(Cnd.where("mobile", "=", mobile));
|
||||
if (user == null) {
|
||||
throw new BaseException("用户不存在");
|
||||
String normalizedMobile = Strings.sNull(mobile).trim();
|
||||
if (!normalizedMobile.matches("^1[3-9]\\d{9}$")) {
|
||||
throw new BaseException("请输入正确的手机号码");
|
||||
}
|
||||
// 手机号不是唯一字段;任何重复关联都拒绝登录,避免任取账号或绕过禁用状态。
|
||||
List<Sys_user> users = this.query(Cnd.where("mobile", "=", normalizedMobile));
|
||||
if (users.isEmpty()) {
|
||||
throw new BaseException("该手机号未绑定系统账号,请联系管理员");
|
||||
}
|
||||
if (users.size() > 1) {
|
||||
throw new BaseException("该手机号关联多个账号,请联系管理员");
|
||||
}
|
||||
Sys_user user = users.get(0);
|
||||
if (user.isDisabled()) {
|
||||
throw new BaseException("用户被禁用");
|
||||
}
|
||||
user = this.fetchLinks(user, "unit");
|
||||
if (Lang.isNotEmpty(user.getUnit()) && StrUtil.isNotBlank(user.getUnit().getUnionId())) {
|
||||
user.setUnion(dao().fetch(Sys_union.class, user.getUnit().getUnionId()));
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* 先确认手机号唯一且账号可用,再交由验证码服务校验滑块凭证、限流并实际发送短信。
|
||||
*
|
||||
* @param mobile 系统人员资料中登记的 11 位大陆手机号
|
||||
* @param captchaKey 滑块验证接口返回的 platformKey
|
||||
* @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证
|
||||
* @param request 当前请求,以 getRemoteAddr 取得的地址作为服务端发送限流依据
|
||||
* @return NutMap,retryAfter 为重发等待秒数,expireIn 为短信验证码有效秒数
|
||||
*/
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
throw new BaseException("登录请求无效");
|
||||
}
|
||||
Sys_user user = this.loginByMobile(mobile);
|
||||
return validateService.sendLoginSmsCode(user.getMobile(), captchaKey, captchaCode, request.getRemoteAddr(), request.getSession().getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用短信验证码登录既有账号,验证成功后复用原有会话、日志和跳转处理。
|
||||
*
|
||||
* @param mobile 已登记且只关联一个账号的 11 位大陆手机号
|
||||
* @param smsCode 登录短信中的验证码,成功校验后一次性消费
|
||||
* @param request 当前请求,供既有登录流程记录客户端 IP 并读取跳转参数
|
||||
* @return String,原有登录流程生成的登录成功跳转地址
|
||||
*/
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public String loginBySmsCode(String mobile, String smsCode, HttpServletRequest request) {
|
||||
if (request == null) {
|
||||
throw new BaseException("登录请求无效");
|
||||
}
|
||||
Sys_user user = this.loginByMobile(mobile);
|
||||
// loginPlus 会读取视图中的工会小组等资料,必须在建立登录态之前确认关联资料存在。
|
||||
if (dao().fetch(View_user.class, Cnd.where("id", "=", user.getId())) == null) {
|
||||
throw new BaseException("账号关联资料不完整,请联系管理员");
|
||||
}
|
||||
validateService.checkLoginSmsCode(user.getMobile(), smsCode);
|
||||
try {
|
||||
return this.loginPlus(user, LoginType.SMS_CODE, request);
|
||||
} catch (RuntimeException e) {
|
||||
// 仅在进入创建登录态的流程后清理;手机号或验证码校验失败不会注销原有登录。
|
||||
try {
|
||||
StpUtil.logout();
|
||||
} catch (RuntimeException cleanupException) {
|
||||
e.addSuppressed(cleanupException);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Sys_user loginByLoginName(String loginname) {
|
||||
Sys_user user = this.fetch(Cnd.where("loginname", "=", loginname));
|
||||
@@ -446,6 +524,7 @@ public class SysUserServiceImpl extends BaseServiceImpl<Sys_user> implements Sys
|
||||
}
|
||||
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public String loginPlus(Sys_user user, LoginType loginType, HttpServletRequest request) {
|
||||
String wxOpenId = request.getParameter("wxOpenId");
|
||||
|
||||
|
||||
@@ -3,19 +3,30 @@ package com.budwk.app.web.commons.auth.service;
|
||||
import com.budwk.app.base.constant.RedisConstant;
|
||||
import com.budwk.app.base.exception.BaseException;
|
||||
import com.budwk.app.base.utils.RsaUtil;
|
||||
import com.budwk.app.zhgh.dayofficework.message.service.TencentSmsService;
|
||||
import com.budwk.app.zhgh.dayofficework.message.service.SliderCaptchaService;
|
||||
import com.wf.captcha.ArithmeticCaptcha;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.integration.jedis.JedisAgent;
|
||||
import org.nutz.integration.jedis.RedisService;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.impl.PropertiesProxy;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Strings;
|
||||
import org.nutz.lang.random.R;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
import redis.clients.jedis.Jedis;
|
||||
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* @author wizzer@qq.com
|
||||
@@ -23,14 +34,45 @@ import java.util.Base64;
|
||||
@IocBean
|
||||
@Slf4j
|
||||
public class ValidateService {
|
||||
private static final int SMS_EXPIRE_SECONDS = 300;
|
||||
// 同一手机号的重发等待时间与短信验证码有效期一致,发送成功后五分钟内禁止重发。
|
||||
private static final int SMS_RETRY_SECONDS = 300;
|
||||
private static final SecureRandom SMS_RANDOM = new SecureRandom();
|
||||
|
||||
// 计数与有效期原子设置,避免并发发送绕过限流或产生永不过期的计数键。
|
||||
private static final String HOUR_LIMIT_SCRIPT = "local count=redis.call('incr',KEYS[1]); "
|
||||
+ "if count==1 then redis.call('expire',KEYS[1],3600) end; return count";
|
||||
// 成功发送后才保存验证码;带号码 hash tag 的键可在同一个 Redis 分片原子更新。
|
||||
private static final String SAVE_SMS_SCRIPT = "if redis.call('get',KEYS[1])~=ARGV[1] then return 0 end; "
|
||||
+ "redis.call('set',KEYS[2],ARGV[2],'EX',ARGV[3]); redis.call('del',KEYS[3]); "
|
||||
+ "redis.call('expire',KEYS[1],ARGV[4]); return 1";
|
||||
private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get',KEYS[1])==ARGV[1] "
|
||||
+ "then return redis.call('del',KEYS[1]) else return 0 end";
|
||||
// 校验、错误计数和消费必须原子完成,同一码只能有一个请求成功,五次输错后作废。
|
||||
private static final String VERIFY_SMS_SCRIPT = "local code=redis.call('get',KEYS[1]); "
|
||||
+ "if not code then return 0 end; if code~=ARGV[1] then "
|
||||
+ "local count=redis.call('incr',KEYS[2]); local ttl=redis.call('ttl',KEYS[1]); "
|
||||
+ "redis.call('expire',KEYS[2],math.max(ttl,1)); "
|
||||
+ "if count>=5 then redis.call('del',KEYS[1],KEYS[2]); return -2 end; return -1 end; "
|
||||
+ "redis.call('del',KEYS[1],KEYS[2]); return 1";
|
||||
|
||||
@Inject
|
||||
private RedisService redisService;
|
||||
@Inject
|
||||
private JedisAgent jedisAgent;
|
||||
@Inject
|
||||
private PropertiesProxy conf;
|
||||
@Inject
|
||||
private TencentSmsService tencentSmsService;
|
||||
@Inject
|
||||
private SliderCaptchaService sliderCaptchaService;
|
||||
|
||||
/**
|
||||
* 获取验证码
|
||||
*
|
||||
* @return
|
||||
* @return key 为图形验证码标识,codeUrl 为可直接展示的 Base64 图片;验证码有效期为三分钟
|
||||
*/
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public NutMap getCaptcha() {
|
||||
String uuid = R.UU32();
|
||||
ArithmeticCaptcha captcha = new ArithmeticCaptcha(120, 40);
|
||||
@@ -41,21 +83,66 @@ public class ValidateService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
* 校验发送条件后发送登录短信,只有腾讯云确认发送成功才写入有效验证码。
|
||||
*
|
||||
* @param mobile 手机号码
|
||||
* @throws BaseException
|
||||
* @param mobile 已由用户 service 确认唯一绑定、未禁用的 11 位大陆手机号
|
||||
* @param captchaKey 滑块验证接口返回的 platformKey
|
||||
* @param captchaCode 滑块验证接口返回的一次性 platformCaptcha 凭证
|
||||
* @param sessionId 服务端当前 Servlet 会话 ID,不接受前端指定
|
||||
* @param clientIp 请求连接的来源 IP,用于后端发送频控
|
||||
* @return retryAfter 为重发等待秒数,expireIn 为本次短信验证码有效秒数,不返回验证码或 Redis 键
|
||||
* @throws BaseException 参数无效、滑块凭证不正确、频控触发或短信发送失败
|
||||
*/
|
||||
public void getSmsCode(String mobile) throws BaseException {
|
||||
String text = R.captchaNumber(4);
|
||||
String codeFromRedis = redisService.get(RedisConstant.UCENTER_SMSCODE + mobile + ":LOCK");
|
||||
if (Strings.isNotBlank(codeFromRedis)) {
|
||||
throw new BaseException("请1分钟之后再试");
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public NutMap sendLoginSmsCode(String mobile, String captchaKey, String captchaCode, String clientIp, String sessionId) {
|
||||
if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$") || Strings.isBlank(clientIp)) {
|
||||
throw new BaseException("手机号或请求来源不正确");
|
||||
}
|
||||
sliderCaptchaService.consume(sessionId, captchaKey, captchaCode, SliderCaptchaService.SMS, mobile);
|
||||
tencentSmsService.checkLoginConfiguration();
|
||||
String mobileKey = RedisConstant.LOGIN_SMSCODE + "{" + mobile + "}";
|
||||
String lockKey = mobileKey + ":LOCK";
|
||||
String lockToken = R.UU32();
|
||||
try (Jedis jedis = jedisAgent.getResource()) {
|
||||
if (!"OK".equals(jedis.set(lockKey, lockToken, "NX", "EX", SMS_RETRY_SECONDS))) {
|
||||
throw new BaseException("验证码发送频繁,请5分钟后再试");
|
||||
}
|
||||
}
|
||||
|
||||
boolean saved = false;
|
||||
try {
|
||||
try (Jedis jedis = jedisAgent.getResource()) {
|
||||
long mobileCount = ((Number) jedis.eval(HOUR_LIMIT_SCRIPT,
|
||||
Collections.singletonList(mobileKey + ":HOUR"), Collections.emptyList())).longValue();
|
||||
long ipCount = ((Number) jedis.eval(HOUR_LIMIT_SCRIPT,
|
||||
Collections.singletonList(RedisConstant.LOGIN_SMSCODE + "ip:" + clientIp), Collections.emptyList())).longValue();
|
||||
if (mobileCount > Math.max(1, conf.getInt("sms.login.mobile-hour-limit", 10))
|
||||
|| ipCount > Math.max(1, conf.getInt("sms.login.ip-hour-limit", 30))) {
|
||||
throw new BaseException("验证码请求次数过多,请稍后再试");
|
||||
}
|
||||
}
|
||||
String code = String.format(Locale.ROOT, "%06d", SMS_RANDOM.nextInt(1000000));
|
||||
tencentSmsService.sendLoginCode(mobile, code);
|
||||
try (Jedis jedis = jedisAgent.getResource()) {
|
||||
Number saveResult = (Number) jedis.eval(SAVE_SMS_SCRIPT,
|
||||
Arrays.asList(lockKey, mobileKey, mobileKey + ":FAIL"),
|
||||
Arrays.asList(lockToken, code, String.valueOf(SMS_EXPIRE_SECONDS), String.valueOf(SMS_RETRY_SECONDS)));
|
||||
if (saveResult.longValue() != 1) {
|
||||
throw new BaseException("短信验证码处理超时,请稍后重新获取");
|
||||
}
|
||||
saved = true;
|
||||
}
|
||||
return NutMap.NEW().addv("retryAfter", SMS_RETRY_SECONDS).addv("expireIn", SMS_EXPIRE_SECONDS);
|
||||
} finally {
|
||||
// 失败只释放本请求持有的锁,不覆盖旧验证码,也不删除其他请求的新发送锁。
|
||||
if (!saved) {
|
||||
try (Jedis jedis = jedisAgent.getResource()) {
|
||||
jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey), Collections.singletonList(lockToken));
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("登录短信发送锁释放失败,锁将在五分钟内自动过期");
|
||||
}
|
||||
}
|
||||
}
|
||||
// smsSendServer.sendCode(mobile, text);
|
||||
log.debug("sms code:::" + text);
|
||||
redisService.setex(RedisConstant.UCENTER_SMSCODE + mobile, 300, text);
|
||||
redisService.setex(RedisConstant.UCENTER_SMSCODE + mobile + ":LOCK", 60, text);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +152,7 @@ public class ValidateService {
|
||||
* @param code 验证码的值
|
||||
* @throws BaseException
|
||||
*/
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void checkCode(String key, String code) throws BaseException {
|
||||
String codeFromRedis = redisService.get(RedisConstant.UCENTER_CAPTCHA + key);
|
||||
|
||||
@@ -81,27 +169,34 @@ public class ValidateService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 核验短信验证码
|
||||
* 按服务端收到的手机号核验登录短信验证码,成功后原子删除,输错五次作废。
|
||||
*
|
||||
* @param mobile 手机号码
|
||||
* @param code 验证码值
|
||||
* @throws BaseException
|
||||
* @param mobile 本次登录的 11 位大陆手机号,与发送时的手机号完全一致
|
||||
* @param code 用户输入的六位数字短信验证码
|
||||
* @throws BaseException 验证码为空、格式错误、不匹配、过期或已使用
|
||||
*/
|
||||
public void checkSMSCode(String mobile, String code) throws BaseException {
|
||||
String codeFromRedis = redisService.get(RedisConstant.UCENTER_SMSCODE + mobile);
|
||||
|
||||
if (Strings.isBlank(code)) {
|
||||
throw new BaseException("请输入短信验证码");
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void checkLoginSmsCode(String mobile, String code) throws BaseException {
|
||||
if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$") || code == null || !code.matches("^\\d{6}$")) {
|
||||
throw new BaseException("请输入正确的手机号和六位短信验证码");
|
||||
}
|
||||
if (Strings.isEmpty(codeFromRedis)) {
|
||||
throw new BaseException("短信验证码已过期");
|
||||
String mobileKey = RedisConstant.LOGIN_SMSCODE + "{" + mobile + "}";
|
||||
try (Jedis jedis = jedisAgent.getResource()) {
|
||||
long result = ((Number) jedis.eval(VERIFY_SMS_SCRIPT,
|
||||
Arrays.asList(mobileKey, mobileKey + ":FAIL"), Collections.singletonList(code))).longValue();
|
||||
if (result == 0) {
|
||||
throw new BaseException("短信验证码已过期或已使用,请重新获取");
|
||||
}
|
||||
if (result == -2) {
|
||||
throw new BaseException("短信验证码错误次数过多,请重新获取");
|
||||
}
|
||||
if (result != 1) {
|
||||
throw new BaseException("短信验证码不正确");
|
||||
}
|
||||
}
|
||||
if (!Strings.equalsIgnoreCase(code, codeFromRedis)) {
|
||||
throw new BaseException("短信验证码不正确");
|
||||
}
|
||||
redisService.del(RedisConstant.UCENTER_SMSCODE + mobile);
|
||||
}
|
||||
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public String decryptPwd(String keyId, String password) throws Exception {
|
||||
// 从 Redis 获取私钥
|
||||
String rsaKey = RedisConstant.RSA_KEY_PREFIX + keyId;
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.budwk.app.zhgh.dayofficework.message.service;
|
||||
|
||||
import com.budwk.app.base.constant.RedisConstant;
|
||||
import com.budwk.app.base.exception.BaseException;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.integration.jedis.RedisService;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.json.Json;
|
||||
import org.nutz.lang.Strings;
|
||||
import org.nutz.lang.util.NutMap;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.BasicStroke;
|
||||
import java.awt.Color;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.geom.Area;
|
||||
import java.awt.geom.Ellipse2D;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* PC 登录拼图验证码:原图仅在服务端缩放,正确横坐标只保存在 Redis。
|
||||
* 数据库事务注解遵循项目规范;Redis 状态转换的原子性由 Lua 保证。
|
||||
*/
|
||||
@IocBean
|
||||
public class SliderCaptchaService {
|
||||
public static final String PASSWORD = "password";
|
||||
public static final String SMS = "sms";
|
||||
private static final int WIDTH = 320;
|
||||
private static final int HEIGHT = 160;
|
||||
private static final int PIECE_SIZE = 50;
|
||||
private static final int CHALLENGE_SECONDS = 180;
|
||||
private static final int TICKET_SECONDS = 60;
|
||||
// PC 和手机端共用图片池:0~9 为风景,10~19 为动物,文件位于 static/captcha/slider。
|
||||
private static final String[] BACKGROUNDS = {
|
||||
"0.jpg", "1.jpg", "2.jpg", "3.jpg", "4.jpg",
|
||||
"5.jpg", "6.jpg", "7.jpg", "8.jpg", "9.jpg",
|
||||
"10.jpg", "11.jpg", "12.jpg", "13.jpg", "14.jpg",
|
||||
"15.jpg", "16.jpg", "17.jpg", "18.jpg", "19.jpg"
|
||||
};
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
private volatile BufferedImage[] backgrounds;
|
||||
|
||||
@Inject
|
||||
private RedisService redisService;
|
||||
|
||||
// 计数和首次设置有效期必须同时完成,防止进程中断留下永不过期的限流键。
|
||||
private static final String RATE_SCRIPT = "local n = redis.call('INCR', KEYS[1]); "
|
||||
+ "if n == 1 then redis.call('EXPIRE', KEYS[1], 60) end; return n";
|
||||
|
||||
// 一个挑战仅允许提交一次。旧挑战不能删除刷新后生成的新挑战,已通过状态不能再次换票。
|
||||
private static final String VERIFY_SCRIPT = "local raw = redis.call('GET', KEYS[1]); "
|
||||
+ "if not raw then return 0 end; local s = cjson.decode(raw); "
|
||||
+ "if s.id ~= ARGV[1] or s.state ~= 'pending' then return 0 end; "
|
||||
+ "if math.abs(s.x - tonumber(ARGV[2])) > 4 then redis.call('DEL', KEYS[1]); return -1 end; "
|
||||
+ "redis.call('SETEX', KEYS[1], ARGV[4], "
|
||||
+ "cjson.encode({id=s.id,state='passed',ticket=ARGV[3],binding=s.binding})); return 1";
|
||||
|
||||
// 比对与删除在同一个 Redis 命令中执行,并发登录最多只能消费一次凭证。
|
||||
private static final String CONSUME_SCRIPT = "local raw = redis.call('GET', KEYS[1]); "
|
||||
+ "if not raw then return 0 end; local s = cjson.decode(raw); "
|
||||
+ "if s.state ~= 'passed' or s.id ~= ARGV[1] or s.ticket ~= ARGV[2] or s.binding ~= ARGV[3] then return 0 end; "
|
||||
+ "redis.call('DEL', KEYS[1]); return 1";
|
||||
|
||||
/**
|
||||
* 创建当前会话的新挑战,覆盖该会话之前的挑战或登录凭证。
|
||||
*
|
||||
* @param sessionId Servlet 生成的当前会话 ID,不接受前端自行指定
|
||||
* @param remoteAddress Servlet 连接的客户端 IP,用于独立于会话的请求限流
|
||||
* @param scene password 为账号登录,sms 为发送登录短信
|
||||
* @param subject password 场景传原始账号,sms 场景传已去除首尾空格的手机号
|
||||
* @return NutMap:challengeId 为挑战编号;background/piece 为图像 data URL;
|
||||
* width/height/pieceSize/pieceY 为原图像素尺寸和拼图纵坐标;expiresIn 为有效秒数
|
||||
*/
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public NutMap create(String sessionId, String remoteAddress, String scene, String subject) throws IOException {
|
||||
String binding = binding(scene, subject);
|
||||
String sessionHash = digest(sessionId);
|
||||
long ipCount = ((Number) redisService.eval(RATE_SCRIPT, 1,
|
||||
RedisConstant.REDIS_SLIDER_RATE_KEY + "ip:" + digest(remoteAddress))).longValue();
|
||||
long sessionCount = ((Number) redisService.eval(RATE_SCRIPT, 1,
|
||||
RedisConstant.REDIS_SLIDER_RATE_KEY + "session:" + sessionHash)).longValue();
|
||||
if (ipCount > 120 || sessionCount > 20) {
|
||||
throw new BaseException("获取拼图过于频繁,请稍后再试");
|
||||
}
|
||||
|
||||
BufferedImage[] images = getBackgrounds();
|
||||
BufferedImage original = images[random.nextInt(images.length)];
|
||||
// 左侧预留拖动起点,四周留边,避免拼图越界或用户无需拖动就能通过。
|
||||
int x = 60 + random.nextInt(WIDTH - PIECE_SIZE - 70);
|
||||
int y = 10 + random.nextInt(HEIGHT - PIECE_SIZE - 20);
|
||||
Area shape = new Area(new Rectangle2D.Double(0, 8, 42, 42));
|
||||
shape.add(new Area(new Ellipse2D.Double(13, 0, 16, 16)));
|
||||
shape.add(new Area(new Ellipse2D.Double(34, 21, 16, 16)));
|
||||
shape.subtract(new Area(new Ellipse2D.Double(-8, 21, 16, 16)));
|
||||
|
||||
BufferedImage background = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D bg = background.createGraphics();
|
||||
try {
|
||||
bg.drawImage(original, 0, 0, null);
|
||||
bg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
bg.translate(x, y);
|
||||
bg.setColor(new Color(0, 0, 0, 170));
|
||||
bg.fill(shape);
|
||||
bg.setColor(new Color(255, 255, 255, 210));
|
||||
bg.setStroke(new BasicStroke(1.5f));
|
||||
bg.draw(shape);
|
||||
} finally {
|
||||
bg.dispose();
|
||||
}
|
||||
BufferedImage piece = new BufferedImage(PIECE_SIZE, PIECE_SIZE, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D pg = piece.createGraphics();
|
||||
try {
|
||||
pg.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
pg.setClip(shape);
|
||||
pg.drawImage(original, -x, -y, null);
|
||||
pg.setClip(null);
|
||||
pg.setColor(Color.WHITE);
|
||||
pg.setStroke(new BasicStroke(1.5f));
|
||||
pg.draw(shape);
|
||||
} finally {
|
||||
pg.dispose();
|
||||
}
|
||||
|
||||
String challengeId = newId();
|
||||
NutMap response = NutMap.NEW().addv("challengeId", challengeId)
|
||||
.addv("background", imageUrl(background, "jpg"))
|
||||
.addv("piece", imageUrl(piece, "png"))
|
||||
.addv("width", WIDTH).addv("height", HEIGHT)
|
||||
.addv("pieceSize", PIECE_SIZE).addv("pieceY", y)
|
||||
.addv("expiresIn", CHALLENGE_SECONDS);
|
||||
redisService.setex(RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + sessionHash, CHALLENGE_SECONDS,
|
||||
Json.toJson(NutMap.NEW().addv("id", challengeId).addv("state", "pending").addv("x", x).addv("binding", binding)));
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验拖动位置并签发登录凭证,失败后当前挑战作废。
|
||||
*
|
||||
* @param sessionId 当前 Servlet 会话 ID
|
||||
* @param challengeId create 返回的 32 位挑战编号
|
||||
* @param offset 拼图左边缘在原图中的横坐标,前端需将显示像素换算为原图像素
|
||||
* @return NutMap:platformKey 为挑战编号,platformCaptcha 为随机的一次性凭证,expiresIn 为有效秒数
|
||||
*/
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public NutMap verify(String sessionId, String challengeId, double offset) {
|
||||
if (!validId(challengeId) || !Double.isFinite(offset) || offset < 0 || offset > WIDTH - PIECE_SIZE) {
|
||||
throw new BaseException("拼图参数不正确,请重新获取");
|
||||
}
|
||||
String ticket = newId();
|
||||
long result = ((Number) redisService.eval(VERIFY_SCRIPT, 1,
|
||||
RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + digest(sessionId), challengeId,
|
||||
Double.toString(offset), ticket, Integer.toString(TICKET_SECONDS))).longValue();
|
||||
if (result != 1) {
|
||||
throw new BaseException(result == -1 ? "未对齐缺口,请重新验证" : "拼图已失效,请重新验证");
|
||||
}
|
||||
return NutMap.NEW().addv("platformKey", challengeId).addv("platformCaptcha", ticket)
|
||||
.addv("expiresIn", TICKET_SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* PC 账号认证之前消费拼图凭证。无返回值代表校验通过,失败抛出验证码认证异常。
|
||||
*
|
||||
* @param sessionId 当前 Servlet 会话 ID,保证其他会话不能使用此凭证
|
||||
* @param challengeId 登录参数 platformKey,取自 verify 的返回结果
|
||||
* @param ticket 登录参数 platformCaptcha,必须为 verify 签发的一次性凭证
|
||||
* @param scene 本次操作用途 password 或 sms,不能跨用途使用
|
||||
* @param subject 本次操作的账号或手机号,必须与创建挑战时一致
|
||||
*/
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void consume(String sessionId, String challengeId, String ticket, String scene, String subject) {
|
||||
if (Strings.isBlank(challengeId) || Strings.isBlank(ticket)) {
|
||||
throw new BaseException("请先完成拼图验证");
|
||||
}
|
||||
if (!validId(challengeId) || !validId(ticket)) {
|
||||
throw new BaseException("拼图验证已失效,请重新验证");
|
||||
}
|
||||
long result = ((Number) redisService.eval(CONSUME_SCRIPT, 1,
|
||||
RedisConstant.REDIS_SLIDER_CAPTCHA_KEY + digest(sessionId), challengeId, ticket, binding(scene, subject))).longValue();
|
||||
if (result != 1) {
|
||||
throw new BaseException("拼图验证已失效,请重新验证");
|
||||
}
|
||||
}
|
||||
|
||||
/** 将用途与账号绑定到摘要中,防止验证后换号或跨用途消费;不在 Redis 保存账号明文。 */
|
||||
private String binding(String scene, String subject) {
|
||||
if ((!PASSWORD.equals(scene) && !SMS.equals(scene)) || Strings.isBlank(subject)
|
||||
|| subject.length() > 200 || (SMS.equals(scene) && !subject.matches("^1[3-9]\\d{9}$"))) {
|
||||
throw new BaseException("请先填写正确的账号或手机号");
|
||||
}
|
||||
return digest(scene + ":" + subject);
|
||||
}
|
||||
|
||||
/** 缩小后的原图只缓存一份且不再修改;使用资源流以兼容 JAR 部署。 */
|
||||
private BufferedImage[] getBackgrounds() throws IOException {
|
||||
if (backgrounds == null) {
|
||||
synchronized (this) {
|
||||
if (backgrounds == null) {
|
||||
BufferedImage[] loaded = new BufferedImage[BACKGROUNDS.length];
|
||||
for (int i = 0; i < BACKGROUNDS.length; i++) {
|
||||
try (InputStream input = SliderCaptchaService.class.getResourceAsStream(
|
||||
"/static/captcha/slider/" + BACKGROUNDS[i])) {
|
||||
if (input == null) {
|
||||
throw new IOException("拼图背景资源缺失:" + BACKGROUNDS[i]);
|
||||
}
|
||||
BufferedImage source = ImageIO.read(input);
|
||||
if (source == null) {
|
||||
throw new IOException("拼图背景无法读取:" + BACKGROUNDS[i]);
|
||||
}
|
||||
loaded[i] = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB);
|
||||
Graphics2D graphics = loaded[i].createGraphics();
|
||||
try {
|
||||
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
|
||||
RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||
graphics.drawImage(source, 0, 0, WIDTH, HEIGHT, null);
|
||||
} finally {
|
||||
graphics.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
backgrounds = loaded;
|
||||
}
|
||||
}
|
||||
}
|
||||
return backgrounds;
|
||||
}
|
||||
|
||||
private String imageUrl(BufferedImage image, String format) throws IOException {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
if (!ImageIO.write(image, format, output)) {
|
||||
throw new IOException("不支持的拼图编码:" + format);
|
||||
}
|
||||
return "data:image/" + ("jpg".equals(format) ? "jpeg" : format) + ";base64,"
|
||||
+ Base64.getEncoder().encodeToString(output.toByteArray());
|
||||
}
|
||||
|
||||
private String newId() {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
private boolean validId(String value) {
|
||||
return value != null && value.matches("[a-f0-9]{32}");
|
||||
}
|
||||
|
||||
/** Redis 键中不暴露原始会话 ID 或 IP,也避免外部字符影响键结构。 */
|
||||
private String digest(String value) {
|
||||
if (Strings.isBlank(value)) {
|
||||
throw new BaseException("会话已失效,请刷新登录页面");
|
||||
}
|
||||
try {
|
||||
byte[] bytes = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("无法计算拼图会话标识", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.budwk.app.zhgh.dayofficework.message.service;
|
||||
|
||||
/**
|
||||
* 腾讯云登录验证码短信服务,与校内通讯平台的业务通知短信分别配置。
|
||||
*/
|
||||
public interface TencentSmsService {
|
||||
|
||||
/**
|
||||
* 校验登录短信开关和腾讯云必填配置,配置缺失时禁止生成有效验证码。
|
||||
*
|
||||
* @throws com.budwk.app.base.exception.BaseException 未启用短信或缺少凭证、签名、应用及模板配置
|
||||
*/
|
||||
void checkLoginConfiguration();
|
||||
|
||||
/**
|
||||
* 向单个已绑定账号的大陆手机号发送登录验证码。
|
||||
*
|
||||
* @param mobile 不带 +86 的 11 位大陆手机号,由登录业务层完成账号绑定及停用校验
|
||||
* @param code 六位数字验证码,作为登录短信模板的唯一变量
|
||||
* @throws com.budwk.app.base.exception.BaseException 配置无效、腾讯云调用失败或目标号码发送状态不为 Ok
|
||||
*/
|
||||
void sendLoginCode(String mobile, String code);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package com.budwk.app.zhgh.dayofficework.message.service.impl;
|
||||
|
||||
import com.budwk.app.base.exception.BaseException;
|
||||
import com.budwk.app.zhgh.dayofficework.message.service.TencentSmsService;
|
||||
import com.tencentcloudapi.common.Credential;
|
||||
import com.tencentcloudapi.common.exception.TencentCloudSDKException;
|
||||
import com.tencentcloudapi.common.profile.ClientProfile;
|
||||
import com.tencentcloudapi.common.profile.HttpProfile;
|
||||
import com.tencentcloudapi.sms.v20190711.SmsClient;
|
||||
import com.tencentcloudapi.sms.v20190711.models.SendSmsRequest;
|
||||
import com.tencentcloudapi.sms.v20190711.models.SendSmsResponse;
|
||||
import com.tencentcloudapi.sms.v20190711.models.SendStatus;
|
||||
import org.nutz.aop.interceptor.ioc.TransAop;
|
||||
import org.nutz.ioc.aop.Aop;
|
||||
import org.nutz.ioc.impl.PropertiesProxy;
|
||||
import org.nutz.ioc.loader.annotation.Inject;
|
||||
import org.nutz.ioc.loader.annotation.IocBean;
|
||||
import org.nutz.lang.Strings;
|
||||
import org.nutz.log.Log;
|
||||
import org.nutz.log.Logs;
|
||||
|
||||
/**
|
||||
* 沿用参考项目的腾讯云 v20190711 SDK、+86 号码和单变量登录模板。
|
||||
*/
|
||||
@IocBean
|
||||
public class TencentSmsServiceImpl implements TencentSmsService {
|
||||
private static final Log log = Logs.get();
|
||||
|
||||
@Inject
|
||||
private PropertiesProxy conf;
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
public void checkLoginConfiguration() {
|
||||
if (!conf.getBoolean("sms.enabled", false)) {
|
||||
throw new BaseException("短信登录暂未启用,请使用账号密码登录");
|
||||
}
|
||||
requiredConfig("sms.tencent.secret-id");
|
||||
requiredConfig("sms.tencent.secret-key");
|
||||
requiredConfig("sms.tencent.appid");
|
||||
requiredConfig("sms.tencent.sign");
|
||||
requiredConfig("sms.tencent.tpl.login_code");
|
||||
}
|
||||
|
||||
/** {@inheritDoc} */
|
||||
@Override
|
||||
@Aop(TransAop.READ_COMMITTED)
|
||||
public void sendLoginCode(String mobile, String code) {
|
||||
if (mobile == null || !mobile.matches("^1[3-9]\\d{9}$")
|
||||
|| code == null || !code.matches("^\\d{6}$")) {
|
||||
throw new BaseException("短信手机号或验证码格式不正确");
|
||||
}
|
||||
checkLoginConfiguration();
|
||||
// 单调时钟统计本次 SDK 调用(含客户端初始化)的耗时,不受系统时间校准影响。
|
||||
long startedAt = System.nanoTime();
|
||||
try {
|
||||
Credential credential = new Credential(requiredConfig("sms.tencent.secret-id"), requiredConfig("sms.tencent.secret-key"));
|
||||
HttpProfile httpProfile = new HttpProfile();
|
||||
httpProfile.setEndpoint("sms.tencentcloudapi.com");
|
||||
// 超时应小于发送锁有效期,避免慢请求与下一次重发同时写入验证码。
|
||||
httpProfile.setConnTimeout(5);
|
||||
httpProfile.setReadTimeout(10);
|
||||
httpProfile.setWriteTimeout(10);
|
||||
ClientProfile profile = new ClientProfile();
|
||||
profile.setHttpProfile(httpProfile);
|
||||
SmsClient client = new SmsClient(credential, conf.get("sms.tencent.region", "ap-guangzhou"), profile);
|
||||
|
||||
SendSmsRequest request = new SendSmsRequest();
|
||||
request.setSmsSdkAppid(requiredConfig("sms.tencent.appid"));
|
||||
request.setSign(requiredConfig("sms.tencent.sign"));
|
||||
request.setTemplateID(requiredConfig("sms.tencent.tpl.login_code"));
|
||||
request.setPhoneNumberSet(new String[]{"+86" + mobile});
|
||||
request.setTemplateParamSet(new String[]{code});
|
||||
checkSendStatus(client.SendSms(request), "+86" + mobile);
|
||||
} catch (TencentCloudSDKException e) {
|
||||
// SDK 网络异常可能没有 errorCode,实际异常类型及原因保存在 message 中。
|
||||
// 各字段先脱敏再输出,不直接打印异常对象,避免原始消息中的敏感信息进入日志。
|
||||
long elapsedMillis = (System.nanoTime() - startedAt) / 1_000_000L;
|
||||
log.warnf("腾讯云登录短信调用失败,code=%s,requestId=%s,elapsedMs=%s,reason=%s",
|
||||
sanitizeDiagnostic(e.getErrorCode(), mobile, code),
|
||||
sanitizeDiagnostic(e.getRequestId(), mobile, code), elapsedMillis,
|
||||
sanitizeDiagnostic(e.getMessage(), mobile, code));
|
||||
throw new BaseException("短信发送失败,请稍后重试或联系管理员");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保留 SDK 异常诊断信息,移除可能夹带的凭证、手机号与短信码,并限制为单行日志。
|
||||
*
|
||||
* @param value SDK 返回的错误码、请求编号或异常消息,空值显示为 unavailable
|
||||
* @param mobile 本次发送的大陆手机号,禁止在诊断字段中原样输出
|
||||
* @param code 本次发送的六位短信验证码,禁止在诊断字段中原样输出
|
||||
* @return 已脱敏的单行文本,最长 1000 个字符,超长内容附加省略标记
|
||||
*/
|
||||
private String sanitizeDiagnostic(String value, String mobile, String code) {
|
||||
if (Strings.isBlank(value)) {
|
||||
return "unavailable";
|
||||
}
|
||||
String sanitized = value;
|
||||
String[] sensitiveValues = {Strings.trim(conf.get("sms.tencent.secret-id", "")),
|
||||
Strings.trim(conf.get("sms.tencent.secret-key", "")), mobile, code};
|
||||
for (String sensitive : sensitiveValues) {
|
||||
if (!Strings.isBlank(sensitive)) {
|
||||
sanitized = sanitized.replace(sensitive, "[REDACTED]");
|
||||
}
|
||||
}
|
||||
// 同时遮盖异常文本中其他大陆手机号、签名及授权字段;先脱敏,再截断。
|
||||
sanitized = sanitized.replaceAll("(?<!\\d)(?:\\+86)?1[3-9]\\d{9}(?!\\d)", "[PHONE]")
|
||||
.replaceAll("(?i)(authorization|signature|token)[\"']?\\s*[:=]\\s*(\"[^\"]*\"|'[^']*'|[^,;}\\r\\n]+)",
|
||||
"$1=[REDACTED]")
|
||||
.replaceAll("[\\p{Cntrl}\\u2028\\u2029]", " ").trim();
|
||||
return sanitized.length() > 1000 ? sanitized.substring(0, 1000) + "..." : sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 核对单号码业务发送状态,HTTP 调用成功不代表短信发送成功。
|
||||
*
|
||||
* @param response 腾讯云发送响应,必须恰有一个与请求号码相同的成功状态
|
||||
* @param phoneNumber 请求中的 +86 国际格式号码
|
||||
*/
|
||||
private void checkSendStatus(SendSmsResponse response, String phoneNumber) {
|
||||
SendStatus[] statuses = response == null ? null : response.getSendStatusSet();
|
||||
if (statuses == null || statuses.length != 1 || statuses[0] == null
|
||||
|| !"Ok".equals(statuses[0].getCode())
|
||||
|| !phoneNumber.equals(statuses[0].getPhoneNumber())) {
|
||||
String statusCode = statuses != null && statuses.length > 0 && statuses[0] != null
|
||||
? statuses[0].getCode() : "EmptyStatus";
|
||||
log.warnf("腾讯云登录短信未发送成功,code=%s,requestId=%s", statusCode,
|
||||
response == null ? "" : response.getRequestId());
|
||||
throw new BaseException("短信发送失败,请稍后重试或联系管理员");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取必填腾讯云配置,错误提示仅包含配置名称。
|
||||
*
|
||||
* @param key sms.tencent 下的完整配置键
|
||||
* @return 去除首尾空格的非空配置值
|
||||
*/
|
||||
private String requiredConfig(String key) {
|
||||
String value = Strings.trim(conf.get(key, ""));
|
||||
if (Strings.isBlank(value)) {
|
||||
throw new BaseException("短信服务配置不完整,请联系管理员检查 " + key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -31,11 +31,17 @@
|
||||
<div class="login-form">
|
||||
<div class="my-3 text-6xl font-semibold">${AppName!}${base!}</div>
|
||||
<div class="flx-center-center">
|
||||
<div class="p-12 bg-white rounded-3xl" style="box-sizing: border-box">
|
||||
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" auto-complete="on" label-position="left">
|
||||
<div class="login-card p-12 bg-white rounded-3xl" style="box-sizing: border-box">
|
||||
<div class="login-tabs" role="tablist" aria-label="登录方式">
|
||||
<button type="button" role="tab" :aria-selected="loginMode === 'password'" :class="{ active: loginMode === 'password' }" :disabled="formLoading || smsSending || captchaVisible" @click="switchLoginMode('password')">账号密码</button>
|
||||
<button v-if="smsLoginVisible" type="button" role="tab" :aria-selected="loginMode === 'sms'" :class="{ active: loginMode === 'sms' }" :disabled="formLoading || smsSending || captchaVisible" @click="switchLoginMode('sms')">手机验证码</button>
|
||||
</div>
|
||||
<el-form v-show="loginMode === 'password'" ref="loginForm" :model="loginForm" :rules="loginRules" auto-complete="on" label-position="left">
|
||||
<el-form-item prop="username">
|
||||
<el-input
|
||||
v-model="loginForm.username"
|
||||
:value="loginForm.username"
|
||||
@input="$set(loginForm, 'username', $event)"
|
||||
:disabled="formLoading || captchaVisible"
|
||||
type="text"
|
||||
auto-complete="off"
|
||||
placeholder="账号"
|
||||
@@ -44,7 +50,9 @@
|
||||
</el-form-item>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
v-model="loginForm.password"
|
||||
:value="loginForm.password"
|
||||
@input="$set(loginForm, 'password', $event)"
|
||||
:disabled="formLoading || captchaVisible"
|
||||
type="password"
|
||||
auto-complete="off"
|
||||
placeholder="密码"
|
||||
@@ -52,37 +60,58 @@
|
||||
prefix-icon="el-icon-lock"
|
||||
></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!captchaDisabled" prop="platformCaptcha">
|
||||
<el-input
|
||||
v-model="loginForm.platformCaptcha"
|
||||
auto-complete="off"
|
||||
placeholder="验证码"
|
||||
style="width: 60%"
|
||||
@keyup.enter.native="handleLogin"
|
||||
prefix-icon="el-icon-key"
|
||||
></el-input>
|
||||
<div class="login-code">
|
||||
<img :src="codeUrl" @click="getCode" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 100%">
|
||||
<el-button
|
||||
:loading="loading"
|
||||
:loading="formLoading"
|
||||
size="medium"
|
||||
type="primary"
|
||||
style="width: 100%; --color-primary: #1867b0"
|
||||
@click.native.prevent="handleLogin"
|
||||
>
|
||||
<span v-if="!loading">登 录</span>
|
||||
<span v-if="!formLoading">登 录</span>
|
||||
<span v-else>登 录 中...</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-form v-show="smsLoginVisible && loginMode === 'sms'" ref="smsForm" :model="smsForm" :rules="smsRules" auto-complete="off" label-position="left">
|
||||
<el-form-item prop="mobile">
|
||||
<el-input :value="smsForm.mobile" @input="handleMobileChange" :disabled="formLoading || smsSending || captchaVisible" maxlength="11" inputmode="tel" placeholder="请输入系统绑定的手机号" prefix-icon="el-icon-mobile-phone"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item prop="smsCode">
|
||||
<el-input :value="smsForm.smsCode" @input="$set(smsForm, 'smsCode', $event)" :disabled="formLoading || captchaVisible" maxlength="6" inputmode="numeric" autocomplete="one-time-code" placeholder="6位短信验证码" @keyup.enter.native="handleSmsLogin" prefix-icon="el-icon-message">
|
||||
<el-button slot="append" :loading="smsSending" :disabled="formLoading || smsCountdown > 0 || captchaVisible" @click="sendSmsCode">{{ smsCountdown > 0 ? smsCountdown + '秒后重发' : '获取验证码' }}</el-button>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item style="width: 100%">
|
||||
<el-button :loading="formLoading" :disabled="smsSending || captchaVisible" size="medium" type="primary" style="width: 100%; --color-primary: #1867b0" @click.native.prevent="handleSmsLogin">
|
||||
<span v-if="!formLoading">登 录</span>
|
||||
<span v-else>登 录 中...</span>
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<p class="login-tip">请使用系统账号绑定的手机号</p>
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 两种登录方式共用滑块弹框;系统返回键只关闭当前弹框。 -->
|
||||
<el-dialog title="安全验证" custom-class="captcha-dialog" :visible="captchaVisible" :before-close="closeCaptcha" :close-on-click-modal="false" append-to-body>
|
||||
<div class="captcha-panel" v-loading="captchaLoading">
|
||||
<div class="captcha-heading">
|
||||
<span>拖动滑块,完成拼图</span>
|
||||
<el-button type="text" :disabled="captchaLoading || captchaChecking" @click="loadSlider">换一张</el-button>
|
||||
</div>
|
||||
<div class="captcha-picture">
|
||||
<template v-if="sliderData">
|
||||
<img :key="sliderData.challengeId + '-background'" class="captcha-background" :src="sliderData.background" alt="拼图背景" @load="onSliderImageLoad('background', $event)" @error="onSliderImageError" />
|
||||
<img :key="sliderData.challengeId + '-piece'" class="captcha-piece" :src="sliderData.piece" :style="pieceStyle" alt="待移动拼图" @load="onSliderImageLoad('piece', $event)" @error="onSliderImageError" />
|
||||
</template>
|
||||
</div>
|
||||
<input type="range" class="captcha-range" min="0" :max="sliderMax" step="1" :value="sliderOffset" :disabled="!sliderReady || captchaChecking || captchaLoading" aria-label="拖动滑块使拼图对齐缺口" @input="onSliderInput" @change="checkSlider" />
|
||||
<div class="captcha-hint" role="status">{{ captchaChecking ? '正在验证…' : captchaHint }}</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</body>
|
||||
<!-- import Vue before Element -->
|
||||
@@ -97,18 +126,12 @@
|
||||
<script src="${base!}/assets/platform/plugins/sm-crypto/sm2.js"></script>
|
||||
<!-- import commonUtil -->
|
||||
<script src="${base!}/assets/platform/js/util/commonUtil.js"></script>
|
||||
<script src="${base!}/assets/platform/js/util/h5PopupHistory.js"></script>
|
||||
<!-- import jsencrypt-->
|
||||
<script src="${base!}/assets/platform/plugins/jsencrypt/jsencrypt.min.js"></script>
|
||||
<script nonce="${cspNonce!}">
|
||||
// 在加载 lodash 后、使用前插入
|
||||
const originalDefaultsDeep = window._.defaultsDeep;
|
||||
window._.defaultsDeep = function(...args) {
|
||||
// 先对所有参数做原型污染清洗
|
||||
const cleanArgs = args.map(arg => sanitizeForPrototypePollution(arg));
|
||||
return originalDefaultsDeep.apply(window._, cleanArgs);
|
||||
};
|
||||
|
||||
function sanitizeForPrototypePollution(obj) {
|
||||
// 登录页未加载 lodash 时跳过补丁,避免在 Vue 初始化前中断脚本。
|
||||
const sanitizeForPrototypePollution = (obj) => {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
if (Array.isArray(obj)) return obj.map(sanitizeForPrototypePollution);
|
||||
|
||||
@@ -122,29 +145,64 @@
|
||||
}
|
||||
return clean;
|
||||
}
|
||||
if (window._ && typeof window._.defaultsDeep === "function") {
|
||||
const originalDefaultsDeep = window._.defaultsDeep;
|
||||
window._.defaultsDeep = (...args) => {
|
||||
const cleanArgs = args.map(arg => sanitizeForPrototypePollution(arg));
|
||||
return originalDefaultsDeep.apply(window._, cleanArgs);
|
||||
};
|
||||
}
|
||||
</script>
|
||||
<script nonce="${cspNonce!}">
|
||||
// 临时屏蔽指定局域网 IP 的验证码;恢复时删除 192.168.0.108,并同步删除后端对应条件。
|
||||
const DISABLE_LOGIN_CAPTCHA = ["localhost", "127.0.0.1", "::1", "192.168.0.108"].includes(window.location.hostname)
|
||||
// 本页沿用原 axios 响应结构(res.data),不使用会解包响应的全局拦截器。
|
||||
Vue.prototype.$axios = axios
|
||||
|
||||
new Vue({
|
||||
el: "#app",
|
||||
data() {
|
||||
return {
|
||||
codeUrl: "",
|
||||
captchaDisabled: DISABLE_LOGIN_CAPTCHA,
|
||||
loginMode: "password",
|
||||
// 服务端仅输出布尔值;参数缺失时默认隐藏短信入口。
|
||||
smsLoginVisible: "${smsLoginVisible!false}" === "true",
|
||||
loginForm: {
|
||||
username: "",
|
||||
password: "",
|
||||
platformKey: "",
|
||||
grantType: "captcha"
|
||||
},
|
||||
loginRules: {
|
||||
username: [{ required: true, trigger: ["blur", "change"], message: "账号不能为空" }],
|
||||
password: [{ required: true, trigger: ["blur", "change"], message: "密码不能为空" }],
|
||||
platformCaptcha: []
|
||||
},
|
||||
loading: false,
|
||||
smsForm: {
|
||||
mobile: "",
|
||||
smsCode: ""
|
||||
},
|
||||
smsRules: {
|
||||
mobile: [
|
||||
{ required: true, trigger: "blur", message: "请输入手机号" },
|
||||
{ pattern: /^1[3-9]\d{9}$/, trigger: "blur", message: "请输入正确的11位手机号" }
|
||||
],
|
||||
smsCode: [
|
||||
{ required: true, trigger: "blur", message: "请输入短信验证码" },
|
||||
{ pattern: /^\d{6}$/, trigger: "blur", message: "请输入6位数字短信验证码" }
|
||||
]
|
||||
},
|
||||
formLoading: false,
|
||||
smsSending: false,
|
||||
captchaVisible: false,
|
||||
captchaLoading: false,
|
||||
captchaChecking: false,
|
||||
captchaHint: '拖动下方滑块,将拼图移到缺口处',
|
||||
sliderData: null,
|
||||
sliderOffset: 0,
|
||||
sliderImages: { background: false, piece: false },
|
||||
sliderVersion: 0,
|
||||
sliderTimer: null,
|
||||
sliderContext: null,
|
||||
popupToken: null,
|
||||
pageShowHandler: null,
|
||||
smsCountdown: 0,
|
||||
smsTimer: null,
|
||||
rasParams: {
|
||||
publicKey: '',
|
||||
keyId: '',
|
||||
@@ -152,72 +210,251 @@
|
||||
},
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getCode() {
|
||||
if (this.captchaDisabled) {
|
||||
return
|
||||
computed: {
|
||||
sliderMax() {
|
||||
return this.sliderData ? this.sliderData.width - this.sliderData.pieceSize : 270
|
||||
},
|
||||
sliderReady() {
|
||||
return !!this.sliderData && this.sliderImages.background && this.sliderImages.piece
|
||||
},
|
||||
pieceStyle() {
|
||||
if (!this.sliderData) return {}
|
||||
return {
|
||||
width: this.sliderData.pieceSize / this.sliderData.width * 100 + '%',
|
||||
left: this.sliderOffset / this.sliderData.width * 100 + '%',
|
||||
top: this.sliderData.pieceY / this.sliderData.height * 100 + '%'
|
||||
}
|
||||
axios.post("/platform/login/captcha").then((resp) => {
|
||||
if (resp.data.code === 0) {
|
||||
this.codeUrl = resp.data.data.codeUrl
|
||||
this.loginForm.platformKey = resp.data.data.key
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
switchLoginMode(mode) {
|
||||
if (mode === 'sms' && !this.smsLoginVisible) return
|
||||
if (this.formLoading || this.smsSending || this.captchaVisible || this.loginMode === mode) return
|
||||
this.$set(this, 'loginMode', mode)
|
||||
this.$nextTick(() => {
|
||||
this.$refs.loginForm.clearValidate()
|
||||
this.$refs.smsForm.clearValidate()
|
||||
})
|
||||
},
|
||||
handleMobileChange(mobile) {
|
||||
if (mobile !== this.smsForm.mobile) {
|
||||
this.$set(this.smsForm, 'mobile', mobile)
|
||||
// 短信验证码只适用于发送时的号码;改号后清空输入,保留发送倒计时。
|
||||
this.$set(this.smsForm, 'smsCode', '')
|
||||
}
|
||||
},
|
||||
// Element 按字段回调校验结果,所有字段完成后返回是否允许提交。
|
||||
validateSmsFields(fields) {
|
||||
return new Promise((resolve) => {
|
||||
let remaining = fields.length
|
||||
let valid = true
|
||||
this.$refs.smsForm.validateField(fields, (message) => {
|
||||
valid = valid && !message
|
||||
remaining -= 1
|
||||
if (remaining === 0) resolve(valid)
|
||||
})
|
||||
})
|
||||
},
|
||||
handleLogin() {
|
||||
this.$refs.loginForm.validate(async (valid) => {
|
||||
if (valid) {
|
||||
await this.ensurePublicKey()
|
||||
const { publicKey, keyId } = this.rasParams
|
||||
if (!publicKey || !keyId) {
|
||||
this.$message({
|
||||
message: "Login key unavailable, please refresh and try again",
|
||||
type: "error"
|
||||
})
|
||||
return
|
||||
}
|
||||
// 加密密码
|
||||
const encrypt = new JSEncrypt()
|
||||
encrypt.setPublicKey(publicKey)
|
||||
const encryptedPassword = encrypt.encrypt(this.loginForm.password)
|
||||
axios
|
||||
.post("/platform/login/doLogin", null, {
|
||||
params: {
|
||||
...this.loginForm,
|
||||
//password: window.btoa(this.loginForm.password)
|
||||
password: encryptedPassword,
|
||||
keyId: keyId
|
||||
}
|
||||
})
|
||||
.then((resp) => {
|
||||
if (resp.data.code === 0) {
|
||||
this.$message.success("登录成功")
|
||||
const redirect = new URLSearchParams(window.location.search).get("redirect")
|
||||
if (redirect) {
|
||||
window.location.href = "${base!}" + redirect || ""
|
||||
} else {
|
||||
window.location.href = "${base!}/platform/home"
|
||||
}
|
||||
} else {
|
||||
this.getCode()
|
||||
this.$message({
|
||||
message: resp.data.msg,
|
||||
type: "error"
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error)
|
||||
})
|
||||
} else {
|
||||
console.log("cccccc")
|
||||
}
|
||||
if (this.formLoading || this.smsSending || this.captchaVisible || this.loginMode !== 'password') return
|
||||
this.$refs.loginForm.validate((valid) => {
|
||||
if (valid) this.openSlider('password')
|
||||
})
|
||||
},
|
||||
// 获取公钥
|
||||
sendSmsCode() {
|
||||
if (this.smsSending || this.formLoading || this.captchaVisible || this.smsCountdown > 0) return
|
||||
this.validateSmsFields(['mobile']).then((valid) => {
|
||||
if (valid && !this.captchaVisible && !this.formLoading && !this.smsSending && this.smsCountdown === 0) this.openSlider('sms')
|
||||
})
|
||||
},
|
||||
// 固定本次验证的用途及输入,避免异步返回时把凭证用于另一个账号或号码。
|
||||
openSlider(scene) {
|
||||
this.$set(this, 'sliderContext', {
|
||||
scene: scene,
|
||||
subject: scene === 'password' ? this.loginForm.username : this.smsForm.mobile,
|
||||
password: scene === 'password' ? this.loginForm.password : ''
|
||||
})
|
||||
this.$set(this, 'captchaVisible', true)
|
||||
window.h5PopupHistory.open(this.popupToken, 'login-slider')
|
||||
this.loadSlider()
|
||||
},
|
||||
// scene/subject 绑定操作;返回图片、挑战编号和原图尺寸,不包含正确横坐标。
|
||||
loadSlider() {
|
||||
if (!this.captchaVisible || !this.sliderContext || this.captchaLoading || this.captchaChecking) return
|
||||
clearTimeout(this.sliderTimer)
|
||||
this.$set(this, 'sliderVersion', this.sliderVersion + 1)
|
||||
const version = this.sliderVersion
|
||||
this.$set(this, 'sliderData', null)
|
||||
this.$set(this, 'sliderOffset', 0)
|
||||
this.$set(this, 'sliderImages', { background: false, piece: false })
|
||||
this.$set(this, 'captchaLoading', true)
|
||||
this.$set(this, 'captchaHint', '图片加载中…')
|
||||
return this.$axios.post('${base!}/platform/login/sliderCaptcha', null, {
|
||||
params: { scene: this.sliderContext.scene, subject: this.sliderContext.subject }
|
||||
}).then((resp) => {
|
||||
if (version !== this.sliderVersion || !this.captchaVisible) return
|
||||
if (resp.data.code !== 0 || !resp.data.data) throw new Error(resp.data.msg || '滑块加载失败')
|
||||
this.$set(this, 'sliderData', resp.data.data)
|
||||
this.$set(this, 'captchaHint', '拖动下方滑块,将拼图移到缺口处')
|
||||
this.$set(this, 'sliderTimer', setTimeout(() => {
|
||||
if (version !== this.sliderVersion) return
|
||||
this.$set(this, 'sliderData', null)
|
||||
this.$set(this, 'captchaHint', '拼图已过期,请点击“换一张”重新验证')
|
||||
}, Math.max(1, Number(resp.data.data.expiresIn) || 180) * 1000))
|
||||
}).catch((error) => {
|
||||
if (version !== this.sliderVersion || !this.captchaVisible) return
|
||||
this.$set(this, 'captchaHint', error.message || '加载失败,请点击“换一张”重试')
|
||||
}).finally(() => {
|
||||
if (version === this.sliderVersion) this.$set(this, 'captchaLoading', false)
|
||||
})
|
||||
},
|
||||
onSliderImageLoad(kind, event) {
|
||||
// 旧图片延迟触发 load 时不能把新一轮挑战提前标记为可拖动。
|
||||
if (this.sliderData && event.target.getAttribute('src') === this.sliderData[kind]) {
|
||||
this.$set(this.sliderImages, kind, true)
|
||||
}
|
||||
},
|
||||
onSliderImageError() {
|
||||
this.$set(this, 'sliderImages', { background: false, piece: false })
|
||||
this.$set(this, 'captchaHint', '图片加载失败,请点击“换一张”重试')
|
||||
},
|
||||
onSliderInput(event) {
|
||||
if (!this.sliderReady || this.captchaChecking) return
|
||||
this.$set(this, 'sliderOffset', Math.max(0, Math.min(this.sliderMax, Number(event.target.value))))
|
||||
},
|
||||
// offset 使用原图像素;服务器返回一次性凭证,关闭弹框历史后再提交登录或发送短信。
|
||||
checkSlider() {
|
||||
if (!this.sliderReady || this.captchaChecking || !this.captchaVisible) return
|
||||
const version = this.sliderVersion
|
||||
const context = Object.assign({}, this.sliderContext)
|
||||
this.$set(this, 'captchaChecking', true)
|
||||
return this.$axios.post('${base!}/platform/login/checkSliderCaptcha', null, {
|
||||
params: { challengeId: this.sliderData.challengeId, offset: this.sliderOffset }
|
||||
}).then((resp) => {
|
||||
if (version !== this.sliderVersion || !this.captchaVisible) return
|
||||
if (resp.data.code !== 0 || !resp.data.data) throw new Error(resp.data.msg || '验证失败,请重试')
|
||||
const ticket = resp.data.data
|
||||
if (context.scene === 'password' && (context.subject !== this.loginForm.username || context.password !== this.loginForm.password)) {
|
||||
throw new Error('账号信息已变化,请重新验证')
|
||||
}
|
||||
if (context.scene === 'sms' && context.subject !== this.smsForm.mobile) throw new Error('手机号已变化,请重新验证')
|
||||
const loadingField = context.scene === 'password' ? 'formLoading' : 'smsSending'
|
||||
this.$set(this, loadingField, true)
|
||||
// clear 会同步触发关闭回调,因此使用局部快照保留本次请求参数。
|
||||
return window.h5PopupHistory.clear(this.popupToken).then(() => {
|
||||
return context.scene === 'password' ? this.submitPassword(context, ticket) : this.submitSms(context, ticket)
|
||||
}).finally(() => {
|
||||
this.$set(this, loadingField, false)
|
||||
})
|
||||
}).catch((error) => {
|
||||
if (version !== this.sliderVersion || !this.captchaVisible) return
|
||||
this.$set(this, 'sliderData', null)
|
||||
this.$set(this, 'captchaHint', (error.message || '验证失败') + ',请点击“换一张”')
|
||||
}).finally(() => {
|
||||
if (version === this.sliderVersion) this.$set(this, 'captchaChecking', false)
|
||||
})
|
||||
},
|
||||
closeCaptcha() {
|
||||
// 关闭按钮、ESC 与系统返回共用全局历史管理器,不单独注册 popstate。
|
||||
if (!window.h5PopupHistory.close(this.popupToken, 'login-slider')) this.resetSlider()
|
||||
},
|
||||
resetSlider() {
|
||||
clearTimeout(this.sliderTimer)
|
||||
this.$set(this, 'sliderVersion', this.sliderVersion + 1)
|
||||
this.$set(this, 'captchaVisible', false)
|
||||
this.$set(this, 'captchaLoading', false)
|
||||
this.$set(this, 'captchaChecking', false)
|
||||
this.$set(this, 'sliderData', null)
|
||||
this.$set(this, 'sliderContext', null)
|
||||
this.$set(this, 'sliderOffset', 0)
|
||||
},
|
||||
// 沿用 RSA 公钥加密;只提交服务器签发的滑块凭证,不提交滑块横坐标作为登录凭证。
|
||||
submitPassword(context, ticket) {
|
||||
return this.ensurePublicKey().then(() => {
|
||||
const encrypt = new JSEncrypt()
|
||||
encrypt.setPublicKey(this.rasParams.publicKey)
|
||||
const encryptedPassword = encrypt.encrypt(context.password)
|
||||
if (!encryptedPassword) throw new Error('密码加密失败,请刷新页面后重试')
|
||||
return this.$axios.post('${base!}/platform/login/doLogin', null, {
|
||||
params: { username: context.subject, password: encryptedPassword, keyId: this.rasParams.keyId,
|
||||
platformKey: ticket.platformKey, platformCaptcha: ticket.platformCaptcha, grantType: 'captcha' }
|
||||
})
|
||||
}).then((resp) => {
|
||||
this.handleLoginResult(resp.data)
|
||||
}).catch((error) => {
|
||||
this.$message.error(error.message || '登录失败,请重新验证后重试')
|
||||
})
|
||||
},
|
||||
// mobile 为绑定号码,两个 platform 参数为滑块凭证;返回重发等待时间及短信有效时间。
|
||||
submitSms(context, ticket) {
|
||||
return this.$axios.post('${base!}/platform/login/mobileCaptcha', null, {
|
||||
params: { mobile: context.subject, platformKey: ticket.platformKey, platformCaptcha: ticket.platformCaptcha }
|
||||
}).then((resp) => {
|
||||
if (resp.data.code !== 0) throw new Error(resp.data.msg || '短信验证码发送失败')
|
||||
this.$set(this.smsForm, 'smsCode', '')
|
||||
// 优先使用后端重发等待秒数,缺失时按五分钟倒计时。
|
||||
this.startSmsCountdown(Number((resp.data.data || {}).retryAfter) || 300)
|
||||
this.$message.success('验证码已发送,5分钟内有效')
|
||||
}).catch((error) => {
|
||||
this.$message.error(error.message || '短信验证码发送失败,请重新验证后重试')
|
||||
})
|
||||
},
|
||||
startSmsCountdown(seconds) {
|
||||
if (this.smsTimer) {
|
||||
clearInterval(this.smsTimer)
|
||||
}
|
||||
const retryAt = Date.now() + Math.max(1, seconds) * 1000
|
||||
this.$set(this, "smsCountdown", Math.ceil(seconds))
|
||||
this.$set(this, "smsTimer", setInterval(() => {
|
||||
this.$set(this, "smsCountdown", Math.max(0, Math.ceil((retryAt - Date.now()) / 1000)))
|
||||
if (this.smsCountdown === 0) {
|
||||
clearInterval(this.smsTimer)
|
||||
this.$set(this, "smsTimer", null)
|
||||
}
|
||||
}, 1000))
|
||||
},
|
||||
handleSmsLogin() {
|
||||
if (this.formLoading || this.smsSending || this.captchaVisible || this.loginMode !== "sms") {
|
||||
return
|
||||
}
|
||||
// mobile 为当前手机号,smsCode 为6位短信码;接口返回标准 Result,code=0 时按原路径进入系统。
|
||||
this.$set(this, "formLoading", true)
|
||||
this.validateSmsFields(["mobile", "smsCode"])
|
||||
.then((valid) => {
|
||||
if (!valid) {
|
||||
return
|
||||
}
|
||||
return this.$axios.post("${base!}/platform/login/doSmsLogin", null, {
|
||||
params: { mobile: this.smsForm.mobile, smsCode: this.smsForm.smsCode }
|
||||
}).then((resp) => {
|
||||
this.handleLoginResult(resp.data)
|
||||
})
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$message.error(error.message || "登录失败,请稍后重试")
|
||||
})
|
||||
.finally(() => {
|
||||
this.$set(this, "formLoading", false)
|
||||
})
|
||||
},
|
||||
// 两种登录方式共用原有成功提示与登录后跳转,保持 redirect 参数行为。
|
||||
handleLoginResult(result) {
|
||||
if (result.code !== 0) {
|
||||
throw new Error(result.msg || "登录失败,请稍后重试")
|
||||
}
|
||||
this.$message.success("登录成功")
|
||||
const redirect = new URLSearchParams(window.location.search).get("redirect")
|
||||
window.h5PopupHistory.clear(this.popupToken).then(() => {
|
||||
window.location.href = redirect ? "${base!}" + redirect : "${base!}/platform/home"
|
||||
})
|
||||
},
|
||||
// 公钥接口沿用原 GET 方式;返回 publicKey、keyId、expireAt 供密码登录加密和过期判断。
|
||||
fetchPublicKey() {
|
||||
return axios.get("/platform/login/publicKey").then(res => {
|
||||
this.rasParams = res.data.data
|
||||
return this.$axios.get("${base!}/platform/login/publicKey").then((res) => {
|
||||
if (res.data.code !== 0 || !res.data.data || !res.data.data.publicKey || !res.data.data.keyId) {
|
||||
throw new Error(res.data.msg || "登录公钥获取失败,请稍后重试")
|
||||
}
|
||||
this.$set(this, "rasParams", res.data.data)
|
||||
})
|
||||
},
|
||||
ensurePublicKey() {
|
||||
@@ -229,8 +466,18 @@
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.fetchPublicKey()
|
||||
this.getCode()
|
||||
this.$set(this, 'popupToken', window.h5PopupHistory.register(() => this.resetSlider()))
|
||||
this.$set(this, 'pageShowHandler', (event) => {
|
||||
// 从浏览器往返缓存恢复时丢弃旧挑战,避免显示已过期凭证或遗留弹框。
|
||||
if (event.persisted) window.h5PopupHistory.clear(this.popupToken).then(() => this.resetSlider())
|
||||
})
|
||||
window.addEventListener('pageshow', this.pageShowHandler)
|
||||
},
|
||||
beforeDestroy() {
|
||||
clearInterval(this.smsTimer)
|
||||
clearTimeout(this.sliderTimer)
|
||||
window.removeEventListener('pageshow', this.pageShowHandler)
|
||||
window.h5PopupHistory.clear(this.popupToken).then(() => window.h5PopupHistory.unregister(this.popupToken))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -263,6 +510,8 @@
|
||||
}
|
||||
|
||||
.login-container {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
@@ -291,6 +540,121 @@
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
justify-content: center;
|
||||
margin-bottom: 28px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.login-tabs button {
|
||||
padding: 0 0 14px;
|
||||
border: 0;
|
||||
border-bottom: 3px solid transparent;
|
||||
background: transparent;
|
||||
color: #7c8798;
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-tabs button.active {
|
||||
border-bottom-color: #1867b0;
|
||||
color: #1867b0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.login-tabs button:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.captcha-dialog {
|
||||
width: 400px;
|
||||
max-width: calc(100vw - 32px);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.captcha-dialog .el-dialog__body {
|
||||
padding: 10px 20px 20px;
|
||||
}
|
||||
.captcha-panel {
|
||||
padding: 12px;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 6px;
|
||||
background: #f7faff;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.captcha-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.captcha-picture {
|
||||
position: relative;
|
||||
padding-top: 50%;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #e9eef5;
|
||||
}
|
||||
.captcha-background {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.captcha-piece {
|
||||
position: absolute;
|
||||
filter: drop-shadow(0 2px 3px rgba(0, 0, 0, .5));
|
||||
}
|
||||
.captcha-picture img {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
.captcha-range {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
margin: 12px 0 0;
|
||||
border-radius: 5px;
|
||||
background: #e4eaf2;
|
||||
cursor: ew-resize;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
.captcha-range::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 42px;
|
||||
height: 40px;
|
||||
border-radius: 5px;
|
||||
border: 2px solid #fff;
|
||||
background: #409eff;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, .2);
|
||||
}
|
||||
.captcha-range::-moz-range-thumb {
|
||||
width: 38px;
|
||||
height: 36px;
|
||||
border-radius: 5px;
|
||||
border: 2px solid #fff;
|
||||
background: #409eff;
|
||||
}
|
||||
.captcha-range:focus-visible { outline: 2px solid #409eff; outline-offset: 3px; }
|
||||
.captcha-range:disabled { opacity: .55; cursor: wait; }
|
||||
.captcha-hint { margin-top: 6px; color: #909399; font-size: 12px; text-align: center; }
|
||||
|
||||
.login-tip {
|
||||
margin: -4px 0 0;
|
||||
color: #8a94a4;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -317,18 +681,6 @@
|
||||
border-radius: 1.5rem;
|
||||
}
|
||||
|
||||
.login-code {
|
||||
display: inline-block;
|
||||
float: right;
|
||||
width: 38%;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.login-code img {
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-bg-img {
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
@@ -344,7 +696,32 @@
|
||||
}
|
||||
|
||||
.login-container .login-box .login-form {
|
||||
width: 100vw !important;
|
||||
width: 100% !important;
|
||||
padding: 24px 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 30px 22px;
|
||||
}
|
||||
|
||||
.text-6xl {
|
||||
font-size: 24px;
|
||||
line-height: 1.5;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.login-tabs {
|
||||
gap: 26px;
|
||||
}
|
||||
|
||||
.login-tabs button {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
.el-input-group__append {
|
||||
padding: 0 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||