在为若依(RuoYi)框架集成第三方登录(如钉钉、微信)时,许多开发者会遇到一个看似匪夷所思的问题:明明已经通过第三方回调成功获取了用户信息,并且在本地数据库中也找到了对应的用户,但在最后调用系统登录服务生成Token时,却抛出了 UserPasswordNotMatchException: 用户不存在/密码错误 的异常。
本文将详细剖析这个问题的根源,并提供一套在若依框架中集成第三方登录的正确、标准的代码实践。
问题场景重现
通常,我们的钉钉登录回调处理逻辑会像下面这样:
- 接收钉钉服务器回调的请求体(Body),其中包含用户的手机号、昵称等信息。
- 通过回调中的手机号,查询本地 sys_user 表,找到与之关联的系统用户 SysUser 对象。
- 获取到 sysUser 对象后,尝试调用若依框架自带的 SysLoginService 来完成登录并获取Token。
错误的尝试代码:
codeJava
// ... Controller中
@Autowired
private ISysUserService iSysUserService;
@Autowired
private SysLoginService loginService;
public AjaxResult dingTalkCallback(String body) {
// 1. 解析钉钉回调信息
DingTalkLoginBody loginBody = JSON.parseObject(body, DingTalkLoginBody.class);
// 2. 根据手机号查询本地用户
SysUser sysUser = iSysUserService.selectUserByPhoneNumber(loginBody.getMobile());
if (sysUser == null) {
return AjaxResult.error("登录失败,用户未关联系统账号");
}
// 3. 错误的关键点:尝试使用用户的账号和数据库中的加密密码进行登录
// 这里的 sysUser.getPassword() 是一个加密后的哈希值!
String token = loginService.login(sysUser.getUserName(), sysUser.getPassword());
AjaxResult ajax = AjaxResult.success();
ajax.put(Constants.TOKEN, token);
return ajax;
}
代码执行到 loginService.login(...) 时,便会抛出文章开头提到的密码错误异常。
核心问题分析:为何密码会错误?
这个问题的根源在于对若依(及Spring Security)标准登录流程的误用。
SysLoginService.login(String username, String password) 这个方法,是为传统的、基于用户名和密码的登录场景设计的。它的内部工作流如下:
- 接收一个明文用户名和明文密码作为输入。
- 调用 AuthenticationManager(Spring Security的核心认证管理器)。
- AuthenticationManager 会找到 UserDetailsServiceImpl,根据 username 从数据库加载用户信息,其中包括加密后的密码。
- Spring Security 使用 PasswordEncoder(通常是BCrypt)将用户输入的明文密码进行加密。
- 比较:将刚刚加密后的密码,与从数据库中取出的加密密码进行比对。
- 只有当两者匹配时,认证才算成功。
在我们的错误代码中,我们传给 login 方法的第二个参数 sysUser.getPassword() 是什么?它是我们从数据库里直接查出来的、已经被加密过的密码字符串(例如 10$xxxxxxxx...)。
当这个加密字符串被当作“明文密码”传入 login 方法后,Spring Security 会对它再次进行加密,然后才去和数据库里的原始加密字符串比较。结果必然是 encrypt("加密字符串") 不等于 "加密字符串",认证失败,从而抛出“用户不存在/密码错误”的异常。
一个形象的比喻: 这就好比你的保险箱密码是123。你把写着123的纸条给管家,他能打开。但现在,你把一张写着“保险箱密码是123”的纸条锁进另一个盒子里,然后把这个锁上的盒子交给管家,让他用这个“盒子”去开保险箱,他当然打不开了。
正确的解决思路:信任与授权,而非密码验证
对于钉钉这类第三方登录,其认证逻辑是基于信任与授权,而非密码。用户在钉钉上确认登录,钉钉告诉我们的系统:“这个用户是合法的,他的身份标识是xxx(例如手机号)”。我们的系统在收到这个信息后,要做的不是去验证他的密码,而是:
- 信任钉钉的认证结果。
- 根据钉钉提供的身份标识,找到我们系统内的对应用户。
- 直接为这个已确认身份的用户颁发一个访问令牌(Token)。
这个过程完全不需要用户的密码参与。
标准的解决方案代码
正确的做法是绕过 loginService.login() 方法,在确认用户身份后,手动构建 LoginUser 对象,并直接调用 TokenService 来生成令牌。
第一步:在Controller中注入所需服务
codeJava
@Autowired
private ISysUserService iSysUserService;
@Autowired
private TokenService tokenService; // 核心:用于生成Token
@Autowired
private SysPermissionService permissionService; // 核心:用于获取用户权限
@Autowired
private SysLoginService loginService; // 辅助:用于记录登录日志
第二步:重写回调方法
codeJava
public AjaxResult dingTalkCallback(String body) {
System.out.println("body = " + body);
DingTalkLoginBody loginBody = JSON.parseObject(body, DingTalkLoginBody.class);
if (loginBody == null || StringUtils.isBlank(loginBody.getMobile())) {
return AjaxResult.error("钉钉登录失败,无法获取手机号");
}
// 1. 根据手机号查询系统用户
SysUser sysUser = iSysUserService.selectUserByPhoneNumber(loginBody.getMobile());
// 2. 必要的健壮性校验
if (sysUser == null) {
return AjaxResult.error("登录失败,该手机号尚未关联系统账号");
}
if (UserStatus.DELETED.getCode().equals(sysUser.getDelFlag())) {
return AjaxResult.error("对不起,您的账号已被删除");
}
if (UserStatus.DISABLE.getCode().equals(sysUser.getStatus())) {
return AjaxResult.error("对不起,您的账号已被停用");
}
// --- 核心修改:绕过密码验证,直接构建认证主体并生成Token ---
// 3. 手动构建 LoginUser 对象
// LoginUser 是若依在Security中实际使用的用户认证对象,包含了权限信息
LoginUser loginUser = new LoginUser(sysUser.getUserId(), sysUser.getDeptId(), sysUser,
permissionService.getMenuPermission(sysUser));
// 4. (推荐) 记录登录日志,保持行为一致性
loginService.recordLoginInfo(sysUser.getUserId());
// 5. 直接调用 TokenService 生成令牌
String token = tokenService.createToken(loginUser);
// 6. 返回成功结果
AjaxResult ajax = AjaxResult.success("登录成功");
ajax.put(Constants.TOKEN, token);
return ajax;
}
总结
关键的认知转变:
- 账号密码登录 = 验证凭证,需要调用 loginService.login()。
- 第三方授权登录 = 信任授权,需要绕过密码验证,直接为已识别的用户 SysUser 构建 LoginUser 并调用 tokenService.createToken()。