1019 lines
36 KiB
PHP
1019 lines
36 KiB
PHP
<?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);
|
||
}
|
||
}
|