财务花名册准入与钉钉授权自动开户 #114
@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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']];
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -35,6 +35,7 @@ class Kernel extends HttpKernel
|
||||
'cookies.encrypt',
|
||||
'cookies.response',
|
||||
'session.start',
|
||||
\App\Http\Middleware\LogoutIfDingTalkBindingRevoked::class,
|
||||
// 'session.auth',
|
||||
'session.errors',
|
||||
'csrf',
|
||||
|
||||
@ -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;
|
||||
|
||||
63
app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php
Normal file
63
app/Http/Middleware/LogoutIfDingTalkBindingRevoked.php
Normal file
@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
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');
|
||||
$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'));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
108
app/Services/Auth/FinanceRoster.php
Normal file
108
app/Services/Auth/FinanceRoster.php
Normal file
@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
|
||||
/** Single-person, current HR authorization lookup; no HR rows or stale grants are cached. */
|
||||
class FinanceRoster
|
||||
{
|
||||
public function __construct(private readonly UpstreamReadOnlyQueryFactory $queries)
|
||||
{
|
||||
}
|
||||
|
||||
public function findEligible(string $userId): ?array
|
||||
{
|
||||
if (trim($userId) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = $this->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));
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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'),
|
||||
|
||||
63
docs/钉钉花名册登录.md
Normal file
63
docs/钉钉花名册登录.md
Normal file
@ -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 功能测试验证。
|
||||
- 尚未发布,尚未进行真实钉钉扫码开户验收。
|
||||
@ -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' => [
|
||||
|
||||
@ -54,6 +54,12 @@ return [
|
||||
'unavailable' => '钉钉登录尚未启用,请暂时使用邮箱密码登录。',
|
||||
'failed' => '钉钉登录未完成,请重新发起授权。',
|
||||
'denied' => '当前钉钉账号未获财务系统授权,请联系系统管理员。',
|
||||
'pending' => '钉钉身份已确认,账号绑定申请正在等待管理员审批。审批通过后请重新登录。',
|
||||
'rejected' => '钉钉账号绑定申请已被拒绝,如需开通请联系系统管理员。',
|
||||
'revoked' => '钉钉账号绑定已解除,如需恢复请联系系统管理员。',
|
||||
'session_expired' => '钉钉登录已失效,请重新登录;如账号已停用或解除绑定,请联系系统管理员。',
|
||||
'outside_roster' => '当前仅向花名册中财务中心及其下级部门的在职人员开放。如人员资料有误,请联系管理员核对花名册。',
|
||||
'roster_unavailable' => '暂时无法核验财务花名册,请稍后重新尝试钉钉登录。',
|
||||
],
|
||||
|
||||
'notification' => [
|
||||
|
||||
10
resources/views/business/access-pending.blade.php
Normal file
10
resources/views/business/access-pending.blade.php
Normal file
@ -0,0 +1,10 @@
|
||||
<x-layouts.admin>
|
||||
<x-slot name="title">工作台</x-slot>
|
||||
<x-slot name="content">
|
||||
<section class="my-6 rounded-xl border border-[#D8D2C7] bg-white p-6" aria-labelledby="access-title">
|
||||
<h1 id="access-title" class="text-sm font-semibold text-[#171717]">{{ user()->name }},你已登录</h1>
|
||||
<p class="mt-3 text-sm text-[#595959]">当前账号尚未分配业务权限,请联系管理员开通需要使用的功能。</p>
|
||||
<p class="mt-2 text-sm text-[#595959]">权限分配后,重新打开工作台即可使用,无需再次注册。</p>
|
||||
</section>
|
||||
</x-slot>
|
||||
</x-layouts.admin>
|
||||
@ -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())
|
||||
@ -20,6 +20,8 @@
|
||||
$eventLabels = [
|
||||
'binding_pending' => '提交绑定申请',
|
||||
'binding_auto_approved' => '自动绑定',
|
||||
'account_auto_created' => '钉钉自动开户',
|
||||
'session_revoked' => '登录会话失效',
|
||||
'binding_admin_approved' => '管理员绑定',
|
||||
'binding_rejected' => '拒绝绑定',
|
||||
'binding_revoked' => '解除绑定',
|
||||
|
||||
@ -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();
|
||||
|
||||
65
tests/Unit/Auth/FinanceRosterTest.php
Normal file
65
tests/Unit/Auth/FinanceRosterTest.php
Normal file
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Auth;
|
||||
|
||||
use App\Services\Auth\FinanceRoster;
|
||||
use Mockery;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQuery;
|
||||
use Modules\EcommerceFinance\Services\UpstreamReadOnlyQueryFactory;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use Tests\TestCase;
|
||||
|
||||
class FinanceRosterTest extends TestCase
|
||||
{
|
||||
#[DataProvider('rosterCases')]
|
||||
public function test_roster_authorization(array $overrides, bool $expected): void
|
||||
{
|
||||
config(['dingtalk.finance_department_ids' => ['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],
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user