钉钉网页版是如何检测到本地客户端并自动登录的?
你有没有遇到过这样的场景:
打开钉钉文档链接,页面提示需要登录,正准备掏出手机扫码,突然页面上出现了你的头像和昵称,提示"已添加到网页",点一下就登录成功了——整个过程没有扫码、没有输密码,甚至没有打开钉钉客户端。
这是怎么做到的?网页是如何知道你电脑上装了钉钉,并且已经登录的?
这背后涉及本地服务检测、单点登录(SSO)、安全令牌交换等多项技术的组合。今天我们就来拆解这套方案的完整实现。
核心技术:本地 HTTP/WebSocket 服务
钉钉客户端在启动时,会在本地开启一个 HTTP 或 WebSocket 服务,监听 127.0.0.1 的某个端口(比如 12345)。
网页通过 JavaScript 尝试连接这个本地服务,如果连接成功,就说明客户端正在运行,并且可以拿到当前登录用户的信息。
客户端侧实现(Node.js/Electron 示例)
javascript
const express = require('express')
const app = express()
// CORS 配置:只允许钉钉官方域名访问
app.use((req, res, next) => {
const origin = req.headers.origin
if (origin && origin.includes('dingtalk.com')) {
res.header('Access-Control-Allow-Origin', origin)
res.header('Access-Control-Allow-Headers', 'X-App-Token')
}
next()
})
// 提供用户信息接口
app.get('/api/user-info', (req, res) => {
// 验证请求来源(防止恶意网页滥用)
const token = req.headers['x-app-token']
if (token !== process.env.SECRET_TOKEN) {
return res.status(403).json({ error: 'Forbidden' })
}
// 返回当前登录用户信息 + 临时令牌
res.json({
userId: '12345',
nickname: 'Gavin',
avatar: 'https://avatar.dingtalk.com/xxx.jpg',
tempToken: generateOneTimeToken(), // 5分钟有效,只能用一次
timestamp: Date.now()
})
})
// 只监听本地回环地址,外部无法访问
app.listen(12345, '127.0.0.1', () => {
console.log('本地服务已启动:http://127.0.0.1:12345')
})
// 生成一次性临时令牌
function generateOneTimeToken() {
const crypto = require('crypto')
const token = crypto.randomBytes(32).toString('hex')
// 存储到 Redis,5分钟后过期
redis.setex(`temp_token:${token}`, 300, JSON.stringify({
userId: '12345',
createdAt: Date.now()
}))
return token
}关键点:
- 只监听
127.0.0.1:外部网络无法访问,只有本机的浏览器能连接 - CORS 白名单:只允许
dingtalk.com域名的网页访问 - Token 验证:防止恶意网页伪造请求
- 临时令牌:不直接返回完整登录凭证,只给一个一次性 Token
网页侧实现
javascript
// 检测本地客户端
async function detectDingTalkClient() {
try {
const response = await fetch('http://127.0.0.1:12345/api/user-info', {
method: 'GET',
headers: {
'X-App-Token': 'your-secret-token' // 和客户端约定的密钥
},
signal: AbortSignal.timeout(1000) // 1秒超时
})
if (response.ok) {
const userData = await response.json()
return userData
}
} catch (error) {
// 客户端未运行或连接失败
console.log('未检测到钉钉客户端')
return null
}
}
// 使用临时令牌完成登录
async function loginWithClient(tempToken) {
const response = await fetch('https://login.dingtalk.com/api/client-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tempToken })
})
if (response.ok) {
const { sessionId, userId } = await response.json()
// 设置 Cookie,完成登录
document.cookie = `session_id=${sessionId}; path=/; secure; samesite=strict`
location.reload()
}
}
// 页面加载时执行
window.addEventListener('DOMContentLoaded', async () => {
const user = await detectDingTalkClient()
if (user) {
// 显示用户信息,等待确认
showUserCard({
avatar: user.avatar,
nickname: user.nickname,
onConfirm: () => loginWithClient(user.tempToken)
})
} else {
// 显示扫码登录、手机号登录等其他方式
showOtherLoginMethods()
}
})完整的登录流程
┌─────────────┐
│ 用户打开网页 │
└──────┬──────┘
│
▼
┌─────────────────────────────┐
│ JS 尝试连接 localhost:12345 │
└──────┬──────────────────────┘
│
├─ 成功 ──────────────────┐
│ │
│ ▼
│ ┌──────────────────────┐
│ │ 客户端返回用户信息 + │
│ │ 临时 Token (5分钟有效) │
│ └──────────┬───────────┘
│ │
│ ▼
│ ┌──────────────────────┐
│ │ 网页显示用户头像昵称 │
│ │ "已添加到网页" │
│ └──────────┬───────────┘
│ │
│ ▼
│ ┌──────────────────────┐
│ │ 用户点击确认登录 │
│ └──────────┬───────────┘
│ │
│ ▼
│ ┌──────────────────────┐
│ │ 网页将 tempToken 发送 │
│ │ 到钉钉服务器验证 │
│ └──────────┬───────────┘
│ │
│ ▼
│ ┌──────────────────────┐
│ │ 服务器验证通过,返回 │
│ │ Session/Cookie │
│ └──────────┬───────────┘
│ │
│ ▼
│ ┌──────────────────────┐
│ │ 登录成功,刷新页面 │
│ └──────────────────────┘
│
└─ 失败 ──────────────────┐
│
▼
┌──────────────────────┐
│ 显示扫码登录、手机号 │
│ 登录等其他方式 │
└──────────────────────┘安全机制设计
1. 临时令牌(Temporary Token)
客户端不会直接把完整的登录凭证(如 Session ID、Access Token)给网页,而是生成一个一次性临时令牌:
javascript
// 服务器端验证临时令牌
app.post('/api/client-login', async (req, res) => {
const { tempToken } = req.body
// 从 Redis 中查询令牌
const tokenData = await redis.get(`temp_token:${tempToken}`)
if (!tokenData) {
return res.status(401).json({ error: 'Invalid or expired token' })
}
const { userId, createdAt } = JSON.parse(tokenData)
// 检查是否过期(5分钟)
if (Date.now() - createdAt > 5 * 60 * 1000) {
await redis.del(`temp_token:${tempToken}`)
return res.status(401).json({ error: 'Token expired' })
}
// 验证通过,删除令牌(只能用一次)
await redis.del(`temp_token:${tempToken}`)
// 生成真正的 Session
const sessionId = generateSessionId(userId)
res.json({ sessionId, userId })
})为什么要这样设计?
- 防止令牌被截获后重复使用(一次性)
- 限制有效期,降低泄露风险(5分钟)
- 即使令牌泄露,攻击者也无法直接登录,还需要服务器验证
2. CORS 白名单
本地服务只允许特定域名访问,防止恶意网页滥用:
javascript
app.use((req, res, next) => {
const origin = req.headers.origin
const allowedOrigins = [
'https://alidocs.dingtalk.com',
'https://login.dingtalk.com',
'https://im.dingtalk.com'
]
if (allowedOrigins.some(allowed => origin?.includes(allowed))) {
res.header('Access-Control-Allow-Origin', origin)
} else {
return res.status(403).json({ error: 'Origin not allowed' })
}
next()
})3. 用户确认机制
即使检测到客户端,也不会自动登录,必须用户点击确认:
javascript
// 显示确认对话框
function showUserCard({ avatar, nickname, onConfirm }) {
const card = document.createElement('div')
card.innerHTML = `
<div class="user-card">
<img src="${avatar}" alt="${nickname}">
<p>${nickname}</p>
<button id="confirm-login">确认登录</button>
<button id="cancel-login">使用其他方式</button>
</div>
`
document.body.appendChild(card)
document.getElementById('confirm-login').onclick = onConfirm
document.getElementById('cancel-login').onclick = () => {
card.remove()
showOtherLoginMethods()
}
}这样可以防止恶意网页在用户不知情的情况下窃取登录状态。
辅助技术:自定义协议(Custom Protocol)
除了本地服务,钉钉还注册了 dingtalk:// 自定义协议,用于:
- 检测客户端是否安装
- 唤起客户端(比如点击"在客户端中打开")
注册自定义协议(Windows 示例)
客户端安装时会在注册表中添加:
HKEY_CLASSES_ROOT\dingtalk
(Default) = "URL:DingTalk Protocol"
URL Protocol = ""
HKEY_CLASSES_ROOT\dingtalk\shell\open\command
(Default) = "C:\Program Files\DingTalk\DingTalk.exe" "%1"网页调用
javascript
// 尝试唤起客户端
function openInClient(docUrl) {
window.location.href = `dingtalk://open?url=${encodeURIComponent(docUrl)}`
// 检测是否成功唤起
setTimeout(() => {
if (document.hidden) {
// 客户端被唤起,页面进入后台
console.log('客户端已打开')
} else {
// 未安装或未运行
alert('请先安装钉钉客户端')
}
}, 500)
}类似实现的其他应用
| 应用 | 检测方式 | 使用场景 |
|---|---|---|
| 微信网页版 | 本地服务 + 轮询 | 检测手机微信在线状态 |
| 企业微信 | 本地服务 + 自定义协议 | 客户端自动登录 |
| 飞书 | 本地 WebSocket | 实时同步客户端状态 |
| Steam | 本地 HTTP 服务 | 网页一键启动游戏 |
| Spotify | 本地服务 + OAuth | 网页控制客户端播放 |
实现一个简化版 Demo
完整的 Electron + Web 示例:
客户端(Electron):
javascript
// main.js
const { app, BrowserWindow } = require('electron')
const express = require('express')
let mainWindow
const server = express()
server.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', 'http://localhost:3000')
next()
})
server.get('/api/user', (req, res) => {
res.json({
userId: '123',
nickname: 'Demo User',
avatar: 'https://via.placeholder.com/100',
tempToken: Math.random().toString(36).slice(2)
})
})
app.whenReady().then(() => {
server.listen(9527, '127.0.0.1')
mainWindow = new BrowserWindow({ width: 800, height: 600 })
mainWindow.loadURL('https://your-app.com')
})网页端:
html
<!DOCTYPE html>
<html>
<head>
<title>客户端登录 Demo</title>
</head>
<body>
<div id="login-container">
<p>正在检测客户端...</p>
</div>
<script>
async function init() {
try {
const res = await fetch('http://127.0.0.1:9527/api/user')
const user = await res.json()
document.getElementById('login-container').innerHTML = `
<img src="${user.avatar}" width="100">
<p>${user.nickname}</p>
<button onclick="login('${user.tempToken}')">确认登录</button>
`
} catch {
document.getElementById('login-container').innerHTML = `
<p>未检测到客户端,请使用其他方式登录</p>
`
}
}
function login(token) {
console.log('使用令牌登录:', token)
// 实际项目中这里会调用服务器 API
alert('登录成功!')
}
init()
</script>
</body>
</html>总结
钉钉的"客户端自动登录"方案是一个精心设计的多层安全架构:
| 层级 | 技术 | 作用 |
|---|---|---|
| 检测层 | 本地 HTTP 服务 | 检测客户端是否运行 |
| 传输层 | 临时令牌 | 安全传递登录凭证 |
| 验证层 | 服务器验证 | 防止令牌伪造和重放 |
| 交互层 | 用户确认 | 防止静默登录 |
| 辅助层 | 自定义协议 | 唤起客户端 |
这套方案在用户体验和安全性之间取得了很好的平衡:
- ✅ 无需扫码、输密码,点一下就登录
- ✅ 临时令牌 + 一次性使用,防止泄露
- ✅ 用户必须确认,防止静默窃取
- ✅ CORS 白名单,防止恶意网页滥用
如果你在开发 Electron/Tauri 应用,完全可以参考这套方案实现自己的"客户端 + 网页"联动登录。
- 本文链接:
- 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 4.0 许可协议。

