From 64f338e527ea169f5e44171f79d8be4765c0478d Mon Sep 17 00:00:00 2001
From: WuYongJie <549727955@qq.com>
Date: Tue, 8 Sep 2026 05:55:47 +0800
Subject: [PATCH 1/2] fix: bind DingTalk authorization and sessions to browser
identity
---
app/Http/Controllers/Auth/DingTalkLogin.php | 48 +++++++++++++++-
app/Http/Controllers/Auth/Login.php | 12 ++--
app/Http/Kernel.php | 1 +
app/Http/Middleware/AddXHeader.php | 4 +-
.../LogoutIfDingTalkBindingRevoked.php | 35 ++++++++++++
resources/lang/en-GB/auth.php | 4 ++
resources/lang/zh-CN/auth.php | 4 ++
tests/Feature/Auth/DingTalkLoginTest.php | 56 ++++++++++++++++++-
8 files changed, 151 insertions(+), 13 deletions(-)
create mode 100644 app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php
diff --git a/app/Http/Controllers/Auth/DingTalkLogin.php b/app/Http/Controllers/Auth/DingTalkLogin.php
index 63c9bffa..01fb018f 100644
--- a/app/Http/Controllers/Auth/DingTalkLogin.php
+++ b/app/Http/Controllers/Auth/DingTalkLogin.php
@@ -3,6 +3,7 @@
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;
@@ -17,8 +18,12 @@ use Throwable;
class DingTalkLogin extends Controller
{
- public function redirect(DingTalkOAuthClient $client): RedirectResponse
+ 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'));
@@ -29,6 +34,7 @@ class DingTalkLogin extends Controller
->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))),
@@ -39,6 +45,10 @@ class DingTalkLogin extends Controller
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', '')));
@@ -46,6 +56,12 @@ class DingTalkLogin extends Controller
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))
@@ -93,7 +109,16 @@ class DingTalkLogin extends Controller
return $this->loginBoundUser($request, $identity, $profile);
}
- return $this->denied();
+ $bindingStatus = DingTalkBindingRequest::query()->where('union_id', $profile['union_id'])->value('status');
+ $message = 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
@@ -109,8 +134,11 @@ class DingTalkLogin extends Controller
return $this->failed();
}
- Auth::login($user, true);
+ 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);
$identity->forceFill([
'open_id' => $profile['open_id'] ?: $identity->open_id,
@@ -146,4 +174,18 @@ class DingTalkLogin extends Controller
? $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']);
+ }
}
diff --git a/app/Http/Controllers/Auth/Login.php b/app/Http/Controllers/Auth/Login.php
index 07453c7e..cc94efc6 100644
--- a/app/Http/Controllers/Auth/Login.php
+++ b/app/Http/Controllers/Auth/Login.php
@@ -42,6 +42,7 @@ class Login extends Controller
}
// Get user object
+ $request->session()->forget('dingtalk_identity_id');
$user = user();
// Check if user is enabled
@@ -138,13 +139,8 @@ class Login extends Controller
{
auth()->logout();
- // Session destroy is required if stored in database
- if (config('session.driver') == 'database') {
- $request = app('Illuminate\Http\Request');
-
- $request->session()->invalidate();
- $request->session()->regenerateToken();
- $request->session()->getHandler()->destroy($request->session()->getId());
- }
+ $request = app('Illuminate\Http\Request');
+ $request->session()->invalidate();
+ $request->session()->regenerateToken();
}
}
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index dd33577a..bb7e98d4 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -35,6 +35,7 @@ class Kernel extends HttpKernel
'cookies.encrypt',
'cookies.response',
'session.start',
+ \App\Http\Middleware\LogoutIfDingTalkBindingRevoked::class,
// 'session.auth',
'session.errors',
'csrf',
diff --git a/app/Http/Middleware/AddXHeader.php b/app/Http/Middleware/AddXHeader.php
index b37f8ed7..955861e5 100644
--- a/app/Http/Middleware/AddXHeader.php
+++ b/app/Http/Middleware/AddXHeader.php
@@ -22,7 +22,9 @@ class AddXHeader
$response->header('X-Akaunting', 'Online Accounting Software');
$response->header('X-Content-Type-Options', 'nosniff');
$response->header('X-Frame-Options', 'SAMEORIGIN');
- $response->header('Referrer-Policy', 'strict-origin-when-cross-origin');
+ if (! $response->headers->has('Referrer-Policy')) {
+ $response->header('Referrer-Policy', 'strict-origin-when-cross-origin');
+ }
$response->header('X-XSS-Protection', '0');
$response->header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
}
diff --git a/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php b/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php
new file mode 100644
index 00000000..c757262d
--- /dev/null
+++ b/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php
@@ -0,0 +1,35 @@
+session()->get('dingtalk_identity_id');
+ if (! $identityId) {
+ return $next($request);
+ }
+
+ $user = $request->user();
+ if ($user && $user->enabled && DingTalkIdentity::query()
+ ->whereKey($identityId)->where('user_id', $user->id)->exists()) {
+ return $next($request);
+ }
+
+ auth()->logout();
+ $request->session()->invalidate();
+ $request->session()->regenerateToken();
+
+ if ($request->expectsJson()) {
+ return response()->json(['message' => trans('auth.dingtalk.session_expired')], 401)
+ ->header('Cache-Control', 'no-store');
+ }
+
+ return redirect()->route('login')
+ ->with('dingtalk_error', trans('auth.dingtalk.session_expired'));
+ }
+}
diff --git a/resources/lang/en-GB/auth.php b/resources/lang/en-GB/auth.php
index 070cf517..8045ce99 100644
--- a/resources/lang/en-GB/auth.php
+++ b/resources/lang/en-GB/auth.php
@@ -54,6 +54,10 @@ return [
'unavailable' => 'DingTalk sign-in is not enabled. Please use email and password.',
'failed' => 'DingTalk sign-in did not complete. Please start the authorization again.',
'denied' => 'This DingTalk account is not authorized for the finance system. Contact the system administrator.',
+ 'pending' => 'Your DingTalk identity is verified. Account binding is awaiting administrator approval. Sign in again after approval.',
+ 'rejected' => 'Your DingTalk binding request was rejected. Contact the system administrator for access.',
+ 'revoked' => 'Your DingTalk account binding was removed. Contact the system administrator to restore access.',
+ 'session_expired' => 'Your DingTalk session is no longer valid. Sign in again, or contact the administrator if your account was disabled or unbound.',
],
'notification' => [
diff --git a/resources/lang/zh-CN/auth.php b/resources/lang/zh-CN/auth.php
index ce3cafe3..ccbb4578 100644
--- a/resources/lang/zh-CN/auth.php
+++ b/resources/lang/zh-CN/auth.php
@@ -54,6 +54,10 @@ return [
'unavailable' => '钉钉登录尚未启用,请暂时使用邮箱密码登录。',
'failed' => '钉钉登录未完成,请重新发起授权。',
'denied' => '当前钉钉账号未获财务系统授权,请联系系统管理员。',
+ 'pending' => '钉钉身份已确认,账号绑定申请正在等待管理员审批。审批通过后请重新登录。',
+ 'rejected' => '钉钉账号绑定申请已被拒绝,如需开通请联系系统管理员。',
+ 'revoked' => '钉钉账号绑定已解除,如需恢复请联系系统管理员。',
+ 'session_expired' => '钉钉登录已失效,请重新登录;如账号已停用或解除绑定,请联系系统管理员。',
],
'notification' => [
diff --git a/tests/Feature/Auth/DingTalkLoginTest.php b/tests/Feature/Auth/DingTalkLoginTest.php
index 336352ee..73b21d7b 100644
--- a/tests/Feature/Auth/DingTalkLoginTest.php
+++ b/tests/Feature/Auth/DingTalkLoginTest.php
@@ -32,6 +32,8 @@ class DingTalkLoginTest extends FeatureTestCase
'dingtalk.user_by_union_id_url' => 'https://oapi.dingtalk.test/topapi/user/getbyunionid',
'dingtalk.user_detail_url' => 'https://oapi.dingtalk.test/topapi/v2/user/get',
]);
+ app('url')->forceRootUrl(config('app.url'));
+ app('url')->forceScheme('https');
}
public function test_login_page_shows_dingtalk_entry_without_password_binding_fields(): void
@@ -163,7 +165,7 @@ class DingTalkLoginTest extends FeatureTestCase
'code' => 'authorization-code',
'state' => $state,
]))->assertRedirect('https://caiwu.jiansuntuzhi.cn:8442/auth/login')
- ->assertSessionHas('dingtalk_error', trans('auth.dingtalk.denied'));
+ ->assertSessionHas('dingtalk_error', trans('auth.dingtalk.pending'));
$this->assertGuest();
$this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-pending']);
@@ -253,6 +255,7 @@ class DingTalkLoginTest extends FeatureTestCase
'state_hash' => hash('sha256', $rawState),
'expires_at' => now()->subMinute(),
]);
+ $this->withSession(['dingtalk_oauth_state_hash' => hash('sha256', $rawState)]);
$this->get(route('login.dingtalk.callback', [
'code' => 'authorization-code',
@@ -263,6 +266,57 @@ class DingTalkLoginTest extends FeatureTestCase
$this->assertGuest();
}
+ public function test_callback_requires_the_browser_that_started_authorization(): void
+ {
+ Http::preventStrayRequests();
+ $state = $this->startAuthorization();
+ $this->app['session']->forget('dingtalk_oauth_state_hash');
+ $this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]))
+ ->assertSessionHas('dingtalk_error', trans('auth.dingtalk.failed'));
+ Http::assertNothingSent();
+ $this->assertGuest();
+ $this->assertDatabaseHas('dingtalk_oauth_states', ['state_hash' => hash('sha256', $state), 'consumed_at' => null]);
+ }
+
+ public function test_consumed_state_cannot_be_used_again_with_matching_browser_state(): void
+ {
+ Http::preventStrayRequests();
+ $state = $this->startAuthorization();
+ DingTalkOAuthState::query()->update(['consumed_at' => now()]);
+ $this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]))
+ ->assertSessionHas('dingtalk_error', trans('auth.dingtalk.failed'));
+ Http::assertNothingSent();
+ $this->assertGuest();
+ }
+
+ public function test_legacy_callback_returns_to_domain_before_consuming_state(): void
+ {
+ Http::preventStrayRequests();
+ $state = $this->startAuthorization();
+ $response = $this->get('http://218.93.190.75:8442/auth/dingtalk/callback?'.http_build_query([
+ 'code' => 'fake-code', 'state' => $state, 'redirect' => 'https://untrusted.test',
+ ]))->assertRedirect();
+ $this->assertStringStartsWith(config('app.url').'/auth/dingtalk/callback?', $response->headers->get('Location'));
+ $this->assertStringNotContainsString('untrusted', $response->headers->get('Location'));
+ $response->assertHeader('Referrer-Policy', 'no-referrer');
+ $this->assertDatabaseHas('dingtalk_oauth_states', ['state_hash' => hash('sha256', $state), 'consumed_at' => null]);
+ Http::assertNothingSent();
+ }
+
+ public function test_revoked_binding_invalidates_logged_in_session_on_next_request(): void
+ {
+ Http::preventStrayRequests();
+ Http::fake($this->dingtalkResponses('union-session', 'open-session', email: null));
+ $identity = DingTalkIdentity::query()->create(['user_id' => $this->user->id, 'union_id' => 'union-session']);
+ $state = $this->startAuthorization();
+ $this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state]))
+ ->assertSessionHas('dingtalk_identity_id', $identity->id);
+ $identity->delete();
+ $this->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized();
+ $this->assertGuest();
+ $this->assertFalse(session()->has('dingtalk_identity_id'));
+ }
+
protected function startAuthorization(): string
{
$response = $this->get(route('login.dingtalk'))->assertRedirect();
--
2.47.2
From cf9f94fa0885d754f8f703c65f4b001ac9dc3bd8 Mon Sep 17 00:00:00 2001
From: WuYongJie <549727955@qq.com>
Date: Tue, 8 Sep 2026 06:13:20 +0800
Subject: [PATCH 2/2] feat: provision finance roster users through DingTalk
login
---
app/Http/Controllers/Auth/DingTalkLogin.php | 11 +-
app/Http/Controllers/Auth/Login.php | 2 +-
app/Http/Controllers/Business/Pages.php | 4 +-
.../Controllers/SystemSettings/Sidebar.php | 2 +-
app/Http/Middleware/AddXHeader.php | 4 +
.../LogoutIfDingTalkBindingRevoked.php | 42 ++++-
app/Services/Auth/DingTalkClosedAccess.php | 133 ++++++++------
app/Services/Auth/FinanceRoster.php | 108 ++++++++++++
app/Utilities/SidebarPermissions.php | 3 +
config/dingtalk.php | 7 +-
docs/钉钉花名册登录.md | 63 +++++++
resources/lang/en-GB/auth.php | 2 +
resources/lang/zh-CN/auth.php | 2 +
.../views/business/access-pending.blade.php | 10 ++
.../dingtalk_bindings/index.blade.php | 4 +-
tests/Feature/Auth/DingTalkLoginTest.php | 166 ++++++++++++++++--
tests/Unit/Auth/FinanceRosterTest.php | 65 +++++++
17 files changed, 541 insertions(+), 87 deletions(-)
create mode 100644 app/Services/Auth/FinanceRoster.php
create mode 100644 docs/钉钉花名册登录.md
create mode 100644 resources/views/business/access-pending.blade.php
create mode 100644 tests/Unit/Auth/FinanceRosterTest.php
diff --git a/app/Http/Controllers/Auth/DingTalkLogin.php b/app/Http/Controllers/Auth/DingTalkLogin.php
index 01fb018f..92c70755 100644
--- a/app/Http/Controllers/Auth/DingTalkLogin.php
+++ b/app/Http/Controllers/Auth/DingTalkLogin.php
@@ -103,14 +103,20 @@ class DingTalkLogin extends Controller
return $this->denied();
}
- $identity = app(DingTalkClosedAccess::class)->resolve($profile, $request);
+ 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 = match ($bindingStatus) {
+ $message = $request->attributes->get('dingtalk_denial') ?? match ($bindingStatus) {
DingTalkBindingRequest::PENDING => 'pending',
DingTalkBindingRequest::REJECTED => 'rejected',
DingTalkBindingRequest::REVOKED => 'revoked',
@@ -139,6 +145,7 @@ class DingTalkLogin extends Controller
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,
diff --git a/app/Http/Controllers/Auth/Login.php b/app/Http/Controllers/Auth/Login.php
index cc94efc6..0c61b49a 100644
--- a/app/Http/Controllers/Auth/Login.php
+++ b/app/Http/Controllers/Auth/Login.php
@@ -42,7 +42,7 @@ class Login extends Controller
}
// Get user object
- $request->session()->forget('dingtalk_identity_id');
+ $request->session()->forget(['dingtalk_identity_id', 'dingtalk_roster_checked_at']);
$user = user();
// Check if user is enabled
diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php
index 3d81822b..5bbde4e4 100644
--- a/app/Http/Controllers/Business/Pages.php
+++ b/app/Http/Controllers/Business/Pages.php
@@ -102,7 +102,9 @@ class Pages extends Controller
{
$section = collect(SidebarPermissions::navigationFor(user()))->first();
- abort_if(empty($section), 403, '当前账号没有已授权的侧边栏功能。');
+ if (empty($section)) {
+ return view('business.access-pending');
+ }
$page = collect($section['pages'] ?? [])->first();
$parameters = ['section' => $section['slug']];
diff --git a/app/Http/Controllers/SystemSettings/Sidebar.php b/app/Http/Controllers/SystemSettings/Sidebar.php
index 897f351a..68328ebd 100644
--- a/app/Http/Controllers/SystemSettings/Sidebar.php
+++ b/app/Http/Controllers/SystemSettings/Sidebar.php
@@ -79,7 +79,7 @@ class Sidebar extends Controller
$users = User::query()
->whereHas('companies', fn ($company) => $company->where('companies.id', company_id()))
- ->whereHas('roles', fn ($roles) => $roles->whereIn('roles.name', [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN]))
+ ->whereHas('roles', fn ($roles) => $roles->whereIn('roles.name', [SystemAccess::SUPER_ADMIN, SystemAccess::ADMIN, SystemAccess::USER]))
->with('roles')
->orderBy('name')
->get();
diff --git a/app/Http/Middleware/AddXHeader.php b/app/Http/Middleware/AddXHeader.php
index 955861e5..461e1884 100644
--- a/app/Http/Middleware/AddXHeader.php
+++ b/app/Http/Middleware/AddXHeader.php
@@ -27,6 +27,10 @@ class AddXHeader
}
$response->header('X-XSS-Protection', '0');
$response->header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
+ if ($request->routeIs('login.dingtalk', 'login.dingtalk.callback')) {
+ $response->header('Cache-Control', 'no-store');
+ $response->header('Referrer-Policy', 'no-referrer');
+ }
}
return $response;
diff --git a/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php b/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php
index c757262d..a06d19b3 100644
--- a/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php
+++ b/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php
@@ -3,23 +3,51 @@
namespace App\Http\Middleware;
use App\Models\Auth\DingTalkIdentity;
+use App\Services\Auth\FinanceRoster;
+use App\Services\Auth\DingTalkClosedAccess;
use Closure;
+use Throwable;
class LogoutIfDingTalkBindingRevoked
{
public function handle($request, Closure $next)
{
$identityId = $request->session()->get('dingtalk_identity_id');
- if (! $identityId) {
- return $next($request);
- }
-
$user = $request->user();
- if ($user && $user->enabled && DingTalkIdentity::query()
- ->whereKey($identityId)->where('user_id', $user->id)->exists()) {
- return $next($request);
+ if (! $identityId) {
+ if (! $user || $user->created_from !== 'dingtalk_roster') {
+ return $next($request);
+ }
+ // A password reset must not bypass HR eligibility for provisioned
+ // accounts. Apply the same boundary after every sign-in method.
+ $identityId = $user->dingtalkIdentity?->id;
+ $request->session()->put('dingtalk_identity_id', $identityId);
+ $request->session()->forget('dingtalk_roster_checked_at');
}
+ $identity = $user ? DingTalkIdentity::query()->whereKey($identityId)->where('user_id', $user->id)->first() : null;
+ if ($user && $user->enabled && $identity) {
+ $checkedAt = (int) $request->session()->get('dingtalk_roster_checked_at', 0);
+ if ($checkedAt > now()->timestamp - 300) {
+ return $next($request);
+ }
+ try {
+ $eligible = app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
+ } catch (Throwable) {
+ // Retain the session during an HR outage, but do not serve data
+ // using an expired authorization decision.
+ return response(trans('auth.dingtalk.roster_unavailable'), 503)
+ ->header('Cache-Control', 'no-store');
+ }
+ if ($eligible) {
+ $request->session()->put('dingtalk_roster_checked_at', now()->timestamp);
+ return $next($request);
+ }
+ }
+
+ app(DingTalkClosedAccess::class)->audit('session_revoked', 'denied', $user?->id, null, $identity?->union_id, $request, [
+ 'reason' => ! $user?->enabled ? 'local_account_ineligible' : (! $identity ? 'binding_revoked' : 'outside_finance_roster'),
+ ]);
auth()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
diff --git a/app/Services/Auth/DingTalkClosedAccess.php b/app/Services/Auth/DingTalkClosedAccess.php
index 3499af25..c785bd72 100644
--- a/app/Services/Auth/DingTalkClosedAccess.php
+++ b/app/Services/Auth/DingTalkClosedAccess.php
@@ -6,8 +6,14 @@ 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
{
@@ -21,6 +27,15 @@ class DingTalkClosedAccess
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'])
@@ -54,38 +69,69 @@ class DingTalkClosedAccess
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(),
+ 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;
+ }
+
+ $role = Role::where('name', SystemAccess::USER)->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);
+ // Shell access only; no financial pages or administrative privileges.
+ $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',
]);
-
- $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;
+ }
+ $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
@@ -114,31 +160,6 @@ class DingTalkClosedAccess
]);
}
- 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
diff --git a/app/Services/Auth/FinanceRoster.php b/app/Services/Auth/FinanceRoster.php
new file mode 100644
index 00000000..474555d7
--- /dev/null
+++ b/app/Services/Auth/FinanceRoster.php
@@ -0,0 +1,108 @@
+queries->forSource('operations_replica');
+ $flowers = $query->select(
+ 'SELECT TOP (2) Id, name, flowerName, deptId, status, empStatus, leaveDate
+ FROM rpt.hr_flowerName_book WHERE flowerNameId = ? AND leaveDate IS NULL
+ ORDER BY Id DESC', [$userId]
+ );
+ if (count($flowers) !== 1) {
+ return null;
+ }
+ $flower = $flowers[0];
+ if ($this->isResigned($flower['status'] ?? null) || $this->isResigned($flower['empStatus'] ?? null)) {
+ return null;
+ }
+ if ($query->first('SELECT TOP (1) userid FROM rpt.hr_employee_dimission WHERE userid = ?', [$userId])) {
+ return null;
+ }
+
+ $employees = $query->select('SELECT TOP (2) active, dept_id_list FROM rpt.hr_employee WHERE userid = ?', [$userId]);
+ if (count($employees) > 1 || (isset($employees[0]['active']) && ! (bool) $employees[0]['active'])) {
+ return null;
+ }
+ $registrations = $query->select(
+ 'SELECT TOP (20) [sys01-employeeStatus] AS employment_status, [sys00-deptIds] AS department_ids,
+ [sys00-mainDeptId] AS main_department_id FROM rpt.hr_employee_register WHERE userid = ?', [$userId]
+ );
+ if (count($registrations) === 20) {
+ return null;
+ }
+
+ $departmentIds = $this->departmentIds($flower['deptId'] ?? null);
+ foreach ($employees as $employee) {
+ $departmentIds = array_merge($departmentIds, $this->departmentIds($employee['dept_id_list'] ?? null));
+ }
+ foreach ($registrations as $registration) {
+ if ($this->isResigned($registration['employment_status'] ?? null)) {
+ return null;
+ }
+ $departmentIds = array_merge($departmentIds,
+ $this->departmentIds($registration['department_ids'] ?? null),
+ $this->departmentIds($registration['main_department_id'] ?? null));
+ }
+ $departmentIds = array_values(array_unique($departmentIds));
+ if ($departmentIds === [] || count($departmentIds) > 64) {
+ return null;
+ }
+
+ $placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
+ $ancestors = $query->select(
+ 'WITH lineage AS (
+ SELECT Id, parent_id, 0 AS depth FROM rpt.hr_groups WHERE Id IN ('.$placeholders.')
+ UNION ALL
+ SELECT parent.Id, parent.parent_id, child.depth + 1
+ FROM rpt.hr_groups parent JOIN lineage child ON parent.Id = child.parent_id
+ WHERE child.depth < 20
+ ) SELECT TOP (2048) Id, parent_id, depth FROM lineage', $departmentIds
+ );
+ if (count($ancestors) === 2048 || array_filter($ancestors, fn ($row) => (int) $row['depth'] >= 20)) {
+ return null;
+ }
+ $allowed = array_map('strval', config('dingtalk.finance_department_ids', []));
+ if (! array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')))) {
+ return null;
+ }
+
+ return [
+ 'roster_id' => (string) $flower['Id'],
+ 'display_name' => trim((string) ($flower['flowerName'] ?: $flower['name'])),
+ 'department_ids' => $departmentIds,
+ ];
+ }
+
+ private function isResigned(mixed $value): bool
+ {
+ $status = trim((string) $value);
+ return $status === '5' || preg_match('/离职|停用|退休/u', $status) === 1;
+ }
+
+ private function departmentIds(mixed $value): array
+ {
+ $value = trim((string) $value);
+ $parts = json_decode($value, true);
+ if (! is_array($parts)) {
+ $parts = preg_split('/[\[\]\/>,,;;||\s]+/u', $value, -1, PREG_SPLIT_NO_EMPTY);
+ }
+ return array_values(array_filter(array_map(
+ fn ($id) => is_scalar($id) ? trim((string) $id) : '', $parts
+ ), fn ($id) => ctype_digit($id) && (int) $id > 0));
+ }
+}
diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php
index 0c36a020..2738c115 100644
--- a/app/Utilities/SidebarPermissions.php
+++ b/app/Utilities/SidebarPermissions.php
@@ -35,6 +35,9 @@ class SidebarPermissions
$subject = self::subjectKey($user);
if (! in_array($subject, $settings['configured'] ?? [], true)) {
+ if ($user->created_from === 'dingtalk_roster' && SystemAccess::roleName($user) === SystemAccess::USER) {
+ return [];
+ }
return null;
}
diff --git a/config/dingtalk.php b/config/dingtalk.php
index 056b0352..0ef36a9e 100644
--- a/config/dingtalk.php
+++ b/config/dingtalk.php
@@ -7,9 +7,14 @@ return [
'client_secret' => env('DINGTALK_CLIENT_SECRET'),
'redirect_uri' => env(
'DINGTALK_REDIRECT_URI',
- 'http://218.93.190.75:8442/auth/dingtalk/callback'
+ rtrim(env('APP_URL', 'https://caiwu.jiansuntuzhi.cn:8442'), '/').'/auth/dingtalk/callback'
),
+ // Verified HR roots: 简尚财务中心 and 龙晨品牌中心/财务中心.
+ 'finance_department_ids' => array_values(array_filter(array_map('trim', explode(',', env('DINGTALK_FINANCE_DEPARTMENT_IDS', '22848291,994080505'))))),
+ // When omitted, automatic provisioning requires exactly one enabled company.
+ 'company_id' => env('DINGTALK_COMPANY_ID'),
+
'authorize_url' => env('DINGTALK_AUTHORIZE_URL', 'https://login.dingtalk.com/oauth2/auth'),
'token_url' => env('DINGTALK_TOKEN_URL', 'https://api.dingtalk.com/v1.0/oauth2/userAccessToken'),
'user_url' => env('DINGTALK_USER_URL', 'https://api.dingtalk.com/v1.0/contact/users/me'),
diff --git a/docs/钉钉花名册登录.md b/docs/钉钉花名册登录.md
new file mode 100644
index 00000000..837704d5
--- /dev/null
+++ b/docs/钉钉花名册登录.md
@@ -0,0 +1,63 @@
+# 钉钉授权与财务花名册自动开户
+
+## 用户流程
+
+首次钉钉授权成功后,系统用企业 `userid` 对应 HR 花名册中的 `flowerNameId`,核验人员状态和组织归属,自动创建普通用户、绑定钉钉身份并登录。不要求填写注册表、设置密码或等待账号绑定审批。
+
+准入组织为负责人确认的两棵子树:
+
+- `22848291`:江苏简尚品牌管理有限公司 / 财务中心,包含财务部及其他下级部门。
+- `994080505`:江苏简尚品牌管理有限公司 / 龙晨品牌中心 / 财务中心,包含下级部门。
+
+按部门 ID 的祖先关系判断,支持花名册、员工表和任职登记中的兼任部门;同名部门不会自动获得准入。花名册缺失、身份多义、明确离职、停用或组织链异常时不开户。钉钉企业接口还必须确认该身份是有效的在职企业成员。
+
+已绑定的启用账号继续使用原账号及原权限。姓名、花名、邮箱和手机号不会用于自动认领其他账号。已拒绝、解绑或停用的身份不会通过再次授权自动恢复。
+
+新账号使用钉钉接口返回的姓名、可用邮箱和手机号;邮箱缺失或已被其他账号使用时,生成稳定的 `@dingtalk.invalid` 内部占位地址。系统生成不可预知的随机本地密码,不展示或发送密码,不发送注册邀请。花名册显示名仅作为姓名缺失时的后备。
+
+新账号赋予现有 `user` 角色及进入应用外壳所需的 `read-admin-panel` 权限,不添加管理权限。业务访问继承管理员明确配置的普通用户权限;尚无配置时默认无业务权限,进入“你已登录,等待分配业务权限”的工作台。管理员可在“侧边栏管理”中为普通用户角色或指定账号配置业务范围。
+
+## 只读数据契约
+
+复用 `operations_replica` 的共享连接和 `UpstreamReadOnlyQueryFactory`,固定读取:
+
+| 对象 | 字段与用途 | 单次上限 |
+| --- | --- | --- |
+| `rpt.hr_flowerName_book` | `Id/name/flowerName/deptId/status/empStatus/leaveDate`,按 `flowerNameId = userid`,无离职日期,按 Id 降序;要求唯一有效身份 | 2 行 |
+| `rpt.hr_employee_dimission` | 按 `userid` 检查明确离职记录 | 1 行 |
+| `rpt.hr_employee` | `active/dept_id_list`,按 `userid` | 2 行 |
+| `rpt.hr_employee_register` | `sys01-employeeStatus/sys00-deptIds/sys00-mainDeptId`,按 `userid` | 20 行,达到上限即拒绝 |
+| `rpt.hr_groups` | `Id/parent_id`,从最多 64 个任职部门沿祖先链查找 | 深度小于 20,最多 2048 行,达到边界即拒绝 |
+
+离职信号优先于其他状态:离职记录、状态码 `5`、含“离职/停用/退休”的状态均拒绝。花名册中的在职口径及钉钉有效企业身份共同约束访问。
+
+这是按单人执行的当前身份授权查询,不是业务页面报表查询。不会建立另一份 HR 全量镜像,也不会把上游原始 HR 行写入系统配置、业务镜像、ClickHouse 或 Parquet。首次登录及每 5 分钟会话复核读取当前来源;来源故障时不得使用已过期的授权结果继续提供业务数据。
+
+## 会话边界
+
+- OAuth state 同时绑定浏览器会话,并校验数据库中的过期和一次性消费状态。跨浏览器和重放均不能兑换授权码。
+- 正式回调默认是 `APP_URL/auth/dingtalk/callback`。旧 IP 回调先跳回固定应用域名,再核验域名下的会话,不接受外部跳转目标。
+- OAuth 响应采用 `no-store` 和 `no-referrer`。钉钉登录不签发长期 remember cookie,并清除浏览器中的旧 remember cookie。
+- 新钉钉会话每次请求核验账号启用状态和绑定记录。解绑或停用后,下一次请求失效;花名册离职或调离财务范围在下一次 5 分钟复核时失效。
+- 自动开户账号即使改用密码登录,也须经过同样的花名册检查,不能通过密码重置绕过准入。
+- 开户、登录拒绝、登录成功、解绑和会话失效有审计;不记录授权码、访问令牌、随机密码或完整 HR 响应。
+
+## 配置与发布边界
+
+本次没有新增数据库结构或镜像迁移。以下生产操作需要发布授权后执行:
+
+1. 发布已合并的 main,并重建应用配置缓存。
+2. 将生产 `DINGTALK_REDIRECT_URI` 设置为 `https://caiwu.jiansuntuzhi.cn:8442/auth/dingtalk/callback`,核对钉钉开放平台对应应用的回调配置。2026-09-08 的只读检查确认生产 `APP_URL` 已是正式域名,但回调变量仍为旧 IP 地址;不能只改代码默认值就宣称生产已切换。
+3. 首次切换须处理旧版已签发的会话与 remember 令牌。旧会话没有钉钉身份标记,不能可靠区分密码登录与钉钉登录;在备份后统一失效旧登录态会要求用户重新登录,须纳入生产授权。当前生产 session driver 为 `file`。回滚旧镜像无法恢复已失效的浏览器登录态。
+4. `DINGTALK_COMPANY_ID` 不配置时只允许唯一启用公司;2026-09-08 只读检查确认生产仅公司 ID 1 启用。若新增公司,需明确配置自动开户的目标公司,不取任意第一条。
+5. 两个财务根部门可通过 `DINGTALK_FINANCE_DEPARTMENT_IDS` 显式配置。扩大组织范围属于权限变更。
+
+代码回滚使用上一应用镜像;已自动建立的账号和审计保留,不执行账号删除。真实验收只需一名范围内人员完成首次授权、再次登录及获授权后的工作台访问;不得使用管理员身份代替首次开户验证。
+
+## 验证证据(2026-09-08)
+
+- 39 项测试、197 个断言通过,使用隔离内存 SQLite 和虚构钉钉/花名册资料,覆盖开户、重复登录、同邮箱/手机号不认领、跨浏览器、重放、权限分配、非财务/离职/停用/解绑、HR 故障、密码登录不绕过及管理员回归。
+- 开发节点通过实际只读入口抽查 3 名有有效钉钉唯一值的财务花名册人员,3 名均被新准入查询识别;不输出人员身份、手机号或原始 HR 行。
+- 生产节点使用现有只读查询入口读到两个确认的财务根部门,说明该节点可独立访问 HR 来源。本次没有在生产执行开户或写入。
+- 浏览器检查实际 Blade 渲染的虚构账号页面:桌面和 390px 窄屏首次登录提示、管理员绑定页新说明和开户审计。权限保存与后续工作台跳转通过 HTTP 功能测试验证。
+- 尚未发布,尚未进行真实钉钉扫码开户验收。
diff --git a/resources/lang/en-GB/auth.php b/resources/lang/en-GB/auth.php
index 8045ce99..deb70ab4 100644
--- a/resources/lang/en-GB/auth.php
+++ b/resources/lang/en-GB/auth.php
@@ -58,6 +58,8 @@ return [
'rejected' => 'Your DingTalk binding request was rejected. Contact the system administrator for access.',
'revoked' => 'Your DingTalk account binding was removed. Contact the system administrator to restore access.',
'session_expired' => 'Your DingTalk session is no longer valid. Sign in again, or contact the administrator if your account was disabled or unbound.',
+ 'outside_roster' => 'Access is limited to current staff in the finance departments listed in the HR roster. Contact an administrator if your record needs correction.',
+ 'roster_unavailable' => 'The finance roster cannot be verified right now. Please try DingTalk sign-in again later.',
],
'notification' => [
diff --git a/resources/lang/zh-CN/auth.php b/resources/lang/zh-CN/auth.php
index ccbb4578..620925e8 100644
--- a/resources/lang/zh-CN/auth.php
+++ b/resources/lang/zh-CN/auth.php
@@ -58,6 +58,8 @@ return [
'rejected' => '钉钉账号绑定申请已被拒绝,如需开通请联系系统管理员。',
'revoked' => '钉钉账号绑定已解除,如需恢复请联系系统管理员。',
'session_expired' => '钉钉登录已失效,请重新登录;如账号已停用或解除绑定,请联系系统管理员。',
+ 'outside_roster' => '当前仅向花名册中财务中心及其下级部门的在职人员开放。如人员资料有误,请联系管理员核对花名册。',
+ 'roster_unavailable' => '暂时无法核验财务花名册,请稍后重新尝试钉钉登录。',
],
'notification' => [
diff --git a/resources/views/business/access-pending.blade.php b/resources/views/business/access-pending.blade.php
new file mode 100644
index 00000000..b1d430ff
--- /dev/null
+++ b/resources/views/business/access-pending.blade.php
@@ -0,0 +1,10 @@
+ 当前账号尚未分配业务权限,请联系管理员开通需要使用的功能。 权限分配后,重新打开工作台即可使用,无需再次注册。{{ user()->name }},你已登录
+