钉钉登录:单独授权人员仅访问三个快递报价页面 (#339)

按精确企业身份开放受限登录,保留在职校验和普通角色;统一限制菜单、页面、导出与预览。59 项相关测试通过,无数据库迁移。
This commit is contained in:
limeng 2026-10-15 15:21:05 +08:00
parent 1214fc365e
commit f41a8fa6ba
12 changed files with 200 additions and 4 deletions

View File

@ -36,6 +36,7 @@ class Kernel extends HttpKernel
'cookies.response',
'session.start',
\App\Http\Middleware\LogoutIfDingTalkBindingRevoked::class,
\App\Http\Middleware\EnsureLimitedDingTalkPageAccess::class,
// 'session.auth',
'session.errors',
'csrf',

View File

@ -0,0 +1,47 @@
<?php
namespace App\Http\Middleware;
use App\Utilities\SidebarPermissions;
use Closure;
use Illuminate\Http\Request;
class EnsureLimitedDingTalkPageAccess
{
public function handle(Request $request, Closure $next): mixed
{
$user = $request->user();
$route = $request->route();
if (! $user || ! $route?->hasParameter('company_id') || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
return $next($request);
}
// Keep the application entrance, sign-out and existing self-profile checks.
if ($route->named('dashboard', 'logout', 'profile.edit', 'profile.update')) {
return $next($request);
}
$allowed = SidebarPermissions::allowedKeys($user);
$key = $route->parameter('section').'/'.$route->parameter('page');
if (in_array($key, $allowed, true)) {
return $next($request);
}
// The section and quote group are navigation containers for the three pages.
if ($route->named('business.pages.show') && $route->parameter('section') === 'courier-payables'
&& in_array($route->parameter('page'), [null, 'courier-quote'], true)) {
return $next($request);
}
// Dedicated detail, file and action routes declare their owning business page.
foreach ($route->gatherMiddleware() as $middleware) {
if (is_string($middleware) && str_starts_with($middleware, 'business.page:')
&& in_array(str_replace(',', '/', substr($middleware, strlen('business.page:'))), $allowed, true)) {
return $next($request);
}
}
// Also deny legacy exports/previews that do not declare a page permission.
abort(403, '当前账号没有该页面权限。');
}
}

View File

@ -15,7 +15,7 @@ class LogoutIfDingTalkBindingRevoked
$identityId = $request->session()->get('dingtalk_identity_id');
$user = $request->user();
if (! $identityId) {
if (! $user || $user->created_from !== 'dingtalk_roster') {
if (! $user || ! in_array($user->created_from, ['dingtalk_roster', 'dingtalk_roster_limited'], true)) {
return $next($request);
}
// A password reset must not bypass HR eligibility for provisioned

View File

@ -111,7 +111,8 @@ class DingTalkClosedAccess
'enabled' => true,
'landing_page' => 'dashboard',
'created_by' => 0,
'created_from' => 'dingtalk_roster',
'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.

View File

@ -12,6 +12,11 @@ class FinanceRoster
return $userId !== '' && in_array($userId, config('dingtalk.super_admin_user_ids', []), true);
}
public static function isLimitedLoginUser(string $userId): bool
{
return $userId !== '' && in_array($userId, config('dingtalk.limited_login_user_ids', []), true);
}
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
{
}
@ -83,6 +88,7 @@ class FinanceRoster
}
$allowed = array_map('strval', config('dingtalk.finance_department_ids', []));
if (! self::isDesignatedSuperAdmin($userId)
&& ! self::isLimitedLoginUser($userId)
&& ! array_intersect($allowed, array_map('strval', array_column($ancestors, 'Id')))) {
return null;
}

View File

@ -4,6 +4,7 @@ namespace App\Utilities;
use App\Models\Auth\User;
use App\Models\Auth\UserAccessGroup;
use App\Services\Auth\FinanceRoster;
class SidebarPermissions
{
@ -41,6 +42,11 @@ class SidebarPermissions
public static function allowedKeys(User $user): ?array
{
// Limited identities cannot inherit full navigation from roles, groups or user overrides.
if (self::hasLimitedDingTalkAccess($user)) {
return config('dingtalk.limited_login_page_keys', []);
}
if (SystemAccess::isSuperAdmin($user)) {
return null;
}
@ -67,6 +73,13 @@ class SidebarPermissions
return self::normalizeAllowedKeys((array) ($settings['subjects'][$subject] ?? []));
}
public static function hasLimitedDingTalkAccess(User $user): bool
{
return $user->created_from === 'dingtalk_roster_limited'
|| (config('dingtalk.limited_login_user_ids', []) !== []
&& FinanceRoster::isLimitedLoginUser((string) $user->dingtalkIdentity?->dingtalk_user_id));
}
public static function navigationFor(User $user): array
{
$navigation = BusinessNavigation::all();

View File

@ -12,6 +12,14 @@ return [
// Verified HR roots: 简尚财务中心 and 龙晨品牌中心/财务中心.
'finance_department_ids' => array_values(array_filter(array_map('trim', explode(',', env('DINGTALK_FINANCE_DEPARTMENT_IDS', '22848291,994080505'))))),
// Individually approved, current enterprise members; never names or whole departments.
// This grants ordinary login only, with the exact courier page scope below.
'limited_login_user_ids' => array_values(array_filter(array_map('trim', explode(',', env('DINGTALK_LIMITED_LOGIN_USER_IDS', ''))))),
'limited_login_page_keys' => [
'courier-payables/courier-onboarding',
'courier-payables/courier-information',
'courier-payables/quote-management',
],
// When omitted, automatic provisioning requires exactly one enabled company.
'company_id' => env('DINGTALK_COMPANY_ID'),

View File

@ -15,6 +15,18 @@
负责人明确指定的主管理员可通过受保护的运行时配置 `DINGTALK_SUPER_ADMIN_USER_IDS` 登记企业 userid(逗号分隔,默认空)。仅这些精确身份可以不受财务部门范围限制,首次开户赋予 `super_admin`;仍必须通过唯一花名册、在职、非离职、有效组织链和钉钉企业成员核验。既有账号角色不因该配置在登录时被覆盖。移除指定身份会恢复财务部门准入要求,但不直接改写既有角色。配置不得使用姓名、手机号或整部门,也不得把真实身份值提交到仓库。
## 单独授权的受限登录
负责人批准的非财务人员通过服务器私有配置 `DINGTALK_LIMITED_LOGIN_USER_IDS` 登记精确企业 userid,逗号分隔、默认空。姓名和花名只用于人工核对,不参与授权,不扩大同部门其他人员的登录范围。真实身份值不得提交到仓库。
这些人员仅跳过财务部门归属限制,仍须满足唯一花名册、在职、无离职记录、启用、有效组织链和钉钉企业成员校验。首次开户沿用普通 `user` 角色,不使用主管理员名单,也不自动分配审核人或管理员权限。
页面范围固定为 `courier-payables/courier-onboarding`、`courier-payables/courier-information`、`courier-payables/quote-management`。菜单、标签、页面及其详情、附件和操作接口共用此范围;不继承角色、访问组或用户配置中的更宽权限,不扩展到“终版”目录。受限会话对带公司范围的网页路由统一校验,只保留应用入口、退出、原有本人资料校验以及这三个页面和对应接口;其他页面、旧导出、预览和通用附件入口直接返回 403,避免遗漏旧路由。三个页面内部已有操作仍按各自现有权限校验。
配置移除后,下次登录及最长 5 分钟会话复核恢复财务组织范围要求。新受限账号保留 `created_from=dingtalk_roster_limited` 标识,移除配置不会在会话复核前回退到完整导航;密码登录同样核验当前花名册。发布时仅备份并更新该私有配置项,重建配置缓存;不手工代建账号、不伪造扫码、不改写人员资料或业务数据。上线后使用真实身份只读检查准入和精确页面范围,首次真实扫码仍由本人完成。
## 普通账号与权限
新账号使用钉钉接口返回的姓名、可用邮箱和手机号;邮箱缺失或已被其他账号使用时,生成稳定的 `@dingtalk.invalid` 内部占位地址。系统生成不可预知的随机本地密码,不展示或发送密码,不发送注册邀请。花名册显示名仅作为姓名缺失时的后备。
新账号赋予现有 `user` 角色及进入应用外壳所需的 `read-admin-panel` 权限,不添加管理权限。按负责人最新要求,尚无页面权限配置时默认显示完整业务导航;主管理员随后可在“侧边栏管理”中设置指定用户的业务范围。配置优先级为指定用户、角色、默认完整导航;显式配置为空时没有业务页面,不回退到完整导航。管理员专属操作(包括店铺密码读取)继续单独鉴权。

View File

@ -58,7 +58,7 @@ return [
'rejected' => '钉钉账号绑定申请已被拒绝,如需开通请联系系统管理员。',
'revoked' => '钉钉账号绑定已解除,如需恢复请联系系统管理员。',
'session_expired' => '钉钉登录已失效,请重新登录;如账号已停用或解除绑定,请联系系统管理员。',
'outside_roster' => '当前仅向花名册中财务中心及其下级部门的在职人员开放。如人员资料有误,请联系管理员核对花名册。',
'outside_roster' => '当前仅向财务中心及其下级部门的在职人员,以及已单独授权的本公司在职人员开放。如有疑问,请联系管理员核对登录资格。',
'roster_unavailable' => '暂时无法核验财务花名册,请稍后重新尝试钉钉登录。',
],

View File

@ -6,7 +6,7 @@
@include('system_settings.partials.navigation')
<div class="mb-5 rounded-xl border border-[#D8D2C7] bg-[#F7F0DE] px-4 py-3 text-sm text-[#595959]">
花名册中两个财务中心及其下级部门的在职人员,首次钉钉授权后会自动创建普通账号并登录,无需另行注册。业务权限由系统管理员分配;已拒绝、解绑或停用的账号不会自动恢复。
花名册中两个财务中心及其下级部门的在职人员,以及已单独授权的本公司在职人员,首次钉钉授权后会自动创建普通账号并登录,无需另行注册。单独授权人员仅可访问指定的快递入驻、快递资料和报价管理页面;已拒绝、解绑或停用的账号不会自动恢复。
</div>
@if ($errors->any())

View File

@ -33,6 +33,7 @@ class DingTalkLoginTest extends FeatureTestCase
'dingtalk.app_token_url' => 'https://oapi.dingtalk.test/gettoken',
'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',
'dingtalk.limited_login_user_ids' => [],
]);
app('url')->forceRootUrl(config('app.url'));
app('url')->forceScheme('https');
@ -131,6 +132,104 @@ class DingTalkLoginTest extends FeatureTestCase
$this->get(route('dashboard', ['company_id' => $this->company->id]))->assertRedirectContains('/workspace/');
}
public function test_limited_login_gets_only_three_courier_pages_and_blocks_direct_urls(): void
{
config(['dingtalk.limited_login_user_ids' => ['ding-user-1']]);
Http::preventStrayRequests();
Http::fake($this->dingtalkResponses('union-limited', 'open-limited'));
$state = $this->startAuthorization();
$this->get(route('login.dingtalk.callback', ['code' => 'fixture', 'state' => $state]))
->assertRedirect(route('dashboard', ['company_id' => $this->company->id]));
$this->get(route('dashboard', ['company_id' => $this->company->id]))
->assertRedirectContains('/workspace/courier-payables/courier-quote');
$created = auth()->user();
$this->assertSame('dingtalk_roster_limited', $created->created_from);
$this->assertTrue($created->hasRole('user'));
$this->assertFalse($created->hasRole('admin'));
$this->assertFalse($created->hasRole('super_admin'));
$keys = [
'courier-payables/courier-onboarding',
'courier-payables/courier-information',
'courier-payables/quote-management',
];
$this->assertSame($keys, SidebarPermissions::allowedKeys($created));
$navigation = SidebarPermissions::navigationFor($created);
$this->assertSame(['courier-payables'], array_column($navigation, 'slug'));
$children = collect($navigation[0]['pages'])->flatMap(fn ($page) => $page['children'] ?? [$page]);
$this->assertEqualsCanonicalizing(['courier-onboarding', 'courier-information', 'quote-management'], $children->pluck('slug')->all());
// Broader role/user grants must not silently expand this person's agreed scope.
setting()->set('system.sidebar_permissions', [
'configured' => ['role_user', 'user_'.$created->id],
'subjects' => ['role_user' => ['company-management'], 'user_'.$created->id => ['courier-payables']],
]);
$this->assertSame($keys, SidebarPermissions::allowedKeys($created));
$this->withExceptionHandling();
foreach ([
['company-management', 'basic-information', 403],
['courier-payables', 'agreement-signing', 403],
['courier-payables', 'shipping-data', 403],
['courier-payables', 'freight-reconciliation', 403],
['courier-payables-final', 'courier-information', 403],
] as [$section, $page, $status]) {
$this->get(route('business.pages.show', ['company_id' => $this->company->id, 'section' => $section, 'page' => $page]))
->assertStatus($status);
}
$this->get(route('system-settings.sidebar.edit'))->assertForbidden();
$this->get(route('business.courier-payables.shipping-data.export', ['company_id' => $this->company->id]))->assertForbidden();
// These older endpoints only have a general read permission, not a business.page guard.
foreach ([
'workspace/product-inventory/monthly-weighted-average-price/export',
'workspace/product-inventory/inventory-report/export',
'preview/invoices/1',
'uploads/1/download',
] as $path) {
$this->get(url($this->company->id.'/'.$path))->assertForbidden();
}
foreach (['courier-onboarding', 'courier-information', 'quote-management'] as $page) {
$request = \Illuminate\Http\Request::create('/');
$request->setUserResolver(fn () => $created);
$response = app(\App\Http\Middleware\EnsureBusinessPageAccess::class)->handle(
$request, fn () => response('allowed'), 'courier-payables', $page
);
$this->assertSame('allowed', $response->getContent());
}
$this->mock(\Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository::class, function ($mock): void {
$mock->shouldReceive('relatedAvailability')->andReturn([
'main' => false, 'networks' => false, 'accounts' => false,
'network_ready' => false, 'account_ready' => false,
]);
$mock->shouldReceive('available')->andReturnFalse();
});
foreach (['courier-onboarding' => '快递入驻', 'courier-information' => '快递资料', 'quote-management' => '报价管理'] as $page => $title) {
$this->get(route('business.pages.show', ['company_id' => $this->company->id, 'section' => 'courier-payables', 'page' => $page]))
->assertOk()->assertSeeText($title)
->assertDontSee(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'shipping-data']), false)
->assertDontSee(route('business.pages.show', ['section' => 'courier-payables', 'page' => 'agreement-signing']), false);
}
// Revoking the login exception must never widen the existing session's page scope.
config(['dingtalk.limited_login_user_ids' => []]);
$this->assertSame($keys, SidebarPermissions::allowedKeys($created));
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->with('ding-user-1')->andReturnNull();
$this->withSession(['dingtalk_roster_checked_at' => now()->subMinutes(6)->timestamp])
->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized();
$this->assertGuest();
}
public function test_limited_password_login_still_checks_current_roster(): void
{
$this->user->update(['created_from' => 'dingtalk_roster_limited']);
DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-limited-password', 'dingtalk_user_id' => 'ding-limited-password']);
$this->mock(FinanceRoster::class)->shouldReceive('findEligible')->with('ding-limited-password')->andReturnNull();
$this->actingAs($this->user)->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized();
$this->assertGuest();
}
public function test_explicitly_designated_identity_gets_highest_role_only_on_first_provisioning(): void
{
config(['dingtalk.super_admin_user_ids' => ['ding-user-1']]);

View File

@ -30,6 +30,7 @@ class FinanceRosterTest extends TestCase
{
config(['dingtalk.finance_department_ids' => ['22848291', '994080505']]);
config(['dingtalk.super_admin_user_ids' => $overrides['designated_ids'] ?? []]);
config(['dingtalk.limited_login_user_ids' => $overrides['limited_ids'] ?? []]);
$data = array_replace([
'flowers' => [['Id' => 123, 'name' => '测试员工', 'flowerName' => '测试花名', 'deptId' => '55248182', 'status' => '', 'empStatus' => '正式']],
'employees' => [['active' => 1, 'dept_id_list' => '[55248182]']],
@ -64,6 +65,14 @@ class FinanceRosterTest extends TestCase
public static function rosterCases(): array
{
return [
'limited current employee outside finance' => [['limited_ids' => ['ding-fixture-id'], 'ancestors' => [['Id' => '55248182', 'parent_id' => '1', 'depth' => 0]]], true],
'limited grant is exact identity only' => [['limited_ids' => ['another-id'], 'ancestors' => [['Id' => '55248182', 'parent_id' => '1', 'depth' => 0]]], false],
'limited grant still requires unique roster identity' => [['limited_ids' => ['ding-fixture-id'], 'flowers' => []], false],
'limited grant rejects duplicate roster identity' => [['limited_ids' => ['ding-fixture-id'], 'flowers' => [['Id' => 1], ['Id' => 2]]], false],
'limited grant cannot bypass resignation' => [['limited_ids' => ['ding-fixture-id'], 'resignation' => ['userid' => 'ding-fixture-id']], false],
'limited grant cannot bypass disabled employment' => [['limited_ids' => ['ding-fixture-id'], 'employees' => [['active' => 0]]], false],
'limited grant cannot bypass resigned registration' => [['limited_ids' => ['ding-fixture-id'], 'registrations' => [['employment_status' => '5']]], false],
'limited grant cannot bypass broken organization' => [['limited_ids' => ['ding-fixture-id'], 'ancestors' => [['Id' => '55248182', 'parent_id' => '55248182', 'depth' => 20]]], false],
'designated active administrator outside finance' => [['designated_ids' => ['ding-fixture-id'], 'ancestors' => [['Id' => '55248182', 'parent_id' => '1', 'depth' => 0]]], true],
'designation does not cover another identity' => [['designated_ids' => ['another-id'], 'ancestors' => [['Id' => '55248182', 'parent_id' => '1', 'depth' => 0]]], false],
'designated administrator still requires active employment' => [['designated_ids' => ['ding-fixture-id'], 'employees' => [['active' => 0]]], false],