diff --git a/app/Http/Controllers/Auth/DingTalkLogin.php b/app/Http/Controllers/Auth/DingTalkLogin.php index 63c9bffa..92c70755 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)) @@ -87,13 +103,28 @@ 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); } - return $this->denied(); + $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 @@ -109,8 +140,12 @@ 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); + $request->session()->put('dingtalk_roster_checked_at', now()->timestamp); $identity->forceFill([ 'open_id' => $profile['open_id'] ?: $identity->open_id, @@ -146,4 +181,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..0c61b49a 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', 'dingtalk_roster_checked_at']); $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/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/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..461e1884 100644 --- a/app/Http/Middleware/AddXHeader.php +++ b/app/Http/Middleware/AddXHeader.php @@ -22,9 +22,15 @@ 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=()'); + 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 new file mode 100644 index 00000000..a06d19b3 --- /dev/null +++ b/app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php @@ -0,0 +1,63 @@ +session()->get('dingtalk_identity_id'); + $user = $request->user(); + 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(); + + 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/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 070cf517..deb70ab4 100644 --- a/resources/lang/en-GB/auth.php +++ b/resources/lang/en-GB/auth.php @@ -54,6 +54,12 @@ 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.', + '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 ce3cafe3..620925e8 100644 --- a/resources/lang/zh-CN/auth.php +++ b/resources/lang/zh-CN/auth.php @@ -54,6 +54,12 @@ return [ 'unavailable' => '钉钉登录尚未启用,请暂时使用邮箱密码登录。', 'failed' => '钉钉登录未完成,请重新发起授权。', 'denied' => '当前钉钉账号未获财务系统授权,请联系系统管理员。', + 'pending' => '钉钉身份已确认,账号绑定申请正在等待管理员审批。审批通过后请重新登录。', + '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 }},你已登录

+

当前账号尚未分配业务权限,请联系管理员开通需要使用的功能。

+

权限分配后,重新打开工作台即可使用,无需再次注册。

+
+
+
diff --git a/resources/views/system_settings/dingtalk_bindings/index.blade.php b/resources/views/system_settings/dingtalk_bindings/index.blade.php index 0aa6c95c..91576a3d 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')
- 钉钉登录不会自动注册账号。只有企业内在职人员与现有启用财务账号唯一匹配时才会自动绑定;其他申请需管理员确认。 + 花名册中两个财务中心及其下级部门的在职人员,首次钉钉授权后会自动创建普通账号并登录,无需另行注册。业务权限由系统管理员分配;已拒绝、解绑或停用的账号不会自动恢复。
@if ($errors->any()) @@ -20,6 +20,8 @@ $eventLabels = [ 'binding_pending' => '提交绑定申请', 'binding_auto_approved' => '自动绑定', + 'account_auto_created' => '钉钉自动开户', + 'session_revoked' => '登录会话失效', 'binding_admin_approved' => '管理员绑定', 'binding_rejected' => '拒绝绑定', 'binding_revoked' => '解除绑定', diff --git a/tests/Feature/Auth/DingTalkLoginTest.php b/tests/Feature/Auth/DingTalkLoginTest.php index 336352ee..0a6bbc8b 100644 --- a/tests/Feature/Auth/DingTalkLoginTest.php +++ b/tests/Feature/Auth/DingTalkLoginTest.php @@ -7,6 +7,8 @@ use App\Models\Auth\DingTalkIdentity; use App\Models\Auth\DingTalkOAuthState; use App\Models\Auth\User; use App\Models\Common\Contact; +use App\Services\Auth\FinanceRoster; +use App\Utilities\SidebarPermissions; use Illuminate\Http\Client\Request as ClientRequest; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; @@ -24,7 +26,7 @@ class DingTalkLoginTest extends FeatureTestCase 'dingtalk.enabled' => true, 'dingtalk.client_id' => 'test-client-id', 'dingtalk.client_secret' => 'test-client-secret', - 'dingtalk.redirect_uri' => 'http://218.93.190.75:8442/auth/dingtalk/callback', + 'dingtalk.redirect_uri' => 'https://caiwu.jiansuntuzhi.cn:8442/auth/dingtalk/callback', 'dingtalk.authorize_url' => 'https://login.dingtalk.test/oauth2/auth', 'dingtalk.token_url' => 'https://api.dingtalk.test/v1.0/oauth2/userAccessToken', 'dingtalk.user_url' => 'https://api.dingtalk.test/v1.0/contact/users/me', @@ -32,6 +34,11 @@ 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'); + $this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andReturn([ + 'roster_id' => 'fixture-1', 'display_name' => '财务测试员工', 'department_ids' => ['22848291'], + ])->byDefault(); } public function test_login_page_shows_dingtalk_entry_without_password_binding_fields(): void @@ -43,7 +50,7 @@ class DingTalkLoginTest extends FeatureTestCase ->assertDontSeeText('首次使用钉钉登录,请输入现有财务系统账号和密码完成绑定'); } - public function test_redirect_creates_hashed_state_and_uses_registered_http_callback(): void + public function test_redirect_creates_hashed_state_and_uses_domain_callback(): void { $response = $this->get(route('login.dingtalk'))->assertRedirect(); parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query); @@ -88,7 +95,7 @@ class DingTalkLoginTest extends FeatureTestCase Http::assertSentCount(5); } - public function test_unique_verified_email_auto_binds_existing_enabled_account(): void + public function test_finance_staff_are_provisioned_without_claiming_an_existing_email_account(): void { Http::preventStrayRequests(); Http::fake($this->dingtalkResponses('union-auto', 'open-auto', email: 'finance@example.test')); @@ -100,25 +107,31 @@ class DingTalkLoginTest extends FeatureTestCase 'state' => $state, ]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442'); - $this->assertAuthenticatedAs($this->user); + $created = auth()->user(); + $this->assertNotSame($this->user->id, $created->id); + $this->assertTrue($created->hasRole('user')); + $this->assertFalse($created->hasRole('super_admin')); + $this->assertSame([], SidebarPermissions::allowedKeys($created)); $this->assertDatabaseHas('dingtalk_identities', [ - 'user_id' => $this->user->id, + 'user_id' => $created->id, 'union_id' => 'union-auto', 'dingtalk_user_id' => 'ding-user-1', ]); $this->assertDatabaseHas('dingtalk_binding_requests', [ 'union_id' => 'union-auto', 'status' => 'approved', - 'matched_user_id' => $this->user->id, + 'matched_user_id' => $created->id, ]); $this->assertDatabaseHas('dingtalk_auth_audits', [ - 'event' => 'binding_auto_approved', + 'event' => 'account_auto_created', 'outcome' => 'success', - 'user_id' => $this->user->id, + 'user_id' => $created->id, ]); + $this->get(route('dashboard', ['company_id' => $this->company->id]))->assertOk() + ->assertSeeText('你已登录')->assertSeeText('无需再次注册'); } - public function test_unique_verified_mobile_auto_binds_existing_enabled_account(): void + public function test_shared_mobile_does_not_claim_an_existing_account(): void { Http::preventStrayRequests(); Http::fake($this->dingtalkResponses('union-mobile', 'open-mobile', email: null, mobile: '+86 138-0013-8000')); @@ -141,14 +154,14 @@ class DingTalkLoginTest extends FeatureTestCase 'state' => $state, ]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442'); - $this->assertAuthenticatedAs($this->user); + $this->assertNotSame($this->user->id, auth()->id()); $this->assertDatabaseHas('dingtalk_identities', [ - 'user_id' => $this->user->id, + 'user_id' => auth()->id(), 'union_id' => 'union-mobile', ]); } - public function test_unmatched_enterprise_user_creates_pending_request_without_login_or_raw_contact_data(): void + public function test_finance_staff_receive_api_profile_and_no_manual_registration_step(): void { Http::preventStrayRequests(); Http::fake($this->dingtalkResponses( @@ -162,14 +175,16 @@ class DingTalkLoginTest extends FeatureTestCase $this->get(route('login.dingtalk.callback', [ 'code' => 'authorization-code', 'state' => $state, - ]))->assertRedirect('https://caiwu.jiansuntuzhi.cn:8442/auth/login') - ->assertSessionHas('dingtalk_error', trans('auth.dingtalk.denied')); + ]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442'); - $this->assertGuest(); - $this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-pending']); + $this->assertAuthenticated(); + $this->assertDatabaseHas('dingtalk_identities', ['union_id' => 'union-pending', 'user_id' => auth()->id()]); + $this->assertSame('outsider@example.test', auth()->user()->email); + $this->assertSame('企业员工', auth()->user()->name); + $this->assertDatabaseHas('contacts', ['user_id' => auth()->id(), 'phone' => '13800138000']); $this->assertDatabaseHas('dingtalk_binding_requests', [ 'union_id' => 'union-pending', - 'status' => 'pending', + 'status' => 'approved', 'email_hint' => 'o***@example.test', 'mobile_hint' => '*******8000', ]); @@ -253,6 +268,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 +279,176 @@ 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')); + } + + public function test_non_finance_staff_cannot_create_accounts_even_with_a_matching_email(): void + { + $this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andReturnNull(); + Http::preventStrayRequests(); + Http::fake($this->dingtalkResponses('union-outside', 'open-outside', email: $this->user->email)); + $count = User::count(); + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state])) + ->assertSessionHas('dingtalk_error', trans('auth.dingtalk.outside_roster')); + $this->assertGuest(); + $this->assertSame($count, User::count()); + } + + public function test_roster_outage_does_not_create_or_authorize_an_account(): void + { + $this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andThrow(new \RuntimeException('fixture outage')); + Http::preventStrayRequests(); + Http::fake($this->dingtalkResponses('union-outage', 'open-outage')); + $count = User::count(); + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state])) + ->assertSessionHas('dingtalk_error', trans('auth.dingtalk.roster_unavailable')); + $this->assertGuest(); + $this->assertSame($count, User::count()); + } + + public function test_repeat_authorization_keeps_the_same_account_and_does_not_remember_login(): void + { + Http::preventStrayRequests(); + Http::fake($this->dingtalkResponses('union-repeat', 'open-repeat')); + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state])) + ->assertCookieExpired(auth()->guard()->getRecallerName()); + $id = auth()->id(); + $count = User::count(); + auth()->logout(); + session()->invalidate(); + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', ['code' => 'another-code', 'state' => $state])); + $this->assertSame($id, auth()->id()); + $this->assertSame($count, User::count()); + $this->assertSame(1, DingTalkIdentity::where('union_id', 'union-repeat')->count()); + } + + public function test_roster_removal_invalidates_session_at_recheck(): void + { + $identity = DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-removed', 'dingtalk_user_id' => 'ding-removed']); + $this->mock(FinanceRoster::class)->shouldReceive('findEligible')->with('ding-removed')->andReturnNull(); + $this->actingAs($this->user)->withSession([ + 'dingtalk_identity_id' => $identity->id, + 'dingtalk_roster_checked_at' => now()->subMinutes(6)->timestamp, + ])->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized(); + $this->assertGuest(); + } + + public function test_disabled_account_cannot_keep_using_dingtalk_session(): void + { + $identity = DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-disabled']); + $this->user->update(['enabled' => false]); + $this->actingAs($this->user)->withSession([ + 'dingtalk_identity_id' => $identity->id, + 'dingtalk_roster_checked_at' => now()->timestamp, + ])->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized(); + $this->assertGuest(); + } + + public function test_bound_disabled_account_is_not_recreated(): void + { + Http::preventStrayRequests(); + Http::fake($this->dingtalkResponses('union-disabled-login', 'open-disabled')); + DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-disabled-login']); + $this->user->update(['enabled' => false]); + $count = User::count(); + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state])); + $this->assertGuest(); + $this->assertSame($count, User::count()); + } + + public function test_password_session_for_provisioned_user_cannot_bypass_roster(): void + { + $this->user->update(['created_from' => 'dingtalk_roster']); + DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-password', 'dingtalk_user_id' => 'ding-password']); + $this->mock(FinanceRoster::class)->shouldReceive('findEligible')->with('ding-password')->andReturnNull(); + $this->actingAs($this->user)->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertUnauthorized(); + $this->assertGuest(); + } + + public function test_roster_outage_during_session_recheck_serves_no_business_data(): void + { + $identity = DingTalkIdentity::create(['user_id' => $this->user->id, 'union_id' => 'union-recheck', 'dingtalk_user_id' => 'ding-recheck']); + $this->mock(FinanceRoster::class)->shouldReceive('findEligible')->andThrow(new \RuntimeException('fixture outage')); + $this->actingAs($this->user)->withSession(['dingtalk_identity_id' => $identity->id]) + ->getJson(route('dashboard', ['company_id' => $this->company->id]))->assertStatus(503); + $this->assertAuthenticated(); + $this->assertFalse(session()->has('dingtalk_roster_checked_at')); + } + + public function test_admin_can_assign_business_access_to_the_automatically_created_user(): void + { + Http::preventStrayRequests(); + Http::fake($this->dingtalkResponses('union-grant', 'open-grant')); + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', ['code' => 'fake-code', 'state' => $state])); + $created = auth()->user(); + $this->withExceptionHandling()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, 'section' => 'store-management', 'page' => 'basic-information', + ]))->assertForbidden(); + auth()->logout(); session()->invalidate(); + $this->actingAs($this->user)->get(route('system-settings.sidebar.edit', ['subject' => 'user_'.$created->id])) + ->assertOk()->assertSee('value="user_'.$created->id.'"', false); + $this->patch(route('system-settings.sidebar.update'), [ + 'subject' => 'user_'.$created->id, 'permissions' => ['store-management/basic-information'], + ])->assertRedirect(route('dashboard')); + $this->assertSame(['store-management/basic-information'], SidebarPermissions::allowedKeys($created)); + $this->actingAs($created)->withSession(['dingtalk_identity_id' => $created->dingtalkIdentity->id, 'dingtalk_roster_checked_at' => now()->timestamp]) + ->get(route('dashboard'))->assertRedirectContains('/workspace/store-management/basic-information'); + } + protected function startAuthorization(): string { $response = $this->get(route('login.dingtalk'))->assertRedirect(); diff --git a/tests/Unit/Auth/FinanceRosterTest.php b/tests/Unit/Auth/FinanceRosterTest.php new file mode 100644 index 00000000..99016eb8 --- /dev/null +++ b/tests/Unit/Auth/FinanceRosterTest.php @@ -0,0 +1,65 @@ + ['22848291', '994080505']]); + $data = array_replace([ + 'flowers' => [['Id' => 123, 'name' => '测试员工', 'flowerName' => '测试花名', 'deptId' => '55248182', 'status' => '', 'empStatus' => '正式']], + 'employees' => [['active' => 1, 'dept_id_list' => '[55248182]']], + 'registrations' => [['employment_status' => '3', 'department_ids' => '55248182', 'main_department_id' => '55248182']], + 'resignation' => null, + 'ancestors' => [['Id' => '55248182', 'parent_id' => '22848291', 'depth' => 0], ['Id' => '22848291', 'parent_id' => '1', 'depth' => 1]], + ], $overrides); + $query = Mockery::mock(UpstreamReadOnlyQuery::class); + $query->shouldReceive('select')->andReturnUsing(function ($sql, $bindings) use ($data) { + UpstreamReadOnlyQuery::assertReadOnlySql($sql); + if (str_contains($sql, 'WITH lineage')) { + $this->assertContains('55248182', $bindings); + return $data['ancestors']; + } + $this->assertSame(['ding-fixture-id'], $bindings); + return match (true) { + str_contains($sql, 'hr_flowerName_book') => $data['flowers'], + str_contains($sql, 'hr_employee_register') => $data['registrations'], + str_contains($sql, 'hr_employee') => $data['employees'], + }; + }); + $query->shouldReceive('first')->with(Mockery::on(function ($sql) { + UpstreamReadOnlyQuery::assertReadOnlySql($sql); + return str_contains($sql, 'hr_employee_dimission'); + }), ['ding-fixture-id'])->andReturn($data['resignation']); + $factory = Mockery::mock(UpstreamReadOnlyQueryFactory::class); + $factory->shouldReceive('forSource')->with('operations_replica')->andReturn($query); + $result = (new FinanceRoster($factory))->findEligible('ding-fixture-id'); + $this->assertSame($expected, $result !== null); + } + + public static function rosterCases(): array + { + return [ + 'finance descendant' => [[], true], + 'second finance root' => [['ancestors' => [['Id' => '994080505', 'parent_id' => '994121400', 'depth' => 1]]], true], + 'concurrent finance membership' => [['employees' => [['active' => 1, 'dept_id_list' => '[100,55248182]']]], true], + 'not in roster' => [['flowers' => []], false], + 'same department name outside allowed roots' => [['ancestors' => [['Id' => '123456', 'parent_id' => '1', 'depth' => 0]]], false], + 'resignation beats active status' => [['resignation' => ['userid' => 'ding-fixture-id']], false], + 'numeric resigned status' => [['registrations' => [['employment_status' => '5']]], false], + 'disabled employee' => [['employees' => [['active' => 0]]], false], + 'flower-only member' => [['employees' => [], 'registrations' => []], true], + 'ambiguous roster identity' => [['flowers' => [['Id' => 1], ['Id' => 2]]], false], + 'broken department cycle' => [['ancestors' => [['Id' => '22848291', 'parent_id' => '22848291', 'depth' => 20]]], false], + ]; + } +}