feat: enforce closed DingTalk account access
This commit is contained in:
parent
6c574294f7
commit
0584aa3742
@ -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);
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
|
||||
150
app/Http/Controllers/SystemSettings/DingTalkBindings.php
Normal file
150
app/Http/Controllers/SystemSettings/DingTalkBindings.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -26,7 +26,6 @@ class Login extends FormRequest
|
||||
return [
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
'dingtalk_binding' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
39
app/Models/Auth/DingTalkAuthAudit.php
Normal file
39
app/Models/Auth/DingTalkAuthAudit.php
Normal 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');
|
||||
}
|
||||
}
|
||||
48
app/Models/Auth/DingTalkBindingRequest.php
Normal file
48
app/Models/Auth/DingTalkBindingRequest.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
];
|
||||
|
||||
|
||||
203
app/Services/Auth/DingTalkClosedAccess.php
Normal file
203
app/Services/Auth/DingTalkClosedAccess.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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()) {
|
||||
|
||||
@ -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),
|
||||
];
|
||||
|
||||
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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' => [
|
||||
|
||||
@ -53,8 +53,7 @@ return [
|
||||
'or' => '或',
|
||||
'unavailable' => '钉钉登录尚未启用,请暂时使用邮箱密码登录。',
|
||||
'failed' => '钉钉登录未完成,请重新发起授权。',
|
||||
'confirm_binding' => '首次使用钉钉登录,请输入现有财务系统账号和密码完成绑定。',
|
||||
'bound' => '钉钉账号绑定成功,正在为您跳转……',
|
||||
'denied' => '当前钉钉账号未获财务系统授权,请联系系统管理员。',
|
||||
],
|
||||
|
||||
'notification' => [
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
@ -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'],
|
||||
];
|
||||
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
134
tests/Feature/SystemSettings/DingTalkBindingsTest.php
Normal file
134
tests/Feature/SystemSettings/DingTalkBindingsTest.php
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user