CaiWuXiTong/app/Http/Controllers/Auth/DingTalkLogin.php
renshen 566a3ef4db 登录后固定进入首页,不再回跳上次打开的业务页面
- 账号密码登录与钉钉免登都忽略 url.intended,直接进 landing_page(首页/财务驾驶舱)
- 补充登录落地测试,修正首页改版后仍按旧行为断言的测试
2026-10-19 14:26:10 +08:00

202 lines
7.7 KiB
PHP

<?php
namespace App\Http\Controllers\Auth;
use App\Abstracts\Http\Controller;
use App\Models\Auth\DingTalkBindingRequest;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\DingTalkOAuthState;
use App\Services\Auth\DingTalkClosedAccess;
use App\Services\Auth\DingTalkOAuthClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
class DingTalkLogin extends Controller
{
public function redirect(Request $request, DingTalkOAuthClient $client): RedirectResponse
{
if ($canonical = $this->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]);
// 免登后固定回到首页(财务驾驶舱),不再回跳上次打开的业务页面。
$request->session()->forget('url.intended');
return redirect()->to($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']);
}
}