chore: prepare production release package
This commit is contained in:
@@ -3,10 +3,85 @@
|
||||
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
|
||||
{
|
||||
@@ -93,6 +168,262 @@ class MiniProgramAuthService
|
||||
->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_')) {
|
||||
@@ -170,4 +501,47 @@ class MiniProgramAuthService
|
||||
->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 : '';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user