无需微信依赖,纯网页扫码登录实现方案解析及实战

无需微信依赖,纯网页扫码登录实现方案解析及实战

作为一名资深后端开发,你有没有遇到过这样的场景:产品经理跑过来说:"我们网站要支持扫码登录,要像微信一样方便!"但你又不想依赖微信的生态,想自己实现一套完整的扫码登录系统?

今天就来聊聊如何实现一套纯网页的扫码登录系统,不依赖任何第三方平台,让你的用户通过手机扫描网页上的二维码就能快速登录!

一、扫码登录的核心原理

扫码登录的本质是通过二维码作为信息载体,在网页端和手机端之间建立安全的身份验证通道。整个过程可以概括为以下几个步骤:

  1. 生成二维码:网页端请求服务器生成唯一的二维码
  2. 展示二维码:网页端展示二维码并轮询登录状态
  3. 扫描二维码:用户使用手机扫描二维码
  4. 确认登录:手机端确认登录请求
  5. 完成登录:网页端获取登录凭证并完成登录
Web端->服务器: 1. 请求生成二维码
服务器->Web端: 2. 返回二维码ID
Web端->Web端: 3. 展示二维码并轮询
手机端->服务器: 4. 扫描二维码
服务器->手机端: 5. 返回确认信息
手机端->服务器: 6. 确认登录
服务器->Web端: 7. 通知登录成功
Web端->Web端: 8. 完成登录跳转

二、技术架构设计

2.1 核心组件

一个完整的扫码登录系统需要以下核心组件:

  1. 二维码生成服务:负责生成和管理二维码
  2. 状态轮询服务:网页端轮询二维码状态
  3. 手机端API:处理手机扫描和确认请求
  4. 状态存储:存储二维码状态信息
  5. 安全认证:确保登录过程的安全性

2.2 状态管理设计

二维码在整个登录过程中会经历不同的状态:

public enum QrCodeStatus {
    /**
     * 未扫描状态
     */
    UNSCANNED(0, "未扫描"),
    
    /**
     * 已扫描待确认状态
     */
    SCANNED(1, "已扫描"),
    
    /**
     * 已确认登录状态
     */
    CONFIRMED(2, "已确认"),
    
    /**
     * 已取消状态
     */
    CANCELLED(3, "已取消"),
    
    /**
     * 已过期状态
     */
    EXPIRED(4, "已过期");
    
    private final int code;
    private final String description;
    
    QrCodeStatus(int code, String description) {
        this.code = code;
        this.description = description;
    }
    
    // getter方法...
}

三、后端实现详解

3.1 二维码实体类

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class QrCodeInfo {
    
    /**
     * 二维码唯一标识
     */
    private String qrCodeId;
    
    /**
     * 二维码状态
     */
    private QrCodeStatus status;
    
    /**
     * 用户ID(确认登录后填充)
     */
    private Long userId;
    
    /**
     * 用户信息(确认登录后填充)
     */
    private UserInfo userInfo;
    
    /**
     * 创建时间
     */
    private Long createTime;
    
    /**
     * 过期时间
     */
    private Long expireTime;
    
    /**
     * 二维码内容
     */
    private String content;
}

3.2 Redis存储设计

使用Redis来存储二维码状态信息,设置合理的过期时间:

@Service
@Slf4j
public class QrCodeService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private UserService userService;
    
    /**
     * 二维码过期时间(单位:秒)
     */
    private static final long QR_CODE_EXPIRE_TIME = 300; // 5分钟
    
    /**
     * 生成二维码
     */
    public QrCodeInfo generateQrCode() {
        // 生成唯一ID
        String qrCodeId = UUID.randomUUID().toString().replace("-", "");
        
        // 构造二维码内容(可以是JSON或其他格式)
        Map<String, Object> contentMap = new HashMap<>();
        contentMap.put("qrCodeId", qrCodeId);
        contentMap.put("timestamp", System.currentTimeMillis());
        
        String content = JSON.toJSONString(contentMap);
        
        // 创建二维码信息
        QrCodeInfo qrCodeInfo = QrCodeInfo.builder()
                .qrCodeId(qrCodeId)
                .status(QrCodeStatus.UNSCANNED)
                .createTime(System.currentTimeMillis())
                .expireTime(System.currentTimeMillis() + QR_CODE_EXPIRE_TIME * 1000)
                .content(content)
                .build();
        
        // 存储到Redis
        String key = "qrcode:" + qrCodeId;
        redisTemplate.opsForValue().set(key, qrCodeInfo, QR_CODE_EXPIRE_TIME, TimeUnit.SECONDS);
        
        log.info("生成二维码: {}", qrCodeId);
        return qrCodeInfo;
    }
    
    /**
     * 获取二维码状态
     */
    public QrCodeInfo getQrCodeStatus(String qrCodeId) {
        String key = "qrcode:" + qrCodeId;
        QrCodeInfo qrCodeInfo = (QrCodeInfo) redisTemplate.opsForValue().get(key);
        
        // 检查是否过期
        if (qrCodeInfo != null && System.currentTimeMillis() > qrCodeInfo.getExpireTime()) {
            qrCodeInfo.setStatus(QrCodeStatus.EXPIRED);
            redisTemplate.opsForValue().set(key, qrCodeInfo, 10, TimeUnit.SECONDS); // 短暂存储过期状态
        }
        
        return qrCodeInfo;
    }
    
    /**
     * 处理二维码扫描
     */
    public boolean scanQrCode(String qrCodeId, Long userId) {
        String key = "qrcode:" + qrCodeId;
        QrCodeInfo qrCodeInfo = (QrCodeInfo) redisTemplate.opsForValue().get(key);
        
        if (qrCodeInfo == null) {
            return false;
        }
        
        // 检查状态是否允许扫描
        if (qrCodeInfo.getStatus() != QrCodeStatus.UNSCANNED) {
            return false;
        }
        
        // 更新状态为已扫描
        qrCodeInfo.setStatus(QrCodeStatus.SCANNED);
        qrCodeInfo.setUserId(userId);
        
        // 获取用户信息
        UserInfo userInfo = userService.getUserById(userId);
        qrCodeInfo.setUserInfo(userInfo);
        
        // 更新Redis
        redisTemplate.opsForValue().set(key, qrCodeInfo, 
                (qrCodeInfo.getExpireTime() - System.currentTimeMillis()) / 1000, TimeUnit.SECONDS);
        
        log.info("二维码已扫描: {}, 用户ID: {}", qrCodeId, userId);
        return true;
    }
    
    /**
     * 确认登录
     */
    public boolean confirmLogin(String qrCodeId) {
        String key = "qrcode:" + qrCodeId;
        QrCodeInfo qrCodeInfo = (QrCodeInfo) redisTemplate.opsForValue().get(key);
        
        if (qrCodeInfo == null) {
            return false;
        }
        
        // 检查状态是否允许确认
        if (qrCodeInfo.getStatus() != QrCodeStatus.SCANNED) {
            return false;
        }
        
        // 更新状态为已确认
        qrCodeInfo.setStatus(QrCodeStatus.CONFIRMED);
        
        // 更新Redis
        redisTemplate.opsForValue().set(key, qrCodeInfo, 
                (qrCodeInfo.getExpireTime() - System.currentTimeMillis()) / 1000, TimeUnit.SECONDS);
        
        log.info("二维码登录已确认: {}", qrCodeId);
        return true;
    }
    
    /**
     * 取消登录
     */
    public boolean cancelLogin(String qrCodeId) {
        String key = "qrcode:" + qrCodeId;
        QrCodeInfo qrCodeInfo = (QrCodeInfo) redisTemplate.opsForValue().get(key);
        
        if (qrCodeInfo == null) {
            return false;
        }
        
        // 更新状态为已取消
        qrCodeInfo.setStatus(QrCodeStatus.CANCELLED);
        
        // 更新Redis
        redisTemplate.opsForValue().set(key, qrCodeInfo, 
                (qrCodeInfo.getExpireTime() - System.currentTimeMillis()) / 1000, TimeUnit.SECONDS);
        
        log.info("二维码登录已取消: {}", qrCodeId);
        return true;
    }
}

3.3 控制器实现

@RestController
@RequestMapping("/api/qrcode")
@Api(tags = "扫码登录")
@Slf4j
public class QrCodeController {
    
    @Autowired
    private QrCodeService qrCodeService;
    
    @Autowired
    private TokenService tokenService;
    
    /**
     * 生成二维码
     */
    @GetMapping("/generate")
    @ApiOperation("生成登录二维码")
    public Result<QrCodeResponse> generateQrCode() {
        try {
            QrCodeInfo qrCodeInfo = qrCodeService.generateQrCode();
            
            QrCodeResponse response = QrCodeResponse.builder()
                    .qrCodeId(qrCodeInfo.getQrCodeId())
                    .content(qrCodeInfo.getContent())
                    .expireTime(qrCodeInfo.getExpireTime())
                    .build();
            
            return Result.success(response);
        } catch (Exception e) {
            log.error("生成二维码失败", e);
            return Result.error("生成二维码失败");
        }
    }
    
    /**
     * 轮询二维码状态
     */
    @GetMapping("/status/{qrCodeId}")
    @ApiOperation("获取二维码状态")
    public Result<QrCodeStatusResponse> getQrCodeStatus(@PathVariable String qrCodeId) {
        try {
            QrCodeInfo qrCodeInfo = qrCodeService.getQrCodeStatus(qrCodeId);
            
            if (qrCodeInfo == null) {
                return Result.error("二维码不存在或已过期");
            }
            
            QrCodeStatusResponse response = QrCodeStatusResponse.builder()
                    .qrCodeId(qrCodeId)
                    .status(qrCodeInfo.getStatus().getCode())
                    .statusDesc(qrCodeInfo.getStatus().getDescription())
                    .userInfo(qrCodeInfo.getUserInfo())
                    .build();
            
            // 如果是已确认状态,生成token
            if (qrCodeInfo.getStatus() == QrCodeStatus.CONFIRMED) {
                String token = tokenService.generateToken(qrCodeInfo.getUserId());
                response.setToken(token);
            }
            
            return Result.success(response);
        } catch (Exception e) {
            log.error("获取二维码状态失败", e);
            return Result.error("获取二维码状态失败");
        }
    }
    
    /**
     * 手机端扫描二维码
     */
    @PostMapping("/scan")
    @ApiOperation("扫描二维码")
    public Result<String> scanQrCode(@RequestBody ScanQrCodeRequest request) {
        try {
            boolean success = qrCodeService.scanQrCode(request.getQrCodeId(), request.getUserId());
            
            if (success) {
                return Result.success("扫描成功");
            } else {
                return Result.error("扫描失败");
            }
        } catch (Exception e) {
            log.error("扫描二维码失败", e);
            return Result.error("扫描二维码失败");
        }
    }
    
    /**
     * 确认登录
     */
    @PostMapping("/confirm")
    @ApiOperation("确认登录")
    public Result<LoginResponse> confirmLogin(@RequestBody ConfirmLoginRequest request) {
        try {
            boolean success = qrCodeService.confirmLogin(request.getQrCodeId());
            
            if (success) {
                // 获取二维码信息
                QrCodeInfo qrCodeInfo = qrCodeService.getQrCodeStatus(request.getQrCodeId());
                
                // 生成token
                String token = tokenService.generateToken(qrCodeInfo.getUserId());
                
                LoginResponse response = LoginResponse.builder()
                        .token(token)
                        .userInfo(qrCodeInfo.getUserInfo())
                        .build();
                
                return Result.success(response);
            } else {
                return Result.error("确认登录失败");
            }
        } catch (Exception e) {
            log.error("确认登录失败", e);
            return Result.error("确认登录失败");
        }
    }
    
    /**
     * 取消登录
     */
    @PostMapping("/cancel")
    @ApiOperation("取消登录")
    public Result<String> cancelLogin(@RequestBody CancelLoginRequest request) {
        try {
            boolean success = qrCodeService.cancelLogin(request.getQrCodeId());
            
            if (success) {
                return Result.success("取消成功");
            } else {
                return Result.error("取消失败");
            }
        } catch (Exception e) {
            log.error("取消登录失败", e);
            return Result.error("取消登录失败");
        }
    }
}

3.4 请求响应对象

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QrCodeResponse {
    private String qrCodeId;
    private String content;
    private Long expireTime;
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QrCodeStatusResponse {
    private String qrCodeId;
    private Integer status;
    private String statusDesc;
    private UserInfo userInfo;
    private String token;
}

@Data
public class ScanQrCodeRequest {
    private String qrCodeId;
    private Long userId;
}

@Data
public class ConfirmLoginRequest {
    private String qrCodeId;
}

@Data
public class CancelLoginRequest {
    private String qrCodeId;
}

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
    private String token;
    private UserInfo userInfo;
}

四、前端实现详解

4.1 二维码生成和展示

// 扫码登录组件
export default {
    data() {
        return {
            qrCodeId: '',
            qrCodeContent: '',
            qrCodeStatus: 0, // 0:未扫描 1:已扫描 2:已确认 3:已取消 4:已过期
            userInfo: null,
            timer: null,
            countdown: 300 // 倒计时
        }
    },
    
    mounted() {
        this.generateQrCode();
    },
    
    beforeDestroy() {
        this.clearTimer();
    },
    
    methods: {
        // 生成二维码
        async generateQrCode() {
            try {
                const response = await this.$http.get('/api/qrcode/generate');
                if (response.data.code === 200) {
                    this.qrCodeId = response.data.data.qrCodeId;
                    this.qrCodeContent = response.data.data.content;
                    
                    // 生成二维码图片
                    this.$nextTick(() => {
                        this.renderQrCode();
                    });
                    
                    // 开始轮询状态
                    this.startPolling();
                    
                    // 开始倒计时
                    this.startCountdown();
                }
            } catch (error) {
                console.error('生成二维码失败', error);
            }
        },
        
        // 渲染二维码
        renderQrCode() {
            const qrCodeElement = this.$refs.qrCode;
            if (qrCodeElement) {
                // 使用qrcode.js库生成二维码
                new QRCode(qrCodeElement, {
                    text: this.qrCodeContent,
                    width: 200,
                    height: 200,
                    colorDark: '#000000',
                    colorLight: '#ffffff',
                    correctLevel: QRCode.CorrectLevel.H
                });
            }
        },
        
        // 开始轮询状态
        startPolling() {
            this.clearTimer();
            this.timer = setInterval(() => {
                this.checkQrCodeStatus();
            }, 1000); // 每秒轮询一次
        },
        
        // 检查二维码状态
        async checkQrCodeStatus() {
            try {
                const response = await this.$http.get(`/api/qrcode/status/${this.qrCodeId}`);
                if (response.data.code === 200) {
                    const status = response.data.data.status;
                    this.qrCodeStatus = status;
                    
                    // 根据状态处理
                    switch (status) {
                        case 1: // 已扫描
                            this.userInfo = response.data.data.userInfo;
                            break;
                        case 2: // 已确认
                            this.handleLoginSuccess(response.data.data.token);
                            break;
                        case 3: // 已取消
                            this.handleLoginCancel();
                            break;
                        case 4: // 已过期
                            this.handleQrCodeExpired();
                            break;
                    }
                }
            } catch (error) {
                console.error('检查二维码状态失败', error);
            }
        },
        
        // 开始倒计时
        startCountdown() {
            const countdownTimer = setInterval(() => {
                this.countdown--;
                if (this.countdown <= 0) {
                    clearInterval(countdownTimer);
                    this.handleQrCodeExpired();
                }
            }, 1000);
        },
        
        // 处理登录成功
        handleLoginSuccess(token) {
            // 保存token
            localStorage.setItem('token', token);
            
            // 跳转到首页
            this.$router.push('/dashboard');
            
            // 清理定时器
            this.clearTimer();
        },
        
        // 处理登录取消
        handleLoginCancel() {
            this.$message.warning('登录已取消');
            this.clearTimer();
        },
        
        // 处理二维码过期
        handleQrCodeExpired() {
            this.$message.error('二维码已过期,请刷新重试');
            this.clearTimer();
        },
        
        // 清理定时器
        clearTimer() {
            if (this.timer) {
                clearInterval(this.timer);
                this.timer = null;
            }
        },
        
        // 刷新二维码
        refreshQrCode() {
            this.clearTimer();
            this.generateQrCode();
        }
    }
}

4.2 Vue组件模板

<template>
    <div class="qrcode-login">
        <div class="login-container">
            <div class="qrcode-section">
                <h3>扫码登录</h3>
                <div class="qrcode-wrapper">
                    <div v-if="qrCodeContent" ref="qrCode" class="qrcode-display"></div>
                    <div v-else class="qrcode-loading">二维码生成中...</div>
                </div>
                
                <div class="qrcode-status">
                    <div v-if="qrCodeStatus === 0" class="status-text">
                        <i class="el-icon-monitor"></i>
                        请使用手机扫描二维码
                    </div>
                    <div v-else-if="qrCodeStatus === 1" class="status-text scanned">
                        <i class="el-icon-check"></i>
                        扫描成功,请在手机上确认登录
                        <div v-if="userInfo" class="user-info">
                            <img :src="userInfo.avatar" class="user-avatar" />
                            <span>{{ userInfo.nickname }}</span>
                        </div>
                    </div>
                    <div v-else-if="qrCodeStatus === 2" class="status-text confirmed">
                        <i class="el-icon-success"></i>
                        登录成功,正在跳转...
                    </div>
                    <div v-else-if="qrCodeStatus === 3" class="status-text cancelled">
                        <i class="el-icon-circle-close"></i>
                        登录已取消
                    </div>
                    <div v-else-if="qrCodeStatus === 4" class="status-text expired">
                        <i class="el-icon-time"></i>
                        二维码已过期
                    </div>
                </div>
                
                <div class="countdown">
                    二维码有效期: {{ Math.floor(countdown / 60) }}:{{ (countdown % 60).toString().padStart(2, '0') }}
                </div>
                
                <div class="refresh-btn">
                    <el-button v-if="qrCodeStatus === 3 || qrCodeStatus === 4" 
                              type="primary" 
                              @click="refreshQrCode">
                        刷新二维码
                    </el-button>
                </div>
            </div>
            
            <div class="instructions">
                <h4>扫码登录说明</h4>
                <ol>
                    <li>打开手机应用</li>
                    <li>点击右上角"扫一扫"</li>
                    <li>扫描屏幕上的二维码</li>
                    <li>在手机上确认登录</li>
                </ol>
            </div>
        </div>
    </div>
</template>

<script>
import QRCode from 'qrcodejs2'

export default {
    // ... 上面的JavaScript代码
}
</script>

<style scoped>
.qrcode-login {
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
    background-color: #f5f5f5;
}

.login-container {
    display: flex;
    background: white;
    border-radius: 8px;
    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
    padding: 40px;
}

.qrcode-section {
    text-align: center;
    margin-right: 40px;
}

.qrcode-wrapper {
    margin: 20px 0;
}

.qrcode-display {
    display: inline-block;
    padding: 10px;
    border: 1px solid #eee;
    border-radius: 4px;
}

.qrcode-loading {
    width: 200px;
    height: 200px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: #f9f9f9;
    border: 1px dashed #ddd;
}

.qrcode-status {
    margin: 20px 0;
    min-height: 80px;
}

.status-text {
    font-size: 16px;
    color: #666;
}

.status-text.scanned {
    color: #409EFF;
}

.status-text.confirmed {
    color: #67C23A;
}

.status-text.cancelled, .status-text.expired {
    color: #F56C6C;
}

.user-info {
    margin-top: 10px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.user-avatar {
    width: 30px;
    height: 30px;
    border-radius: 50%;
    margin-right: 10px;
}

.countdown {
    color: #999;
    font-size: 14px;
    margin: 10px 0;
}

.instructions {
    border-left: 1px solid #eee;
    padding-left: 40px;
}

.instructions h4 {
    margin-top: 0;
}

.instructions ol {
    text-align: left;
    padding-left: 20px;
}

.instructions li {
    margin: 10px 0;
    color: #666;
}
</style>

五、手机端实现

5.1 扫描二维码处理

@RestController
@RequestMapping("/api/mobile")
@Api(tags = "移动端接口")
@Slf4j
public class MobileController {
    
    @Autowired
    private QrCodeService qrCodeService;
    
    @Autowired
    private UserService userService;
    
    /**
     * 处理扫描到的二维码
     */
    @PostMapping("/scan-qrcode")
    @ApiOperation("处理扫描到的二维码")
    public Result<ScanResult> handleScannedQrCode(@RequestBody ScanQrCodeRequest request) {
        try {
            // 验证用户身份(这里简化处理,实际应该有token验证)
            Long userId = request.getUserId();
            UserInfo userInfo = userService.getUserById(userId);
            
            if (userInfo == null) {
                return Result.error("用户不存在");
            }
            
            // 处理二维码扫描
            boolean success = qrCodeService.scanQrCode(request.getQrCodeId(), userId);
            
            if (success) {
                ScanResult result = ScanResult.builder()
                        .qrCodeId(request.getQrCodeId())
                        .userInfo(userInfo)
                        .build();
                
                return Result.success(result);
            } else {
                return Result.error("二维码处理失败");
            }
        } catch (Exception e) {
            log.error("处理扫描二维码失败", e);
            return Result.error("处理失败");
        }
    }
    
    /**
     * 确认登录
     */
    @PostMapping("/confirm-login")
    @ApiOperation("确认登录")
    public Result<ConfirmResult> confirmLogin(@RequestBody ConfirmLoginRequest request) {
        try {
            // 验证用户身份
            Long userId = request.getUserId();
            UserInfo userInfo = userService.getUserById(userId);
            
            if (userInfo == null) {
                return Result.error("用户不存在");
            }
            
            // 确认登录
            boolean success = qrCodeService.confirmLogin(request.getQrCodeId());
            
            if (success) {
                ConfirmResult result = ConfirmResult.builder()
                        .qrCodeId(request.getQrCodeId())
                        .success(true)
                        .message("登录确认成功")
                        .build();
                
                return Result.success(result);
            } else {
                return Result.error("登录确认失败");
            }
        } catch (Exception e) {
            log.error("确认登录失败", e);
            return Result.error("确认失败");
        }
    }
    
    /**
     * 取消登录
     */
    @PostMapping("/cancel-login")
    @ApiOperation("取消登录")
    public Result<CancelResult> cancelLogin(@RequestBody CancelLoginRequest request) {
        try {
            // 验证用户身份
            Long userId = request.getUserId();
            UserInfo userInfo = userService.getUserById(userId);
            
            if (userInfo == null) {
                return Result.error("用户不存在");
            }
            
            // 取消登录
            boolean success = qrCodeService.cancelLogin(request.getQrCodeId());
            
            if (success) {
                CancelResult result = CancelResult.builder()
                        .qrCodeId(request.getQrCodeId())
                        .success(true)
                        .message("登录已取消")
                        .build();
                
                return Result.success(result);
            } else {
                return Result.error("取消登录失败");
            }
        } catch (Exception e) {
            log.error("取消登录失败", e);
            return Result.error("取消失败");
        }
    }
}

六、安全考虑和最佳实践

6.1 二维码安全

@Service
public class SecureQrCodeService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 生成安全的二维码内容
     */
    public String generateSecureQrContent(String qrCodeId) {
        Map<String, Object> contentMap = new HashMap<>();
        contentMap.put("qrCodeId", qrCodeId);
        contentMap.put("timestamp", System.currentTimeMillis());
        contentMap.put("nonce", generateNonce()); // 添加随机数防止重放攻击
        
        // 对内容进行签名
        String content = JSON.toJSONString(contentMap);
        String signature = generateSignature(content);
        
        contentMap.put("signature", signature);
        
        return JSON.toJSONString(contentMap);
    }
    
    /**
     * 验证二维码内容的签名
     */
    public boolean verifyQrContent(String content) {
        try {
            JSONObject jsonObject = JSON.parseObject(content);
            String signature = jsonObject.getString("signature");
            jsonObject.remove("signature");
            
            String originalContent = jsonObject.toJSONString();
            String expectedSignature = generateSignature(originalContent);
            
            return signature.equals(expectedSignature);
        } catch (Exception e) {
            return false;
        }
    }
    
    /**
     * 生成签名
     */
    private String generateSignature(String content) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKeySpec = new SecretKeySpec("your-secret-key".getBytes(), "HmacSHA256");
            mac.init(secretKeySpec);
            byte[] hash = mac.doFinal(content.getBytes());
            return Base64.getEncoder().encodeToString(hash);
        } catch (Exception e) {
            throw new RuntimeException("生成签名失败", e);
        }
    }
    
    /**
     * 生成随机数
     */
    private String generateNonce() {
        return UUID.randomUUID().toString().replace("-", "");
    }
}

6.2 防止重放攻击

@Service
public class ReplayAttackProtectionService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final String NONCE_PREFIX = "nonce:";
    private static final long NONCE_EXPIRE_TIME = 300; // 5分钟
    
    /**
     * 验证并记录nonce
     */
    public boolean validateAndRecordNonce(String nonce) {
        String key = NONCE_PREFIX + nonce;
        
        // 检查nonce是否已存在
        if (redisTemplate.hasKey(key)) {
            return false; // nonce已存在,可能是重放攻击
        }
        
        // 记录nonce
        redisTemplate.opsForValue().set(key, "1", NONCE_EXPIRE_TIME, TimeUnit.SECONDS);
        
        return true;
    }
}

6.3 限流保护

@Component
public class RateLimitService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 限制同一IP的请求频率
     */
    public boolean isAllowed(String ip, int maxRequests, int timeWindow) {
        String key = "rate_limit:" + ip;
        Long current = redisTemplate.boundValueOps(key).increment(1);
        
        if (current == 1) {
            redisTemplate.expire(key, timeWindow, TimeUnit.SECONDS);
        }
        
        return current <= maxRequests;
    }
}

七、性能优化

7.1 使用WebSocket替代轮询

@Component
@ServerEndpoint("/websocket/qrcode/{qrCodeId}")
@Slf4j
public class QrCodeWebSocket {
    
    private static Map<String, Session> sessions = new ConcurrentHashMap<>();
    
    @OnOpen
    public void onOpen(Session session, @PathParam("qrCodeId") String qrCodeId) {
        sessions.put(qrCodeId, session);
        log.info("WebSocket连接已建立: {}", qrCodeId);
    }
    
    @OnClose
    public void onClose(@PathParam("qrCodeId") String qrCodeId) {
        sessions.remove(qrCodeId);
        log.info("WebSocket连接已关闭: {}", qrCodeId);
    }
    
    @OnMessage
    public void onMessage(String message, Session session, @PathParam("qrCodeId") String qrCodeId) {
        // 处理客户端消息
    }
    
    /**
     * 推送状态更新
     */
    public static void pushStatusUpdate(String qrCodeId, QrCodeStatusResponse response) {
        Session session = sessions.get(qrCodeId);
        if (session != null && session.isOpen()) {
            try {
                session.getBasicRemote().sendText(JSON.toJSONString(response));
            } catch (IOException e) {
                log.error("推送状态更新失败", e);
            }
        }
    }
}

7.2 Redis优化

@Service
public class OptimizedQrCodeService {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 使用Pipeline批量操作
     */
    public void batchUpdateQrCodes(List<QrCodeInfo> qrCodeInfos) {
        List<Object> results = redisTemplate.executePipelined(new RedisCallback<Object>() {
            @Override
            public Object doInRedis(RedisConnection connection) throws DataAccessException {
                StringRedisConnection stringRedisConn = (StringRedisConnection) connection;
                
                for (QrCodeInfo qrCodeInfo : qrCodeInfos) {
                    String key = "qrcode:" + qrCodeInfo.getQrCodeId();
                    stringRedisConn.set(key, JSON.toJSONString(qrCodeInfo));
                    stringRedisConn.expire(key, QR_CODE_EXPIRE_TIME);
                }
                
                return null;
            }
        });
    }
}

八、总结

实现一套纯网页的扫码登录系统,核心要点包括:

  1. 清晰的架构设计:合理划分前后端职责,使用Redis存储状态
  2. 安全考虑:签名验证、防重放攻击、限流保护
  3. 用户体验:合理的轮询频率、清晰的状态提示、倒计时机制
  4. 性能优化:WebSocket替代轮询、Redis Pipeline优化、合理的过期时间

这套方案相比依赖第三方平台的优势:

  • 独立性:不依赖任何第三方服务
  • 可控性:完全掌控登录流程和用户体验
  • 安全性:可以根据业务需求定制安全策略
  • 扩展性:易于扩展到多端登录场景

记住,技术选型要根据实际业务需求来决定。对于简单的应用场景,轮询方案就足够了;对于高并发场景,可以考虑WebSocket方案。

希望今天的分享能帮助你在下次面对扫码登录需求时,能够从容应对!


标题:无需微信依赖,纯网页扫码登录实现方案解析及实战
作者:jiangyi
地址:http://jiangyi.space/articles/2025/12/21/1766304301056.html

    0 评论
avatar