Hi my new friend!

钉钉网页版是如何检测到本地客户端并自动登录的?

Scroll down

钉钉网页版是如何检测到本地客户端并自动登录的?

你有没有遇到过这样的场景:

打开钉钉文档链接,页面提示需要登录,正准备掏出手机扫码,突然页面上出现了你的头像和昵称,提示"已添加到网页",点一下就登录成功了——整个过程没有扫码、没有输密码,甚至没有打开钉钉客户端。

这是怎么做到的?网页是如何知道你电脑上装了钉钉,并且已经登录的?

这背后涉及本地服务检测、单点登录(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
}

关键点:

  1. 只监听 127.0.0.1:外部网络无法访问,只有本机的浏览器能连接
  2. CORS 白名单:只允许 dingtalk.com 域名的网页访问
  3. Token 验证:防止恶意网页伪造请求
  4. 临时令牌:不直接返回完整登录凭证,只给一个一次性 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:// 自定义协议,用于:

  1. 检测客户端是否安装
  2. 唤起客户端(比如点击"在客户端中打开")

注册自定义协议(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 应用,完全可以参考这套方案实现自己的"客户端 + 网页"联动登录。

  • 本文作者:Gavin
  • 本文链接:
  • 版权声明:本博客所有文章除特别声明外,均默认采用 CC BY-NC-SA 4.0 许可协议。
其他文章