feat: enforce closed DingTalk account access

This commit is contained in:
WuYongJie 2026-09-04 23:38:56 +08:00
parent 6c574294f7
commit 0584aa3742
21 changed files with 1139 additions and 164 deletions

View File

@ -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);

View File

@ -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(),
]);

View File

@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\SystemSettings;
use App\Models\Auth\DingTalkAuthAudit;
use App\Models\Auth\DingTalkBindingRequest;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\User;
use App\Services\Auth\DingTalkClosedAccess;
use App\Utilities\SystemAccess;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class DingTalkBindings extends Controller
{
public function __construct()
{
$this->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;
}
}

View File

@ -26,7 +26,6 @@ class Login extends FormRequest
return [
'email' => 'required|email',
'password' => 'required',
'dingtalk_binding' => 'nullable|string|max:255',
];
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Models\Auth;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DingTalkAuthAudit extends Model
{
public $timestamps = false;
protected $table = 'dingtalk_auth_audits';
protected $fillable = [
'event',
'outcome',
'user_id',
'actor_user_id',
'union_id_hash',
'ip_address',
'metadata',
'created_at',
];
protected $casts = [
'metadata' => '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');
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models\Auth;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DingTalkBindingRequest extends Model
{
public const PENDING = 'pending';
public const APPROVED = 'approved';
public const REJECTED = 'rejected';
public const REVOKED = 'revoked';
protected $table = 'dingtalk_binding_requests';
protected $fillable = [
'union_id',
'open_id',
'dingtalk_user_id',
'nickname',
'email_hash',
'mobile_hash',
'email_hint',
'mobile_hint',
'status',
'match_reason',
'matched_user_id',
'reviewed_by',
'last_attempted_at',
'reviewed_at',
];
protected $casts = [
'last_attempted_at' => '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');
}
}

View File

@ -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',
];

View File

@ -0,0 +1,203 @@
<?php
namespace App\Services\Auth;
use App\Models\Auth\DingTalkAuthAudit;
use App\Models\Auth\DingTalkBindingRequest;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DingTalkClosedAccess
{
public function resolve(array $profile, Request $request): ?DingTalkIdentity
{
if (($profile['active'] ?? false) !== true) {
$this->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;
}
}

View File

@ -1,52 +0,0 @@
<?php
namespace App\Services\Auth;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\DingTalkOAuthState;
use App\Models\Auth\User;
use Illuminate\Support\Facades\DB;
class DingTalkIdentityBinder
{
public function bindFromToken(?string $token, User $user): bool
{
if (! is_string($token) || trim($token) === '') {
return false;
}
return DB::transaction(function () use ($token, $user): bool {
$state = DingTalkOAuthState::query()
->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;
});
}
}

View File

@ -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()) {

View File

@ -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),
];

View File

@ -0,0 +1,85 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('dingtalk_identities', function (Blueprint $table): void {
if (! Schema::hasColumn('dingtalk_identities', 'dingtalk_user_id')) {
$table->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');
}
});
}
};

View File

@ -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' => [

View File

@ -53,8 +53,7 @@ return [
'or' => '或',
'unavailable' => '钉钉登录尚未启用,请暂时使用邮箱密码登录。',
'failed' => '钉钉登录未完成,请重新发起授权。',
'confirm_binding' => '首次使用钉钉登录,请输入现有财务系统账号和密码完成绑定。',
'bound' => '钉钉账号绑定成功,正在为您跳转……',
'denied' => '当前钉钉账号未获财务系统授权,请联系系统管理员。',
],
'notification' => [

View File

@ -30,17 +30,7 @@
</div>
@endif
@if (session('dingtalk_binding') || request('dingtalk_binding'))
<div class="w-full border border-amber-200 bg-amber-50 text-amber-800 p-3 rounded-lg font-semibold text-xs my-3">
{{ session('dingtalk_binding', trans('auth.dingtalk.confirm_binding')) }}
</div>
@endif
<x-form id="auth" route="login">
@if (request('dingtalk_binding'))
<input type="hidden" name="dingtalk_binding" value="{{ request('dingtalk_binding') }}">
@endif
<div class="grid sm:grid-cols-6 gap-x-8 gap-y-6 my-3.5 lg:h-64">
<x-form.group.email
name="email"

View File

@ -0,0 +1,144 @@
<x-layouts.admin>
<x-slot name="title">钉钉账号绑定</x-slot>
<x-slot name="content">
<div class="my-6">
@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())
<div class="mb-5 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
@foreach ($errors->all() as $error)<p>{{ $error }}</p>@endforeach
</div>
@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
<section class="mb-6 rounded-xl border border-[#D8D2C7] bg-white p-4">
<h2 class="mb-3 text-sm font-semibold text-[#171717]">待处理与历史申请</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-[#D8D2C7] text-sm">
<thead class="bg-[#F7F0DE] text-left text-[#595959]">
<tr>
<th class="px-4 py-3">钉钉用户</th>
<th class="px-4 py-3">企业资料</th>
<th class="px-4 py-3">状态</th>
<th class="px-4 py-3">最近尝试</th>
<th class="px-4 py-3 text-right">处理</th>
</tr>
</thead>
<tbody class="divide-y divide-[#D8D2C7]">
@forelse ($bindingRequests as $bindingRequest)
<tr>
<td class="px-4 py-3 font-semibold text-[#171717]">{{ $bindingRequest->nickname ?: '未提供姓名' }}</td>
<td class="px-4 py-3 text-[#595959]">
<div>{{ $bindingRequest->email_hint ?: '无企业邮箱' }}</div>
<div>{{ $bindingRequest->mobile_hint ?: '无手机号' }}</div>
</td>
<td class="px-4 py-3">
<span @class([
'rounded-full px-2.5 py-1 text-xs font-semibold',
'bg-amber-50 text-[#935F07]' => $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 }}</span>
</td>
<td class="px-4 py-3 text-[#595959]">{{ optional($bindingRequest->last_attempted_at)->format('Y-m-d H:i:s') ?: '-' }}</td>
<td class="px-4 py-3 text-right">
@if ($bindingRequest->status !== 'approved')
<form method="POST" action="{{ route('system-settings.dingtalk-bindings.approve', $bindingRequest->id) }}" class="inline-flex items-center gap-2">
@csrf
<select name="user_id" required class="h-9 rounded-lg border border-[#D8D2C7] bg-white px-2 text-sm text-[#171717] focus:border-[#856632] focus:ring-[#856632]/20">
<option value="">选择财务账号</option>
@foreach ($eligibleUsers as $eligibleUser)
<option value="{{ $eligibleUser->id }}">{{ $eligibleUser->name }} · {{ $eligibleUser->email }}</option>
@endforeach
</select>
<button type="submit" class="h-9 rounded-lg bg-[#856632] px-3 text-sm font-semibold text-white hover:bg-[#6A5129]">绑定</button>
</form>
<form method="POST" action="{{ route('system-settings.dingtalk-bindings.reject', $bindingRequest->id) }}" class="ml-2 inline" onsubmit="return confirm('确定拒绝该钉钉绑定申请吗?')">
@csrf
<button type="submit" class="text-sm font-semibold text-[#B80000] hover:underline">拒绝</button>
</form>
@else
<span class="text-[#595959]">{{ $bindingRequest->matchedUser?->email ?: '已处理' }}</span>
@endif
</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-10 text-center text-[#595959]">暂无钉钉绑定申请。</td></tr>
@endforelse
</tbody>
</table>
</div>
</section>
<section class="mb-6 rounded-xl border border-[#D8D2C7] bg-white p-4">
<h2 class="mb-3 text-sm font-semibold text-[#171717]">已绑定账号</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-[#D8D2C7] text-sm">
<thead class="bg-[#F7F0DE] text-left text-[#595959]">
<tr><th class="px-4 py-3">财务账号</th><th class="px-4 py-3">钉钉用户</th><th class="px-4 py-3">绑定时间</th><th class="px-4 py-3 text-right">操作</th></tr>
</thead>
<tbody class="divide-y divide-[#D8D2C7]">
@forelse ($identities as $identity)
<tr>
<td class="px-4 py-3"><strong class="text-[#171717]">{{ $identity->user?->name }}</strong><div class="text-[#595959]">{{ $identity->user?->email }}</div></td>
<td class="px-4 py-3 text-[#595959]">{{ $identity->nickname ?: '企业员工' }}</td>
<td class="px-4 py-3 text-[#595959]">{{ optional($identity->bound_at)->format('Y-m-d H:i:s') ?: '-' }}</td>
<td class="px-4 py-3 text-right">
<form method="POST" action="{{ route('system-settings.dingtalk-bindings.destroy', $identity->id) }}" class="inline" onsubmit="return confirm('解除后该用户将不能继续使用钉钉登录,确定继续吗?')">
@csrf
@method('DELETE')
<button type="submit" class="text-sm font-semibold text-[#B80000] hover:underline">解除绑定</button>
</form>
</td>
</tr>
@empty
<tr><td colspan="4" class="px-4 py-10 text-center text-[#595959]">暂无已绑定账号。</td></tr>
@endforelse
</tbody>
</table>
</div>
</section>
<section class="rounded-xl border border-[#D8D2C7] bg-white p-4">
<h2 class="mb-3 text-sm font-semibold text-[#171717]">最近审计记录</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-[#D8D2C7] text-sm">
<thead class="bg-[#F7F0DE] text-left text-[#595959]">
<tr><th class="px-4 py-3">时间</th><th class="px-4 py-3">事件</th><th class="px-4 py-3">财务账号</th><th class="px-4 py-3">操作人</th><th class="px-4 py-3">结果</th></tr>
</thead>
<tbody class="divide-y divide-[#D8D2C7]">
@forelse ($audits as $audit)
<tr>
<td class="px-4 py-3 text-[#595959]">{{ optional($audit->created_at)->format('Y-m-d H:i:s') }}</td>
<td class="px-4 py-3 text-[#171717]">{{ $eventLabels[$audit->event] ?? $audit->event }}</td>
<td class="px-4 py-3 text-[#595959]">{{ $audit->user?->email ?: '-' }}</td>
<td class="px-4 py-3 text-[#595959]">{{ $audit->actor?->email ?: '系统' }}</td>
<td class="px-4 py-3 text-[#595959]">{{ $audit->outcome }}</td>
</tr>
@empty
<tr><td colspan="5" class="px-4 py-10 text-center text-[#595959]">暂无审计记录。</td></tr>
@endforelse
</tbody>
</table>
</div>
</section>
</div>
</x-slot>
</x-layouts.admin>

View File

@ -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'],
];

View File

@ -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');

View File

@ -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');

View File

@ -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),
};

View File

@ -0,0 +1,134 @@
<?php
namespace Tests\Feature\SystemSettings;
use App\Models\Auth\DingTalkBindingRequest;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\Role;
use App\Models\Auth\User;
use App\Utilities\SystemAccess;
use Tests\Feature\FeatureTestCase;
class DingTalkBindingsTest extends FeatureTestCase
{
public function test_super_admin_can_review_masked_pending_requests_and_bind_existing_user(): void
{
$bindingRequest = $this->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));
}
}