Files
anxinyan/server-api/app/support/AppAuthService.php
2026-05-25 14:53:59 +08:00

1019 lines
36 KiB
PHP
Raw Permalink 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 Webman\Http\Request;
use support\think\Db;
class AppAuthService
{
private const WECHAT_H5_AUTH_TYPE = 'wechat_h5';
private const H5_OAUTH_REDIRECT_HASH_PATH = '/#/pages/auth/login';
private const WECHAT_BIND_TICKET_TTL = 600;
public function __construct()
{
$this->ensurePasswordColumn();
$this->ensureUserAuthsTable();
$this->ensureTokenTable();
$this->ensureSmsCodeTable();
}
public function wechatConfig(): array
{
$appId = $this->systemConfig('h5', 'app_id');
$appSecret = $this->systemConfig('h5', 'app_secret');
$redirectUrl = $this->resolveH5OAuthRedirectUrl();
return [
'appid' => $appId,
'oauth_redirect_url' => $redirectUrl,
'enabled' => $appId !== '' && $appSecret !== '' && $redirectUrl !== '',
'scope' => 'snsapi_userinfo',
'state' => $this->createWechatOAuthState(),
];
}
public function sendLoginCode(string $mobile, Request $request): array
{
$mobile = $this->normalizeMobile($mobile);
$user = Db::name('users')->where('mobile', $mobile)->find();
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号已停用,无法发送验证码');
}
$scene = 'login';
$now = time();
$latest = Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', $scene)
->order('id', 'desc')
->find();
if ($latest) {
$retryAt = strtotime((string)$latest['created_at']) + 60;
if ($retryAt > $now) {
throw new \RuntimeException(sprintf('请 %d 秒后再试', max(1, $retryAt - $now)));
}
}
$todayStart = date('Y-m-d 00:00:00');
$todayCount = (int)Db::name('sms_code_logs')
->where('mobile', $mobile)
->where('scene', $scene)
->where('created_at', '>=', $todayStart)
->count();
if ($todayCount >= 20) {
throw new \RuntimeException('今日验证码发送次数已达上限,请明天再试');
}
$code = (string)random_int(100000, 999999);
$nowText = date('Y-m-d H:i:s', $now);
$expireTime = date('Y-m-d H:i:s', $now + 300);
$sendResult = null;
$sendStatus = 'failed';
$failedReason = '';
try {
$sendResult = (new AppSmsService())->sendLoginCode($mobile, $code);
$sendStatus = ($sendResult['provider'] ?? '') === 'debug' ? 'mock' : 'success';
} catch (\Throwable $e) {
$failedReason = $this->truncateText($e->getMessage(), 250);
}
Db::name('sms_code_logs')->insert([
'mobile' => $mobile,
'scene' => $scene,
'code_hash' => $this->codeHash($mobile, $scene, $code),
'send_status' => $sendStatus,
'provider' => $sendResult['provider'] ?? 'aliyun_sms',
'template_code' => $this->systemConfig('sms', 'login_template_code'),
'request_id' => $sendResult['request_id'] ?? '',
'biz_id' => $sendResult['biz_id'] ?? '',
'failed_reason' => $failedReason,
'expire_time' => $expireTime,
'used_at' => null,
'send_ip' => $request->getRealIp(),
'created_at' => $nowText,
'updated_at' => $nowText,
]);
if ($sendStatus === 'failed') {
throw new \RuntimeException($failedReason ?: '验证码发送失败');
}
$payload = [
'mobile' => $mobile,
'scene' => $scene,
'expire_seconds' => 300,
'retry_after_seconds' => 60,
];
if (($sendResult['debug_code'] ?? null) !== null) {
$payload['debug_code'] = $sendResult['debug_code'];
}
return $payload;
}
public function loginByCode(string $mobile, string $code, Request $request): array
{
$mobile = $this->normalizeMobile($mobile);
$now = date('Y-m-d H:i:s');
$this->verifyLoginCode($mobile, $code, $now);
$user = Db::name('users')->where('mobile', $mobile)->find();
if ($user && ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号已停用');
}
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);
return $this->issueToken($userId, $request, 'sms_code');
}
public function exchangeWechatCode(string $code, string $state, Request $request): array
{
$code = trim($code);
if ($code === '') {
throw new \RuntimeException('微信授权 code 不能为空');
}
$this->verifyWechatOAuthState($state);
$config = $this->wechatConfig();
if (!$config['enabled']) {
throw new \RuntimeException('微信授权登录未启用,请先在后台补全 H5 公众号配置和页面根地址');
}
$oauthPayload = $this->fetchWechatOAuthAccessToken($code);
$profilePayload = $this->fetchWechatUserInfoIfPossible($oauthPayload);
$identity = $this->buildWechatIdentity($oauthPayload, $profilePayload, $state);
$auth = $this->findWechatAuth($identity['openid'], $identity['unionid']);
if ($auth) {
$user = Db::name('users')->where('id', $auth['user_id'])->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('微信已绑定账号不存在或已停用');
}
$this->syncWechatAuth((int)$auth['user_id'], $identity, date('Y-m-d H:i:s'), (int)$auth['id']);
return array_merge([
'status' => 'logged_in',
], $this->issueToken((int)$auth['user_id'], $request, self::WECHAT_H5_AUTH_TYPE));
}
return [
'status' => 'need_bind',
'bind_ticket' => $this->createWechatBindTicket($identity),
'expire_seconds' => self::WECHAT_BIND_TICKET_TTL,
'profile' => [
'nickname' => $identity['nickname'],
'avatar' => $identity['avatar'],
],
];
}
public function bindWechatMobile(string $bindTicket, string $mobile, string $code, Request $request): array
{
$identity = $this->verifyWechatBindTicket($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->assertWechatIdentityAvailable($identity, $user ? (int)$user['id'] : null);
$this->verifyLoginCode($mobile, $code, $now);
if (!$user) {
$userId = (int)Db::name('users')->insertGetId([
'nickname' => $identity['nickname'] !== '' ? $this->truncateText($identity['nickname'], 64) : '安心验用户' . substr($mobile, -4),
'avatar' => $this->truncateText($identity['avatar'], 255),
'mobile' => $mobile,
'password' => '',
'status' => 'enabled',
'last_login_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
} else {
$userId = (int)$user['id'];
$profilePatch = $this->buildWechatProfilePatch($user, $identity, $now);
if ($profilePatch) {
Db::name('users')->where('id', $userId)->update($profilePatch);
}
}
$this->syncMobileAuth($userId, $mobile, $now);
$this->syncWechatAuth($userId, $identity, $now);
$payload = array_merge([
'status' => 'logged_in',
], $this->issueToken($userId, $request, self::WECHAT_H5_AUTH_TYPE));
Db::commit();
return $payload;
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
public function loginByPassword(string $mobile, string $password, Request $request): array
{
$mobile = $this->normalizeMobile($mobile);
$password = trim($password);
if ($password === '') {
throw new \RuntimeException('密码不能为空');
}
$user = Db::name('users')->where('mobile', $mobile)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号不存在或已停用');
}
$passwordHash = (string)($user['password'] ?? '');
if ($passwordHash === '') {
throw new \RuntimeException('当前账号尚未设置登录密码,请使用验证码登录');
}
if (!password_verify($password, $passwordHash)) {
throw new \RuntimeException('手机号或密码错误');
}
$this->syncMobileAuth((int)$user['id'], $mobile, date('Y-m-d H:i:s'));
return $this->issueToken((int)$user['id'], $request, 'password');
}
public function logout(Request $request): void
{
$token = $this->extractToken($request);
if ($token === '') {
return;
}
Db::name('user_api_tokens')->where('token_hash', hash('sha256', $token))->delete();
}
public function current(Request $request): ?array
{
$token = $this->extractToken($request);
if ($token === '') {
return null;
}
$record = Db::name('user_api_tokens')
->where('token_hash', hash('sha256', $token))
->find();
if (!$record) {
return null;
}
if (!empty($record['expire_time']) && strtotime((string)$record['expire_time']) < time()) {
Db::name('user_api_tokens')->where('id', $record['id'])->delete();
return null;
}
$user = Db::name('users')->where('id', $record['user_id'])->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
return null;
}
Db::name('user_api_tokens')->where('id', $record['id'])->update([
'last_active_at' => date('Y-m-d H:i:s'),
'last_ip' => $request->getRealIp(),
'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500),
'updated_at' => date('Y-m-d H:i:s'),
]);
return $this->userInfo((int)$user['id']);
}
public function savePassword(int $userId, string $currentPassword, string $newPassword): array
{
$user = Db::name('users')->where('id', $userId)->find();
if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') {
throw new \RuntimeException('账号不存在或已停用');
}
$currentHash = (string)($user['password'] ?? '');
$hadPassword = $currentHash !== '';
if ($currentHash !== '') {
if ($currentPassword === '') {
throw new \RuntimeException('请输入当前密码');
}
if (!password_verify($currentPassword, $currentHash)) {
throw new \RuntimeException('当前密码错误');
}
}
$this->validateNewPassword($newPassword);
Db::name('users')->where('id', $userId)->update([
'password' => password_hash($newPassword, PASSWORD_BCRYPT),
'updated_at' => date('Y-m-d H:i:s'),
]);
return [
'user_id' => $userId,
'password_set' => true,
'had_password' => $hadPassword,
];
}
private function verifyLoginCode(string $mobile, string $code, ?string $now = null): 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('验证码错误');
}
if ($now === null) {
$now = date('Y-m-d H:i:s');
}
Db::name('sms_code_logs')->where('id', $record['id'])->update([
'used_at' => $now,
'updated_at' => $now,
]);
}
private function issueToken(int $userId, Request $request, string $authType): 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' => $authType,
'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 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', $existing['id'])->update($payload);
return;
}
$payload['created_at'] = $now;
Db::name('user_auths')->insert($payload);
}
private function syncWechatAuth(int $userId, array $identity, string $now, ?int $preferredAuthId = null): void
{
$openid = (string)($identity['openid'] ?? '');
$unionid = (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 = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_key', $openid)
->find();
}
if ($existing && (int)$existing['user_id'] !== $userId) {
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
}
if ($unionid !== '') {
$unionAuth = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_union_id', $unionid)
->find();
if ($unionAuth && (int)$unionAuth['user_id'] !== $userId) {
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
}
if (!$existing && $unionAuth) {
$existing = $unionAuth;
}
}
$payload = [
'user_id' => $userId,
'auth_type' => self::WECHAT_H5_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', $existing['id'])->update($payload);
return;
}
$payload['created_at'] = $now;
Db::name('user_auths')->insert($payload);
}
private function assertWechatIdentityAvailable(array $identity, ?int $allowedUserId): void
{
$openid = (string)($identity['openid'] ?? '');
$unionid = (string)($identity['unionid'] ?? '');
$openidAuth = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_key', $openid)
->lock(true)
->find();
if ($openidAuth && ($allowedUserId === null || (int)$openidAuth['user_id'] !== $allowedUserId)) {
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
}
if ($unionid === '') {
return;
}
$unionAuth = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_union_id', $unionid)
->lock(true)
->find();
if ($unionAuth && ($allowedUserId === null || (int)$unionAuth['user_id'] !== $allowedUserId)) {
throw new \RuntimeException('该微信已绑定其他账号,请先解绑或联系管理员');
}
}
private function findWechatAuth(string $openid, string $unionid): ?array
{
$auth = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_key', $openid)
->find();
if ($auth) {
return $auth;
}
if ($unionid === '') {
return null;
}
$auth = Db::name('user_auths')
->where('auth_type', self::WECHAT_H5_AUTH_TYPE)
->where('auth_union_id', $unionid)
->order('id', 'asc')
->find();
return $auth ?: null;
}
private function buildWechatProfilePatch(array $user, array $identity, string $now): array
{
$patch = [];
$nickname = trim((string)($identity['nickname'] ?? ''));
$avatar = trim((string)($identity['avatar'] ?? ''));
$currentNickname = trim((string)($user['nickname'] ?? ''));
$currentAvatar = trim((string)($user['avatar'] ?? ''));
if ($nickname !== '' && ($currentNickname === '' || preg_match('/^安心验用户\d{4}$/u', $currentNickname))) {
$patch['nickname'] = $this->truncateText($nickname, 64);
}
if ($avatar !== '' && $currentAvatar === '') {
$patch['avatar'] = $this->truncateText($avatar, 255);
}
if ($patch) {
$patch['updated_at'] = $now;
}
return $patch;
}
private function fetchWechatOAuthAccessToken(string $code): array
{
if ($this->isWechatMockCode($code)) {
return $this->mockWechatOAuthPayload($code);
}
$appId = $this->systemConfig('h5', 'app_id');
$appSecret = $this->systemConfig('h5', 'app_secret');
$url = 'https://api.weixin.qq.com/sns/oauth2/access_token?' . http_build_query([
'appid' => $appId,
'secret' => $appSecret,
'code' => $code,
'grant_type' => 'authorization_code',
]);
return $this->wechatApiGet($url, '微信授权 code 换取失败');
}
private function fetchWechatUserInfoIfPossible(array $oauthPayload): array
{
$scope = (string)($oauthPayload['scope'] ?? '');
$accessToken = (string)($oauthPayload['access_token'] ?? '');
$openid = (string)($oauthPayload['openid'] ?? '');
if ($openid === '' || $accessToken === '' || strpos($scope, 'snsapi_userinfo') === false) {
return [];
}
if (strpos($accessToken, 'mock_access_token_') === 0) {
return $this->mockWechatProfilePayload($oauthPayload);
}
$url = 'https://api.weixin.qq.com/sns/userinfo?' . http_build_query([
'access_token' => $accessToken,
'openid' => $openid,
'lang' => 'zh_CN',
]);
try {
return $this->wechatApiGet($url, '微信用户资料获取失败');
} catch (\RuntimeException $e) {
return [];
}
}
private function buildWechatIdentity(array $oauthPayload, array $profilePayload, string $state): array
{
$openid = trim((string)($oauthPayload['openid'] ?? $profilePayload['openid'] ?? ''));
if ($openid === '') {
throw new \RuntimeException('微信授权返回缺少 openid请重新登录');
}
$unionid = trim((string)($profilePayload['unionid'] ?? $oauthPayload['unionid'] ?? ''));
$nickname = trim((string)($profilePayload['nickname'] ?? ''));
$avatar = trim((string)($profilePayload['headimgurl'] ?? ''));
return [
'openid' => $openid,
'unionid' => $unionid,
'nickname' => $nickname,
'avatar' => $avatar,
'auth_extra' => [
'oauth' => $this->redactWechatOAuthPayload($oauthPayload),
'profile' => $profilePayload,
'state' => $this->truncateText($state, 128),
'authorized_at' => date('Y-m-d H:i:s'),
],
];
}
private function createWechatBindTicket(array $identity): string
{
$now = time();
$payload = [
'typ' => 'wechat_h5_bind',
'openid' => (string)$identity['openid'],
'unionid' => (string)($identity['unionid'] ?? ''),
'nickname' => $this->truncateText((string)($identity['nickname'] ?? ''), 64),
'avatar' => $this->truncateText((string)($identity['avatar'] ?? ''), 255),
'auth_extra' => $identity['auth_extra'] ?? [],
'iat' => $now,
'exp' => $now + self::WECHAT_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 createWechatOAuthState(): string
{
$expiresAt = str_pad(base_convert((string)(time() + 600), 10, 36), 8, '0', STR_PAD_LEFT);
$base = 'w' . $expiresAt . bin2hex(random_bytes(4));
$signature = substr(hash_hmac('sha256', $base, $this->ticketSecret()), 0, 20);
return $base . $signature;
}
private function verifyWechatOAuthState(string $state): void
{
$state = trim($state);
if (!preg_match('/^w[a-z0-9]{36}$/i', $state)) {
throw new \RuntimeException('微信授权状态不匹配,请重新登录');
}
$base = substr($state, 0, 17);
$signature = substr($state, 17);
$expected = substr(hash_hmac('sha256', $base, $this->ticketSecret()), 0, 20);
if (!hash_equals($expected, strtolower($signature))) {
throw new \RuntimeException('微信授权状态不匹配,请重新登录');
}
$expiresAt = (int)base_convert(substr($state, 1, 8), 36, 10);
if ($expiresAt < time()) {
throw new \RuntimeException('微信授权状态已过期,请重新登录');
}
}
private function verifyWechatBindTicket(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_h5_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'] ?? '')),
'nickname' => trim((string)($decoded['nickname'] ?? '')),
'avatar' => trim((string)($decoded['avatar'] ?? '')),
'auth_extra' => is_array($decoded['auth_extra'] ?? null) ? $decoded['auth_extra'] : [],
];
}
private function wechatApiGet(string $url, string $fallbackMessage): 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($fallbackMessage . '' . $error);
}
if ($httpStatus < 200 || $httpStatus >= 300) {
throw new \RuntimeException($fallbackMessage . ':微信接口 HTTP ' . $httpStatus);
}
$payload = json_decode((string)$response, true);
if (!is_array($payload)) {
throw new \RuntimeException($fallbackMessage . ':微信接口返回异常');
}
$errcode = (int)($payload['errcode'] ?? 0);
if ($errcode !== 0) {
throw new \RuntimeException($this->wechatErrorMessage($errcode, (string)($payload['errmsg'] ?? ''), $fallbackMessage));
}
return $payload;
}
private function wechatErrorMessage(int $errcode, string $errmsg, string $fallbackMessage): string
{
$messages = [
40029 => '微信授权 code 无效或已过期,请重新登录',
40163 => '微信授权 code 已被使用,请重新发起授权',
40013 => 'H5 公众号 AppID 无效,请检查后台配置',
40125 => 'H5 公众号 AppSecret 无效,请检查后台配置',
];
return $messages[$errcode] ?? ($fallbackMessage . ($errmsg !== '' ? '' . $errmsg : ''));
}
private function redactWechatOAuthPayload(array $payload): array
{
unset($payload['access_token'], $payload['refresh_token']);
return $payload;
}
private function isWechatMockCode(string $code): bool
{
if (strpos($code, 'mock_') !== 0) {
return false;
}
return in_array(strtolower((string)($_ENV['WECHAT_H5_AUTH_MOCK'] ?? '')), ['1', 'true', 'yes'], true)
|| in_array(strtolower((string)($_ENV['APP_DEBUG'] ?? 'false')), ['1', 'true'], true);
}
private function mockWechatOAuthPayload(string $code): array
{
if (strpos($code, 'expired') !== false || strpos($code, 'invalid') !== false) {
throw new \RuntimeException('微信授权 code 无效或已过期,请重新登录');
}
$suffix = preg_replace('/[^A-Za-z0-9]/', '', substr($code, 5)) ?: 'user';
return [
'access_token' => 'mock_access_token_' . $suffix,
'expires_in' => 7200,
'refresh_token' => 'mock_refresh_token_' . $suffix,
'openid' => 'mock_openid_' . $suffix,
'scope' => 'snsapi_userinfo',
'unionid' => 'mock_unionid_' . $suffix,
];
}
private function mockWechatProfilePayload(array $oauthPayload): array
{
$openid = (string)($oauthPayload['openid'] ?? '');
$suffix = str_replace('mock_openid_', '', $openid) ?: 'user';
return [
'openid' => $openid,
'nickname' => '微信用户' . $suffix,
'headimgurl' => 'https://thirdwx.qlogo.cn/mmopen/mock/' . rawurlencode($suffix) . '/132',
'privilege' => [],
'unionid' => (string)($oauthPayload['unionid'] ?? ''),
];
}
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 validateNewPassword(string $password): void
{
if (mb_strlen($password) < 8) {
throw new \RuntimeException('密码长度不能少于 8 位');
}
if (!preg_match('/[A-Za-z]/', $password) || !preg_match('/\d/', $password)) {
throw new \RuntimeException('密码需同时包含字母和数字');
}
}
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('h5', 'app_secret');
}
if ($seed === '') {
$seed = 'anxinyan-app-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 : '';
}
private function systemConfig(string $groupCode, string $configKey): string
{
$row = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $configKey)
->find();
return trim((string)($row['config_value'] ?? ''));
}
private function resolveH5OAuthRedirectUrl(): string
{
$pageBaseUrl = $this->normalizeH5PageBaseUrl($this->systemConfig('h5', 'page_base_url'));
if ($pageBaseUrl !== '') {
return $pageBaseUrl . self::H5_OAUTH_REDIRECT_HASH_PATH;
}
return $this->systemConfig('h5', 'oauth_redirect_url');
}
private function normalizeH5PageBaseUrl(string $value): string
{
$baseUrl = trim($value);
if ($baseUrl === '') {
return '';
}
$hashPos = strpos($baseUrl, '#');
if ($hashPos !== false) {
$baseUrl = substr($baseUrl, 0, $hashPos);
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
private function extractToken(Request $request): string
{
$authorization = trim((string)$request->header('authorization', ''));
if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) {
return trim($matches[1]);
}
return '';
}
private function ensurePasswordColumn(): void
{
$column = Db::query("SHOW COLUMNS FROM users LIKE 'password'");
if ($column) {
return;
}
Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile");
}
private function ensureUserAuthsTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS user_auths (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
auth_type VARCHAR(32) NOT NULL,
auth_open_id VARCHAR(128) NOT NULL DEFAULT '',
auth_union_id VARCHAR(128) NOT NULL DEFAULT '',
auth_key VARCHAR(128) NOT NULL DEFAULT '',
auth_extra JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_user_auths_type_key (auth_type, auth_key),
KEY idx_user_auths_user_id (user_id),
KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户认证映射';
SQL);
$index = Db::query("SHOW INDEX FROM user_auths WHERE Key_name = 'idx_user_auths_auth_union_id'");
if (!$index) {
Db::execute("ALTER TABLE user_auths ADD KEY idx_user_auths_auth_union_id (auth_type, auth_union_id)");
}
}
private function ensureTokenTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS user_api_tokens (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
token_hash VARCHAR(64) NOT NULL,
auth_type VARCHAR(32) NOT NULL DEFAULT 'password',
expire_time DATETIME NOT NULL,
last_active_at DATETIME NULL DEFAULT NULL,
last_ip VARCHAR(64) NOT NULL DEFAULT '',
user_agent VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_user_api_tokens_token_hash (token_hash),
KEY idx_user_api_tokens_user_id (user_id),
KEY idx_user_api_tokens_expire_time (expire_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录Token';
SQL);
}
private function ensureSmsCodeTable(): void
{
Db::execute(<<<'SQL'
CREATE TABLE IF NOT EXISTS sms_code_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
mobile VARCHAR(32) NOT NULL,
scene VARCHAR(32) NOT NULL DEFAULT 'login',
code_hash VARCHAR(64) NOT NULL,
send_status VARCHAR(32) NOT NULL DEFAULT 'success',
provider VARCHAR(32) NOT NULL DEFAULT 'aliyun_sms',
template_code VARCHAR(64) NOT NULL DEFAULT '',
request_id VARCHAR(128) NOT NULL DEFAULT '',
biz_id VARCHAR(128) NOT NULL DEFAULT '',
failed_reason VARCHAR(255) NOT NULL DEFAULT '',
expire_time DATETIME NOT NULL,
used_at DATETIME NULL DEFAULT NULL,
send_ip VARCHAR(64) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_sms_code_logs_mobile_scene (mobile, scene),
KEY idx_sms_code_logs_expire_time (expire_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短信验证码发送记录';
SQL);
}
private function truncateText(string $value, int $maxLength): string
{
if (mb_strlen($value) <= $maxLength) {
return $value;
}
return mb_substr($value, 0, $maxLength);
}
}