204 lines
7.5 KiB
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;
|
|
}
|
|
}
|