184 lines
6.7 KiB
PHP
184 lines
6.7 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature\Auth;
|
|
|
|
use App\Models\Auth\DingTalkIdentity;
|
|
use App\Models\Auth\DingTalkOAuthState;
|
|
use Illuminate\Http\Client\Request as ClientRequest;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Tests\Feature\FeatureTestCase;
|
|
|
|
class DingTalkLoginTest extends FeatureTestCase
|
|
{
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
config([
|
|
'dingtalk.enabled' => true,
|
|
'dingtalk.client_id' => 'test-client-id',
|
|
'dingtalk.client_secret' => 'test-client-secret',
|
|
'dingtalk.redirect_uri' => 'http://218.93.190.75:8442/auth/dingtalk/callback',
|
|
'dingtalk.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',
|
|
]);
|
|
}
|
|
|
|
public function test_login_page_shows_dingtalk_entry_when_configured(): void
|
|
{
|
|
$this->get(route('login'))
|
|
->assertOk()
|
|
->assertSeeText(trans('auth.dingtalk.login'));
|
|
}
|
|
|
|
public function test_redirect_creates_hashed_state_and_uses_registered_http_callback(): void
|
|
{
|
|
$response = $this->get(route('login.dingtalk'))->assertRedirect();
|
|
parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query);
|
|
|
|
$this->assertSame('test-client-id', $query['client_id']);
|
|
$this->assertSame('openid', $query['scope']);
|
|
$this->assertSame(config('dingtalk.redirect_uri'), $query['redirect_uri']);
|
|
$this->assertDatabaseHas('dingtalk_oauth_states', [
|
|
'state_hash' => hash('sha256', $query['state']),
|
|
'consumed_at' => null,
|
|
]);
|
|
$this->assertDatabaseMissing('dingtalk_oauth_states', ['state_hash' => $query['state']]);
|
|
}
|
|
|
|
public function test_bound_identity_logs_in_without_storing_access_token(): void
|
|
{
|
|
Http::preventStrayRequests();
|
|
Http::fake($this->dingtalkResponses('union-bound', 'open-bound'));
|
|
|
|
DingTalkIdentity::query()->create([
|
|
'user_id' => $this->user->id,
|
|
'union_id' => 'union-bound',
|
|
]);
|
|
|
|
$state = $this->startAuthorization();
|
|
|
|
$this->get(route('login.dingtalk.callback', [
|
|
'authCode' => 'authorization-code',
|
|
'state' => $state,
|
|
]))->assertRedirect();
|
|
|
|
$this->assertAuthenticatedAs($this->user);
|
|
$this->assertDatabaseHas('dingtalk_identities', [
|
|
'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);
|
|
}
|
|
|
|
public function test_first_authorization_binds_only_after_existing_password_login(): void
|
|
{
|
|
Http::preventStrayRequests();
|
|
Http::fake($this->dingtalkResponses('union-new', 'open-new'));
|
|
|
|
$password = 'KnownPassword123!';
|
|
$this->user->password = $password;
|
|
$this->user->save();
|
|
|
|
$state = $this->startAuthorization();
|
|
$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);
|
|
$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]);
|
|
|
|
$this->assertAuthenticatedAs($this->user);
|
|
$this->assertDatabaseHas('dingtalk_identities', [
|
|
'user_id' => $this->user->id,
|
|
'union_id' => 'union-new',
|
|
'open_id' => 'open-new',
|
|
]);
|
|
$this->assertDatabaseMissing('dingtalk_oauth_states', [
|
|
'binding_token_hash' => hash('sha256', $bindingToken),
|
|
]);
|
|
}
|
|
|
|
public function test_expired_or_replayed_state_never_calls_dingtalk(): void
|
|
{
|
|
Http::preventStrayRequests();
|
|
|
|
$rawState = 'expired-state';
|
|
DingTalkOAuthState::query()->create([
|
|
'state_hash' => hash('sha256', $rawState),
|
|
'expires_at' => now()->subMinute(),
|
|
]);
|
|
|
|
$this->get(route('login.dingtalk.callback', [
|
|
'code' => 'authorization-code',
|
|
'state' => $rawState,
|
|
]))->assertRedirect(route('login'));
|
|
|
|
Http::assertNothingSent();
|
|
$this->assertGuest();
|
|
}
|
|
|
|
protected function startAuthorization(): string
|
|
{
|
|
$response = $this->get(route('login.dingtalk'))->assertRedirect();
|
|
parse_str((string) parse_url($response->headers->get('Location'), PHP_URL_QUERY), $query);
|
|
|
|
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([
|
|
'accessToken' => 'access-token-not-persisted',
|
|
'expireIn' => 7200,
|
|
]),
|
|
'https://api.dingtalk.test/v1.0/contact/users/me' => Http::response([
|
|
'unionId' => $unionId,
|
|
'openId' => $openId,
|
|
'nick' => '测试用户',
|
|
]),
|
|
default => Http::response([], 404),
|
|
};
|
|
};
|
|
}
|
|
}
|