不想使用账号和密码登录,害怕被攻击,也不想做注册等功能;不想使用手机号和验证码登录,没有钱也没有资质去做这些。我想到了二维码,通过二维码进行登录。这里有一个核心的问题还是如何鉴权?
在了解小程序后,我就决定使用小程序扫描二维码登录,使用小程序自带的微信账户体系完成鉴权。
我们要知道,二维码(QR Code)是连接物理世界与数字世界的“虫洞”。在登录系统中,它承载着一个临时的身份信标。
在实现这个Web登录功能的过程中,我们需要:
1. 生成有效二维码
这个二维码需要显示在网页上,供用户扫码登录,登录二维码通常包含一个加密的 URL 或一个唯一的 UUID(通用唯一识别码)。
这个唯一识别码需要具备的特性:
- 唯一性: 每一对扫描动作都必须对应一个独一无二的 ID。
- 时效性: 二维码必须配合 Redis 设置过期时间(如 2 分钟),逾期自动失效。
在本项目中,我们将用户的微信 OpenID 作为 Key,生成的验证码作为 Value,使用Redis进行存储:
// 存储验证码,并设置 5 分钟过期
err := rdb.Set(ctx, "user:123:code", "8888", 5*time.Minute).Err()
// 读取验证码
val, err := rdb.Get(ctx, "user:123:code").Result()
当有了二维码内容后,我们需要工具来生成二维码,在 Go 生态中,我们使用 skip2/go-qrcode 库来完成像素的绘制:
// 生成二维码字节数组
var png []byte
png, _ = qrcode.Encode("91demo.top"+sessionID, qrcode.Medium, 256)
为了防止用户伪造扫码请求,二维码里的内容通常是加密的或者是不可预测的长随机数(UUID)。只有真实存在的 ID 才能通过后端的 Redis 校验。
2. 传输二维码
当在服务端生成二维码后,还需要传递给浏览器,让浏览器进行显示,用户才可以扫码。这里有两个方法:1,生成图片文件,然后浏览器下载后显示。2,将图片内容转为Base64字符串传递给浏览器。这里我们选择了后者,我们不希望在用户硬盘上产生大量的临时 .png 文件。
在Go中可以这样操作:
// 转换为前端可直接识别的 Data URL
base64Img := "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
为了让前端不至于崩溃,后端必须返回统一的格式。
func HandleLogin(c *gin.Context) {
// 逻辑处理...
c.JSON(200, gin.H{
"code": 1,
"content": base64Img,
})
}
在前端,我们不再需要引入沉重的第三方库来做简单的请求。浏览器原生提供的 Fetch API 简洁且基于 Promise。
// 向 豆子实验室发起请求
fetch("api.91demo.top")
.then((response) => response.json())
.then((data) => console.log(data))
.catch((error) => console.error("探索失败:", error));
需要注意的是,当尝试从 91demo.top 请求二维码的数据时,浏览器会为了安全触发跨域资源共享 (CORS) 检查。如果我们使用了不同的域名,需要后端( 豆子实验室的服务器)在响应头中明确标记Access-Control-Allow-Origin。
拿到的原始数据通常是 JSON 格式。我们需要将其解析为 JavaScript 对象,并利用模板引擎或 DOM 操作将其渲染到HTML页面上。这里使用HTML的img标签,二维码就可以正常显示在网页上了。
3. 读取二维码
扫码动作本质上是将桌面端的 SessionID 传递给移动端。日常生活中,我们常见使用APP进行扫码,但是我没有精力去开发APP,所以这里使用了微信小程序的扫码功能。微信小程序提供了极其简便的接口来调用摄像头。它不仅能识别标准的二维码(QR Code),还能识别条形码。当然,这里我们仅仅使用二维码功能。
// 小程序端:发起扫码
wx.scanCode({
onlyFromCamera: true, // 只允许相机扫码,不允许从相册选择
success: (res) => {
// res.result 包含了二维码中的内容,例如:sid=52025
const sid = parseQuery(res.result).sid;
this.confirmLogin(sid);
},
});
当 wx.scanCode 成功后,小程序拿到了二维码里的“信标”(SessionID)。
此时,我们需要在小程序中发起网络请求,告诉后端谁扫描了二维码,这里使用 wx.request 向后端发送:“我是用户 A,我刚扫到了 ID 为 52025 的设备,请记录。”
这里使用了小程序提供的 API 用于与开发者服务器通信。与浏览器不同,它没有跨域限制,但要求通信必须使用安全的 HTTPS 协议。
// 小程序端:确认网站登录
wx.request({
url: "api.91demo.top",
method: "POST",
data: {
sessionId: "xxx", // 扫码拿到的标识
code: "用来在服务端获取openid的code",
},
success: (res) => {
console.log("扫码成功");
},
});
此时,网页就通过二维码有了身份。
4. 后端验证
我们先来介绍一下小程序身份,小程序自带微信账号体系。当小程序传入sessionID时,也携带了用户的小程序身份code,我们需要将这个code从临时凭证转到永久 openid。
微信小程序的登录流程是典型的 三方安全鉴权 模型(小程序客户端、你的服务器、微信服务器)。
首先是临时凭证:Code 的诞生,在小程序前端,我们调用 wx.login()。这个函数会返回一个名为 code 的临时凭证。
- 时效性: code 只有 5 分钟有效期。
- 唯一性: 每次调用生成的 code 都不相同,且只能使用一次。
然后,当我们的服务器拿到 code 后,需要配合 AppID 和 AppSecret,从后端向微信接口发起请求:
// Go 伪代码:请求微信 code2Session 接口
resp, err := http.Get("code2Session接口地址" + code + "&grant_type=authorization_code")
如果 code 有效,微信服务器会返回:
- OpenID: 用户的唯一标识(针对当前小程序)。
- SessionKey: 会话密钥,用于后续对加密数据(如手机号、运动步数)的解密。
这里的OpenID就是我们想要的值,它标识了用户在小程序中的身份。
设计这么复杂是为了防止 AppSecret 泄露。AppSecret 永远只留在我们的服务器上,而 code 在公网传输即使被截获,没有密钥也无法换取用户信息。
好了,有了小程序身份之后,我们需要验证二维码中的sessionID。我们是在生成二维码时将网站生成的 SessionID 存入了 Redis。
当后端接收到小程序的 wx.request 后,会执行以下逻辑:
提取: 获取小程序传来的 SessionID。
校验: 在 Redis 中查询该 ID 是否存在。
标记: 将该 ID 的状态改为“已授权”,并写入用户信息。
通知: 触发 WebSocket 或轮询,告诉网站:“用户已点击确认,准许进入”。
好了,到了这一步,二维码内容中的sessionID已经有了身份,我们来看最后一步,Web端如何知道扫码已经成功。
5. Web端获取登录状态
我们需要知道的是,当二维码显示在界面后,浏览器需要不断询问后端:“有人扫我了吗?”
这里有两个实现方案:
- 方案 A: 简单轮询(每 2 秒发一次 HTTP 请求)。
- 方案 B: 使用 WebSocket,当扫码成功时,后端主动推送到桌面端。
方案A的核心在于启动一个定时器,然后不断轮询后台,当登录成功后,浏览器自动调整到授权的面板页面。
方案B使用了WebSocket,我们来简单介绍一下:
HTTP 协议是“一问一答”的,这种模式在实时性要求高的场景下显得非常笨重。WebSocket 的出现彻底改变了这一点。
在 JavaScript 中,建立WebSocket连接非常简单:
const socket = new WebSocket("wss://api.dou-dou.top/ws");
// 监听连接开启
socket.onopen = () => console.log("心跳建立成功");
// 接收来自 豆子实验室的推送
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("收到实时数据:", data);
};
网络环境是复杂的,连接可能会因为路由超时或断网而静默中断。
在实际应用中,为了维持连接,我们需要实现 心跳检测 (Heartbeat)。就像人类的脉搏一样,客户端每隔一段时间发送一个特定的微型包(Ping),服务器回复一个(Pong),确保通道始终畅通。
同时为了具备“韧性”,这就需要重连机制。如果 WebSocket 意外关闭,前端需要有一套重连算法(通常采用指数退避策略),在不压垮服务器的前提下,尝试自动找回连接。
无论使用哪种方案,都是将后端的身份验证状态告知前端。我们使用cookie来存储token,token中包含用户身份。当浏览器再次请求时,由于携带了cookie,服务端就知道谁在访问?同时也知道了它是否有权限访问。
至此,我们就完成了二维码登录。在我的网站运行一段时间后,我发现还有更好的办法来登录。这就是小程序码登录,这个登录体验更流程,更便捷。
6. 什么是小程序码?
小程序码也称为太阳码,是微信独有的视觉交互方案,它比普通二维码更安全,因为其解析过程完全在微信内部完成。
登录流程和逻辑大同小异,我们仅仅需要替换二维码即可。
同样的道理,我们需要先生成小程序码,在请求微信生成小程序码之前,你的 Go 后端必须先向微信换取一个“通行证”—— access_token, 它是你调用微信服务器 API 的唯一身份证明。由于它有 2 小时的有效期,建议使用 Redis 进行缓存,避免频繁调用被限流。
微信提供了几个接口来生成小程序码,最常用的是 getUnlimited,因为这个接口生成的码数量不受限制,非常适合大规模的登录业务。它允许你传递一个 scene 参数(场景值),scene有长度限制,不能超过32个字符,我们将生成的 SessionID 放入 scene 中。
// Go 伪代码:请求微信生成小程序码
reqBody := map[string]interface{}{
"scene": "sid=52025",
"page": "pages/index/index",
"width": 430,
}
// 微信会直接返回图片的二进制流 (Binary Stream)
与普通 API 返回 JSON 不同,微信这个接口返回的是图片的 二进制数据。
在 Go 后端,我们需要读取响应体的 Body,并将其再次通过 Base64 编码,发送给前端显示。
7. 小程序码登录
当我们打开微信扫描小程序码时,微信会将参数放入 scene 字段。在小程序的 onLoad 函数中,我们可以捕获到该参数值:
onLoad(options) {
if (options.scene) {
// 微信会将 scene 进行 URL 编码,需要解码
const scene = decodeURIComponent(options.scene);
// 提取我们在后端埋下的 sid=52025
this.setData({ sessionId: getQueryString(scene, 'sid') });
}
}
后面的逻辑和二维码就一样了。我个人觉得它的最大便利就是扫码即完成登录。因为它不需要在小程序页面中再提供一个按钮去调用scanCode扫描二维码了。
想尝试吗?可以点击登录体验一下。