diff --git a/app/Http/Controllers/Auth/DingTalkLogin.php b/app/Http/Controllers/Auth/DingTalkLogin.php index 1070e84a..63c9bffa 100644 --- a/app/Http/Controllers/Auth/DingTalkLogin.php +++ b/app/Http/Controllers/Auth/DingTalkLogin.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth; use App\Abstracts\Http\Controller; use App\Models\Auth\DingTalkIdentity; use App\Models\Auth\DingTalkOAuthState; +use App\Services\Auth\DingTalkClosedAccess; use App\Services\Auth\DingTalkOAuthClient; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -64,37 +65,35 @@ class DingTalkLogin extends Controller return $this->failed(); } + $oauthProfile = null; try { $token = $client->exchangeAuthorizationCode($authorizationCode); - $profile = $client->currentUser($token['access_token']); + $oauthProfile = $client->currentUser($token['access_token']); + $profile = $client->enterpriseUser($oauthProfile); } catch (Throwable $exception) { - Log::warning('DingTalk login failed while resolving the authorized user.', [ + Log::warning('DingTalk login failed while resolving the enterprise user.', [ 'exception' => $exception::class, ]); + app(DingTalkClosedAccess::class)->audit( + 'login_denied', + 'denied', + null, + null, + $oauthProfile['union_id'] ?? null, + $request, + ['reason' => 'enterprise_identity_unavailable'] + ); - return $this->failed(); + return $this->denied(); } - $identity = DingTalkIdentity::query() - ->with('user') - ->where('union_id', $profile['union_id']) - ->first(); + $identity = app(DingTalkClosedAccess::class)->resolve($profile, $request); if ($identity) { return $this->loginBoundUser($request, $identity, $profile); } - $bindingToken = Str::random(64); - $state->forceFill([ - 'binding_token_hash' => hash('sha256', $bindingToken), - 'union_id' => $profile['union_id'], - 'open_id' => $profile['open_id'], - 'nickname' => $profile['nickname'], - 'expires_at' => now()->addMinutes(max(1, (int) config('dingtalk.binding_ttl_minutes', 10))), - ])->save(); - - return redirect()->to($this->applicationRoute('login', ['dingtalk_binding' => $bindingToken])) - ->with('dingtalk_binding', trans('auth.dingtalk.confirm_binding')); + return $this->denied(); } protected function loginBoundUser(Request $request, DingTalkIdentity $identity, array $profile): RedirectResponse @@ -115,9 +114,11 @@ class DingTalkLogin extends Controller $identity->forceFill([ 'open_id' => $profile['open_id'] ?: $identity->open_id, + 'dingtalk_user_id' => $profile['dingtalk_user_id'] ?: $identity->dingtalk_user_id, 'nickname' => $profile['nickname'] ?: $identity->nickname, 'last_logged_in_at' => now(), ])->save(); + app(DingTalkClosedAccess::class)->recordLogin($identity, $request); $url = $this->applicationRoute($user->landing_page, ['company_id' => $company->id]); @@ -130,6 +131,12 @@ class DingTalkLogin extends Controller ->with('dingtalk_error', trans('auth.dingtalk.failed')); } + protected function denied(): RedirectResponse + { + return redirect()->to($this->applicationRoute('login')) + ->with('dingtalk_error', trans('auth.dingtalk.denied')); + } + protected function applicationRoute(string $name, array $parameters = []): string { $path = route($name, $parameters, false); diff --git a/app/Http/Controllers/Auth/Login.php b/app/Http/Controllers/Auth/Login.php index 8392e78b..07453c7e 100644 --- a/app/Http/Controllers/Auth/Login.php +++ b/app/Http/Controllers/Auth/Login.php @@ -4,7 +4,6 @@ namespace App\Http\Controllers\Auth; use App\Abstracts\Http\Controller; use App\Http\Requests\Auth\Login as Request; -use App\Services\Auth\DingTalkIdentityBinder; use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -76,11 +75,6 @@ class Login extends Controller return $this->respondLoginFailed(); } - $dingtalkBound = app(DingTalkIdentityBinder::class)->bindFromToken( - $request->input('dingtalk_binding'), - $user - ); - // Redirect to portal if is customer if ($user->isCustomer()) { $path = session('url.intended', ''); @@ -94,7 +88,7 @@ class Login extends Controller 'status' => null, 'success' => true, 'error' => false, - 'message' => $dingtalkBound ? trans('auth.dingtalk.bound') : trans('auth.login_redirect'), + 'message' => trans('auth.login_redirect'), 'data' => null, 'redirect' => url($path), ]); @@ -107,7 +101,7 @@ class Login extends Controller 'status' => null, 'success' => true, 'error' => false, - 'message' => $dingtalkBound ? trans('auth.dingtalk.bound') : trans('auth.login_redirect'), + 'message' => trans('auth.login_redirect'), 'data' => null, 'redirect' => redirect()->intended($url)->getTargetUrl(), ]); diff --git a/app/Http/Controllers/SystemSettings/DingTalkBindings.php b/app/Http/Controllers/SystemSettings/DingTalkBindings.php new file mode 100644 index 00000000..18a93710 --- /dev/null +++ b/app/Http/Controllers/SystemSettings/DingTalkBindings.php @@ -0,0 +1,150 @@ +middleware(function ($request, $next) { + abort_unless(SystemAccess::canOpenSettings(), 403); + + return $next($request); + }); + } + + public function index() + { + $eligibleUsers = $this->eligibleUsers()->with('roles')->orderBy('name')->get(); + $eligibleUserIds = $eligibleUsers->pluck('id'); + $bindingRequests = DingTalkBindingRequest::query()->with(['matchedUser', 'reviewer']); + $identities = DingTalkIdentity::query()->with('user'); + $audits = DingTalkAuthAudit::query()->with(['user', 'actor']); + if (! SystemAccess::isSuperAdmin()) { + $bindingRequests->whereIn('matched_user_id', $eligibleUserIds); + $identities->whereIn('user_id', $eligibleUserIds); + $audits->whereIn('user_id', $eligibleUserIds); + } + + return view('system_settings.dingtalk_bindings.index', [ + 'bindingRequests' => $bindingRequests->latest('last_attempted_at')->get(), + 'identities' => $identities->latest('bound_at')->get(), + 'eligibleUsers' => $eligibleUsers, + 'audits' => $audits->latest('created_at')->limit(50)->get(), + ]); + } + + public function approve(DingTalkBindingRequest $bindingRequest, Request $request, DingTalkClosedAccess $access) + { + $data = $request->validate(['user_id' => ['required', 'integer']]); + $managedUser = $this->eligibleUsers()->findOrFail((int) $data['user_id']); + abort_unless(SystemAccess::canManageUser(user(), $managedUser) || $managedUser->id === user()->id, 403); + abort_unless( + SystemAccess::isSuperAdmin() + || ((int) $bindingRequest->matched_user_id === (int) $managedUser->id), + 403 + ); + + if (DingTalkIdentity::query()->where('union_id', $bindingRequest->union_id)->exists()) { + throw ValidationException::withMessages(['binding' => '该钉钉账号已经完成绑定。']); + } + if (DingTalkIdentity::query()->where('user_id', $managedUser->id)->exists()) { + throw ValidationException::withMessages(['user_id' => '该财务账号已经绑定其他钉钉账号。']); + } + + DB::transaction(function () use ($bindingRequest, $managedUser, $request, $access): void { + DingTalkIdentity::query()->create([ + 'user_id' => $managedUser->id, + 'union_id' => $bindingRequest->union_id, + 'open_id' => $bindingRequest->open_id, + 'dingtalk_user_id' => $bindingRequest->dingtalk_user_id, + 'nickname' => $bindingRequest->nickname, + 'bound_by' => user()->id, + 'bound_at' => now(), + ]); + + $bindingRequest->forceFill([ + 'status' => DingTalkBindingRequest::APPROVED, + 'matched_user_id' => $managedUser->id, + 'match_reason' => 'administrator_approved', + 'reviewed_by' => user()->id, + 'reviewed_at' => now(), + ])->save(); + + $access->audit('binding_admin_approved', 'success', $managedUser->id, user()->id, $bindingRequest->union_id, $request); + }); + + flash('钉钉账号已绑定。')->success(); + + return redirect()->route('system-settings.dingtalk-bindings.index'); + } + + public function reject(DingTalkBindingRequest $bindingRequest, Request $request, DingTalkClosedAccess $access) + { + abort_unless( + SystemAccess::isSuperAdmin() + || ($bindingRequest->matchedUser && SystemAccess::canManageUser(user(), $bindingRequest->matchedUser)), + 403 + ); + abort_if(DingTalkIdentity::query()->where('union_id', $bindingRequest->union_id)->exists(), 422, '已绑定身份不能直接拒绝。'); + + $bindingRequest->forceFill([ + 'status' => DingTalkBindingRequest::REJECTED, + 'matched_user_id' => null, + 'match_reason' => 'administrator_rejected', + 'reviewed_by' => user()->id, + 'reviewed_at' => now(), + ])->save(); + $access->audit('binding_rejected', 'denied', null, user()->id, $bindingRequest->union_id, $request); + + flash('钉钉绑定申请已拒绝。')->success(); + + return redirect()->route('system-settings.dingtalk-bindings.index'); + } + + public function destroy(DingTalkIdentity $identity, Request $request, DingTalkClosedAccess $access) + { + $managedUser = $identity->user()->firstOrFail(); + abort_unless(SystemAccess::canManageUser(user(), $managedUser) || $managedUser->id === user()->id, 403); + + DB::transaction(function () use ($identity, $managedUser, $request, $access): void { + $unionId = $identity->union_id; + $identity->delete(); + DingTalkBindingRequest::query()->where('union_id', $unionId)->update([ + 'status' => DingTalkBindingRequest::REVOKED, + 'match_reason' => 'administrator_revoked', + 'reviewed_by' => user()->id, + 'reviewed_at' => now(), + ]); + $access->audit('binding_revoked', 'success', $managedUser->id, user()->id, $unionId, $request); + }); + + flash('钉钉账号绑定已解除。')->success(); + + return redirect()->route('system-settings.dingtalk-bindings.index'); + } + + private function eligibleUsers() + { + $query = User::query() + ->where('enabled', true) + ->whereHas('companies', fn ($company) => $company->where('companies.id', company_id())->where('companies.enabled', true)); + + if (! SystemAccess::isSuperAdmin()) { + $query->whereHas('roles', fn ($roles) => $roles->where('roles.name', SystemAccess::USER)); + } + + return $query; + } +} diff --git a/app/Http/Requests/Auth/Login.php b/app/Http/Requests/Auth/Login.php index 6a7ddd33..27b87f7b 100644 --- a/app/Http/Requests/Auth/Login.php +++ b/app/Http/Requests/Auth/Login.php @@ -26,7 +26,6 @@ class Login extends FormRequest return [ 'email' => 'required|email', 'password' => 'required', - 'dingtalk_binding' => 'nullable|string|max:255', ]; } } diff --git a/app/Models/Auth/DingTalkAuthAudit.php b/app/Models/Auth/DingTalkAuthAudit.php new file mode 100644 index 00000000..b675069a --- /dev/null +++ b/app/Models/Auth/DingTalkAuthAudit.php @@ -0,0 +1,39 @@ + 'array', + 'created_at' => 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + public function actor(): BelongsTo + { + return $this->belongsTo(User::class, 'actor_user_id'); + } +} diff --git a/app/Models/Auth/DingTalkBindingRequest.php b/app/Models/Auth/DingTalkBindingRequest.php new file mode 100644 index 00000000..346fa529 --- /dev/null +++ b/app/Models/Auth/DingTalkBindingRequest.php @@ -0,0 +1,48 @@ + 'datetime', + 'reviewed_at' => 'datetime', + ]; + + public function matchedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'matched_user_id'); + } + + public function reviewer(): BelongsTo + { + return $this->belongsTo(User::class, 'reviewed_by'); + } +} diff --git a/app/Models/Auth/DingTalkIdentity.php b/app/Models/Auth/DingTalkIdentity.php index 3459a534..ee230ab9 100644 --- a/app/Models/Auth/DingTalkIdentity.php +++ b/app/Models/Auth/DingTalkIdentity.php @@ -13,11 +13,15 @@ class DingTalkIdentity extends Model 'user_id', 'union_id', 'open_id', + 'dingtalk_user_id', 'nickname', + 'bound_by', + 'bound_at', 'last_logged_in_at', ]; protected $casts = [ + 'bound_at' => 'datetime', 'last_logged_in_at' => 'datetime', ]; diff --git a/app/Services/Auth/DingTalkClosedAccess.php b/app/Services/Auth/DingTalkClosedAccess.php new file mode 100644 index 00000000..3499af25 --- /dev/null +++ b/app/Services/Auth/DingTalkClosedAccess.php @@ -0,0 +1,203 @@ +audit('login_denied', 'denied', null, null, $profile['union_id'] ?? null, $request, [ + 'reason' => 'not_active_enterprise_user', + ]); + + return null; + } + + $identity = DingTalkIdentity::query() + ->with('user') + ->where('union_id', $profile['union_id']) + ->first(); + + if ($identity) { + if (! $this->isEligible($identity->user)) { + $this->audit('login_denied', 'denied', $identity->user_id, null, $profile['union_id'], $request, [ + 'reason' => 'local_account_ineligible', + ]); + + return null; + } + + $identity->forceFill([ + 'open_id' => $profile['open_id'] ?: $identity->open_id, + 'dingtalk_user_id' => $profile['dingtalk_user_id'], + 'nickname' => $profile['nickname'] ?: $identity->nickname, + ])->save(); + + return $identity; + } + + $existingRequest = DingTalkBindingRequest::query()->where('union_id', $profile['union_id'])->first(); + if ($existingRequest && in_array($existingRequest->status, [DingTalkBindingRequest::REJECTED, DingTalkBindingRequest::REVOKED], true)) { + $existingRequest->forceFill(['last_attempted_at' => now()])->save(); + $this->audit('login_denied', 'denied', $existingRequest->matched_user_id, null, $profile['union_id'], $request, [ + 'reason' => 'binding_previously_denied', + ]); + + 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(), + ]); + + $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; + } + + public function recordLogin(DingTalkIdentity $identity, Request $request): void + { + $this->audit('login_success', 'success', $identity->user_id, null, $identity->union_id, $request); + } + + public function audit( + string $event, + string $outcome, + ?int $userId, + ?int $actorUserId, + ?string $unionId, + Request $request, + array $metadata = [] + ): void { + DingTalkAuthAudit::query()->create([ + 'event' => $event, + 'outcome' => $outcome, + 'user_id' => $userId, + 'actor_user_id' => $actorUserId, + 'union_id_hash' => $unionId ? hash('sha256', $unionId) : null, + 'ip_address' => $request->ip(), + 'metadata' => $metadata ?: null, + 'created_at' => now(), + ]); + } + + 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 + && $user->enabled + && $user->withoutEvents(fn () => $user->companies()->enabled()->exists()); + } + + private function upsertRequest(array $profile, string $status, string $reason, ?int $matchedUserId): void + { + $email = $this->normalizeEmail($profile['email'] ?? null); + $mobile = $this->normalizeMobile($profile['mobile'] ?? null); + $bindingRequest = DingTalkBindingRequest::query()->firstOrNew(['union_id' => $profile['union_id']]); + $terminalStatus = in_array($bindingRequest->status, [DingTalkBindingRequest::REJECTED, DingTalkBindingRequest::REVOKED], true); + + $bindingRequest->fill([ + 'open_id' => $profile['open_id'] ?? null, + 'dingtalk_user_id' => $profile['dingtalk_user_id'] ?? null, + 'nickname' => $profile['nickname'] ?? null, + 'email_hash' => $email ? hash('sha256', $email) : null, + 'mobile_hash' => $mobile ? hash('sha256', $mobile) : null, + 'email_hint' => $this->maskEmail($email), + 'mobile_hint' => $this->maskMobile($mobile), + 'status' => $terminalStatus && $status === DingTalkBindingRequest::PENDING ? $bindingRequest->status : $status, + 'match_reason' => $reason, + 'matched_user_id' => $matchedUserId, + 'last_attempted_at' => now(), + ])->save(); + } + + private function normalizeEmail(mixed $value): ?string + { + $email = strtolower(trim((string) $value)); + + return filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : null; + } + + private function normalizeMobile(mixed $value): ?string + { + $mobile = preg_replace('/\D+/', '', (string) $value); + if (str_starts_with($mobile, '86') && strlen($mobile) === 13) { + $mobile = substr($mobile, 2); + } + + return strlen($mobile) >= 7 ? $mobile : null; + } + + private function maskEmail(?string $email): ?string + { + if (! $email) { + return null; + } + + [$local, $domain] = explode('@', $email, 2); + + return substr($local, 0, 1).'***@'.$domain; + } + + private function maskMobile(?string $mobile): ?string + { + return $mobile ? str_repeat('*', max(0, strlen($mobile) - 4)).substr($mobile, -4) : null; + } +} diff --git a/app/Services/Auth/DingTalkIdentityBinder.php b/app/Services/Auth/DingTalkIdentityBinder.php deleted file mode 100644 index 0f155cb0..00000000 --- a/app/Services/Auth/DingTalkIdentityBinder.php +++ /dev/null @@ -1,52 +0,0 @@ -where('binding_token_hash', hash('sha256', $token)) - ->lockForUpdate() - ->first(); - - if (! $state || $state->expires_at->isPast() || ! $state->union_id) { - return false; - } - - $unionOwner = DingTalkIdentity::query()->where('union_id', $state->union_id)->first(); - if ($unionOwner && (int) $unionOwner->user_id !== (int) $user->id) { - return false; - } - - $userIdentity = DingTalkIdentity::query()->where('user_id', $user->id)->first(); - if ($userIdentity && $userIdentity->union_id !== $state->union_id) { - return false; - } - - DingTalkIdentity::query()->updateOrCreate( - ['user_id' => $user->id], - [ - 'union_id' => $state->union_id, - 'open_id' => $state->open_id, - 'nickname' => $state->nickname, - ] - ); - - $state->forceFill(['binding_token_hash' => null])->save(); - - return true; - }); - } -} diff --git a/app/Services/Auth/DingTalkOAuthClient.php b/app/Services/Auth/DingTalkOAuthClient.php index 16c832de..ede03c58 100644 --- a/app/Services/Auth/DingTalkOAuthClient.php +++ b/app/Services/Auth/DingTalkOAuthClient.php @@ -63,9 +63,7 @@ class DingTalkOAuthClient ]; } - /** - * @return array{union_id: string, open_id: string|null, nickname: string|null} - */ + /** @return array{union_id: string, open_id: string|null, nickname: string|null} */ public function currentUser(string $accessToken): array { try { @@ -90,6 +88,102 @@ class DingTalkOAuthClient ]; } + /** + * Resolve a user through the enterprise application. A personal DingTalk + * account is not considered trusted unless the enterprise application can + * resolve it to an active internal user. + * + * @return array{ + * union_id: string, + * open_id: string|null, + * dingtalk_user_id: string, + * nickname: string|null, + * email: string|null, + * mobile: string|null, + * active: bool + * } + */ + public function enterpriseUser(array $oauthProfile): array + { + $unionId = trim((string) ($oauthProfile['union_id'] ?? '')); + if ($unionId === '') { + throw new RuntimeException('DingTalk enterprise lookup requires a union id.'); + } + + $appToken = $this->appAccessToken(); + + try { + $userIdResponse = Http::asJson() + ->acceptJson() + ->timeout($this->timeout()) + ->withQueryParameters(['access_token' => $appToken]) + ->post($this->configuredString('dingtalk.user_by_union_id_url'), ['unionid' => $unionId]) + ->throw(); + } catch (RequestException $exception) { + throw new RuntimeException('DingTalk enterprise user lookup failed.', 0, $exception); + } + + $userId = trim((string) $userIdResponse->json('result.userid')); + if ((int) $userIdResponse->json('errcode', -1) !== 0 || $userId === '') { + throw new RuntimeException('DingTalk identity is not an enterprise user.'); + } + + try { + $detailResponse = Http::asJson() + ->acceptJson() + ->timeout($this->timeout()) + ->withQueryParameters(['access_token' => $appToken]) + ->post($this->configuredString('dingtalk.user_detail_url'), ['userid' => $userId]) + ->throw(); + } catch (RequestException $exception) { + throw new RuntimeException('DingTalk enterprise user detail lookup failed.', 0, $exception); + } + + $detail = $detailResponse->json('result'); + if ((int) $detailResponse->json('errcode', -1) !== 0 || ! is_array($detail)) { + throw new RuntimeException('DingTalk enterprise user detail was unavailable.'); + } + + $detailUserId = trim((string) ($detail['userid'] ?? '')); + if ($detailUserId !== '' && ! hash_equals($userId, $detailUserId)) { + throw new RuntimeException('DingTalk enterprise user identity was inconsistent.'); + } + + $active = filter_var($detail['active'] ?? null, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE); + + return [ + 'union_id' => $unionId, + 'open_id' => $oauthProfile['open_id'] ?? null, + 'dingtalk_user_id' => $userId, + 'nickname' => $this->nullableString($detail['name'] ?? ($oauthProfile['nickname'] ?? null)), + 'email' => $this->nullableString($detail['email'] ?? null), + 'mobile' => $this->nullableString($detail['mobile'] ?? null), + 'active' => $active === true, + ]; + } + + protected function appAccessToken(): string + { + try { + $response = Http::acceptJson() + ->timeout($this->timeout()) + ->get($this->configuredString('dingtalk.app_token_url'), [ + 'appkey' => $this->configuredString('dingtalk.client_id'), + 'appsecret' => $this->configuredString('dingtalk.client_secret'), + ]) + ->throw(); + } catch (RequestException $exception) { + throw new RuntimeException('DingTalk application token lookup failed.', 0, $exception); + } + + $token = trim((string) $response->json('access_token')); + if ((int) $response->json('errcode', -1) !== 0 || $token === '') { + throw new RuntimeException('DingTalk application token was unavailable.'); + } + + return $token; + } + protected function ensureConfigured(): void { if (! $this->isConfigured()) { diff --git a/config/dingtalk.php b/config/dingtalk.php index 1b798326..056b0352 100644 --- a/config/dingtalk.php +++ b/config/dingtalk.php @@ -13,8 +13,10 @@ return [ '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'), + 'app_token_url' => env('DINGTALK_APP_TOKEN_URL', 'https://oapi.dingtalk.com/gettoken'), + 'user_by_union_id_url' => env('DINGTALK_USER_BY_UNION_ID_URL', 'https://oapi.dingtalk.com/topapi/user/getbyunionid'), + 'user_detail_url' => env('DINGTALK_USER_DETAIL_URL', 'https://oapi.dingtalk.com/topapi/v2/user/get'), 'timeout' => (int) env('DINGTALK_HTTP_TIMEOUT', 10), 'state_ttl_minutes' => (int) env('DINGTALK_STATE_TTL_MINUTES', 5), - 'binding_ttl_minutes' => (int) env('DINGTALK_BINDING_TTL_MINUTES', 10), ]; diff --git a/database/migrations/2026_09_03_190000_create_dingtalk_closed_access_tables.php b/database/migrations/2026_09_03_190000_create_dingtalk_closed_access_tables.php new file mode 100644 index 00000000..924a8ae0 --- /dev/null +++ b/database/migrations/2026_09_03_190000_create_dingtalk_closed_access_tables.php @@ -0,0 +1,85 @@ +string('dingtalk_user_id', 128)->nullable()->unique()->after('open_id'); + } + if (! Schema::hasColumn('dingtalk_identities', 'bound_by')) { + $table->unsignedInteger('bound_by')->nullable()->after('nickname'); + $table->foreign('bound_by')->references('id')->on('users')->nullOnDelete(); + } + if (! Schema::hasColumn('dingtalk_identities', 'bound_at')) { + $table->timestamp('bound_at')->nullable()->after('bound_by'); + } + }); + + if (! Schema::hasTable('dingtalk_binding_requests')) { + Schema::create('dingtalk_binding_requests', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('union_id', 128)->unique(); + $table->string('open_id', 128)->nullable()->index(); + $table->string('dingtalk_user_id', 128)->nullable()->index(); + $table->string('nickname', 191)->nullable(); + $table->char('email_hash', 64)->nullable()->index(); + $table->char('mobile_hash', 64)->nullable()->index(); + $table->string('email_hint', 191)->nullable(); + $table->string('mobile_hint', 32)->nullable(); + $table->string('status', 24)->default('pending')->index(); + $table->string('match_reason', 64)->nullable(); + $table->unsignedInteger('matched_user_id')->nullable(); + $table->unsignedInteger('reviewed_by')->nullable(); + $table->timestamp('last_attempted_at')->nullable(); + $table->timestamp('reviewed_at')->nullable(); + $table->timestamps(); + + $table->foreign('matched_user_id')->references('id')->on('users')->nullOnDelete(); + $table->foreign('reviewed_by')->references('id')->on('users')->nullOnDelete(); + }); + } + + if (! Schema::hasTable('dingtalk_auth_audits')) { + Schema::create('dingtalk_auth_audits', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('event', 64)->index(); + $table->string('outcome', 24)->index(); + $table->unsignedInteger('user_id')->nullable(); + $table->unsignedInteger('actor_user_id')->nullable(); + $table->char('union_id_hash', 64)->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->json('metadata')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->foreign('user_id')->references('id')->on('users')->nullOnDelete(); + $table->foreign('actor_user_id')->references('id')->on('users')->nullOnDelete(); + }); + } + } + + public function down(): void + { + Schema::dropIfExists('dingtalk_auth_audits'); + Schema::dropIfExists('dingtalk_binding_requests'); + + Schema::table('dingtalk_identities', function (Blueprint $table): void { + if (Schema::hasColumn('dingtalk_identities', 'bound_by')) { + $table->dropForeign(['bound_by']); + $table->dropColumn('bound_by'); + } + if (Schema::hasColumn('dingtalk_identities', 'bound_at')) { + $table->dropColumn('bound_at'); + } + if (Schema::hasColumn('dingtalk_identities', 'dingtalk_user_id')) { + $table->dropUnique(['dingtalk_user_id']); + $table->dropColumn('dingtalk_user_id'); + } + }); + } +}; diff --git a/resources/lang/en-GB/auth.php b/resources/lang/en-GB/auth.php index f07cf20c..070cf517 100644 --- a/resources/lang/en-GB/auth.php +++ b/resources/lang/en-GB/auth.php @@ -53,8 +53,7 @@ return [ 'or' => 'or', 'unavailable' => 'DingTalk sign-in is not enabled. Please use email and password.', 'failed' => 'DingTalk sign-in did not complete. Please start the authorization again.', - 'confirm_binding' => 'For the first DingTalk sign-in, enter your existing account and password to confirm the binding.', - 'bound' => 'DingTalk account linked. Redirecting…', + 'denied' => 'This DingTalk account is not authorized for the finance system. Contact the system administrator.', ], 'notification' => [ diff --git a/resources/lang/zh-CN/auth.php b/resources/lang/zh-CN/auth.php index 3d8bb186..ce3cafe3 100644 --- a/resources/lang/zh-CN/auth.php +++ b/resources/lang/zh-CN/auth.php @@ -53,8 +53,7 @@ return [ 'or' => '或', 'unavailable' => '钉钉登录尚未启用,请暂时使用邮箱密码登录。', 'failed' => '钉钉登录未完成,请重新发起授权。', - 'confirm_binding' => '首次使用钉钉登录,请输入现有财务系统账号和密码完成绑定。', - 'bound' => '钉钉账号绑定成功,正在为您跳转……', + 'denied' => '当前钉钉账号未获财务系统授权,请联系系统管理员。', ], 'notification' => [ diff --git a/resources/views/auth/login/create.blade.php b/resources/views/auth/login/create.blade.php index ec189814..1855c21d 100644 --- a/resources/views/auth/login/create.blade.php +++ b/resources/views/auth/login/create.blade.php @@ -30,17 +30,7 @@ @endif - @if (session('dingtalk_binding') || request('dingtalk_binding')) -
- {{ session('dingtalk_binding', trans('auth.dingtalk.confirm_binding')) }} -
- @endif - - @if (request('dingtalk_binding')) - - @endif -
+ 钉钉账号绑定 + + +
+ @include('system_settings.partials.navigation') + +
+ 钉钉登录不会自动注册账号。只有企业内在职人员与现有启用财务账号唯一匹配时才会自动绑定;其他申请需管理员确认。 +
+ + @if ($errors->any()) +
+ @foreach ($errors->all() as $error)

{{ $error }}

@endforeach +
+ @endif + + @php + $statusLabels = ['pending' => '待处理', 'approved' => '已绑定', 'rejected' => '已拒绝', 'revoked' => '已解绑']; + $eventLabels = [ + 'binding_pending' => '提交绑定申请', + 'binding_auto_approved' => '自动绑定', + 'binding_admin_approved' => '管理员绑定', + 'binding_rejected' => '拒绝绑定', + 'binding_revoked' => '解除绑定', + 'login_success' => '钉钉登录成功', + 'login_denied' => '钉钉登录拒绝', + ]; + @endphp + +
+

待处理与历史申请

+
+ + + + + + + + + + + + @forelse ($bindingRequests as $bindingRequest) + + + + + + + + @empty + + @endforelse + +
钉钉用户企业资料状态最近尝试处理
{{ $bindingRequest->nickname ?: '未提供姓名' }} +
{{ $bindingRequest->email_hint ?: '无企业邮箱' }}
+
{{ $bindingRequest->mobile_hint ?: '无手机号' }}
+
+ $bindingRequest->status === 'pending', + 'bg-green-50 text-[#53793E]' => $bindingRequest->status === 'approved', + 'bg-red-50 text-[#B80000]' => in_array($bindingRequest->status, ['rejected', 'revoked'], true), + ])>{{ $statusLabels[$bindingRequest->status] ?? $bindingRequest->status }} + {{ optional($bindingRequest->last_attempted_at)->format('Y-m-d H:i:s') ?: '-' }} + @if ($bindingRequest->status !== 'approved') +
+ @csrf + + +
+
+ @csrf + +
+ @else + {{ $bindingRequest->matchedUser?->email ?: '已处理' }} + @endif +
暂无钉钉绑定申请。
+
+
+ +
+

已绑定账号

+
+ + + + + + @forelse ($identities as $identity) + + + + + + + @empty + + @endforelse + +
财务账号钉钉用户绑定时间操作
{{ $identity->user?->name }}
{{ $identity->user?->email }}
{{ $identity->nickname ?: '企业员工' }}{{ optional($identity->bound_at)->format('Y-m-d H:i:s') ?: '-' }} +
+ @csrf + @method('DELETE') + +
+
暂无已绑定账号。
+
+
+ +
+

最近审计记录

+
+ + + + + + @forelse ($audits as $audit) + + + + + + + + @empty + + @endforelse + +
时间事件财务账号操作人结果
{{ optional($audit->created_at)->format('Y-m-d H:i:s') }}{{ $eventLabels[$audit->event] ?? $audit->event }}{{ $audit->user?->email ?: '-' }}{{ $audit->actor?->email ?: '系统' }}{{ $audit->outcome }}
暂无审计记录。
+
+
+
+
+ diff --git a/resources/views/system_settings/partials/navigation.blade.php b/resources/views/system_settings/partials/navigation.blade.php index 9791faf9..b43c8795 100644 --- a/resources/views/system_settings/partials/navigation.blade.php +++ b/resources/views/system_settings/partials/navigation.blade.php @@ -2,6 +2,7 @@ @php $items = [ ['system-settings.users.index', '用户管理', 'people'], + ['system-settings.dingtalk-bindings.index', '钉钉绑定', 'admin_panel_settings'], ['system-settings.sidebar.edit', '侧边栏管理', 'menu'], ['system-settings.databases.index', '数据库管理', 'storage'], ]; diff --git a/routes/admin.php b/routes/admin.php index b2e2c2d7..41583ed3 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -171,6 +171,11 @@ Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], functi Route::patch('users/{user}', 'SystemSettings\Users@update')->name('users.update'); Route::delete('users/{user}', 'SystemSettings\Users@destroy')->name('users.destroy'); + Route::get('dingtalk-bindings', 'SystemSettings\DingTalkBindings@index')->name('dingtalk-bindings.index'); + Route::post('dingtalk-bindings/{bindingRequest}/approve', 'SystemSettings\DingTalkBindings@approve')->name('dingtalk-bindings.approve'); + Route::post('dingtalk-bindings/{bindingRequest}/reject', 'SystemSettings\DingTalkBindings@reject')->name('dingtalk-bindings.reject'); + Route::delete('dingtalk-bindings/identities/{identity}', 'SystemSettings\DingTalkBindings@destroy')->name('dingtalk-bindings.destroy'); + Route::get('sidebar', 'SystemSettings\Sidebar@edit')->name('sidebar.edit'); Route::patch('sidebar', 'SystemSettings\Sidebar@update')->name('sidebar.update'); diff --git a/routes/guest.php b/routes/guest.php index fa22916f..064d3a06 100644 --- a/routes/guest.php +++ b/routes/guest.php @@ -12,8 +12,8 @@ use Illuminate\Support\Facades\Route; Route::group(['prefix' => 'auth'], function () { Route::get('login', 'Auth\Login@create')->name('login'); Route::post('login', 'Auth\Login@store')->name('login.store'); - Route::get('dingtalk', 'Auth\DingTalkLogin@redirect')->name('login.dingtalk'); - Route::get('dingtalk/callback', 'Auth\DingTalkLogin@callback')->name('login.dingtalk.callback'); + Route::get('dingtalk', 'Auth\DingTalkLogin@redirect')->middleware('throttle:10,1')->name('login.dingtalk'); + Route::get('dingtalk/callback', 'Auth\DingTalkLogin@callback')->middleware('throttle:20,1')->name('login.dingtalk.callback'); Route::get('forgot', 'Auth\Forgot@create')->name('forgot'); Route::post('forgot', 'Auth\Forgot@store')->name('forgot.store'); diff --git a/tests/Feature/Auth/DingTalkLoginTest.php b/tests/Feature/Auth/DingTalkLoginTest.php index 321b0dad..336352ee 100644 --- a/tests/Feature/Auth/DingTalkLoginTest.php +++ b/tests/Feature/Auth/DingTalkLoginTest.php @@ -2,10 +2,15 @@ namespace Tests\Feature\Auth; +use App\Models\Auth\DingTalkBindingRequest; use App\Models\Auth\DingTalkIdentity; use App\Models\Auth\DingTalkOAuthState; +use App\Models\Auth\User; +use App\Models\Common\Contact; use Illuminate\Http\Client\Request as ClientRequest; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Str; use Tests\Feature\FeatureTestCase; class DingTalkLoginTest extends FeatureTestCase @@ -23,14 +28,19 @@ class DingTalkLoginTest extends FeatureTestCase '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', + 'dingtalk.app_token_url' => 'https://oapi.dingtalk.test/gettoken', + 'dingtalk.user_by_union_id_url' => 'https://oapi.dingtalk.test/topapi/user/getbyunionid', + 'dingtalk.user_detail_url' => 'https://oapi.dingtalk.test/topapi/v2/user/get', ]); } - public function test_login_page_shows_dingtalk_entry_when_configured(): void + public function test_login_page_shows_dingtalk_entry_without_password_binding_fields(): void { $this->get(route('login')) ->assertOk() - ->assertSeeText(trans('auth.dingtalk.login')); + ->assertSeeText(trans('auth.dingtalk.login')) + ->assertDontSee('name="dingtalk_binding"', false) + ->assertDontSeeText('首次使用钉钉登录,请输入现有财务系统账号和密码完成绑定'); } public function test_redirect_creates_hashed_state_and_uses_registered_http_callback(): void @@ -48,102 +58,196 @@ class DingTalkLoginTest extends FeatureTestCase $this->assertDatabaseMissing('dingtalk_oauth_states', ['state_hash' => $query['state']]); } - public function test_bound_identity_logs_in_without_storing_access_token(): void + public function test_bound_active_enterprise_identity_logs_in_without_storing_access_tokens(): void { Http::preventStrayRequests(); - Http::fake($this->dingtalkResponses('union-bound', 'open-bound')); + Http::fake($this->dingtalkResponses('union-bound', 'open-bound', email: null)); DingTalkIdentity::query()->create([ 'user_id' => $this->user->id, 'union_id' => 'union-bound', + 'dingtalk_user_id' => 'ding-user-1', ]); $state = $this->startAuthorization(); - $this->get(route('login.dingtalk.callback', [ 'authCode' => 'authorization-code', 'state' => $state, ]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442'); $this->assertAuthenticatedAs($this->user); - $this->assertDatabaseHas('dingtalk_identities', [ + $this->assertDatabaseHas('dingtalk_auth_audits', [ + 'event' => 'login_success', + 'outcome' => 'success', 'user_id' => $this->user->id, - 'union_id' => 'union-bound', - 'open_id' => 'open-bound', ]); - $this->assertFalse( collect(DingTalkOAuthState::query()->first()->getAttributes()) ->contains(fn ($value) => is_string($value) && str_contains($value, 'access-token')) ); - - Http::assertSent(function (ClientRequest $request): bool { - if ($request->url() === 'https://api.dingtalk.test/v1.0/oauth2/userAccessToken') { - return $request->method() === 'POST' - && $request['clientId'] === 'test-client-id' - && $request['clientSecret'] === 'test-client-secret' - && $request['code'] === 'authorization-code' - && $request['grantType'] === 'authorization_code'; - } - - if ($request->url() === 'https://api.dingtalk.test/v1.0/contact/users/me') { - return $request->hasHeader('x-acs-dingtalk-access-token', 'access-token-not-persisted'); - } - - return false; - }); - Http::assertSentCount(2); + Http::assertSentCount(5); } - public function test_first_authorization_binds_only_after_existing_password_login(): void + public function test_unique_verified_email_auto_binds_existing_enabled_account(): void { Http::preventStrayRequests(); - Http::fake($this->dingtalkResponses('union-new', 'open-new')); - - $password = 'KnownPassword123!'; - $this->user->password = $password; - $this->user->save(); + Http::fake($this->dingtalkResponses('union-auto', 'open-auto', email: 'finance@example.test')); + $this->user->update(['email' => 'finance@example.test']); $state = $this->startAuthorization(); - $callback = $this->get(route('login.dingtalk.callback', [ + $this->get(route('login.dingtalk.callback', [ 'code' => 'authorization-code', 'state' => $state, - ]))->assertRedirect(); - - parse_str((string) parse_url($callback->headers->get('Location'), PHP_URL_QUERY), $query); - $this->assertSame('https', parse_url($callback->headers->get('Location'), PHP_URL_SCHEME)); - $this->assertSame('caiwu.jiansuntuzhi.cn', parse_url($callback->headers->get('Location'), PHP_URL_HOST)); - $this->assertSame(8442, parse_url($callback->headers->get('Location'), PHP_URL_PORT)); - $bindingToken = $query['dingtalk_binding']; - - $this->assertGuest(); - $this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-new']); - $this->assertDatabaseHas('dingtalk_oauth_states', [ - 'binding_token_hash' => hash('sha256', $bindingToken), - 'union_id' => 'union-new', - ]); - - $this->post(route('login.store'), [ - 'email' => $this->user->email, - 'password' => $password, - 'dingtalk_binding' => $bindingToken, - ])->assertOk()->assertJson(['success' => true]); + ]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442'); $this->assertAuthenticatedAs($this->user); $this->assertDatabaseHas('dingtalk_identities', [ 'user_id' => $this->user->id, - 'union_id' => 'union-new', - 'open_id' => 'open-new', + 'union_id' => 'union-auto', + 'dingtalk_user_id' => 'ding-user-1', ]); - $this->assertDatabaseMissing('dingtalk_oauth_states', [ - 'binding_token_hash' => hash('sha256', $bindingToken), + $this->assertDatabaseHas('dingtalk_binding_requests', [ + 'union_id' => 'union-auto', + 'status' => 'approved', + 'matched_user_id' => $this->user->id, + ]); + $this->assertDatabaseHas('dingtalk_auth_audits', [ + 'event' => 'binding_auto_approved', + 'outcome' => 'success', + 'user_id' => $this->user->id, + ]); + } + + public function test_unique_verified_mobile_auto_binds_existing_enabled_account(): void + { + Http::preventStrayRequests(); + Http::fake($this->dingtalkResponses('union-mobile', 'open-mobile', email: null, mobile: '+86 138-0013-8000')); + Contact::query()->create([ + 'company_id' => $this->company->id, + 'type' => Contact::EMPLOYEE_TYPE, + 'name' => $this->user->name, + 'email' => $this->user->email, + 'user_id' => $this->user->id, + 'phone' => '13800138000', + 'currency_code' => default_currency(), + 'enabled' => true, + 'created_from' => 'test', + 'created_by' => $this->user->id, + ]); + + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', [ + 'code' => 'authorization-code', + 'state' => $state, + ]))->assertRedirectContains('https://caiwu.jiansuntuzhi.cn:8442'); + + $this->assertAuthenticatedAs($this->user); + $this->assertDatabaseHas('dingtalk_identities', [ + 'user_id' => $this->user->id, + 'union_id' => 'union-mobile', + ]); + } + + public function test_unmatched_enterprise_user_creates_pending_request_without_login_or_raw_contact_data(): void + { + Http::preventStrayRequests(); + Http::fake($this->dingtalkResponses( + 'union-pending', + 'open-pending', + email: 'outsider@example.test', + mobile: '13800138000' + )); + + $state = $this->startAuthorization(); + $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')); + + $this->assertGuest(); + $this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-pending']); + $this->assertDatabaseHas('dingtalk_binding_requests', [ + 'union_id' => 'union-pending', + 'status' => 'pending', + 'email_hint' => 'o***@example.test', + 'mobile_hint' => '*******8000', + ]); + $row = (array) DB::table('dingtalk_binding_requests')->where('union_id', 'union-pending')->first(); + $this->assertStringNotContainsString('outsider@example.test', json_encode($row)); + $this->assertStringNotContainsString('13800138000', json_encode($row)); + } + + public function test_inactive_enterprise_user_is_denied_without_creating_an_account(): void + { + Http::preventStrayRequests(); + Http::fake($this->dingtalkResponses('union-inactive', 'open-inactive', active: false)); + + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', [ + 'code' => 'authorization-code', + 'state' => $state, + ]))->assertRedirect('https://caiwu.jiansuntuzhi.cn:8442/auth/login'); + + $this->assertGuest(); + $this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-inactive']); + $this->assertDatabaseMissing('dingtalk_binding_requests', ['union_id' => 'union-inactive']); + $this->assertDatabaseHas('dingtalk_auth_audits', [ + 'event' => 'login_denied', + 'outcome' => 'denied', + ]); + } + + public function test_external_dingtalk_user_is_denied_when_enterprise_application_cannot_resolve_it(): void + { + Http::preventStrayRequests(); + Http::fake($this->dingtalkResponses('union-external', 'open-external', enterpriseMember: false)); + $userCount = User::query()->count(); + + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', [ + 'code' => 'authorization-code', + 'state' => $state, + ]))->assertRedirect('https://caiwu.jiansuntuzhi.cn:8442/auth/login'); + + $this->assertGuest(); + $this->assertSame($userCount, User::query()->count()); + $this->assertDatabaseMissing('dingtalk_binding_requests', ['union_id' => 'union-external']); + $this->assertDatabaseHas('dingtalk_auth_audits', [ + 'event' => 'login_denied', + 'outcome' => 'denied', + ]); + } + + public function test_rejected_binding_request_cannot_be_automatically_reactivated(): void + { + Http::preventStrayRequests(); + Http::fake($this->dingtalkResponses('union-rejected', 'open-rejected', email: 'finance@example.test')); + $this->user->update(['email' => 'finance@example.test']); + DingTalkBindingRequest::query()->create([ + 'union_id' => 'union-rejected', + 'status' => DingTalkBindingRequest::REJECTED, + 'match_reason' => 'administrator_rejected', + 'last_attempted_at' => now()->subMinute(), + ]); + + $state = $this->startAuthorization(); + $this->get(route('login.dingtalk.callback', [ + 'code' => 'authorization-code', + 'state' => $state, + ]))->assertRedirect('https://caiwu.jiansuntuzhi.cn:8442/auth/login'); + + $this->assertGuest(); + $this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-rejected']); + $this->assertDatabaseHas('dingtalk_binding_requests', [ + 'union_id' => 'union-rejected', + 'status' => DingTalkBindingRequest::REJECTED, ]); } public function test_expired_or_replayed_state_never_calls_dingtalk(): void { Http::preventStrayRequests(); - $rawState = 'expired-state'; DingTalkOAuthState::query()->create([ 'state_hash' => hash('sha256', $rawState), @@ -167,18 +271,44 @@ class DingTalkLoginTest extends FeatureTestCase return $query['state']; } - protected function dingtalkResponses(string $unionId, string $openId): callable - { - return function (ClientRequest $request) use ($unionId, $openId) { - return match ($request->url()) { - 'https://api.dingtalk.test/v1.0/oauth2/userAccessToken' => Http::response([ + protected function dingtalkResponses( + string $unionId, + string $openId, + ?string $email = 'finance@example.test', + ?string $mobile = null, + bool $active = true, + bool $enterpriseMember = true + ): callable { + return function (ClientRequest $request) use ($unionId, $openId, $email, $mobile, $active, $enterpriseMember) { + $url = $request->url(); + + return match (true) { + $url === 'https://api.dingtalk.test/v1.0/oauth2/userAccessToken' => Http::response([ 'accessToken' => 'access-token-not-persisted', 'expireIn' => 7200, ]), - 'https://api.dingtalk.test/v1.0/contact/users/me' => Http::response([ + $url === 'https://api.dingtalk.test/v1.0/contact/users/me' => Http::response([ 'unionId' => $unionId, 'openId' => $openId, - 'nick' => '测试用户', + 'nick' => '企业员工', + ]), + Str::startsWith($url, 'https://oapi.dingtalk.test/gettoken?') => Http::response([ + 'errcode' => 0, + 'access_token' => 'app-access-token-not-persisted', + ]), + Str::startsWith($url, 'https://oapi.dingtalk.test/topapi/user/getbyunionid?') => Http::response([ + 'errcode' => $enterpriseMember ? 0 : 60121, + 'result' => $enterpriseMember ? ['userid' => 'ding-user-1'] : null, + ]), + Str::startsWith($url, 'https://oapi.dingtalk.test/topapi/v2/user/get?') => Http::response([ + 'errcode' => 0, + 'result' => [ + 'userid' => 'ding-user-1', + 'name' => '企业员工', + 'email' => $email, + 'mobile' => $mobile, + 'active' => $active, + ], ]), default => Http::response([], 404), }; diff --git a/tests/Feature/SystemSettings/DingTalkBindingsTest.php b/tests/Feature/SystemSettings/DingTalkBindingsTest.php new file mode 100644 index 00000000..e83637d2 --- /dev/null +++ b/tests/Feature/SystemSettings/DingTalkBindingsTest.php @@ -0,0 +1,134 @@ +pendingRequest(); + + $this->loginAs() + ->get(route('system-settings.dingtalk-bindings.index')) + ->assertOk() + ->assertSeeText('钉钉账号绑定') + ->assertSeeText('企***@example.test') + ->assertSeeText('*******8000') + ->assertDontSee('enterprise-user@example.test') + ->assertDontSee('13800138000'); + + $this->post(route('system-settings.dingtalk-bindings.approve', $bindingRequest->id), [ + 'user_id' => $this->user->id, + ])->assertRedirect(route('system-settings.dingtalk-bindings.index')); + + $this->assertDatabaseHas('dingtalk_identities', [ + 'user_id' => $this->user->id, + 'union_id' => 'union-pending-admin', + 'bound_by' => $this->user->id, + ]); + $this->assertDatabaseHas('dingtalk_binding_requests', [ + 'id' => $bindingRequest->id, + 'status' => DingTalkBindingRequest::APPROVED, + 'matched_user_id' => $this->user->id, + 'reviewed_by' => $this->user->id, + ]); + $this->assertDatabaseHas('dingtalk_auth_audits', [ + 'event' => 'binding_admin_approved', + 'outcome' => 'success', + 'user_id' => $this->user->id, + 'actor_user_id' => $this->user->id, + ]); + } + + public function test_admin_can_reject_pending_request_without_creating_identity(): void + { + $bindingRequest = $this->pendingRequest(); + + $this->loginAs() + ->post(route('system-settings.dingtalk-bindings.reject', $bindingRequest->id)) + ->assertRedirect(route('system-settings.dingtalk-bindings.index')); + + $this->assertDatabaseHas('dingtalk_binding_requests', [ + 'id' => $bindingRequest->id, + 'status' => DingTalkBindingRequest::REJECTED, + ]); + $this->assertDatabaseMissing('dingtalk_identities', ['union_id' => 'union-pending-admin']); + $this->assertDatabaseHas('dingtalk_auth_audits', [ + 'event' => 'binding_rejected', + 'outcome' => 'denied', + ]); + } + + public function test_admin_can_revoke_binding_and_audit_the_action(): void + { + $bindingRequest = $this->pendingRequest(['status' => DingTalkBindingRequest::APPROVED]); + $identity = DingTalkIdentity::query()->create([ + 'user_id' => $this->user->id, + 'union_id' => $bindingRequest->union_id, + 'open_id' => $bindingRequest->open_id, + 'dingtalk_user_id' => $bindingRequest->dingtalk_user_id, + 'nickname' => $bindingRequest->nickname, + 'bound_at' => now(), + ]); + + $this->loginAs() + ->delete(route('system-settings.dingtalk-bindings.destroy', $identity->id)) + ->assertRedirect(route('system-settings.dingtalk-bindings.index')); + + $this->assertDatabaseMissing('dingtalk_identities', ['id' => $identity->id]); + $this->assertDatabaseHas('dingtalk_binding_requests', [ + 'id' => $bindingRequest->id, + 'status' => DingTalkBindingRequest::REVOKED, + ]); + $this->assertDatabaseHas('dingtalk_auth_audits', [ + 'event' => 'binding_revoked', + 'outcome' => 'success', + 'user_id' => $this->user->id, + ]); + } + + public function test_regular_user_cannot_open_binding_administration(): void + { + $regularUser = User::query()->create([ + 'name' => '普通用户', + 'email' => 'regular-user@example.test', + 'password' => 'KnownPassword123!', + 'locale' => 'zh-CN', + 'enabled' => true, + 'landing_page' => 'dashboard', + 'created_by' => $this->user->id, + 'created_from' => 'test', + ]); + $regularUser->roles()->attach(Role::where('name', SystemAccess::USER)->firstOrFail()->id); + $regularUser->companies()->attach($this->company->id); + $this->withExceptionHandling(); + + $this->loginAs($regularUser) + ->get(route('system-settings.dingtalk-bindings.index')) + ->assertForbidden(); + } + + private function pendingRequest(array $overrides = []): DingTalkBindingRequest + { + return DingTalkBindingRequest::query()->create(array_merge([ + 'union_id' => 'union-pending-admin', + 'open_id' => 'open-pending-admin', + 'dingtalk_user_id' => 'ding-user-pending-admin', + 'nickname' => '企业员工', + 'email_hash' => hash('sha256', 'enterprise-user@example.test'), + 'mobile_hash' => hash('sha256', '13800138000'), + 'email_hint' => '企***@example.test', + 'mobile_hint' => '*******8000', + 'status' => DingTalkBindingRequest::PENDING, + 'match_reason' => 'no_unique_account', + 'last_attempted_at' => now(), + ], $overrides)); + } +}