快速开始

1. 创建 App

扫码登录开发者中心,在“应用管理”创建 App。App Secret 只显示一次;丢失后只能重置。

2. 请求登录二维码

调用 POST /api/open/login/qrcode。请求头:

请求头值
X-CL-App-IdApp ID
X-CL-TimestampUnix 秒,允许前后 5 分钟
X-CL-Nonce16–128 位随机字符串,10 分钟内不可重复
X-CL-SignatureHMAC-SHA256 十六进制小写签名

签名原文依次拼接并用换行分隔:HTTP 方法、路径、规范化查询串、timestamp、nonce、原始请求体 SHA-256。

以下示例使用 Node.js 18 或更高版本,无需安装第三方依赖。先设置三个环境变量:

COOL_LOGIN_BASE_URL=https://login.example.com
COOL_LOGIN_APP_ID=cl_app_xxx
COOL_LOGIN_APP_SECRET=cl_secret_xxx

创建 cool-login.mjs,加入生成签名请求头的公共代码:

import { createHash, createHmac, randomBytes } from 'node:crypto'

const baseUrl = process.env.COOL_LOGIN_BASE_URL
const appId = process.env.COOL_LOGIN_APP_ID
const appSecret = process.env.COOL_LOGIN_APP_SECRET

if (!baseUrl || !appId || !appSecret) {
  throw new Error('请设置 COOL_LOGIN_BASE_URL、COOL_LOGIN_APP_ID 和 COOL_LOGIN_APP_SECRET')
}

function encodeRfc3986(value) {
  return encodeURIComponent(value).replace(/[!'()*]/g, character =>
    `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
  )
}

function canonicalizeQuery(searchParams) {
  return Array.from(searchParams.entries())
    .filter(([key]) => key !== 'signature')
    .map(([key, value]) => [encodeRfc3986(key), encodeRfc3986(value)])
    .sort(([leftKey, leftValue], [rightKey, rightValue]) =>
      leftKey.localeCompare(rightKey) || leftValue.localeCompare(rightValue),
    )
    .map(([key, value]) => `${key}=${value}`)
    .join('&')
}

function createSignedHeaders(method, url, body = '') {
  const timestamp = Math.floor(Date.now() / 1000).toString()
  const nonce = randomBytes(16).toString('hex')
  const bodyHash = createHash('sha256').update(body, 'utf8').digest('hex')
  const canonicalRequest = [
    method.toUpperCase(),
    url.pathname,
    canonicalizeQuery(url.searchParams),
    timestamp,
    nonce,
    bodyHash,
  ].join('\n')
  const signature = createHmac('sha256', appSecret)
    .update(canonicalRequest, 'utf8')
    .digest('hex')

  return {
    'X-CL-App-Id': appId,
    'X-CL-Timestamp': timestamp,
    'X-CL-Nonce': nonce,
    'X-CL-Signature': signature,
  }
}

接着请求登录二维码。该接口没有请求参数,请求体为空字符串:

async function createLoginQrCode() {
  const method = 'POST'
  const url = new URL('/api/open/login/qrcode', baseUrl)
  const response = await fetch(url, {
    method,
    headers: createSignedHeaders(method, url),
  })
  const result = await response.json()

  if (!response.ok || !result.success) {
    throw new Error(result.message || `请求二维码失败:HTTP ${response.status}`)
  }

  return result.data
}

const { ticket, qrCodeBase64, expiresIn } = await createLoginQrCode()
console.log({ ticket, qrCodeBase64, expiresIn })

成功响应同时包含 ticket、qrCodeBase64 和 expiresIn。直接把 Base64 Data URL 放到图片元素中展示。

3. 轮询结果

对 GET /api/open/login/status?ticket=... 使用同样的签名规则。状态为 success 时同时返回:

在同一个 cool-login.mjs 中继续加入轮询代码。查询参数必须先写入 URL,再参与签名;每次轮询也必须重新生成 timestamp 和 nonce:

const wait = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))

async function getLoginStatus(ticket) {
  const method = 'GET'
  const url = new URL('/api/open/login/status', baseUrl)
  url.searchParams.set('ticket', ticket)

  const response = await fetch(url, {
    method,
    headers: createSignedHeaders(method, url),
  })
  const result = await response.json()

  if (!response.ok || !result.success) {
    throw new Error(result.message || `查询登录状态失败:HTTP ${response.status}`)
  }

  return result.data
}

async function waitForLogin(ticket) {
  while (true) {
    const result = await getLoginStatus(ticket)

    if (result.status === 'success') {
      return {
        unionUserId: result.unionUserId,
        appUserId: result.appUserId,
      }
    }
    if (result.status === 'expired') {
      throw new Error('登录二维码已过期,请重新获取')
    }
    if (result.status === 'failed') {
      throw new Error(`登录失败:${result.errorCode || 'unknown'}`)
    }

    await wait(1500)
  }
}

const user = await waitForLogin(ticket)
console.log('登录成功', user)
字段稳定范围用途
unionUserId当前开发者名下的所有 App在自己的多个 App 之间识别并共享同一用户
appUserId当前 App建立只属于当前 App 的独立用户记录
{
  "success": true,
  "data": {
    "ticket": "cl_xxx",
    "status": "success",
    "unionUserId": "cl_union_v1_xxx",
    "appUserId": "cl_app_user_v1_xxx"
  }
}

两个字段均由 Cool Login 对微信身份进行不可逆保护后生成,不是微信原始 OpenID 或 UnionID。同一微信用户登录同一开发者的不同 App 时,unionUserId 相同而 appUserId 不同。结果窗口为 30 秒,之后返回 expired。

不要在浏览器里生成签名或暴露 App Secret。轮询必须由你的服务端完成。

使用托管登录页

如果不想自行展示二维码和维护轮询页面,可以使用 Portal 便捷登录。Cool Login 会完成二维码展示和扫码状态确认,再携带原 ticket 返回你的应用;你的服务端对现有状态接口查询一次即可。