CaiWuXiTong/app/Services/Auth/DingTalkClosedAccess.php

204 lines
7.5 KiB
PHP

<?php
namespace App\Services\Auth;
use App\Models\Auth\DingTalkAuthAudit;
use App\Models\Auth\DingTalkBindingRequest;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DingTalkClosedAccess
{
public function resolve(array $profile, Request $request): ?DingTalkIdentity
{
if (($profile['active'] ?? false) !== true) {
$this->audit('login_denied', 'denied', null, null, $profile['union_id'] ?? null, $request, [
'reason' => 'not_active_enterprise_user',
]);
return null;
}
$identity = DingTalkIdentity::query()
->with('user')
->where('union_id', $profile['union_id'])
->first();
if ($identity) {
if (! $this->isEligible($identity->user)) {
$this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [
'reason' => 'local_account_ineligible',
]);
return null;
}
$identity->forceFill([
'open_id' => $profile['open_id'] ?: $identity->open_id,
'dingtalk_user_id' => $profile['dingtalk_user_id'],
'nickname' => $profile['nickname'] ?: $identity->nickname,
])->save();
return $identity;
}
$existingRequest = DingTalkBindingRequest::query()->where('union_id', $profile['union_id'])->first();
if ($existingRequest && in_array($existingRequest->status, [DingTalkBindingRequest::REJECTED, DingTalkBindingRequest::REVOKED], true)) {
$existingRequest->forceFill(['last_attempted_at' => now()])->save();
$this->audit('login_denied', 'denied', $existingRequest->matched_user_id, null, $profile['union_id'], $request, [
'reason' => 'binding_previously_denied',
]);
return null;
}
[$candidate, $reason] = $this->uniqueCandidate($profile);
if ($candidate && ! $candidate->dingtalkIdentity) {
return DB::transaction(function () use ($candidate, $profile, $request): DingTalkIdentity {
$identity = DingTalkIdentity::query()->create([
'user_id' => $candidate->id,
'union_id' => $profile['union_id'],
'open_id' => $profile['open_id'],
'dingtalk_user_id' => $profile['dingtalk_user_id'],
'nickname' => $profile['nickname'],
'bound_by' => null,
'bound_at' => now(),
]);
$this->upsertRequest($profile, DingTalkBindingRequest::APPROVED, 'automatic_unique_match', $candidate->id);
$this->audit('binding_auto_approved', 'success', $candidate->id, null, $profile['union_id'], $request, [
'match' => 'verified_email_or_mobile',
]);
return $identity->load('user');
});
}
if ($candidate?->dingtalkIdentity) {
$reason = 'local_account_already_bound';
}
$this->upsertRequest($profile, DingTalkBindingRequest::PENDING, $reason, $candidate?->id);
$this->audit('binding_pending', 'pending', $candidate?->id, null, $profile['union_id'], $request, [
'reason' => $reason,
]);
return null;
}
public function recordLogin(DingTalkIdentity $identity, Request $request): void
{
$this->audit('login_success', 'success', $identity->user_id, null, $identity->union_id, $request);
}
public function audit(
string $event,
string $outcome,
?int $userId,
?int $actorUserId,
?string $unionId,
Request $request,
array $metadata = []
): void {
DingTalkAuthAudit::query()->create([
'event' => $event,
'outcome' => $outcome,
'user_id' => $userId,
'actor_user_id' => $actorUserId,
'union_id_hash' => $unionId ? hash('sha256', $unionId) : null,
'ip_address' => $request->ip(),
'metadata' => $metadata ?: null,
'created_at' => now(),
]);
}
private function uniqueCandidate(array $profile): array
{
$email = $this->normalizeEmail($profile['email'] ?? null);
$mobile = $this->normalizeMobile($profile['mobile'] ?? null);
$eligible = User::query()
->with(['contact', 'dingtalkIdentity'])
->where('enabled', true)
->whereHas('companies', fn ($company) => $company->where('companies.enabled', true))
->get();
$emailMatches = $email
? $eligible->filter(fn (User $user) => $this->normalizeEmail($user->email) === $email)
: collect();
$mobileMatches = $mobile
? $eligible->filter(fn (User $user) => $this->normalizeMobile($user->contact?->phone) === $mobile)
: collect();
$matches = $emailMatches->merge($mobileMatches)->unique('id')->values();
if ($matches->count() === 1) {
return [$matches->first(), 'unique_match'];
}
return [null, $matches->isEmpty() ? 'no_unique_account' : 'multiple_accounts'];
}
private function isEligible(?User $user): bool
{
return $user
&& $user->enabled
&& $user->withoutEvents(fn () => $user->companies()->enabled()->exists());
}
private function upsertRequest(array $profile, string $status, string $reason, ?int $matchedUserId): void
{
$email = $this->normalizeEmail($profile['email'] ?? null);
$mobile = $this->normalizeMobile($profile['mobile'] ?? null);
$bindingRequest = DingTalkBindingRequest::query()->firstOrNew(['union_id' => $profile['union_id']]);
$terminalStatus = in_array($bindingRequest->status, [DingTalkBindingRequest::REJECTED, DingTalkBindingRequest::REVOKED], true);
$bindingRequest->fill([
'open_id' => $profile['open_id'] ?? null,
'dingtalk_user_id' => $profile['dingtalk_user_id'] ?? null,
'nickname' => $profile['nickname'] ?? null,
'email_hash' => $email ? hash('sha256', $email) : null,
'mobile_hash' => $mobile ? hash('sha256', $mobile) : null,
'email_hint' => $this->maskEmail($email),
'mobile_hint' => $this->maskMobile($mobile),
'status' => $terminalStatus && $status === DingTalkBindingRequest::PENDING ? $bindingRequest->status : $status,
'match_reason' => $reason,
'matched_user_id' => $matchedUserId,
'last_attempted_at' => now(),
])->save();
}
private function normalizeEmail(mixed $value): ?string
{
$email = strtolower(trim((string) $value));
return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null;
}
private function normalizeMobile(mixed $value): ?string
{
$mobile = preg_replace('/\D+/', '', (string) $value);
if (str_starts_with($mobile, '86') && strlen($mobile) === 13) {
$mobile = substr($mobile, 2);
}
return strlen($mobile) >= 7 ? $mobile : null;
}
private function maskEmail(?string $email): ?string
{
if (! $email) {
return null;
}
[$local, $domain] = explode('@', $email, 2);
return substr($local, 0, 1).'***@'.$domain;
}
private function maskMobile(?string $mobile): ?string
{
return $mobile ? str_repeat('*', max(0, strlen($mobile) - 4)).substr($mobile, -4) : null;
}
}