一、扫码登录pc端功能实现逻辑

1、pc生成二维码(包含一个二维码token,后端生成token并存入redis和初始状态为等待,设置二维码过期时间)
2、打开二维码页面时,生成二维码,并开始不断向后端轮询检查redis中二维码token状态
3、手机端扫码获取二维码的token,并向后端更新token状态(更新redis中的token状态为已扫码,存入移动端登录的用户名)
4、轮询还在继续,在轮询中判断获取到的token状态是否是已扫码,是的话提取其中用户名并中断轮询,再后端发起系统的登入请求(额外写一个只需用户名登录的接口,获取token,这一步可以和状态方法合并,避免免密登录接口暴露)成功后再重定向到系统首页

二、java后端实现

1.扫码登录业务服务 Service

@Service
public class ScanLoginService {
    @Autowired
    private RedisCache redisCache;

    private static final String LOGIN_TOKEN_PREFIX = "scan_token:";
    //创建token并记录到redis
    public String createLoginToken() {
        String token = UUID.randomUUID().toString();
        redisCache.setCacheObject(LOGIN_TOKEN_PREFIX + token, "waiting", 10, TimeUnit.MINUTES);
        return token;
    }
    //扫码后调用更新redis中token状态
    public boolean scanToken(String token, String username) {
        String key = LOGIN_TOKEN_PREFIX + token;
        String status = redisCache.getCacheObject(key);
        if (status == null || !"waiting".equals(status)) {
            return false;
        }
        // 标记为已扫码,存用户名(后续可以自动登录)
        redisCache.setCacheObject(key, "scanned:" + username, 10, TimeUnit.MINUTES);
        return true;
    }
    //检查redis中token状态
    public String checkStatus(String token) {
        return redisCache.getCacheObject(LOGIN_TOKEN_PREFIX + token);
    }

}

2.新建扫码登录 Controller

主要接口:二维码token、移动端扫码后调用、查询扫码状态(合并登录判断,避免用户名登入接口对外开放),同时在放开接口权限:在SecurityConfig的configure(HttpSecurity httpSecurity)方法中.antMatchers("/scanLogin/**").permitAll()

@RestController
@RequestMapping("/scanLogin")
public class ScanLoginController {

    @Autowired
    private ScanLoginService scanLoginService;
    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private TokenService tokenService;

    @Autowired
    private ISysUserService userService;


    // 生成二维码token
    @GetMapping("/token")
    public AjaxResult getToken() {
        String token = scanLoginService.createLoginToken();
        return AjaxResult.success("token", token);
    }

    // 扫码接口,扫码端调用(微信扫码确认登录)
    @PostMapping("/scan")
    public AjaxResult scan(@RequestBody Map<String, String> params) {
        String token = params.get("token");
        String username = params.get("username");
        boolean success = scanLoginService.scanToken(token, username);
        if (success) {
            return AjaxResult.success("扫码成功");
        }
        return AjaxResult.error("扫码失败,token无效或超时");
    }

    // 查询扫码状态
    @GetMapping("/status")
    public AjaxResult status(@RequestParam String token) {
        String status = scanLoginService.checkStatus(token);
        if (status == null) {
            return AjaxResult.error("token无效或过期");
        }
        if(status.startsWith("scanned:")){
            String[] split = status.split(":");
            String username = split[1];
            SysUser user = userService.selectUserByUserName(username);
            if (user == null) {
                return AjaxResult.error("用户不存在");
            }
            try {
                // 不用密码 用自定义AuthenticationProvider认证
                Authentication authentication = authenticationManager.authenticate(new ScanAuthenticationToken(username));
                SecurityContextHolder.getContext().setAuthentication(authentication);
                LoginUser loginUser = (LoginUser) authentication.getPrincipal();
                // 生成JWT token返回
                String jwtToken = tokenService.createToken(loginUser, CacheConstants.LOGIN_PC);
                String res="token:"+jwtToken;
                return AjaxResult.success("status",res);
            } catch (AuthenticationException e) {
                return AjaxResult.error("认证失败:" + e.getMessage());
            }
        }
        return AjaxResult.success("status",status);
    }
}

(中场休息)前面接口某些类会报红不存在,下面处理这个问题,处理新增扫码的认证实现:在com.ruoyi.framework.security下建包scan,然后加入下面三个自定义内容

3.自定义扫码登录认证 Provider

import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

/**
 * @author Administrator
 */
public class ScanAuthenticationProvider implements AuthenticationProvider {

    private UserDetailsService userDetailsService;

    public ScanAuthenticationProvider(UserDetailsService userDetailsService){
        setUserDetailsService(userDetailsService);
    }

    /**
     * 重写 authenticate方法,实现身份验证逻辑。
     */
    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        ScanAuthenticationToken authenticationToken = (ScanAuthenticationToken) authentication;
        String username = (String) authenticationToken.getPrincipal();
        // 委托 UserDetailsService 查找系统用户
        UserDetails userDetails = userDetailsService.loadUserByUsername(username);
        // 鉴权成功,返回一个拥有鉴权的 AbstractAuthenticationToken
        ScanAuthenticationToken authenticationResult = new ScanAuthenticationToken(userDetails, userDetails.getAuthorities());
        authenticationResult.setDetails(authenticationToken.getDetails());
        return authenticationResult;
    }

    /**
     * 重写supports方法,指定此 AuthenticationProvider 仅支持短信验证码身份验证。
     */
    @Override
    public boolean supports(Class<?> authentication) {
        return ScanAuthenticationToken.class.isAssignableFrom(authentication);
    }

    public UserDetailsService getUserDetailsService() {
        return userDetailsService;
    }

    public void setUserDetailsService(UserDetailsService userDetailsService) {
        this.userDetailsService = userDetailsService;
    }
}

4.自定义扫码登录token验证

import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;

import java.util.Collection;

/**
 * 自定义扫码登录token验证
 */
public class ScanAuthenticationToken extends AbstractAuthenticationToken {

    /**
     * 存储用户id
     */
    private final Object principal;

    /**
     * 构建一个没有鉴权的构造函数
     */
    public ScanAuthenticationToken(Object principal) {
        super(null);
        this.principal = principal;

        setAuthenticated(false);
    }

    /**
     * 构建一个拥有鉴权的构造函数
     */
    public ScanAuthenticationToken(Object principal, Collection<? extends GrantedAuthority> authorities) {
        super(authorities);
        this.principal = principal;
        //告诉后面的过滤器这个已经 认证通过了
        super.setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null;
    }

    @Override
    public Object getPrincipal() {
        return this.principal;
    }

    @Override
    public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
        if (isAuthenticated) {
            throw new IllegalArgumentException(
                    "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
        }
        super.setAuthenticated(false);
    }

    @Override
    public void eraseCredentials() {
        super.eraseCredentials();
    }

}

5.自定义扫码登录用户验证处理

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

/**
 * 用户验证处理
 *
 * @author Administrator
 */
@Service("userDetailsByScan")
public class UserDetailsByScan implements UserDetailsService {

    private static final Logger log = LoggerFactory.getLogger(UserDetailsByScan.class);

    @Autowired
    private ISysUserService userService;

    @Autowired
    private SysPermissionService permissionService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 防止漏查角色等所需的信息,尽量模仿根据用户名查用户的方法开发:ISysUserService.selectUserByUserName(username)
        SysUser user = userService.selectUserByUserName(username);
        if (StringUtils.isNull(user)) {
            log.info("登录用户:{} 不存在.", username);
            throw new ServiceException("登录用户:" + username+ " 不存在");
        } else if (UserStatus.DELETED.getCode().equals(user.getDelFlag())) {
            log.info("登录用户:{} 已被删除.", username);
            throw new ServiceException("对不起,您的账号:" + username+ " 已被删除");
        } else if (UserStatus.DISABLE.getCode().equals(user.getStatus())) {
            log.info("登录用户:{} 已被停用.", username);
            throw new ServiceException("对不起,您的账号:" + username+ " 已停用");
        }
        return createLoginUser(user);
    }

    public UserDetails createLoginUser(SysUser user) {
        return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
    }

}

6.Security配置中注册自定义Provider

找到SecurityConfig,在configure加入ScanAuthenticationProvider

Autowired
@Qualifier("userDetailsByScan")
private UserDetailsByScan userDetailsByScan;

/**
 * 身份认证接口
 */
 @Override
 protected void configure(AuthenticationManagerBuilder auth) throws Exception
 {
     if (!casProperties.isCasEnable()) {
         //扫码登录provider
         auth.authenticationProvider(new ScanAuthenticationProvider(userDetailsByScan));
         // 用户密码登录
           auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
      }else{
         super.configure(auth);
         auth.authenticationProvider(casAuthenticationProvider());
      }
 }

三、前端逻辑实现

1、接口API的封装js

import request from '@/utils/request'

// 获取扫码token
export function getScanToken() {
  return request({
    url: '/scanLogin/token',
    method: 'get'
  })
}
// 检查扫码token状态
export function getScanTokenStatus(token) {
  return request({
    url: '/scanLogin/status',
    method: 'get',
    params: { token }
  });
}

2、登录页面

关注实现逻辑,一些前端样式可能会有问题

<template>  
  <div class="login">  
///////////其他/////////////////
      <div v-if="currentLoginMethod === 'scan'" class="scan-login">  
    <h2 style="margin-top: 50px;">扫码登录</h2>  
    <div class="qr-code-container" style="position: relative; width: 240px; height: 240px;">  
      <img  
        v-if="qrCodeUrl"  
        :src="qrCodeUrl"  
        class="qr-code-img"  
        alt="QR Code"  
        style="width: 240px; height: 240px;"  
      />  
      <div v-if="qrCodeExpired">  
        <p class="expired-text" style="color:red;font-weight:bold;">二维码已失效</p>  
        <button class="button1" @click="refreshQRCode">请点击刷新</button>  
      </div>  
    </div>  
    <p style="margin-bottom: 52px;">请使用手机扫描二维码进行登录</p>
      </div>  
  </div>  
</template>  

<script>  
import { getScanToken, getScanTokenStatus} from "@/api/scanLogin";  
import QRCode from "qrcodejs2";  // 你需要安装 qrcode:npm install 
import { setToken } from '@/utils/auth';  

export default {  
  name: "Login",  
  data() {  
    return {  
      currentLoginMethod: 'form', // 'form'或'scan'  
      qrCodeExpired: false,  
      scanToken: '',  
      pollingInterval: null,  
      qrCodeUrl:'',
    };  
  },  
  watch: {  
    currentLoginMethod(newVal) {  
      if (newVal === 'scan') {  
        this.initScanLogin();  
      } else {  
        this.clearScanLogin();  
      }  
    }  
  },  
  mounted() {  
    if (this.currentLoginMethod === 'scan') {  
      this.initScanLogin();  
    }  
  },  
  beforeDestroy() {  
    this.clearScanLogin();  
  },  
  methods: {  
    toggleLoginMethod() {  
      this.currentLoginMethod = this.currentLoginMethod === 'form' ? 'scan' : 'form';  
    },  
    //初始化二维码相关方法
    async initScanLogin() {  
      this.qrCodeExpired = false;  
      await this.createQRCode();  
      this.startPolling();  
      this.startQRCodeTimer();  
    },  

    clearScanLogin() {  
      this.qrCodeExpired = false;  
      if (this.pollingInterval) {  
        clearInterval(this.pollingInterval);  
        this.pollingInterval = null;  
      }  
      this.stopQRCodeTimer();  
      this.scanToken = '';  
      if (this.$refs.qrCodeContainer) {  
        this.$refs.qrCodeContainer.innerHTML = '';  
      }  
    },  
    //创建二维码
    async createQRCode() {  
      try {  
        const res = await getScanToken();  
        this.scanToken = res.token;  
        //看业务情况自定义内容,不一定要放链接,可以直接给token也行,具体看移动端实现
        // 二维码内容,扫码端打开确认页面URL,携带token  
        const qrContent = `${window.location.origin}/scanConfirm?token=${this.scanToken}`;  

        // 清空之前二维码  
        if (this.$refs.qrCodeContainer) {  
          this.$refs.qrCodeContainer.innerHTML = '';  
          // 生成二维码  
          new QRCode(this.$refs.qrCodeContainer, {  
            text: qrContent,  
            width: 240,  
            height: 240,  
            colorDark: "#000000",  
            colorLight: "#ffffff",  
            correctLevel: QRCode.CorrectLevel.H  
          });  
        }  
      } catch (error) {  
        this.$message.error('获取扫码二维码失败,请稍后重试');  
        console.error(error);  
      }  
    },  

      //轮询获取扫码状态
      startPolling() {
        if (this.pollingInterval) {
          clearInterval(this.pollingInterval);
        }
        this.pollingInterval = setInterval(async () => {
          if (!this.scanToken) return;
          try {
            const res = await getScanTokenStatus(this.scanToken);
            if (res.code!=200) {
              // token过期或无效
              this.qrCodeExpired = true;
              clearInterval(this.pollingInterval);
              this.pollingInterval = null;
              return;
            }
            const status = res.data || '';
            if(status.startsWith('token:')){
               const jwtToken = status.split(':')[1];
               clearInterval(this.pollingInterval);
               this.pollingInterval = null;
               this.qrCodeExpired = true; // 防止继续轮询
               // 调用扫码登录接口,自动登录
               await this.scanLogin(jwtToken);
            }
          } catch (error) {
            console.error('扫码状态轮询异常', error);
          }
        }, 2000);
      },
      //二维码登录跳转
      async scanLogin(jwtToken) {
        try {
            setToken(jwtToken);
            this.$message.success('扫码登录成功');
            // 登录成功后跳转首页或重定向页面
            this.$router.push(this.redirect || '/');
        } catch (error) {
          this.$message.error('扫码登录异常,请重试');
          console.error(error);
        }
      },
    //启动定时器
    startQRCodeTimer() {  
      this.qrCodeExpired = false;  
      if (this.qrCodeTimeout) {  
        clearTimeout(this.qrCodeTimeout);  
      }  
      this.qrCodeTimeout = setTimeout(() => {  
        this.qrCodeExpired = true;  
        if (this.pollingInterval) {  
          clearInterval(this.pollingInterval);  
          this.pollingInterval = null;  
        }  
      }, 30000); // 30秒二维码过期  
    },  
    //定时器停止
    stopQRCodeTimer() {  
      if (this.qrCodeTimeout) {  
        clearTimeout(this.qrCodeTimeout);  
      }  
    },  
    //刷新二维码
    refreshQRCode() {  
      this.qrCodeExpired = false;  
      this.createQRCode();  
      this.startPolling();  
      this.startQRCodeTimer();  
    }  
  }  
};  
</script>  

四、总结

关注实现的具体逻辑思路就行。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐