Files
anxinyan/server-api/app/support/MiniProgramAuthService.php
2026-06-05 16:12:56 +08:00

548 lines
18 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\support;
use support\think\Db;
use Webman\Http\Request;
class MiniProgramAuthService
{
public const AUTH_TYPE = 'wechat_mini_program';
private const BIND_TICKET_TTL = 600;
public function exchangeCode(string $code, Request $request): array
{
$identity = $this->buildIdentityByCode($code);
$auth = $this->findAuth($identity['openid'], $identity['unionid']);
if ($auth) {
$userId = (int)$auth['user_id'];
$user = Db::name('users')->where('id', $userId)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('小程序微信已绑定账号不存在或已停用');
}
$this->syncAuth($userId, $identity, date('Y-m-d H:i:s'), (int)$auth['id']);
return array_merge([
'status' => 'logged_in',
], $this->issueToken($userId, $request));
}
return [
'status' => 'need_bind',
'bind_ticket' => $this->createBindTicket($identity),
'expire_seconds' => self::BIND_TICKET_TTL,
'profile' => [
'nickname' => '',
'avatar' => '',
],
];
}
public function bindMobile(string $bindTicket, string $mobile, string $code, Request $request): array
{
$identity = $this->verifyBindTicket($bindTicket);
$mobile = $this->normalizeMobile($mobile);
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$user = Db::name('users')->where('mobile', $mobile)->lock(true)->find();
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号已停用,无法绑定小程序微信');
}
$this->assertIdentityAvailable($identity, $user ? (int)$user['id'] : null);
$this->verifyLoginCode($mobile, $code, $now);
if (!$user) {
$userId = (int)Db::name('users')->insertGetId([
'nickname' => '安心验用户' . substr($mobile, -4),
'avatar' => '',
'mobile' => $mobile,
'password' => '',
'status' => 'enabled',
'last_login_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
} else {
$userId = (int)$user['id'];
}
$this->syncMobileAuth($userId, $mobile, $now);
$this->syncAuth($userId, $identity, $now);
$payload = array_merge([
'status' => 'logged_in',
], $this->issueToken($userId, $request));
Db::commit();
return $payload;
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
public function bindOpenid(int $userId, string $code): array
{
$code = trim($code);
if ($userId <= 0) {
throw new \RuntimeException('用户登录状态无效');
}
if ($code === '') {
throw new \RuntimeException('小程序登录 code 不能为空');
}
$identity = $this->fetchOpenidByCode($code);
$openid = (string)$identity['openid'];
$unionid = (string)($identity['unionid'] ?? '');
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$existing = Db::name('user_auths')
->where('auth_type', self::AUTH_TYPE)
->where('auth_key', $openid)
->lock(true)
->find();
if ($existing && (int)$existing['user_id'] !== $userId) {
throw new \RuntimeException('该小程序微信身份已绑定其他账号');
}
if ($unionid !== '') {
$unionAuth = Db::name('user_auths')
->where('auth_type', self::AUTH_TYPE)
->where('auth_union_id', $unionid)
->lock(true)
->find();
if ($unionAuth && (int)$unionAuth['user_id'] !== $userId) {
throw new \RuntimeException('该小程序微信身份已绑定其他账号');
}
if (!$existing && $unionAuth) {
$existing = $unionAuth;
}
}
$payload = [
'user_id' => $userId,
'auth_type' => self::AUTH_TYPE,
'auth_key' => $openid,
'auth_open_id' => $openid,
'auth_union_id' => $unionid,
'auth_extra' => json_encode([
'session_key_present' => ((string)($identity['session_key'] ?? '')) !== '',
'bound_at' => $now,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'updated_at' => $now,
];
if ($existing) {
Db::name('user_auths')->where('id', (int)$existing['id'])->update($payload);
} else {
$payload['created_at'] = $now;
Db::name('user_auths')->insert($payload);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'openid' => $openid,
'unionid' => $unionid,
];
}
public function openidForUser(int $userId): string
{
if ($userId <= 0) {
return '';
}
return (string)Db::name('user_auths')
->where('user_id', $userId)
->where('auth_type', self::AUTH_TYPE)
->order('id', 'desc')
->value('auth_open_id');
}
private function buildIdentityByCode(string $code): array
{
$payload = $this->fetchOpenidByCode($code);
$openid = trim((string)($payload['openid'] ?? ''));
if ($openid === '') {
throw new \RuntimeException('微信小程序登录返回缺少 openid');
}
$sessionKey = (string)($payload['session_key'] ?? '');
return [
'openid' => $openid,
'unionid' => trim((string)($payload['unionid'] ?? '')),
'auth_extra' => [
'source' => 'mini_program_login',
'session_key_present' => $sessionKey !== '',
'authorized_at' => date('Y-m-d H:i:s'),
],
];
}
private function findAuth(string $openid, string $unionid, bool $lock = false): ?array
{
$query = Db::name('user_auths')
->where('auth_type', self::AUTH_TYPE)
->where('auth_key', $openid);
if ($lock) {
$query->lock(true);
}
$auth = $query->find();
if ($auth) {
return $auth;
}
if ($unionid === '') {
return null;
}
$query = Db::name('user_auths')
->where('auth_type', self::AUTH_TYPE)
->where('auth_union_id', $unionid)
->order('id', 'asc');
if ($lock) {
$query->lock(true);
}
$auth = $query->find();
return $auth ?: null;
}
private function assertIdentityAvailable(array $identity, ?int $allowedUserId): void
{
$auth = $this->findAuth((string)$identity['openid'], (string)$identity['unionid'], true);
if ($auth && ($allowedUserId === null || (int)$auth['user_id'] !== $allowedUserId)) {
throw new \RuntimeException('该小程序微信身份已绑定其他账号');
}
}
private function syncAuth(int $userId, array $identity, string $now, ?int $preferredAuthId = null): void
{
$openid = trim((string)($identity['openid'] ?? ''));
$unionid = trim((string)($identity['unionid'] ?? ''));
if ($openid === '') {
throw new \RuntimeException('小程序 openid 不能为空');
}
$existing = null;
if ($preferredAuthId) {
$existing = Db::name('user_auths')->where('id', $preferredAuthId)->find();
}
if (!$existing) {
$existing = $this->findAuth($openid, $unionid);
}
if ($existing && (int)$existing['user_id'] !== $userId) {
throw new \RuntimeException('该小程序微信身份已绑定其他账号');
}
$payload = [
'user_id' => $userId,
'auth_type' => self::AUTH_TYPE,
'auth_key' => $openid,
'auth_open_id' => $openid,
'auth_union_id' => $unionid,
'auth_extra' => json_encode($identity['auth_extra'] ?? [], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'updated_at' => $now,
];
if ($existing) {
Db::name('user_auths')->where('id', (int)$existing['id'])->update($payload);
return;
}
$payload['created_at'] = $now;
Db::name('user_auths')->insert($payload);
}
private function syncMobileAuth(int $userId, string $mobile, string $now): void
{
$existing = Db::name('user_auths')
->where('auth_type', 'mobile')
->where('auth_key', $mobile)
->find();
$payload = [
'user_id' => $userId,
'auth_type' => 'mobile',
'auth_key' => $mobile,
'auth_open_id' => '',
'auth_union_id' => '',
'auth_extra' => json_encode(['mobile' => $mobile], JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
];
if ($existing) {
Db::name('user_auths')->where('id', (int)$existing['id'])->update($payload);
return;
}
$payload['created_at'] = $now;
Db::name('user_auths')->insert($payload);
}
private function verifyLoginCode(string $mobile, string $code, string $now): void
{
$code = trim($code);
if (!preg_match('/^\d{6}$/', $code)) {
throw new \RuntimeException('验证码格式不正确');
}
$record = Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', 'login')
->whereIn('send_status', ['success', 'mock'])
->whereNull('used_at')
->order('id', 'desc')
->find();
if (!$record) {
throw new \RuntimeException('验证码不存在或已失效');
}
if (strtotime((string)$record['expire_time']) < time()) {
throw new \RuntimeException('验证码已过期,请重新获取');
}
if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) {
throw new \RuntimeException('验证码错误');
}
Db::name('sms_code_logs')->where('id', (int)$record['id'])->update([
'used_at' => $now,
'updated_at' => $now,
]);
}
private function issueToken(int $userId, Request $request): array
{
$user = Db::name('users')->where('id', $userId)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号不存在或已停用');
}
$token = bin2hex(random_bytes(24));
$tokenHash = hash('sha256', $token);
$now = date('Y-m-d H:i:s');
$expireTime = date('Y-m-d H:i:s', time() + 30 * 24 * 3600);
Db::name('user_api_tokens')->where('user_id', $userId)->delete();
Db::name('user_api_tokens')->insert([
'user_id' => $userId,
'token_hash' => $tokenHash,
'auth_type' => self::AUTH_TYPE,
'expire_time' => $expireTime,
'last_active_at' => $now,
'last_ip' => $request->getRealIp(),
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
'created_at' => $now,
'updated_at' => $now,
]);
Db::name('users')->where('id', $userId)->update([
'last_login_at' => $now,
'updated_at' => $now,
]);
return [
'token' => $token,
'user_info' => $this->userInfo($userId),
];
}
private function userInfo(int $userId): array
{
$user = Db::name('users')->where('id', $userId)->find();
return [
'id' => (int)($user['id'] ?? 0),
'nickname' => $user['nickname'] ?: '安心验用户',
'mobile' => $user['mobile'] ?? '',
'avatar' => $user['avatar'] ?? '',
'status' => $user['status'] ?? 'enabled',
'password_set' => ((string)($user['password'] ?? '')) !== '',
];
}
private function createBindTicket(array $identity): string
{
$now = time();
$payload = [
'typ' => 'wechat_mini_program_bind',
'openid' => (string)$identity['openid'],
'unionid' => (string)($identity['unionid'] ?? ''),
'auth_extra' => $identity['auth_extra'] ?? [],
'iat' => $now,
'exp' => $now + self::BIND_TICKET_TTL,
'nonce' => bin2hex(random_bytes(8)),
];
$body = $this->base64UrlEncode(json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$signature = hash_hmac('sha256', $body, $this->ticketSecret());
return $body . '.' . $signature;
}
private function verifyBindTicket(string $ticket): array
{
$ticket = trim($ticket);
if ($ticket === '' || strpos($ticket, '.') === false) {
throw new \RuntimeException('小程序绑定凭证无效,请重新授权');
}
[$body, $signature] = explode('.', $ticket, 2);
$expected = hash_hmac('sha256', $body, $this->ticketSecret());
if (!hash_equals($expected, strtolower($signature))) {
throw new \RuntimeException('小程序绑定凭证签名无效,请重新授权');
}
$decoded = json_decode($this->base64UrlDecode($body), true);
if (!is_array($decoded) || ($decoded['typ'] ?? '') !== 'wechat_mini_program_bind') {
throw new \RuntimeException('小程序绑定凭证无效,请重新授权');
}
if ((int)($decoded['exp'] ?? 0) < time()) {
throw new \RuntimeException('小程序绑定凭证已过期,请重新授权');
}
$openid = trim((string)($decoded['openid'] ?? ''));
if ($openid === '') {
throw new \RuntimeException('小程序绑定凭证缺少 openid请重新授权');
}
return [
'openid' => $openid,
'unionid' => trim((string)($decoded['unionid'] ?? '')),
'auth_extra' => is_array($decoded['auth_extra'] ?? null) ? $decoded['auth_extra'] : [],
];
}
private function fetchOpenidByCode(string $code): array
{
if (str_starts_with($code, 'mock_mp_')) {
return [
'openid' => 'mock_mp_openid_' . substr($code, 8),
'unionid' => '',
'session_key' => 'mock_session_key',
];
}
$appId = $this->systemConfig('mini_program', 'app_id');
$appSecret = $this->systemConfig('mini_program', 'app_secret');
if ($appId === '' || $appSecret === '') {
throw new \RuntimeException('小程序 AppID 或 AppSecret 未配置');
}
$url = 'https://api.weixin.qq.com/sns/jscode2session?' . http_build_query([
'appid' => $appId,
'secret' => $appSecret,
'js_code' => $code,
'grant_type' => 'authorization_code',
]);
$payload = $this->wechatApiGet($url);
$openid = trim((string)($payload['openid'] ?? ''));
if ($openid === '') {
throw new \RuntimeException('微信小程序登录返回缺少 openid');
}
return [
'openid' => $openid,
'unionid' => trim((string)($payload['unionid'] ?? '')),
'session_key' => (string)($payload['session_key'] ?? ''),
];
}
private function wechatApiGet(string $url): array
{
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 8,
CURLOPT_CONNECTTIMEOUT => 4,
]);
$response = curl_exec($ch);
$errno = curl_errno($ch);
$error = curl_error($ch);
$httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($errno) {
throw new \RuntimeException('微信小程序登录换取 openid 失败:' . $error);
}
if ($httpStatus < 200 || $httpStatus >= 300) {
throw new \RuntimeException('微信小程序登录接口 HTTP 状态异常:' . $httpStatus);
}
$payload = json_decode((string)$response, true);
if (!is_array($payload)) {
throw new \RuntimeException('微信小程序登录接口返回格式异常');
}
$errcode = (int)($payload['errcode'] ?? 0);
if ($errcode !== 0) {
throw new \RuntimeException((string)($payload['errmsg'] ?? '微信小程序登录接口返回错误'));
}
return $payload;
}
private function systemConfig(string $group, string $key): string
{
return trim((string)Db::name('system_configs')
->where('config_group', $group)
->where('config_key', $key)
->value('config_value'));
}
private function normalizeMobile(string $mobile): string
{
$mobile = preg_replace('/\D+/', '', $mobile) ?: '';
if (!preg_match('/^1\d{10}$/', $mobile)) {
throw new \RuntimeException('请输入正确的手机号');
}
return $mobile;
}
private function codeHash(string $mobile, string $scene, string $code): string
{
return hash('sha256', implode('|', [$mobile, $scene, $code]));
}
private function ticketSecret(): string
{
$seed = trim((string)($_ENV['APP_KEY'] ?? $_ENV['JWT_SECRET'] ?? ''));
if ($seed === '') {
$seed = $this->systemConfig('mini_program', 'app_secret');
}
if ($seed === '') {
$seed = 'anxinyan-mini-program-auth-secret-key';
}
return hash('sha256', $seed, true);
}
private function base64UrlEncode(string $value): string
{
return rtrim(strtr(base64_encode($value), '+/', '-_'), '=');
}
private function base64UrlDecode(string $value): string
{
$padding = strlen($value) % 4;
if ($padding > 0) {
$value .= str_repeat('=', 4 - $padding);
}
$decoded = base64_decode(strtr($value, '-_', '+/'), true);
return is_string($decoded) ? $decoded : '';
}
}