Login Widget
Login Widget 允许你在网站中嵌入 SafeW 登录按钮,用户可以通过 SafeW 账号一键授权登录你的网站。
前置准备
- 通过 BotFather 创建 Bot 并获取 Bot Token
- 使用
/setdomain命令绑定你的网站域名
TIP
建议将 Bot 头像设置为与你的网站 Logo 一致,以提升用户的信任感。
嵌入 Widget
Login Widget 支持两种模式:Callback 模式 和 Redirect 模式。
Callback 模式
用户授权后调用页面上的 JavaScript 回调函数,适合单页应用(SPA):
html
<script async src="https://safew.com/js/safew-widget.js"
data-safew-login="YOUR_BOT_USERNAME"
data-size="large"
data-onauth="onSafeWAuth(user)"
data-request-access="write">
</script>
<script>
function onSafeWAuth(user) {
console.log('用户已登录:', user);
// 将 user 数据发送到你的服务端进行验证
fetch('/auth/safew', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(user)
});
}
</script>Redirect 模式
用户授权后跳转到指定 URL,用户数据通过查询参数传递:
html
<script async src="https://safew.com/js/safew-widget.js"
data-safew-login="YOUR_BOT_USERNAME"
data-size="large"
data-auth-url="https://example.com/auth/safew"
data-request-access="write">
</script>授权成功后,用户将被重定向到:
https://example.com/auth/safew?id=12345&first_name=John&username=john_doe&photo_url=...&auth_date=1234567890&hash=abc123...配置选项
| 属性 | 类型 | 必填 | 描述 |
|---|---|---|---|
data-safew-login | String | 是 | Bot 用户名(不含 @) |
data-size | String | 否 | 按钮尺寸:large、medium、small,默认 large |
data-userpic | Boolean | 否 | 是否显示用户头像,默认 true |
data-radius | Integer | 否 | 按钮圆角半径(px) |
data-onauth | String | 否 | Callback 模式的回调函数名 |
data-auth-url | String | 否 | Redirect 模式的跳转 URL |
data-request-access | String | 否 | 设为 write 可请求向用户发送消息的权限 |
WARNING
data-onauth 和 data-auth-url 二选一。如果两者都未设置,Widget 将仅显示按钮但不会处理授权结果。
返回的用户数据
授权成功后,你将收到以下用户数据:
| 字段 | 类型 | 描述 |
|---|---|---|
id | Integer | 用户唯一 ID |
first_name | String | 用户名(名) |
last_name | String | 用户名(姓),可选 |
username | String | 用户名,可选 |
photo_url | String | 用户头像 URL,可选 |
auth_date | Integer | 授权时间(Unix 时间戳) |
hash | String | 数据校验哈希 |
Callback 模式接收数据
javascript
function onSafeWAuth(user) {
// user 对象包含上述所有字段
console.log(user.id); // 12345
console.log(user.first_name); // "John"
console.log(user.auth_date); // 1234567890
console.log(user.hash); // "abc123..."
}Redirect 模式接收数据
javascript
// 从 URL 查询参数中解析用户数据
const params = new URLSearchParams(window.location.search);
const user = {
id: params.get('id'),
first_name: params.get('first_name'),
last_name: params.get('last_name'),
username: params.get('username'),
photo_url: params.get('photo_url'),
auth_date: params.get('auth_date'),
hash: params.get('hash')
};验证授权数据
WARNING
必须在服务端验证授权数据。 不要信任客户端传来的数据,攻击者可以伪造任意用户信息。
验证步骤
- 从接收到的数据中提取
hash字段 - 将剩余字段按字母顺序排序,以
key=value格式用换行符\n连接成data_check_string - 对 Bot Token 进行 SHA256 哈希得到
secret_key - 使用
secret_key对data_check_string进行 HMAC-SHA256 运算 - 将运算结果与
hash进行比对
与 Mini App 数据验证的区别
Login Widget 使用 SHA256(bot_token) 作为 HMAC 密钥,而 Mini App 数据验证 使用 HMAC-SHA256("WebAppData", bot_token) 作为密钥。两者不可混用。
Python 示例
python
import hashlib
import hmac
def validate_login_widget(data: dict, bot_token: str) -> bool:
"""验证 Login Widget 授权数据"""
# 提取 hash
received_hash = data.pop('hash', None)
if not received_hash:
return False
# 按字母顺序排序并构建 data_check_string
data_check_pairs = []
for key in sorted(data.keys()):
data_check_pairs.append(f"{key}={data[key]}")
data_check_string = "\n".join(data_check_pairs)
# 计算 secret_key(注意:这里用 SHA256,不是 HMAC)
secret_key = hashlib.sha256(bot_token.encode()).digest()
# 计算并比对 hash
computed_hash = hmac.new(
secret_key,
data_check_string.encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(computed_hash, received_hash)Node.js 示例
javascript
const crypto = require('crypto');
function validateLoginWidget(data, botToken) {
const hash = data.hash;
delete data.hash;
// 按字母顺序排序并构建 data_check_string
const dataCheckString = Object.keys(data)
.sort()
.map(key => `${key}=${data[key]}`)
.join('\n');
// 计算 secret_key(SHA256)
const secretKey = crypto
.createHash('sha256')
.update(botToken)
.digest();
// 计算并比对 hash
const computedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex');
return computedHash === hash;
}Go 示例
go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
)
func ValidateLoginWidget(data map[string]string, botToken string) bool {
receivedHash, ok := data["hash"]
if !ok {
return false
}
delete(data, "hash")
// 按字母顺序排序
keys := make([]string, 0, len(data))
for k := range data {
keys = append(keys, k)
}
sort.Strings(keys)
pairs := make([]string, 0, len(keys))
for _, k := range keys {
pairs = append(pairs, fmt.Sprintf("%s=%s", k, data[k]))
}
dataCheckString := strings.Join(pairs, "\n")
// 计算 secret_key(SHA256)
h := sha256.Sum256([]byte(botToken))
secretKey := h[:]
// 计算并比对 hash
mac := hmac.New(sha256.New, secretKey)
mac.Write([]byte(dataCheckString))
computedHash := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(computedHash), []byte(receivedHash))
}安全建议
- 服务端验证 — 始终在服务端验证
hash,永远不要信任客户端传来的数据 - 检查 auth_date — 拒绝过期的授权数据(建议有效期不超过 1 小时)
- 常量时间比较 — 使用
hmac.compare_digest(Python)或crypto.timingSafeEqual(Node.js)防止时序攻击 - 保护 Bot Token — 永远不要在客户端代码中暴露 Bot Token
- 使用 HTTPS — 确保你的网站使用 HTTPS,防止数据在传输中被篡改
javascript
// 完整的服务端验证示例
const crypto = require('crypto');
function verifyAndAuthenticate(data, botToken) {
// 1. 验证哈希
const hashCopy = data.hash;
const dataCopy = { ...data };
delete dataCopy.hash;
const dataCheckString = Object.keys(dataCopy)
.sort()
.map(key => `${key}=${dataCopy[key]}`)
.join('\n');
const secretKey = crypto
.createHash('sha256')
.update(botToken)
.digest();
const computedHash = crypto
.createHmac('sha256', secretKey)
.update(dataCheckString)
.digest('hex');
// 常量时间比较
if (!crypto.timingSafeEqual(
Buffer.from(computedHash, 'hex'),
Buffer.from(hashCopy, 'hex')
)) {
return { valid: false, error: 'Invalid hash' };
}
// 2. 检查 auth_date
const authDate = parseInt(data.auth_date);
const now = Math.floor(Date.now() / 1000);
if (now - authDate > 3600) {
return { valid: false, error: 'Data expired' };
}
return { valid: true, user: data };
}