From f41a8fa6ba20250a4fd3b15ed3e4ed3cce36a2ab Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Thu, 15 Oct 2026 15:21:05 +0800 Subject: [PATCH] =?UTF-8?q?=E9=92=89=E9=92=89=E7=99=BB=E5=BD=95=EF=BC=9A?= =?UTF-8?q?=E5=8D=95=E7=8B=AC=E6=8E=88=E6=9D=83=E4=BA=BA=E5=91=98=E4=BB=85?= =?UTF-8?q?=E8=AE=BF=E9=97=AE=E4=B8=89=E4=B8=AA=E5=BF=AB=E9=80=92=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7=E9=A1=B5=E9=9D=A2=20(#339)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按精确企业身份开放受限登录,保留在职校验和普通角色;统一限制菜单、页面、导出与预览。59 项相关测试通过,无数据库迁移。 --- app/Http/Kernel.php | 1 + .../EnsureLimitedDingTalkPageAccess.php | 47 +++++++++ .../LogoutIfDingTalkBindingRevoked.php | 2 +- app/Services/Auth/DingTalkClosedAccess.php | 3 +- app/Services/Auth/FinanceRoster.php | 6 ++ app/Utilities/SidebarPermissions.php | 13 +++ config/dingtalk.php | 8 ++ docs/钉钉花名册登录.md | 12 +++ resources/lang/zh-CN/auth.php | 2 +- .../dingtalk_bindings/index.blade.php | 2 +- tests/Feature/Auth/DingTalkLoginTest.php | 99 +++++++++++++++++++ tests/Unit/Auth/FinanceRosterTest.php | 9 ++ 12 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index bb7e98d4..907e47df 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -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', diff --git a/app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php b/app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php new file mode 100644 index 00000000..3fee8f79 --- /dev/null +++ b/app/Http/Middleware/EnsureLimitedDingTalkPageAccess.php @@ -0,0 +1,47 @@ +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, '当前账号没有该页面权限。'); + } +} diff --git a/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php b/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php index a06d19b3..009d72be 100644 --- a/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php +++ b/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php @@ -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 diff --git a/app/Services/Auth/DingTalkClosedAccess.php b/app/Services/Auth/DingTalkClosedAccess.php index 753f0b59..dd8ad949 100644 --- a/app/Services/Auth/DingTalkClosedAccess.php +++ b/app/Services/Auth/DingTalkClosedAccess.php @@ -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. diff --git a/app/Services/Auth/FinanceRoster.php b/app/Services/Auth/FinanceRoster.php index c106b27b..ee8deae1 100644 --- a/app/Services/Auth/FinanceRoster.php +++ b/app/Services/Auth/FinanceRoster.php @@ -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; } diff --git a/app/Utilities/SidebarPermissions.php b/app/Utilities/SidebarPermissions.php index 5edcaa50..297b93ce 100644 --- a/app/Utilities/SidebarPermissions.php +++ b/app/Utilities/SidebarPermissions.php @@ -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(); diff --git a/config/dingtalk.php b/config/dingtalk.php index 6fe50220..eda77691 100644 --- a/config/dingtalk.php +++ b/config/dingtalk.php @@ -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'), diff --git a/docs/钉钉花名册登录.md b/docs/钉钉花名册登录.md index 435c2429..e3b32c2b 100644 --- a/docs/钉钉花名册登录.md +++ b/docs/钉钉花名册登录.md @@ -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` 权限,不添加管理权限。按负责人最新要求,尚无页面权限配置时默认显示完整业务导航;主管理员随后可在“侧边栏管理”中设置指定用户的业务范围。配置优先级为指定用户、角色、默认完整导航;显式配置为空时没有业务页面,不回退到完整导航。管理员专属操作(包括店铺密码读取)继续单独鉴权。 diff --git a/resources/lang/zh-CN/auth.php b/resources/lang/zh-CN/auth.php index 620925e8..c8b3fab9 100644 --- a/resources/lang/zh-CN/auth.php +++ b/resources/lang/zh-CN/auth.php @@ -58,7 +58,7 @@ return [ 'rejected' => '钉钉账号绑定申请已被拒绝,如需开通请联系系统管理员。', 'revoked' => '钉钉账号绑定已解除,如需恢复请联系系统管理员。', 'session_expired' => '钉钉登录已失效,请重新登录;如账号已停用或解除绑定,请联系系统管理员。', - 'outside_roster' => '当前仅向花名册中财务中心及其下级部门的在职人员开放。如人员资料有误,请联系管理员核对花名册。', + 'outside_roster' => '当前仅向财务中心及其下级部门的在职人员,以及已单独授权的本公司在职人员开放。如有疑问,请联系管理员核对登录资格。', 'roster_unavailable' => '暂时无法核验财务花名册,请稍后重新尝试钉钉登录。', ], diff --git a/resources/views/system_settings/dingtalk_bindings/index.blade.php b/resources/views/system_settings/dingtalk_bindings/index.blade.php index 91576a3d..4f57e735 100644 --- a/resources/views/system_settings/dingtalk_bindings/index.blade.php +++ b/resources/views/system_settings/dingtalk_bindings/index.blade.php @@ -6,7 +6,7 @@ @include('system_settings.partials.navigation')