228 lines
9.7 KiB
PHP
228 lines
9.7 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 App\Models\Auth\Role;
|
|
use App\Models\Auth\Permission;
|
|
use App\Models\Common\Company;
|
|
use App\Models\Common\Contact;
|
|
use App\Utilities\SystemAccess;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Str;
|
|
|
|
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;
|
|
}
|
|
|
|
$roster = app(FinanceRoster::class)->findEligible((string) ($profile['dingtalk_user_id'] ?? ''));
|
|
if (! $roster) {
|
|
$this->audit('login_denied', 'denied', null, null, $profile['union_id'] ?? null, $request, [
|
|
'reason' => 'outside_finance_roster',
|
|
]);
|
|
$request->attributes->set('dingtalk_denial', 'outside_roster');
|
|
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;
|
|
}
|
|
|
|
return DB::transaction(function () use ($profile, $roster, $request): ?DingTalkIdentity {
|
|
// Serialize first-time provisioning on the target company. Both the
|
|
// account and unique identity are committed together, including retries.
|
|
$companies = Company::enabled()
|
|
->when(config('dingtalk.company_id'), fn ($q, $id) => $q->whereKey($id))
|
|
->orderBy('id')->limit(2)->lockForUpdate()->get();
|
|
if ($companies->count() !== 1) {
|
|
$request->attributes->set('dingtalk_denial', 'unavailable');
|
|
return null;
|
|
}
|
|
$existing = DingTalkIdentity::query()->with('user')->where('union_id', $profile['union_id'])->first();
|
|
if ($existing) {
|
|
return $this->isEligible($existing->user) ? $existing : null;
|
|
}
|
|
$binding = DingTalkBindingRequest::query()->where('union_id', $profile['union_id'])->lockForUpdate()->first();
|
|
if ($binding && in_array($binding->status, [DingTalkBindingRequest::REJECTED, DingTalkBindingRequest::REVOKED], true)) {
|
|
return null;
|
|
}
|
|
// Never claim a local account by a shared name, email or phone number.
|
|
if (DingTalkIdentity::query()->where('dingtalk_user_id', $profile['dingtalk_user_id'])->exists()) {
|
|
return null;
|
|
}
|
|
|
|
$roleName = FinanceRoster::isDesignatedSuperAdmin((string) $profile['dingtalk_user_id'])
|
|
? SystemAccess::SUPER_ADMIN : SystemAccess::USER;
|
|
$role = Role::where('name', $roleName)->firstOrFail();
|
|
$email = $this->normalizeEmail($profile['email'] ?? null);
|
|
if (! $email || User::withTrashed()->where('email', $email)->exists()) {
|
|
$email = 'ding-'.hash('sha256', $profile['union_id']).'@dingtalk.invalid';
|
|
}
|
|
// A deleted/disabled account must not be silently resurrected.
|
|
if (User::withTrashed()->where('email', $email)->exists()) {
|
|
return null;
|
|
}
|
|
$account = User::create([
|
|
'name' => Str::limit($profile['nickname'] ?: $roster['display_name'], 191, ''),
|
|
'email' => $email,
|
|
'password' => Str::random(64),
|
|
'locale' => 'zh-CN',
|
|
'enabled' => true,
|
|
'landing_page' => 'dashboard',
|
|
'created_by' => 0,
|
|
'created_from' => FinanceRoster::isLimitedLoginUser((string) $profile['dingtalk_user_id'])
|
|
? 'dingtalk_roster_limited' : 'dingtalk_roster',
|
|
]);
|
|
$account->roles()->attach($role->id);
|
|
// Explicit designation controls the role; ordinary roster users get shell access.
|
|
$account->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
|
|
$account->companies()->attach($companies->first()->id);
|
|
if ($mobile = $this->normalizeMobile($profile['mobile'] ?? null)) {
|
|
Contact::create([
|
|
'company_id' => $companies->first()->id, 'type' => Contact::EMPLOYEE_TYPE,
|
|
'user_id' => $account->id, 'name' => $account->name, 'email' => $email,
|
|
'phone' => $mobile, 'currency_code' => $companies->first()->currency ?: 'CNY',
|
|
'enabled' => true, 'created_by' => 0, 'created_from' => 'dingtalk_roster',
|
|
]);
|
|
}
|
|
$identity = DingTalkIdentity::create([
|
|
'user_id' => $account->id, 'union_id' => $profile['union_id'],
|
|
'open_id' => $profile['open_id'], 'dingtalk_user_id' => $profile['dingtalk_user_id'],
|
|
'nickname' => $profile['nickname'], 'bound_at' => now(),
|
|
]);
|
|
$this->upsertRequest($profile, DingTalkBindingRequest::APPROVED, 'finance_roster_provisioned', $account->id);
|
|
$this->audit('account_auto_created', 'success', $account->id, null, $profile['union_id'], $request);
|
|
return $identity->load('user');
|
|
}, 3);
|
|
}
|
|
|
|
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 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;
|
|
}
|
|
}
|