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' => '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; } }