noreply 发信与第三方登录接入
前言
给网站加登录入口,常见的做法是发邮箱验证码,或者让用户使用已有的第三方账号登录。
这篇文章记录我在自己站点上实际跑通的一套做法:用自有域名的 noreply@<域名> 发验证码,再接入 Google / GitHub 登录。文中的代码和流程都经过验证,但 DNS 值、客户端凭据这类内容必须在你自己的环境里当场生成,不能照抄。
哪些照抄,哪些必须自己生成
可以照抄:SPF / DKIM / DMARC 的作用与配置顺序、OAuth 授权码 + PKCE 流程、id_token 验签规则、故障排查步骤,以及文中的代码(替换占位值即可)。
必须替换的实例值:
| 占位 | 说明 |
|---|---|
<域名> | 发信认证用,必须能改它的 DNS |
<发信地址> | 例如 noreply@example.com,纯发信,不需要真实收件箱 |
<站名> | 发件人显示名、OAuth 应用名 |
<站点地址> | 站点首页,例如 https://example.com |
<回调地址> | <站点地址>/api/auth/oauth/<provider>/callback,先定死再去注册应用,少一个斜杠都会被拒 |
必须当场生成、不能跨项目复用:
| 项目 | 为什么不能复用 | 怎么验证 |
|---|---|---|
| DNS 认证记录 | 值里含服务商给这个域名生成的标识 | 控制台验证全绿,再实发一封看是否进收件箱 |
| SMTP 登录名与密钥 | 绑定发信账号;登录名是自动生成的地址,不是注册邮箱 | 走一次完整验证码流程 |
| OAuth 客户端 ID / 密钥 | 绑定具体应用和回调地址,密钥关掉弹窗就看不到 | 走一次完整登录 |
会话密钥 AUTH_SECRET | 泄漏等于可以伪造任意用户 | openssl rand -base64 48,每个环境一把 |
| 隐私政策页 | 内容要写站点真实收集的数据 | 公网能打开 |
三条通用约束
- 凭据放在部署目录外、权限
600的文件里,用环境变量引用。放在部署目录里,rm -rf重新部署会一起删掉;进了 git 就等于公开。 - Google 登录客户端单独一个 Cloud 项目,项目里只放 OAuth 客户端。 不要和 API 密钥(尤其是 AI Studio 自动生成的
gen-lang-client-*项目里的 Gemini 密钥)、服务账号放在一起。同项目任一凭据泄漏,Google 会停用整个项目,登录随之报401 disabled_client,后面有一次真实事故。 - 凭据有时效。 Brevo 的 SMTP 密钥长期不用会静默过期,Microsoft 的 client secret 有有效期。功能「一直好好的突然坏了」,先怀疑凭据,再怀疑代码。
用自有域名发送验证码
原理
收件方用 DNS 里的 SPF / DKIM / DMARC 判断「自称 <发信地址> 的信是不是域名主人授权的服务器发的」。所以 noreply 不需要收件箱,但必须在 DNS 里授权一个发信服务。用个人邮箱冒充 noreply 发信,会被判伪造进垃圾箱;微软系(outlook / hotmail)未认证时甚至直接拒收。
| 机制 | 作用 |
|---|---|
| SPF | 判断发送服务器是否获信封发件域授权 |
| DKIM | 对邮件签名,收件方通过 DNS 公钥验证 |
| DMARC | 检查可见 From 与通过认证的域是否对齐,并声明处理策略 |
发信服务选型
| 情况 | 选择 |
|---|---|
| 验证码量级(每天几十封) | Brevo:约 300 封/天,每天刷新,只需 DNS 验证 |
| 域名未 ICP 备案 / 服务器在海外 | 只能用海外服务(Brevo、Resend);阿里云、腾讯云邮件推送要求发信域名已备案 |
| Brevo 不可用 | Resend(约 3000 封/月) |
不要选一次性总量额度的服务(如阿里云 DirectMail 的 2000 封),用完就开始收费。
Brevo 配置过程
注册 brevo.com 免费套餐(手机号验证,不绑卡)。组织名、地址必填但不核验;地址框是联想组件,必须点选下拉建议才算填上。
头像 →「SMTP & API」→「SMTP」→ 生成 SMTP 密钥(
xsmtpsib-开头,只显示一次)。记下页面显示的 SMTP 登录名(形如xxxx@smtp-brevo.com)。主机smtp-relay.brevo.com,端口 587。「授权 IP」白名单保持不开。「Senders & IP」→「Domains」→ 添加域名,把控制台给出的记录逐条加到 DNS 服务商(通常是 1 条
@TXT 验证码、2 条brevoN._domainkeyCNAME、1 条_dmarcTXT),回 Brevo 点验证。修改前先检查已有记录,同一域名不要重复添加多条 SPF,已有 DMARC 也要合并考虑。写服务端环境变量(部署目录外):
SMTP_HOST=smtp-relay.brevo.com SMTP_PORT=587 SMTP_SECURE=false # 465 端口时改 true SMTP_USER=<第 2 步的 SMTP 登录名> SMTP_PASS=<第 2 步的 SMTP 密钥> SMTP_FROM=<发信地址> SMTP_FROM_NAME=<站名>重启服务,走一次验证码流程;在 QQ / 163 / outlook 收件箱确认发件人显示
<站名> <<发信地址>>且不在垃圾箱。Brevo 日志里出现Delivered才算通,Sent后跟Error多半是 DNS 认证没完成。
具体步骤以 Brevo 域名认证说明和 SMTP 接入说明为准。
核心代码(Node + nodemailer)
import nodemailer from 'nodemailer'
const IS_PROD = process.env.NODE_ENV === 'production'
const SMTP_PORT = Number(process.env.SMTP_PORT) || 587
const SMTP_FROM = process.env.SMTP_FROM?.trim() || ''
const SMTP_FROM_NAME = process.env.SMTP_FROM_NAME?.trim() || '<站名>'
let transporter: nodemailer.Transporter | null = null
function getTransporter() {
if (!process.env.SMTP_HOST || !SMTP_FROM) throw new Error('邮箱服务尚未配置')
transporter ??= nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: SMTP_PORT,
secure: process.env.SMTP_SECURE === 'true' || SMTP_PORT === 465,
connectionTimeout: 10_000, greetingTimeout: 10_000, socketTimeout: 15_000,
auth: { user: process.env.SMTP_USER!, pass: process.env.SMTP_PASS! },
})
return transporter
}
export async function sendEmailCode(email: string, code: string) {
// 开发环境把验证码打到服务端终端;生产绝不能退化成把验证码写进日志
if (!IS_PROD && !process.env.SMTP_HOST) return console.info(`[auth-email] ${email} 验证码 ${code}`)
await getTransporter().sendMail({
from: `"${SMTP_FROM_NAME.replace(/["\\]/g, '')}" <${SMTP_FROM}>`,
to: email,
subject: `${SMTP_FROM_NAME} 邮箱验证码`,
text: `你的验证码是:${code}\n10 分钟内有效,只能使用一次。不是本人操作请忽略。`,
})
}secure: false 配合 587 端口,由库自动升级为 STARTTLS,不是明文传输;465 端口则是隐式 TLS。
验证码本身
请求验证码 → 检查频率 → 生成一次性验证码 → 发送邮件
提交验证码 → 校验有效期与尝试次数 → 原子消费 → 建立本站会话6 位数字、10 分钟有效、单次使用、按邮箱和 IP 双重限流,库里只存哈希,成功后原子作废。响应不要泄露某个邮箱是否已注册。SMTP 接受或服务商显示已发送,只代表相应阶段成功,不等于用户已看到邮件,投递验收要实际在多家邮箱里测。
Google 登录
控制台配置过程
- 新建专用项目:Cloud 控制台 → 名称如
<站名> Login→ 创建并切换过去。这个项目里不启用任何 API、不建 API 密钥、不建服务账号。顶部「$300 赠金」不要点。 - 同意屏:☰ →「Google Auth Platform」→「概览」→ 开始配置。应用名
<站名>,支持邮箱和联系邮箱填自己的,受众选外部,勾选同意用户数据政策 → 创建。 - 客户端:「客户端」→「创建客户端」→ Web 应用 → 已获授权的重定向 URI 加
<站点地址>/api/auth/oauth/google/callback和本地开发的回调地址 → 创建,当场复制客户端 ID(….apps.googleusercontent.com)和密钥(GOCSPX-…)。 - 品牌:填应用首页和隐私政策链接(见后文)→ 保存。不要上传徽标,上传后就要提交品牌验证。
- 发布:「目标对象」→「发布应用」→ 确认,状态变成「正式版」。不发布的话只有测试用户能登录;品牌页没有首页和隐私政策链接时,发布按钮是灰的。scope 只用
openid email profile,属于非敏感 scope,发布不需要安全评估。
服务端环境变量:GOOGLE_CLIENT_ID、GOOGLE_CLIENT_SECRET、AUTH_SECRET。两个 Google 变量缺任一,就不显示 Google 登录按钮。
流程
浏览器点按钮 → GET /api/auth/oauth/google/start
服务端生成 state / nonce / PKCE verifier,HMAC 签名后放进 5 分钟短效 cookie(SameSite=Lax)
302 → accounts.google.com(带 state、nonce、code_challenge)
用户选账号 → Google 302 → /api/auth/oauth/google/callback?code&state
核对 cookie 里的 state → 用 code + verifier + secret 换 id_token
JWKS 验签 id_token,核对 iss / aud / exp / nonce
只认 email_verified=true 的邮箱 → 命中账号就登录,否则以该邮箱建号
发本站会话 cookie → 302 回站内几个容易错的点:
- 交易 cookie 必须
SameSite=Lax。回调是 Google 发起的跨站顶层跳转,Strict的 cookie 不会带回来。 - 反向代理后 Node 看到的是
http://127.0.0.1,回调地址要按X-Forwarded-Proto+Host拼,否则和登记的 https 地址对不上,报redirect_uri_mismatch。如果你的代理环境不可信,更稳的做法是把站点地址写进配置,不从请求头拼。 - 未验签的 id_token 等于别人递来的身份卡。签名、
iss、aud、exp、nonce五项一项都不能少。 start之后跳转的returnTo只允许站内路径,挡掉//evil.com这类开放跳转。
核心代码(Hono,换 Express 只需改路由写法)
import { Hono, type Context } from 'hono'
import { getCookie, setCookie, deleteCookie } from 'hono/cookie'
import { createHash, createHmac, createPublicKey, randomBytes, timingSafeEqual, verify } from 'node:crypto'
const { GOOGLE_CLIENT_ID = '', GOOGLE_CLIENT_SECRET = '', AUTH_SECRET = '' } = process.env
const CALLBACK_PATH = '/api/auth/oauth/google/callback'
const TX_COOKIE = 'oauth_tx'
const TX_TTL = 5 * 60 * 1000
const SECURE = process.env.NODE_ENV === 'production'
type Tx = { s: string; n: string; v: string; r: string; exp: number }
const safeEq = (a: string, b: string) => {
const x = Buffer.from(a), y = Buffer.from(b)
return x.length === y.length && timingSafeEqual(x, y)
}
const sign = (p: string) => createHmac('sha256', AUTH_SECRET).update(p).digest('base64url')
const encodeTx = (tx: Tx) => { const p = Buffer.from(JSON.stringify(tx)).toString('base64url'); return `${p}.${sign(p)}` }
function decodeTx(v?: string): Tx | null {
const i = v?.lastIndexOf('.') ?? -1
if (!v || i <= 0 || !safeEq(v.slice(i + 1), sign(v.slice(0, i)))) return null
try { const tx = JSON.parse(Buffer.from(v.slice(0, i), 'base64url').toString()) as Tx; return Date.now() < tx.exp ? tx : null } catch { return null }
}
function origin(c: Context) {
const u = new URL(c.req.url)
const proto = c.req.header('x-forwarded-proto')?.split(',')[0]?.trim() || u.protocol.replace(':', '')
return `${proto}://${c.req.header('host') || u.host}`
}
// 只允许站内路径,挡 //evil.com 这类开放跳转
const safeReturn = (v?: string) => (!v || !v.startsWith('/') || v.startsWith('//') || /[\\\x00-\x20]/.test(v) ? '/' : v)
let jwks: { keys: any[]; at: number } | null = null
async function googleKeys(force = false) {
if (!force && jwks && Date.now() - jwks.at < 3600_000) return jwks.keys
const r = await fetch('https://www.googleapis.com/oauth2/v3/certs', { signal: AbortSignal.timeout(10_000) })
if (!r.ok) throw new Error('JWKS 获取失败')
jwks = { keys: ((await r.json()) as { keys: any[] }).keys, at: Date.now() }
return jwks.keys
}
async function verifyIdToken(idToken: string, nonce: string) {
const [h, p, s] = idToken.split('.')
const header = JSON.parse(Buffer.from(h, 'base64url').toString())
if (header.alg !== 'RS256' || !header.kid) throw new Error('算法不符')
// Google 轮换密钥后 kid 会对不上,强制刷新一次
let jwk = (await googleKeys()).find((k) => k.kid === header.kid) ?? (await googleKeys(true)).find((k) => k.kid === header.kid)
if (!jwk) throw new Error('签名密钥不存在')
const ok = verify('RSA-SHA256', Buffer.from(`${h}.${p}`), createPublicKey({ key: jwk, format: 'jwk' }), Buffer.from(s, 'base64url'))
if (!ok) throw new Error('签名无效')
const c = JSON.parse(Buffer.from(p, 'base64url').toString())
if (!['accounts.google.com', 'https://accounts.google.com'].includes(c.iss) || c.aud !== GOOGLE_CLIENT_ID) throw new Error('iss/aud 不符')
if (c.exp * 1000 <= Date.now()) throw new Error('已过期')
if (typeof c.nonce !== 'string' || !safeEq(c.nonce, nonce)) throw new Error('nonce 不符')
return c as { sub: string; email?: string; email_verified?: boolean }
}
export const oauth = new Hono()
oauth.get('/providers', (c) => c.json({ google: !!GOOGLE_CLIENT_ID && !!GOOGLE_CLIENT_SECRET }))
oauth.get('/google/start', (c) => {
if (!GOOGLE_CLIENT_ID || !GOOGLE_CLIENT_SECRET) return c.json({ error: 'Google 登录未启用' }, 404)
// 这里加按 IP 限流(如 15 分钟 20 次)
const tx: Tx = {
s: randomBytes(24).toString('base64url'),
n: randomBytes(16).toString('base64url'),
v: randomBytes(32).toString('base64url'),
r: safeReturn(c.req.query('returnTo')),
exp: Date.now() + TX_TTL,
}
setCookie(c, TX_COOKIE, encodeTx(tx), { httpOnly: true, secure: SECURE, sameSite: 'Lax', path: '/api/auth/oauth', maxAge: TX_TTL / 1000 })
const q = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: origin(c) + CALLBACK_PATH,
response_type: 'code',
scope: 'openid email profile',
state: tx.s,
nonce: tx.n,
code_challenge: createHash('sha256').update(tx.v).digest('base64url'),
code_challenge_method: 'S256',
prompt: 'select_account',
})
return c.redirect(`https://accounts.google.com/o/oauth2/v2/auth?${q}`)
})
oauth.get('/google/callback', async (c) => {
const tx = decodeTx(getCookie(c, TX_COOKIE))
deleteCookie(c, TX_COOKIE, { path: '/api/auth/oauth' }) // 一次性消费
const back = (code: string) => c.redirect(`${tx?.r ?? '/'}?oauth=${code}`)
if (c.req.query('error')) return back('cancelled') // 用户在 Google 页点了取消,不是错误
const code = c.req.query('code') ?? ''
if (!tx || !code || !safeEq(tx.s, c.req.query('state') ?? '')) return back('failed')
let email = ''
try {
const r = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code,
client_id: GOOGLE_CLIENT_ID, client_secret: GOOGLE_CLIENT_SECRET,
code_verifier: tx.v, redirect_uri: origin(c) + CALLBACK_PATH,
}),
signal: AbortSignal.timeout(10_000),
})
if (!r.ok) throw new Error(`token HTTP ${r.status}`)
const { id_token } = (await r.json()) as { id_token?: string }
const claims = await verifyIdToken(id_token ?? '', tx.n)
if (claims.email && claims.email_verified === true) email = claims.email.trim().toLowerCase()
} catch (e) {
console.error('[oauth/google] callback failed', e) // 吞错前先打出原因
return back('failed')
}
if (!email) return back('failed')
// ↓ 接你自己的用户层:按邮箱查,没有就建号(建号要按 IP 限流),然后发会话 cookie
const user = findUserByEmail(email) ?? createUserWithEmail(email)
await issueSession(c, user)
return back('ok')
})
// 挂载:app.route('/api/auth/oauth', oauth)findUserByEmail / createUserWithEmail / issueSession 是项目已有的用户与会话函数,按现有登录逻辑实现即可。
GitHub 登录
控制台配置过程
- 打开 github.com/settings/developers →「OAuth Apps」→「New OAuth App」:Application name 填
<站名>,Homepage URL 填<站点地址>,Authorization callback URL 填<站点地址>/api/auth/oauth/github/callback(不能留空)。 - 创建后复制 Client ID;点「Generate a new client secret」,只显示一次。
- 可以再加一条本地回调地址;不要开 wildcard matching,也不要开 Device Flow。
- 没有测试状态,创建后立即对所有 GitHub 账号可用,不需要隐私政策页。
环境变量:GITHUB_CLIENT_ID、GITHUB_CLIENT_SECRET。
与 Google 的代码差异
start 路由同上(同样的 state + PKCE cookie),只把授权地址和 scope 换掉。GitHub 不返回 id_token,不用 nonce 和 JWKS,改成拿 access token 去查已验证的主邮箱:
// start:授权地址
const q = new URLSearchParams({
client_id: GITHUB_CLIENT_ID, redirect_uri: origin(c) + '/api/auth/oauth/github/callback',
scope: 'user:email', state: tx.s,
code_challenge: createHash('sha256').update(tx.v).digest('base64url'), code_challenge_method: 'S256',
})
return c.redirect(`https://github.com/login/oauth/authorize?${q}`)
// callback:state 核对同 Google,然后
const t = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ client_id: GITHUB_CLIENT_ID, client_secret: GITHUB_CLIENT_SECRET, code, code_verifier: tx.v, redirect_uri: origin(c) + '/api/auth/oauth/github/callback' }),
})
const { access_token } = (await t.json()) as { access_token?: string }
const r = await fetch('https://api.github.com/user/emails?per_page=100', {
headers: { Authorization: `Bearer ${access_token}`, Accept: 'application/vnd.github+json', 'User-Agent': '<站名>' },
})
const emails = (await r.json()) as { email: string; primary: boolean; verified: boolean }[]
const primary = emails.find((e) => e.primary && e.verified)?.email.toLowerCase() ?? ''
// 私密邮箱会返回 @users.noreply.github.com,不能当身份用,提示用户改用其他方式
if (!primary || primary.endsWith('@users.noreply.github.com')) return back('github_email_required')
// 之后同 Google:按邮箱登录或建号;access_token 用完即丢,不保存不要按 GitHub 用户名建号,因为用户名可以改,邮箱才是这里的身份。
账号怎么对应
我的做法是以经过平台验证的邮箱作为身份:Google 登录、GitHub 登录和邮箱验证码登录,只要邮箱相同就进入同一个账号,不需要另建绑定表。
这个做法成立有两个前提,缺一不可:
- 只认平台明确标记已验证的邮箱(Google 的
email_verified === true,GitHub 的primary && verified),未验证的邮箱一律拒绝。 - 拿不到可用邮箱的平台(隐私中继邮箱、QQ 这类只返回 openid 的平台)不能走这条路,只能按
provider + openid另建账号,也就无法和邮箱账号合并。
如果你的站点有更高的账号安全要求,或要接入不保证邮箱已验证的平台,更稳妥的做法是把第三方身份(iss + sub)单独存表,已有账号登录后显式发起绑定,不按邮箱静默合并。
隐私政策页
Google 发布正式版需要一个隐私政策链接。用纯静态 HTML 放在站点的静态目录(如 Vite 的 public/privacy.html),构建后直接以 <站点地址>/privacy.html 提供,不经过前端路由。写的时候注意:
- 面向用户写,不解释实现细节。
- 只写真做到的事。比如设置页没有删除功能,就别写「可在设置页删除」。
- 需要覆盖的内容:收集了什么(账号信息;Google 登录只读取已核验的邮箱,不读联系人和文件,不保存访问令牌;运行日志含 IP)、如何使用(只用于登录与安全,不出售不用于广告)、如何删除数据(给出联系方式)。
- 站点有 CSP 时,样式和脚本写成内联,不要用外链。
- 上线后确认返回 200,再填进 Google 品牌页:
curl -s -o /dev/null -w "%{http_code}" <站点地址>/privacy.html把凭据安全地写进服务器
原则:密钥不出现在命令行参数、shell 历史、聊天记录或截图里,只落在服务器上部署目录外的 600 文件里。
下面这个本地脚本交互读取 ID 和密钥,输入不回显,经 SSH 标准输入写进服务器的环境文件,改之前先备份:
#!/usr/bin/env bash
set -euo pipefail
SSH="ssh -p <端口> -i <私钥路径> <用户>@<服务器IP>"
ENV_FILE=/opt/<项目>-data/.env # 部署目录外、600 权限的 KEY=VALUE 文件
RELOAD="systemctl restart <服务名>" # 或 pm2 start <配置文件> --update-env
read -rp 'GOOGLE_CLIENT_ID: ' id
read -rsp 'GOOGLE_CLIENT_SECRET: ' secret; echo
[[ $id == *.apps.googleusercontent.com && $secret == GOCSPX-* ]] || { echo '格式不对' >&2; exit 1; }
printf '%s\n%s\n' "$id" "$secret" | $SSH "ENV_FILE=$ENV_FILE RELOAD='$RELOAD' bash -c '
set -euo pipefail
read -r id; read -r secret
cp -p \"\$ENV_FILE\" \"\$ENV_FILE.bak-\$(date +%F-%H%M)\"
grep -vE \"^GOOGLE_CLIENT_(ID|SECRET)=\" \"\$ENV_FILE.bak-\$(date +%F-%H%M)\" > \"\$ENV_FILE\" || true
printf \"GOOGLE_CLIENT_ID=%s\nGOOGLE_CLIENT_SECRET=%s\n\" \"\$id\" \"\$secret\" >> \"\$ENV_FILE\"
\$RELOAD && echo 已更新并重启
'"- 备份文件里有旧密钥,权限与原文件一样是 600;确认新的能用之后可以删掉。
- 用 pm2 时注意,
pm2 restart --update-env不会重读配置文件。改了ecosystem.config.cjs里的变量,要用pm2 start <配置文件> --update-env。 - 进程以低权限用户运行时,环境文件的属主要给那个用户,
cp -p会保留属主。 - 更新已有项目的配置时,不要顺手更换会话密钥,否则所有用户会被意外登出。
故障排查
先按 Google 错误码分流
| 报错 | 含义 | 去哪查 |
|---|---|---|
401 disabled_client | 客户端或它所在的整个项目被停用 | 见下一节 |
401 invalid_client | 服务器上的 ID / 密钥填错,或客户端被删了 | 核对服务器环境变量和控制台「客户端」列表 |
400 redirect_uri_mismatch | 回调地址和登记的不一致 | 看报错里的实际地址;多半是反代后协议成了 http,或少了斜杠 |
403 access_denied +「应用正在测试」 | 还没发布,用户不在测试名单里 | 「目标对象」→ 发布应用 |
| 按钮不出现 | 服务端没读到两个 Google 变量 | curl <站点地址>/api/auth/oauth/providers 看 google 字段 |
一次真实事故:401 disabled_client
Google 登录突然报 The OAuth client was disabled。原因是 OAuth 客户端当时放在 AI Studio 建 Gemini 密钥时自动生成的 gen-lang-client-* 项目里。那把 Gemini 密钥被 Google 判定公开泄漏、遭人盗用,于是整个项目被暂停,登录客户端跟着失效。
泄漏源最终没有查出来:站点仓库的 git 历史、本机文件、相关博客文章里都没有这把密钥。Google 的通知只会说「项目里的 API 密钥或服务账号凭据泄漏」,不会告诉你是在哪里发现的。所以只能靠预防,不能靠事后追查。预防方法就是前面的约束 2:登录客户端单独一个项目。
排查:
打开 管理资源,看各项目「状态」列有没有「已暂停」。
点进被暂停的项目,顶部红色横幅点「展开」,读停用原因。
到 AI Studio 的 API 密钥页 和该项目的「凭据」页,确认项目里有哪些密钥,判断是哪一把出事。
在自己的仓库里扫一遍历史,有结果就说明在这里泄漏,要从历史里清掉并轮换:
git log --all -p | grep -oE "AIza[0-9A-Za-z_-]{35}|GOCSPX-[A-Za-z0-9_-]{20,}"
修复:不要等申诉,直接换项目,二三十分钟能恢复。
- 按前面 Google 配置的 1–5 步,在新的专用项目里重建客户端并发布。
- 把新的 ID 和密钥写进服务器并重启服务。
- 验证:
/providers接口返回google: true;点 Google 登录能看到「选择账号 · 继续前往 <域名>」;选一个账号能正常进站。 - 确认没问题后删旧项目(管理资源 → 勾选 →「删除」→ 输入项目 ID 确认,30 天内可恢复)。泄漏的密钥随项目作废;还要用 Gemini 的话,在另一个项目里重建,别放进登录项目。
- 在项目的 README 或协作规范里写上「登录客户端独立项目」这条规则,防止以后又有人把东西放回去。
发信相关
| 现象 | 先查 |
|---|---|
| 一直正常,某天突然「邮箱服务暂不可用」 | SMTP 密钥是否长期不用被过期,重新生成后换环境变量 |
| 连不上 SMTP | SMTP_USER 是不是填成了注册邮箱(应该是自动生成的登录名) |
| QQ / 163 能收,outlook 收不到 | DNS 认证没完成,回 Brevo「Domains」看是否全绿 |
| 进垃圾箱 | 缺 DMARC,或 SMTP_FROM 不是已认证域名下的地址 |
其他平台速查
这些平台我没有接入,只记录申请门槛供决策,具体以官方当前要求为准:
| 平台 | 门槛 | 要点 |
|---|---|---|
| Microsoft | 个人账号已不能直接注册应用(2026-08 实测),需要 M365 开发者租户(长期不活跃可能被回收)或带国际信用卡的 Azure 账号 | 账户类型选「任何组织 + 个人」;client secret 有有效期,要按时轮换 |
| Apple | Apple Developer Program 标准年费 99 美元 | client_secret 要用 .p8 私钥以 ES256 现签 JWT,参见 Web 登录配置 |
| 域名需 ICP 备案,需平台审核,周期不固定 | 拿不到邮箱,只能按 provider + openid 建号,无法和邮箱账号合并 | |
| 163 / 网易 | 没有面向个人站点的开放登录 | 用邮箱验证码覆盖 |
任何平台都接不上时,邮箱验证码能覆盖全部用户,少一个按钮不影响上线。
上线前验收
| 检查项 | 预期结果 |
|---|---|
| 域名与邮件认证 | DNS 验证全绿,受控实发进收件箱,Brevo 日志为 Delivered |
| 验证码 | 正确码可登录;错误、过期、重复和超限请求被拒绝 |
| 第三方登录 | 注册、再次登录、取消授权和异常回调均有明确结果 |
| 安全校验 | 错误 state、过期 code、错误发行方或受众不能建立会话 |
| 账号关联 | 未验证邮箱、私密邮箱被拒绝,相同已验证邮箱进入同一账号 |
| 环境与凭据 | 开发和生产回调隔离,前端及日志没有密钥,登录客户端独立成项目 |
发信认证解决邮件身份,平台授权解决第三方身份,本站会话和账号规则决定用户最终进入哪个账号。三处都验证过,才算登录接入完成。

暂无评论