canonicalRedirect($request, 'login.dingtalk')) { return $canonical; } if (! $client->isConfigured()) { return redirect()->to($this->applicationRoute('login')) ->with('dingtalk_error', trans('auth.dingtalk.unavailable')); } DingTalkOAuthState::query() ->where('expires_at', '<', now()->subDay()) ->delete(); $state = Str::random(64); $request->session()->put('dingtalk_oauth_state_hash', hash('sha256', $state)); DingTalkOAuthState::query()->create([ 'state_hash' => hash('sha256', $state), 'expires_at' => now()->addMinutes(max(1, (int) config('dingtalk.state_ttl_minutes', 5))), ]); return redirect()->away($client->authorizationUrl($state)); } public function callback(Request $request, DingTalkOAuthClient $client): RedirectResponse { if ($canonical = $this->canonicalRedirect($request, 'login.dingtalk.callback', $request->only(['state', 'code', 'authCode']))) { return $canonical; } $stateValue = trim((string) $request->query('state', '')); $authorizationCode = trim((string) ($request->query('authCode') ?: $request->query('code', ''))); if ($stateValue === '' || $authorizationCode === '') { return $this->failed(); } $sessionState = $request->session()->get('dingtalk_oauth_state_hash'); if (! is_string($sessionState) || ! hash_equals($sessionState, hash('sha256', $stateValue))) { return $this->failed(); } $request->session()->forget('dingtalk_oauth_state_hash'); $state = DB::transaction(function () use ($stateValue): ?DingTalkOAuthState { $candidate = DingTalkOAuthState::query() ->where('state_hash', hash('sha256', $stateValue)) ->lockForUpdate() ->first(); if (! $candidate || $candidate->consumed_at || $candidate->expires_at->isPast()) { return null; } $candidate->forceFill(['consumed_at' => now()])->save(); return $candidate; }); if (! $state) { return $this->failed(); } $oauthProfile = null; try { $token = $client->exchangeAuthorizationCode($authorizationCode); $oauthProfile = $client->currentUser($token['access_token']); $profile = $client->enterpriseUser($oauthProfile); } catch (Throwable $exception) { Log::warning('DingTalk login failed while resolving the enterprise user.', [ 'exception' => $exception::class, ]); app(DingTalkClosedAccess::class)->audit( 'login_denied', 'denied', null, null, $oauthProfile['union_id'] ?? null, $request, ['reason' => 'enterprise_identity_unavailable'] ); return $this->denied(); } try { $identity = app(DingTalkClosedAccess::class)->resolve($profile, $request); } catch (Throwable $exception) { Log::warning('DingTalk finance roster authorization unavailable.', ['exception' => $exception::class]); return redirect()->to($this->applicationRoute('login')) ->with('dingtalk_error', trans('auth.dingtalk.roster_unavailable')); } if ($identity) { return $this->loginBoundUser($request, $identity, $profile); } $bindingStatus = DingTalkBindingRequest::query()->where('union_id', $profile['union_id'])->value('status'); $message = $request->attributes->get('dingtalk_denial') ?? match ($bindingStatus) { DingTalkBindingRequest::PENDING => 'pending', DingTalkBindingRequest::REJECTED => 'rejected', DingTalkBindingRequest::REVOKED => 'revoked', default => 'denied', }; return redirect()->to($this->applicationRoute('login')) ->with('dingtalk_error', trans('auth.dingtalk.'.$message)); } protected function loginBoundUser(Request $request, DingTalkIdentity $identity, array $profile): RedirectResponse { $user = $identity->user; $company = $user?->withoutEvents(fn () => $user->companies()->enabled()->first()); if (! $user || ! $user->enabled || ! $company) { Log::info('DingTalk login denied for a disabled or unassigned account.', [ 'identity_id' => $identity->id, ]); return $this->failed(); } Auth::login($user, false); // Remove any remember cookie left by an earlier login in this browser. cookie()->queue(cookie()->forget(Auth::guard()->getRecallerName())); $request->session()->regenerate(); $request->session()->put('dingtalk_identity_id', $identity->id); $request->session()->put('dingtalk_roster_checked_at', now()->timestamp); $identity->forceFill([ 'open_id' => $profile['open_id'] ?: $identity->open_id, 'dingtalk_user_id' => $profile['dingtalk_user_id'] ?: $identity->dingtalk_user_id, 'nickname' => $profile['nickname'] ?: $identity->nickname, 'last_logged_in_at' => now(), ])->save(); app(DingTalkClosedAccess::class)->recordLogin($identity, $request); $url = $this->applicationRoute($user->landing_page, ['company_id' => $company->id]); return redirect()->intended($url); } protected function failed(): RedirectResponse { return redirect()->to($this->applicationRoute('login')) ->with('dingtalk_error', trans('auth.dingtalk.failed')); } protected function denied(): RedirectResponse { return redirect()->to($this->applicationRoute('login')) ->with('dingtalk_error', trans('auth.dingtalk.denied')); } protected function applicationRoute(string $name, array $parameters = []): string { $path = route($name, $parameters, false); $applicationUrl = rtrim((string) config('app.url'), '/'); return $applicationUrl !== '' ? $applicationUrl.'/'.ltrim($path, '/') : route($name, $parameters); } private function canonicalRedirect(Request $request, string $route, array $query = []): ?RedirectResponse { $target = $this->applicationRoute($route); if ($request->url() === $target) { return null; } // Legacy registered callbacks may use an IP host. Return to the fixed // application origin before checking its browser session; never use an // incoming host or redirect parameter to construct this destination. return redirect()->to($target.($query ? '?'.http_build_query($query) : '')) ->withHeaders(['Cache-Control' => 'no-store', 'Referrer-Policy' => 'no-referrer']); } }