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')) -
{{ $error }}
@endforeach +| 钉钉用户 | +企业资料 | +状态 | +最近尝试 | +处理 | +
|---|---|---|---|---|
| {{ $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') + + + @else + {{ $bindingRequest->matchedUser?->email ?: '已处理' }} + @endif + | +
| 暂无钉钉绑定申请。 | ||||
| 财务账号 | 钉钉用户 | 绑定时间 | 操作 |
|---|---|---|---|
| {{ $identity->user?->name }} {{ $identity->user?->email }} |
+ {{ $identity->nickname ?: '企业员工' }} | +{{ optional($identity->bound_at)->format('Y-m-d H:i:s') ?: '-' }} | ++ + | +
| 暂无已绑定账号。 | |||
| 时间 | 事件 | 财务账号 | 操作人 | 结果 |
|---|---|---|---|---|
| {{ optional($audit->created_at)->format('Y-m-d H:i:s') }} | +{{ $eventLabels[$audit->event] ?? $audit->event }} | +{{ $audit->user?->email ?: '-' }} | +{{ $audit->actor?->email ?: '系统' }} | +{{ $audit->outcome }} | +
| 暂无审计记录。 | ||||