RuoYi 教程-自定义登录逻辑

RuoYi 框架的登录逻辑分析,自定义登录逻辑的基本思路,微信小程序登录例子

前言

本文主要内容:

  • RuoYi 框架的登录逻辑分析
  • 自定义登录逻辑的基本思路
  • 几个例子,如微信小程序登录

RuoYi 框架的登录逻辑分析

登录接口

  • 登录入口,SysLoginControllerlogin 方法,调用loginService.login最终返回一个 Jwt token(登录完成)

  • loginService.login

    • 验证码校验

    • 登录前置校验, 如输入校验、IP黑名单等

    • 生成一个UsernamePasswordAuthenticationToken,并放到 ThreadLocal,然后作为参数调用 spring security 的authenticationManager.authenticate 该方法最终会调用UserDetailsServiceImpl.loadUserByUsername

    • authenticationManager.authenticate 的返回值中获取LoginUser,并作为参数调用tokenService.createToken来获得 Jwt token 并记录 LoginUser信息到 redis(登录完成)

  • UserDetailsServiceImpl.loadUserByUsername

    • userService.selectUserByUserName(username)从系统用户表获取用户 SysUser对象

    • 用户状态检查(是否存在、是否禁用等)

    • passwordService.validate(user); 检查是否短时间内登录失败次数(用到了 ThreadLocal来获得用户名密码)、调用PasswordEncoder(默认是BCryptPasswordEncoder)的 matches方法检验密码是否正确

    • SysUser对象转换为LoginUser对象返回给authenticationManager.authenticateLoginUser 类是 spring security UserDetails 的实现

登录态验证

  • ruoyi-framework/src/main/java/com/ruoyi/framework/config/SecurityConfig.java中,有一行代码是 .addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class),这个过滤器是 JwtAuthenticationTokenFilter
  • 过滤器的 doFilterInternal方法首先 通过tokenService.getLoginUser获取了LoginUser,通过请求中的 Jwt token,还原出 token 的内容,是个 UUID,再通过 UUID 从 redis 中查找对应的 LoginUser
  • 检查LoginUser token 有效期,并在需要时刷新 token 有效期
  • 生成UsernamePasswordAuthenticationToken,并设置到SecurityContextHolder的认证信息中;如果这里不能从 token正常得到认证信息,那么就会没有认证信息,后续 security 流程就会需要重新登录。

自定义登录逻辑的基本思路

了解了登录流程之后,修改登录逻辑的切入点就很多了,这里列举几种思路。

微信小程序开放登录场景(调用第三方 API场景)

  • 开放一个新的 wxLogin 接口
  • 根据前端 wx.login() 获取的凭证 code,调用 auth.code2Session 接口换取唯一标识 openid
  • 查找 sys_user 表中 username 为 openid 的记录,如果没有,存储到 sys_user 表中
  • 如果系统里需要有除了sys_user 字段之外的用户信息要存储,建议新增一个用户表存储,不修改sys_user 表,便于与 RuoYi 保持同步,新的用户表的用户 id(或主键 id) 与 sys_user 表的 user_id 一致,这样业务上的用户 id 与系统登录态的用户 id 是一致的
  • SysUser对象转换为LoginUser对象
  • 创建一个UsernamePasswordAuthenticationToken对象,并调用setDetails(loginUser)
  • 设置到SecurityContextHolder的认证信息中
  • 调用 tokenService.createToken(loginUser)并返回,在此处登录信息已被记录到 redis
  • 后续 请求会由JwtAuthenticationTokenFilter 来还原登录信息

登录界面不变,通过外部 API 验证登录,并同步外部用户信息

比如要做一个医院内的系统,医生信息存在于 HIS,如果本系统需要医生登录,需要通过 HIS 的登录接口进行验证,验证通过后,在本系统记录登录态,本系统知道当前用户是哪个医生。

  • 复用现有的登录界面,用户输入用户名密码后,请求到/login接口

  • 改造UserDetailsServiceImpl.loadUserByUsername方法,不再直接从数据库查询用户信息,而是新增一层

    • @Override
          public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
          {
              //这里进行修改,对接医院 HIS 获取用户,如果记录不存在,保存到用户表
              if(")
              Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext();
              String uid = usernamePasswordAuthenticationToken.getName();
              String pwd = usernamePasswordAuthenticationToken.getCredentials().toString();
              hisUserService.login(uid, pwd);
      
              //原始内容保留
              SysUser user = userService.selectUserByUserName(username);
              //省略...
              //这里进行修改,注释掉密码验证
              //passwordService.validate(user);
      
  • ruoyi-system模块下新增一个 IHisLoginService及其实现类 HisLoginServiceImpl(没有直接修改 SysLoginServiceImpl方便同步 Ruoyi 的更新),在实现类中,通过用户输入的用户名和密码,去调用 HIS 的登录接口

    • 如果登录失败,直接抛出异常,就像UserDetailsServiceImpl.loadUserByUsername那样
    • 如果登录成功,向sys_user表同步 HIS 接口返回的信息,如医生名、科室信息等

注意: spring security 还会调用 org.springframework.security.authentication.dao.DaoAuthenticationProvideradditionalAuthenticationChecks方法,这个方法会再次进行密码比对 if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) {,所以可以在存储用户信息时,使用 SecurityUtils.encryptPassword对密码加密后存储到 password 字段

下面是一个例子

@Service
public class HisUserServiceImpl implements IHisUserService {

    Logger logger = LoggerFactory.getLogger(HisUserServiceImpl.class);

    @Autowired
    ISysUserService sysUserService;

    @Override
    public SysUser login(String uid, String pwd) {
        RestTemplate restTemplate = new RestTemplate();
        try {
            String resp = restTemplate.getForObject(String.format("http://localhost:8080/login?uid=%s&pwd=%s", uid, pwd), String.class);
            logger.info("收到HIS登录接口响应{}", resp);
            ObjectMapper objectMapper = new ObjectMapper();
            Map<String, Object> map = objectMapper.readValue(resp, Map.class);

            if(ObjectUtils.nullSafeEquals(map.get("Result"), true)){
                SysUser user =  sysUserService.selectUserByUserName(uid);
                if(user != null){
                    user.setNickName(map.getOrDefault("CZYM", "医生").toString());
                    user.setPassword(SecurityUtils.encryptPassword(pwd));
                    sysUserService.updateUser(user);
                }else{
                    user = new SysUser();
                    user.setUserName(uid);
                    user.setNickName(map.getOrDefault("CZYM", "医生").toString());
                    user.setPassword(SecurityUtils.encryptPassword(pwd));
                    sysUserService.insertUser(user);
                }
            } else if(ObjectUtils.nullSafeEquals(map.get("Result"), false)){
                throw new ServiceException(map.getOrDefault("Message", "").toString());
            } else {
                throw new Exception("登录接口数据异常");
            }
        } catch (ServiceException e1){
            throw new ServiceException("登录失败:" + e1.getMessage());
        } catch (Exception e){
            logger.error("HIS 登录接口异常:{}", e.getMessage());
            throw new ServiceException("HIS 登录接口异常,请重试");
        }

        return null;
    }
}