个人站长也要懂接口鉴权
过去个人站就是一堆静态页面,现在不一样了:小程序要调你的接口、手机 App 要拉你的内容、第三方工具要订阅你的数据、前后端分离之后 PHP 只负责出 JSON。一旦有了接口,第一个问题就是「怎么确定请求方是谁、能干什么」。很多个人站的做法是把 API Key 写死在客户端里直接比对,结果不出三天就被人扒出来刷爆。这篇文章用个人站长能落地的粒度,把 OAuth2、JWT、单点登录(SSO)这三件事讲清楚,并给出一个不需要引入重型框架的最小实现。
先分清认证与授权,别把它们混为一谈
这两个词经常被混用,但概念完全不同:认证(Authentication)回答「你是谁」,授权(Authorization)回答「你能做什么」。登录输密码是认证;登录后能不能删文章是授权。这个区分不是学究抠字眼,而是直接决定方案选型:认证方案解决「证明身份」,授权方案解决「限定范围」。后面要讲的 OAuth2 主要解决授权(让第三方在限定范围内代表你操作),JWT 则是承载认证与授权结果的容器。
还有第三个维度常被忽略:会话状态。传统 PHP 网站用 Session,服务端存一份状态、浏览器只拿一个 Session ID。而 API 场景通常希望「无状态」,服务端不存东西,所有信息都塞进令牌里——这就是 JWT 流行起来的根本原因。
OAuth2 的四种授权模式(以及你该用哪种)
OAuth2 是「授权框架」,本身不规定令牌长什么样。它定义了四种拿到令牌的方式(Grant Type),选择哪种取决于客户端类型:
- 授权码模式(Authorization Code):最安全、最常用。客户端先把用户重定向到授权服务器,用户登录同意后拿回一个一次性 code,再用 code 换取 access token。code 是短时、一次性的,所以令牌不会暴露在浏览器地址栏里。前后端分离的 Web 应用、第三方登录都应该用它。
- 客户端凭证模式(Client Credentials):没有用户参与,纯服务端到服务端通信。客户端直接用
client_id+client_secret换令牌。适合定时任务、数据同步这类机器调用。 - 密码模式(Password):客户端直接拿用户名密码去换令牌。OAuth 2.1 已建议废弃,因为它要求用户把密码交给第三方应用。只在完全自有的第一方 App 里勉强可用。
- 隐式模式(Implicit):令牌直接放在 URL fragment 里回传,已不推荐,被授权码 + PKCE 取代。
对个人站长最实用的判断:有用户交互选授权码 + PKCE;纯机器调用选客户端凭证。其他两种知道存在即可。
授权码模式 + PKCE 的最小流程
不引入任何第三方 OAuth 服务,自己在 PHP 里实现一套最简版,核心就四步。第一步,生成 PKCE 的 code_verifier 与它的哈希 code_challenge,并把用户导向授权端点:
// 1. 生成一次性校验串(前端或后端都可以)
$verifier = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
// 2. 重定向到你的授权页,带上 challenge
$url = '/oauth/authorize?' . http_build_query([
'response_type' => 'code',
'client_id' => 'web-app',
'redirect_uri' => 'https://app.example.com/callback',
'scope' => 'read_posts',
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]);
header('Location: ' . $url);
第二步,用户在你的授权页登录并同意后,服务器生成一个短期有效的 code 存起来(推荐 Redis,设 60 秒过期)并重定向回 redirect_uri。第三步,客户端拿 code 和原始 verifier 换取令牌:
// 3. 用 code 换 token(服务端 POST)
$code = $_POST['code'] ?? '';
$verifier = $_POST['code_verifier'] ?? '';
// 从 Redis 取回之前存的 challenge
$stored = $redis->get('oauth_code:' . $code);
if (!$stored) { http_response_code(400); exit('invalid_code'); }
// 校验:客户端出示的 verifier 的哈希必须等于当初的 challenge
$calc = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
if (!hash_equals($stored, $calc)) { http_response_code(400); exit('pkce_failed'); }
$redis->del('oauth_code:' . $code); // 一次性,用完即删
hash_equals() 是必须的——它做常量时间比较,防止通过响应时间差异逐字节猜测。整个 PKCE 的意义在于:即使授权码在重定向途中被截获,没有原始 code_verifier 也换不到令牌,公共客户端(无法安全保存 secret 的 SPA、App)因此也能安全使用授权码模式。
JWT:把身份和权限装进令牌
拿到授权之后怎么表达「我是谁、能干什么」,最省事的载体就是 JWT。JWT 由三段用点号连接:header.payload.signature。前两段只是 Base64Url 编码的 JSON,任何人都能解开看内容,真正的安全来自第三段的签名。
{
"alg": "HS256",
"typ": "JWT"
}
.
{
"sub": "user_1024",
"scope": "read_posts",
"iss": "https://www.example.com",
"aud": "web-app",
"iat": 1759100000,
"exp": 1759103600
}
.
HMACSHA256(base64Url(header) + "." + base64Url(payload), secret)
签发与校验同样可以用几十行 PHP 完成,不必上框架:
function b64url($d){ return rtrim(strtr(base64_encode($d), '+/', '-_'), '='); }
function jwt_sign(array $payload, string $secret): string {
$header = b64url(json_encode(['alg'=>'HS256','typ'=>'JWT']));
$body = b64url(json_encode($payload));
$sig = b64url(hash_hmac('sha256', "$header.$body", $secret, true));
return "$header.$body.$sig";
}
function jwt_verify(string $token, string $secret): ?array {
$p = explode('.', $token);
if (count($p) !== 3) return null;
[$h, $b, $s] = $p;
// 关键:签名校验必须先做,且用 hash_equals 防时序攻击
$expect = b64url(hash_hmac('sha256', "$h.$b", $secret, true));
if (!hash_equals($expect, $s)) return null;
$payload = json_decode(base64_decode(strtr($b, '-_', '+/')), true);
if (!is_array($payload)) return null;
// 必查 exp,过期直接拒绝
if (isset($payload['exp']) && time() > $payload['exp']) return null;
return $payload;
}
JWT 的五个致命误用
JWT 简单到容易被用错,下面五条每一条都真实造成过事故:
alg: none漏洞:如果校验方信任客户端传来的 header 里的算法,攻击者把alg改成none并去掉签名,服务端就会「验证通过」。对策:算法必须写死在服务端,绝不读 header 里的 alg。- HS256 与 RS256 混淆:如果用 RS256 公钥验签,却按 HS256 逻辑处理,攻击者可以用公开的 RSA 公钥当 HMAC 密钥伪造签名。对策:明确区分非对称与对称校验路径,不要写「自动判断」。secret 绝不能弱、绝不能提交进 Git。
- 不做签名校验只解 Base64:payload 是明文,很多人图省事直接
json_decode就信了。这等于没有鉴权——任何人都能自己编一个 payload。 - 忘记校验
exp/aud/iss:签名对只代表「内容没被篡改」,不代表「还有效」或「是发给我的」。三个声明都要查。 - 把敏感数据塞进 payload:手机号、身份证、密码哈希都不能放,因为任何人 Base64 解一下就能看到。
还有一个绕不过去的问题:JWT 一旦签发,在过期前无法主动作废。要支持「立即踢下线」或「改密码后旧令牌失效」,就得引入黑名单或版本号——在 payload 里放一个 token_version,用户改密码时版本号 +1 存 Redis,校验时比对。这样既保留了无状态的性能优势,又拿到了可控的失效能力。
access token 与 refresh token 的分工
一个成熟的做法是双令牌:access_token 短命(15 分钟到 1 小时),refresh_token 长命(7 天到 30 天)。业务接口只认 access token,暴露风险窗口小;过期后用 refresh token 换一对新令牌。refresh token 必须可撤销、且一次性轮换——每次刷新都作废旧的那个,如果检测到旧 refresh token 被重复使用,说明令牌可能已被窃取,应当立即吊销整条会话链。
# 刷新时轮换(伪代码)
if (!refresh_token_is_valid($rt)) return 401;
revoke($rt); # 旧的立即作废
$new = issue_token_pair($user); # 发一对新的
return $new;
单点登录(SSO)对个人站意味着什么
严格来说 SSO 是「多个系统共用一次登录」。个人站长通常有两三个自建服务(论坛、统计面板、图床后台),每个都单独登录确实烦。落地 SSO 有两条路:一是自己当身份提供方(IdP),用一个中央登录页 + 自签 JWT 分发给各子站;二是把身份交给第三方(GitHub OAuth、公众号网页授权),各子站只做回调校验。
对个人站,第二条路更省心:让用户用 GitHub 登录,你只需要在回调里拿 code 换 access token、再拉一次用户信息、比对你的白名单,通过就签一张自己域的 JWT 存进 Cookie。子站之间共享同一份 JWT 签名密钥即可实现天然 SSO。关键是把 state 参数用起来防 CSRF——授权前生成随机 state 存 Session,回调时严格比对,不然攻击者能诱导用户绑定到攻击者的账号。
接口安全的收尾清单
- 令牌走
Authorization: Bearer头,不要放在 URL 里(会被写进 Nginx access log 和 Referer)。 - Cookie 存令牌时加
HttpOnly+Secure+SameSite=Lax,防 XSS 读取与 CSRF。 - 所有签名校验用
hash_equals,所有随机数用random_bytes,绝不用rand()/mt_rand()生成安全令牌。 - 接口做限流,按 IP 或按用户维度,Nginx 的
limit_req或应用层计数都行。 - 密钥(JWT secret、client_secret)放环境变量或权限 600 的文件,不进代码库、不进前端。
- 授权范围最小化,scope 只给必要的,
client_secret泄露时损失才可控。 - 日志里不要记录完整令牌,要打就打个前八位哈希,方便排查又不泄露。
小结
OAuth2 是「怎么安全地把权限授出去」,JWT 是「怎么轻便地携带身份和权限」,SSO 是「多系统怎么共用一次登录」。三者不是竞争关系,而是同一条链路上的不同环节。个人站长不必一上来就部署 Keycloak、Auth0 这类重型方案——授权码 + PKCE、HS256 签名的 JWT、refresh token 轮换、state 防 CSRF,这四件套用几百行 PHP 就能实现,覆盖 90% 的实际需求。真正要牢记的是那条永远不变的原则:永远不要信任客户端的任何输入,包括它自称的算法、它带来的令牌、和它声明的身份——服务端该验的一条都不能省。