Java SpringBoot实现简易扫码登录流程 附项目代码

先总结流程:

  1. 移动端请求扫码登录,服务端生成二维码并缓存二维码ID和状态,将二维码的Base64格式返回给前端展示;
  2. PC端页面轮询检查二维码状态;
  3. 手机扫码后调用扫码接口,携带移动端的Token和二维码ID请求服务端,服务端根据扫码的信息生成临时Totken,将二维码状态更新为已扫码;
  4. PC端页面轮询检查二维码状态;
  5. 用户在移动端确认登录后,携带临时Token和二维码ID请求服务端,服务端校验临时Token,将二维码状态更新为已确认,并生成效期Token;
  6. 电脑端轮询到二维码状态为已确认后,获取效期Token登录成功;


先看简易流程图, 流程图planUML文件在文末的 Github 链接:


流程大概明白后,再看项目简易效果。

启动项目,进入登录界面:http://localhost:8080/login,控制台查看Network, generate 接口获取到了二维码和uuid等信息,同时页面以 uuid 轮询获取二维码最新状态

先使用postMan模拟扫码动作,请求:http://localhost:8080/api/qrcode/scan/2aa6dd82-96e9-4c13-a476-1fa07b913ae

再模拟确认登录,请求:localhost:8080/api/qrcode/confirm/2aa6dd82-96e9-4c13-a476-1fa07b913ae4

如果用户一直没扫码或扫码后一直没确认,二维码会自动提示过期:

再上代码:先创建一个项目,pom.xml 文件引入二维码以及Jwt扩展包:

复制代码
<dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>com.google.zxing</groupId>
            <artifactId>core</artifactId>
            <version>3.4.1</version>
        </dependency>
        <dependency>
            <groupId>com.google.zxing</groupId>
            <artifactId>javase</artifactId>
            <version>3.4.1</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>0.11.5</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>0.11.5</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
            <version>2.1.6.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>2.0.28</version>
        </dependency>
</dependencies>
pom.xml
复制代码

 登录页面controller:HomeController

复制代码
package com.cnblog.qrcodeLogIn.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.servlet.ModelAndView;

/**
 * @author AnYuan
 */

@Controller
@Slf4j
public class HomeController {
    
    @GetMapping("/login")
    public ModelAndView login() {
        log.info("用户进入登录页面");
        return new ModelAndView("login");
    }
   
    @GetMapping("/home")
    public ModelAndView home() {
        log.info("用户扫码登录成功");
        return new ModelAndView("home");
    }
    
}
复制代码

获取二维码生成、更新的controller: LoginController。为什么使用两个controller,是因为 HomeContrller 使用的是模板页面,使用的是 @Controller 注解声明的,而二维码功能接口是使用 @RestControlle注解声明的

复制代码
package com.cnblog.qrcodeLogIn.controller;

import com.alibaba.fastjson2.JSONObject;
import com.cnblog.qrcodeLogIn.dto.LoginInfoDTO;
import com.cnblog.qrcodeLogIn.vo.ResponseVO;
import com.cnblog.qrcodeLogIn.enums.LoginStatusEnum;
import com.cnblog.qrcodeLogIn.utils.JwtUtil;
import com.cnblog.qrcodeLogIn.utils.QRCodeUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;
import java.util.concurrent.TimeUnit;


/**
 * @author AnYuan
 */

@Slf4j
@RestController
@RequestMapping("/api/qrcode")
public class LoginController {
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    
    private String cacheKey(String uuid) {
        return "users:login:" + uuid;
    }
    
    private void cache(LoginInfoDTO loginInfoDTO) {
        // 获取登录缓存信息,有效期2分钟
        stringRedisTemplate.opsForValue().set(cacheKey(loginInfoDTO.getUuid()), JSONObject.toJSONString(loginInfoDTO), 2, TimeUnit.MINUTES);
    }
    
    private LoginInfoDTO getCache(String uuid) {
        // 获取登录缓存信息
        String s = stringRedisTemplate.opsForValue().get(cacheKey(uuid));
        return s == null ? null : JSONObject.parseObject(s, LoginInfoDTO.class);
    }
    
    /**
     * 生成二维码
     * @return ResponseEntity
     * @throws Exception
     */
    @GetMapping("/generate")
    public ResponseEntity<ResponseVO> generateQRCode() throws Exception {
        String uuid = UUID.randomUUID().toString();
        String base64QR = QRCodeUtil.generateQRCode(uuid, 200, 200);
    
        LoginInfoDTO loginInfoDTO = new LoginInfoDTO();
        loginInfoDTO.setStatus(LoginStatusEnum.UNSCANNED.name());
        loginInfoDTO.setUuid(uuid);
    
        // 二维码uuid绑定,存入缓存
        cache(loginInfoDTO);
        
        // 返回生成的二维码信息
        ResponseVO vo = ResponseVO.builder().uuid(uuid).qrcode("data:image/png;base64," + base64QR).build();
        
        log.info("-------生成二维码成功:{}-------", uuid);
        return ResponseEntity.ok(vo);
    }
    
    /**
     * 检查扫码状态
     * @param uuid
     * @return
     */
    @GetMapping("/check/{uuid}")
    public ResponseEntity<?> checkStatus(@PathVariable String uuid) {
    
        LoginInfoDTO loginInfoDTO = getCache(uuid);
        if (loginInfoDTO == null) {
            return ResponseEntity.status(410).body("二维码已过期");
        }
    
        String token = "";
        if (LoginStatusEnum.CONFIRMED.name().equals(loginInfoDTO.getStatus())) {
            token = JwtUtil.generateAuthToken(uuid);
        }
    
        ResponseVO vo = ResponseVO.builder().token(token).status(loginInfoDTO.getStatus()).build();
        
        log.info("-------校验二维码状态uuid:{}, 状态:{}-------", uuid, loginInfoDTO.getStatus());
        return ResponseEntity.ok(vo);
    }
    
    /**
     * 手机端确认登录
     * @param uuid
     * @return
     */
    @PostMapping("/scan/{uuid}")
    public ResponseEntity<?> scanQrCode(@PathVariable String uuid) {
    
        LoginInfoDTO loginInfoDTO = getCache(uuid);
        loginInfoDTO.setStatus(LoginStatusEnum.SCANNED.name());
        
        cache(loginInfoDTO);
    
        log.info("-------扫码成功uuid:{}-------", uuid);
        return ResponseEntity.ok().build();
    }
    
    /**
     * 手机端确认登录
     * @param uuid
     * @return
     */
    @PostMapping("/confirm/{uuid}")
    public ResponseEntity<?> confirm(@PathVariable String uuid) {
    
        LoginInfoDTO loginInfoDTO = getCache(uuid);
        loginInfoDTO.setStatus(LoginStatusEnum.CONFIRMED.name());
    
        cache(loginInfoDTO);
        
        log.info("-------确认登录成功uuid:{}-------", uuid);
        return ResponseEntity.ok().build();
    }
}
复制代码

二维码状态枚举类:LoginStatusEnum

复制代码
package com.cnblog.qrcodeLogIn.enums;

import lombok.Getter;

@Getter
public enum LoginStatusEnum {
    UNSCANNED("未扫描"),
    SCANNED("已扫描"),
    CONFIRMED("已确认");
    
    private String desc;
    
    
    LoginStatusEnum(String desc) {
        this.desc = desc;
    }
}
复制代码

记录二维码状态存储到Redis的实体DTO:LoginInfoDTO

复制代码
package com.cnblog.qrcodeLogIn.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginInfoDTO {
    
    /**
     * 唯一标识
     */
    private String uuid;
    /**
     * 设备号
     */
    private String device;
    
    /**
     * jwt令牌
     */
    private String token;
    
    /**
     * 扫码状态
     */
    private String status;
}
复制代码

返回给前端页面是对象VO:ResponseVO

复制代码
package com.cnblog.qrcodeLogIn.vo;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ResponseVO {
    
    /**
     * 唯一标识
     */
    private String uuid;
    /**
     * 登录二维码
     */
    private String qrcode;
    
    /**
     * jwt令牌
     */
    private String token;
    
    /**
     * 扫码状态
     */
    private String status;
}
复制代码

生成Jwt令牌的工具类:

复制代码
package com.cnblog.qrcodeLogIn.utils;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;

import java.nio.charset.StandardCharsets;
import java.util.Date;

public class JwtUtil {
    
    private static final String SECRET_KEY = "9dad5e7e-bcb7-438f-b39e-ad8c67814915";
    
    public static  String generateAuthToken(String uuid) {
        // 生成JWT或其他形式令牌
        return Jwts.builder()
            .setSubject(uuid)
            .setExpiration(new Date(System.currentTimeMillis() + 3600000)) // 1小时过期
            .signWith(SignatureAlgorithm.HS256, SECRET_KEY.getBytes(StandardCharsets.UTF_8))
            .compact();
    }
}
复制代码

生成二维码的工具类:

复制代码
package com.cnblog.qrcodeLogIn.utils;

import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.MultiFormatWriter;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import com.google.zxing.common.BitMatrix;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Base64;
import java.util.Hashtable;

public class QRCodeUtil {
    
    public static String generateQRCode(String content, int width, int height)
        throws WriterException, IOException {
        
        Hashtable<EncodeHintType, Object> hints = new Hashtable<>();
        hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
        
        BitMatrix matrix = new MultiFormatWriter().encode(
            content, BarcodeFormat.QR_CODE, width, height, hints);
        
        ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
        MatrixToImageWriter.writeToStream(matrix, "PNG", outputStream);
return Base64.getEncoder().encodeToString(outputStream.toByteArray()); } }
复制代码

一个登录页面:login.html

复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>登录界面</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f4f4f4;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
        }

        .login-container {
            background-color: #fff;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
            width: 100%;
            max-width: 400px;
            text-align: center;
        }

        .login-container h2 {
            margin-bottom: 20px;
            color: #333;
        }

        .login-container .forgot-password {
            margin-top: 15px;
            display: block;
            color: #007bff;
            text-decoration: none;
            font-size: 14px;
        }

        .login-container .forgot-password:hover {
            text-decoration: underline;
        }

        .login-container .register-link {
            margin-top: 20px;
            font-size: 14px;
            color: #666;
        }

        .login-container .register-link a {
            color: #007bff;
            text-decoration: none;
        }

        .login-container .register-link a:hover {
            text-decoration: underline;
        }
        .blur-image {
            filter: blur(3px);
        }
    </style>
</head>
<body>

<div class="login-container">
    <h2>扫码登录</h2>
    <div id="qrcode-container">
        <img id="qrcode-img" src="" alt="QR Code" class="">
        <div id="status"></div>
    </div>
    <a href="#" class="forgot-password" id="forgetPsw">忘记密码?</a>
    <div class="register-link" id="signUp">
        还没有账号? <a href="#">立即注册</a>
    </div>
</div>
<script>

    let currentUUID = null;
    let host = "http://localhost:8080";

    // 初始化二维码
    function initQRCode() {
        fetch(host+'/api/qrcode/generate')
            .then(res => res.json())
            .then(data => {
                currentUUID = data.uuid;
                document.getElementById('qrcode-img').src = data.qrcode;
                startPolling();
            });
    }

    // 轮询检查状态
    function startPolling() {
        const interval = setInterval(() => {
            fetch(host+`/api/qrcode/check/${currentUUID}`)
                .then(res => {
                    if(res.status === 410) {
                        clearInterval(interval);
                        showStatus('二维码已过期,请刷新页面');
                        return;
                    }
                    return res.json();
                })
                .then(data => {
                    if(data.status === 'CONFIRMED') {
                        clearInterval(interval);
                        handleLoginSuccess(data.token);
                    } else if(data.status === 'SCANNED') {
                        showStatus('已扫描,请在手机上确认登录');
                    }
                });
        }, 2000);
    }

    function handleLoginSuccess(token) {
        localStorage.setItem('authToken', token);
        window.location.href = '/home';
    }

    function showStatus(msg) {
        document.getElementById('status').innerHTML = msg;
        document.getElementById('forgetPsw').innerHTML="";
        document.getElementById('signUp').innerHTML="";
        document.getElementById('qrcode-img').className = 'blur-image';
    }

    // 初始化
    initQRCode();
</script>
</body>
</html>
login.html
复制代码

最后是扫码登录成功的跳转首页:home.html

复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>登录成功-首页</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f4f4f4;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            margin: 0;
        }

        .login-container {
            background-color: #fff;
            padding: 40px;
            border-radius: 10px;
            box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1);
            width: 100%;
            max-width: 400px;
            text-align: center;
        }

        .login-container h2 {
            margin-bottom: 20px;
            color: #333;
        }
    </style>
</head>
<body>
<div class="login-container">
    <h2>扫码登录成功</h2>
</div>
</body>
</html>
home.html
复制代码

流程中生成临时token的操作没有细写,可根据流程图在对应的逻辑里添加即可。

完整项目结构:

src/main
└── java
    └── com
        └── cnblog
            └── qrcodeLogIn
                ├── controller       // 控制器
                ├── dto         // 实体类
                ├── enums      // 枚举类
                ├── utils         // 工具类
                ├── vo         // 响应类
                └── QrcodeLogInApplication.java
└── resources
    └── templates     // html模板文件
        └── home.html
        └── login.html

 

本篇代码Github:https://github.com/Journeyerr/cnblogs/tree/master/qrcodeLogIn

 

posted @   安逺  阅读(35)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?
点击右上角即可分享
微信分享提示