feat: add DingTalk account login

This commit is contained in:
WuYongJie 2026-09-03 15:42:46 +08:00
parent cb02ca8a36
commit 6995ad31c5
20 changed files with 829 additions and 2 deletions

View File

@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Abstracts\Http\Controller;
use App\Models\Auth\DingTalkIdentity;
use App\Models\Auth\DingTalkOAuthState;
use App\Services\Auth\DingTalkOAuthClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Throwable;
class DingTalkLogin extends Controller
{
public function redirect(DingTalkOAuthClient $client): RedirectResponse
{
if (! $client->isConfigured()) {
return redirect()->route('login')->with('dingtalk_error', trans('auth.dingtalk.unavailable'));
}
DingTalkOAuthState::query()
->where('expires_at', '<', now()->subDay())
->delete();
$state = Str::random(64);
DingTalkOAuthState::query()->create([
'state_hash' => hash('sha256', $state),
'expires_at' => now()->addMinutes(max(1, (int) config('dingtalk.state_ttl_minutes', 5))),
]);
return redirect()->away($client->authorizationUrl($state));
}
public function callback(Request $request, DingTalkOAuthClient $client): RedirectResponse
{
$stateValue = trim((string) $request->query('state', ''));
$authorizationCode = trim((string) ($request->query('authCode') ?: $request->query('code', '')));
if ($stateValue === '' || $authorizationCode === '') {
return $this->failed();
}
$state = DB::transaction(function () use ($stateValue): ?DingTalkOAuthState {
$candidate = DingTalkOAuthState::query()
->where('state_hash', hash('sha256', $stateValue))
->lockForUpdate()
->first();
if (! $candidate || $candidate->consumed_at || $candidate->expires_at->isPast()) {
return null;
}
$candidate->forceFill(['consumed_at' => now()])->save();
return $candidate;
});
if (! $state) {
return $this->failed();
}
try {
$token = $client->exchangeAuthorizationCode($authorizationCode);
$profile = $client->currentUser($token['access_token']);
} catch (Throwable $exception) {
Log::warning('DingTalk login failed while resolving the authorized user.', [
'exception' => $exception::class,
]);
return $this->failed();
}
$identity = DingTalkIdentity::query()
->with('user')
->where('union_id', $profile['union_id'])
->first();
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()->route('login', ['dingtalk_binding' => $bindingToken])
->with('dingtalk_binding', trans('auth.dingtalk.confirm_binding'));
}
protected function loginBoundUser(Request $request, DingTalkIdentity $identity, array $profile): RedirectResponse
{
$user = $identity->user;
$company = $user?->withoutEvents(fn () => $user->companies()->enabled()->first());
if (! $user || ! $user->enabled || ! $company) {
Log::info('DingTalk login denied for a disabled or unassigned account.', [
'identity_id' => $identity->id,
]);
return $this->failed();
}
Auth::login($user, true);
$request->session()->regenerate();
$identity->forceFill([
'open_id' => $profile['open_id'] ?: $identity->open_id,
'nickname' => $profile['nickname'] ?: $identity->nickname,
'last_logged_in_at' => now(),
])->save();
$url = route($user->landing_page, ['company_id' => $company->id]);
return redirect()->intended($url);
}
protected function failed(): RedirectResponse
{
return redirect()->route('login')->with('dingtalk_error', trans('auth.dingtalk.failed'));
}
}

View File

@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
use App\Abstracts\Http\Controller; use App\Abstracts\Http\Controller;
use App\Http\Requests\Auth\Login as Request; use App\Http\Requests\Auth\Login as Request;
use App\Services\Auth\DingTalkIdentityBinder;
use Illuminate\Foundation\Auth\AuthenticatesUsers; use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -75,6 +76,11 @@ class Login extends Controller
return $this->respondLoginFailed(); return $this->respondLoginFailed();
} }
$dingtalkBound = app(DingTalkIdentityBinder::class)->bindFromToken(
$request->input('dingtalk_binding'),
$user
);
// Redirect to portal if is customer // Redirect to portal if is customer
if ($user->isCustomer()) { if ($user->isCustomer()) {
$path = session('url.intended', ''); $path = session('url.intended', '');
@ -88,7 +94,7 @@ class Login extends Controller
'status' => null, 'status' => null,
'success' => true, 'success' => true,
'error' => false, 'error' => false,
'message' => trans('auth.login_redirect'), 'message' => $dingtalkBound ? trans('auth.dingtalk.bound') : trans('auth.login_redirect'),
'data' => null, 'data' => null,
'redirect' => url($path), 'redirect' => url($path),
]); ]);
@ -101,7 +107,7 @@ class Login extends Controller
'status' => null, 'status' => null,
'success' => true, 'success' => true,
'error' => false, 'error' => false,
'message' => trans('auth.login_redirect'), 'message' => $dingtalkBound ? trans('auth.dingtalk.bound') : trans('auth.login_redirect'),
'data' => null, 'data' => null,
'redirect' => redirect()->intended($url)->getTargetUrl(), 'redirect' => redirect()->intended($url)->getTargetUrl(),
]); ]);

View File

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

View File

@ -0,0 +1,28 @@
<?php
namespace App\Models\Auth;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DingTalkIdentity extends Model
{
protected $table = 'dingtalk_identities';
protected $fillable = [
'user_id',
'union_id',
'open_id',
'nickname',
'last_logged_in_at',
];
protected $casts = [
'last_logged_in_at' => 'datetime',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Models\Auth;
use Illuminate\Database\Eloquent\Model;
class DingTalkOAuthState extends Model
{
protected $table = 'dingtalk_oauth_states';
protected $fillable = [
'state_hash',
'binding_token_hash',
'union_id',
'open_id',
'nickname',
'expires_at',
'consumed_at',
];
protected $casts = [
'expires_at' => 'datetime',
'consumed_at' => 'datetime',
];
}

View File

@ -78,6 +78,11 @@ class User extends Authenticatable implements HasLocalePreference
return $this->belongsToMany('App\Models\Common\Company', 'App\Models\Auth\UserCompany'); return $this->belongsToMany('App\Models\Common\Company', 'App\Models\Auth\UserCompany');
} }
public function dingtalkIdentity()
{
return $this->hasOne(DingTalkIdentity::class, 'user_id');
}
public function contact() public function contact()
{ {
return $this->hasOne('App\Models\Common\Contact', 'user_id', 'id'); return $this->hasOne('App\Models\Common\Contact', 'user_id', 'id');

View File

@ -0,0 +1,52 @@
<?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

@ -0,0 +1,116 @@
<?php
namespace App\Services\Auth;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class DingTalkOAuthClient
{
public function isConfigured(): bool
{
return (bool) config('dingtalk.enabled')
&& $this->configuredString('dingtalk.client_id') !== ''
&& $this->configuredString('dingtalk.client_secret') !== ''
&& $this->configuredString('dingtalk.redirect_uri') !== '';
}
public function authorizationUrl(string $state): string
{
$this->ensureConfigured();
return rtrim($this->configuredString('dingtalk.authorize_url'), '?') . '?' . http_build_query([
'redirect_uri' => $this->configuredString('dingtalk.redirect_uri'),
'response_type' => 'code',
'client_id' => $this->configuredString('dingtalk.client_id'),
'scope' => 'openid',
'prompt' => 'consent',
'state' => $state,
], '', '&', PHP_QUERY_RFC3986);
}
/**
* @return array{access_token: string, expires_in: int|null}
*/
public function exchangeAuthorizationCode(string $code): array
{
$this->ensureConfigured();
try {
$response = Http::asJson()
->acceptJson()
->timeout($this->timeout())
->post($this->configuredString('dingtalk.token_url'), [
'clientId' => $this->configuredString('dingtalk.client_id'),
'clientSecret' => $this->configuredString('dingtalk.client_secret'),
'code' => $code,
'grantType' => 'authorization_code',
])
->throw();
} catch (RequestException $exception) {
throw new RuntimeException('DingTalk token exchange failed.', 0, $exception);
}
$accessToken = trim((string) $response->json('accessToken'));
if ($accessToken === '') {
throw new RuntimeException('DingTalk token response did not contain an access token.');
}
return [
'access_token' => $accessToken,
'expires_in' => is_numeric($response->json('expireIn')) ? (int) $response->json('expireIn') : null,
];
}
/**
* @return array{union_id: string, open_id: string|null, nickname: string|null}
*/
public function currentUser(string $accessToken): array
{
try {
$response = Http::acceptJson()
->withHeaders(['x-acs-dingtalk-access-token' => $accessToken])
->timeout($this->timeout())
->get($this->configuredString('dingtalk.user_url'))
->throw();
} catch (RequestException $exception) {
throw new RuntimeException('DingTalk user lookup failed.', 0, $exception);
}
$unionId = trim((string) $response->json('unionId'));
if ($unionId === '') {
throw new RuntimeException('DingTalk user response did not contain a union id.');
}
return [
'union_id' => $unionId,
'open_id' => $this->nullableString($response->json('openId')),
'nickname' => $this->nullableString($response->json('nick')),
];
}
protected function ensureConfigured(): void
{
if (! $this->isConfigured()) {
throw new RuntimeException('DingTalk login is not configured.');
}
}
protected function configuredString(string $key): string
{
return trim((string) config($key, ''));
}
protected function timeout(): int
{
return max(3, min(30, (int) config('dingtalk.timeout', 10)));
}
protected function nullableString(mixed $value): ?string
{
$value = trim((string) $value);
return $value === '' ? null : $value;
}
}

20
config/dingtalk.php Normal file
View File

@ -0,0 +1,20 @@
<?php
return [
'enabled' => (bool) env('DINGTALK_LOGIN_ENABLED', false),
'client_id' => env('DINGTALK_CLIENT_ID'),
'client_secret' => env('DINGTALK_CLIENT_SECRET'),
'redirect_uri' => env(
'DINGTALK_REDIRECT_URI',
'http://218.93.190.75:8442/auth/dingtalk/callback'
),
'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'),
'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,47 @@
<?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
{
if (! Schema::hasTable('dingtalk_identities')) {
Schema::create('dingtalk_identities', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('user_id')->unique();
$table->string('union_id', 128)->unique();
$table->string('open_id', 128)->nullable()->index();
$table->string('nickname', 191)->nullable();
$table->timestamp('last_logged_in_at')->nullable();
$table->timestamps();
$table->foreign('user_id')->references('id')->on('users')->cascadeOnDelete();
});
}
if (! Schema::hasTable('dingtalk_oauth_states')) {
Schema::create('dingtalk_oauth_states', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->char('state_hash', 64)->unique();
$table->char('binding_token_hash', 64)->nullable()->unique();
$table->string('union_id', 128)->nullable();
$table->string('open_id', 128)->nullable();
$table->string('nickname', 191)->nullable();
$table->timestamp('expires_at');
$table->timestamp('consumed_at')->nullable();
$table->timestamps();
$table->index(['expires_at', 'consumed_at']);
});
}
}
public function down(): void
{
Schema::dropIfExists('dingtalk_oauth_states');
Schema::dropIfExists('dingtalk_identities');
}
};

View File

@ -0,0 +1,50 @@
#!/bin/sh
set -eu
SERVER_IP="${1:?SERVER_IP is required}"
REMOTE_DIR="${2:-/opt/caiwuxitong}"
SOURCE_CONFIG="$REMOTE_DIR/host-nginx-8442-dingtalk.conf"
TARGET_CONFIG="/etc/nginx/conf.d/caiwuxitong-backend-8442.conf"
BACKUP_DIR="$REMOTE_DIR/backups/nginx-dingtalk-callback-$(date +%Y%m%d-%H%M%S)"
if [ "$(id -u)" -ne 0 ]; then
echo "configure-dingtalk-callback.sh must run as root" >&2
exit 1
fi
if [ "$SERVER_IP" != "218.93.190.75" ]; then
echo "Refusing to configure an unexpected server IP: $SERVER_IP" >&2
exit 1
fi
test -s "$SOURCE_CONFIG"
test -s "$TARGET_CONFIG"
test -s /etc/nginx/caiwuxitong-backend.crt
test -s /etc/nginx/caiwuxitong-backend.key
install -d -m 700 "$BACKUP_DIR"
install -m 600 "$TARGET_CONFIG" "$BACKUP_DIR/caiwuxitong-backend-8442.conf"
install -m 644 "$SOURCE_CONFIG" "$TARGET_CONFIG"
if ! nginx -t; then
install -m 644 "$BACKUP_DIR/caiwuxitong-backend-8442.conf" "$TARGET_CONFIG"
nginx -t
echo "Nginx validation failed; the previous configuration was restored." >&2
exit 1
fi
systemctl reload nginx
status="$(curl -sS -o /dev/null -w '%{http_code}' --max-time 15 \
"http://$SERVER_IP:8442/auth/dingtalk/callback?state=configuration-probe")"
if [ "$status" = "308" ] || [ "$status" = "400" ] || [ "$status" = "000" ]; then
install -m 644 "$BACKUP_DIR/caiwuxitong-backend-8442.conf" "$TARGET_CONFIG"
nginx -t
systemctl reload nginx
echo "DingTalk HTTP callback probe failed with status $status; the previous configuration was restored." >&2
exit 1
fi
echo "DINGTALK_HTTP_CALLBACK_READY=http://$SERVER_IP:8442/auth/dingtalk/callback"
echo "NGINX_BACKUP=$BACKUP_DIR/caiwuxitong-backend-8442.conf"

View File

@ -0,0 +1,76 @@
server {
listen 8442 ssl default_server;
listen [::]:8442 ssl default_server;
server_name 218.93.190.75;
ssl_certificate /etc/nginx/caiwuxitong-backend.crt;
ssl_certificate_key /etc/nginx/caiwuxitong-backend.key;
ssl_session_cache shared:CAIWU_BACKEND_SSL:2m;
ssl_session_timeout 10m;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 12m;
# Plain HTTP is accepted only for the DingTalk OAuth callback registered
# by the enterprise administrator. Every other HTTP request keeps the
# existing HTTPS redirect behavior.
error_page 497 = @plain_http_request;
location @plain_http_request {
if ($uri != /auth/dingtalk/callback) {
return 308 https://218.93.190.75:8442$request_uri;
}
proxy_pass http://127.0.0.1:18088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 8442;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto http;
proxy_read_timeout 30s;
}
location / {
proxy_pass http://127.0.0.1:18088;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port 8442;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s;
}
}
server {
listen 8442 ssl;
listen [::]:8442 ssl;
server_name 118.196.84.79;
ssl_certificate /etc/nginx/caiwuxitong-backend.crt;
ssl_certificate_key /etc/nginx/caiwuxitong-backend.key;
ssl_session_cache shared:CAIWU_BACKEND_SSL:2m;
ssl_session_timeout 10m;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 12m;
allow 127.0.0.1;
allow ::1;
allow 118.196.84.79;
deny all;
location / {
proxy_pass http://127.0.0.1:18088;
proxy_http_version 1.1;
proxy_set_header Host 218.93.190.75;
proxy_set_header X-Forwarded-Host 218.93.190.75;
proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s;
}
}

View File

@ -28,6 +28,7 @@ services:
- ./app:/var/www/html/app - ./app:/var/www/html/app
- ./config:/var/www/html/config - ./config:/var/www/html/config
- ./database:/var/www/html/database - ./database:/var/www/html/database
- ./deploy:/var/www/html/deploy
- ./modules:/var/www/html/modules - ./modules:/var/www/html/modules
- ./resources:/var/www/html/resources - ./resources:/var/www/html/resources
- ./routes:/var/www/html/routes - ./routes:/var/www/html/routes

View File

@ -48,6 +48,15 @@ return [
'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.',
'disabled' => 'This account is disabled. Please, contact the system administrator.', 'disabled' => 'This account is disabled. Please, contact the system administrator.',
'dingtalk' => [
'login' => 'Sign in with DingTalk',
'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…',
],
'notification' => [ 'notification' => [
'message_1' => 'You are receiving this email because we received a password reset request for your account.', 'message_1' => 'You are receiving this email because we received a password reset request for your account.',
'message_2' => 'If you did not request a password reset, no further action is required.', 'message_2' => 'If you did not request a password reset, no further action is required.',

View File

@ -48,6 +48,15 @@ return [
'throttle' => '尝试登录次数过多。请在 :seconds 秒后重试。', 'throttle' => '尝试登录次数过多。请在 :seconds 秒后重试。',
'disabled' => '此账号已被停用。请联系系统管理员。', 'disabled' => '此账号已被停用。请联系系统管理员。',
'dingtalk' => [
'login' => '使用钉钉登录',
'or' => '或',
'unavailable' => '钉钉登录尚未启用,请暂时使用邮箱密码登录。',
'failed' => '钉钉登录未完成,请重新发起授权。',
'confirm_binding' => '首次使用钉钉登录,请输入现有财务系统账号和密码完成绑定。',
'bound' => '钉钉账号绑定成功,正在为您跳转……',
],
'notification' => [ 'notification' => [
'message_1' => '您收到此邮件是因为我们收到了您账户的密码重置请求。', 'message_1' => '您收到此邮件是因为我们收到了您账户的密码重置请求。',
'message_2' => '如果您未请求重置密码,则无需进一步操作。', 'message_2' => '如果您未请求重置密码,则无需进一步操作。',

View File

@ -24,7 +24,23 @@
v-cloak v-cloak
></div> ></div>
@if (session('dingtalk_error'))
<div class="w-full border border-red-200 bg-red-50 text-red-700 p-3 rounded-lg font-semibold text-xs my-3">
{{ session('dingtalk_error') }}
</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"> <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"> <div class="grid sm:grid-cols-6 gap-x-8 gap-y-6 my-3.5 lg:h-64">
<x-form.group.email <x-form.group.email
name="email" name="email"
@ -75,6 +91,21 @@
</x-button> </x-button>
</div> </div>
</x-form> </x-form>
@if (config('dingtalk.enabled') && config('dingtalk.client_id') && config('dingtalk.client_secret'))
<div class="flex items-center gap-3 my-5" aria-hidden="true">
<span class="h-px flex-1 bg-gray-200"></span>
<span class="text-xs text-gray-500">{{ trans('auth.dingtalk.or') }}</span>
<span class="h-px flex-1 bg-gray-200"></span>
</div>
<a href="{{ route('login.dingtalk') }}"
class="w-full h-9 inline-flex items-center justify-center gap-2 border border-[#856632] bg-white text-[#856632] hover:bg-[#F7F0DE] focus:outline-none focus:ring-2 focus:ring-[#856632]/20 rounded-lg text-sm font-semibold"
>
<span class="material-icons-outlined text-base" aria-hidden="true">qr_code_scanner</span>
{{ trans('auth.dingtalk.login') }}
</a>
@endif
</x-slot> </x-slot>
<x-script folder="auth" file="common" /> <x-script folder="auth" file="common" />

View File

@ -12,6 +12,8 @@ use Illuminate\Support\Facades\Route;
Route::group(['prefix' => 'auth'], function () { Route::group(['prefix' => 'auth'], function () {
Route::get('login', 'Auth\Login@create')->name('login'); Route::get('login', 'Auth\Login@create')->name('login');
Route::post('login', 'Auth\Login@store')->name('login.store'); 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('forgot', 'Auth\Forgot@create')->name('forgot'); Route::get('forgot', 'Auth\Forgot@create')->name('forgot');
Route::post('forgot', 'Auth\Forgot@store')->name('forgot.store'); Route::post('forgot', 'Auth\Forgot@store')->name('forgot.store');

View File

@ -20,6 +20,7 @@ param(
[int]$BuildCacheMaxAgeHours = 720, [int]$BuildCacheMaxAgeHours = 720,
[switch]$BootstrapServer, [switch]$BootstrapServer,
[switch]$ConfigureHttps, [switch]$ConfigureHttps,
[switch]$ConfigureDingTalkCallback,
[switch]$AllowNonMain, [switch]$AllowNonMain,
[switch]$AllowMigrations [switch]$AllowMigrations
) )
@ -403,6 +404,8 @@ try {
deploy/config-db-init.sh ` deploy/config-db-init.sh `
deploy/update-runtime-url.sh ` deploy/update-runtime-url.sh `
deploy/setup-https.sh ` deploy/setup-https.sh `
deploy/configure-dingtalk-callback.sh `
deploy/host-nginx-8442-dingtalk.conf `
deploy/host-nginx-http.conf ` deploy/host-nginx-http.conf `
deploy/host-nginx.conf ` deploy/host-nginx.conf `
deploy/setup-incremental-registry.sh ` deploy/setup-incremental-registry.sh `
@ -520,6 +523,12 @@ try {
} '服务器 HTTPS 配置或证书申请失败。' } '服务器 HTTPS 配置或证书申请失败。'
} }
if ($ConfigureDingTalkCallback) {
Invoke-Native {
ssh -i $keyFullPath -o BatchMode=yes $sshTarget "chmod 700 '$RemotePath/configure-dingtalk-callback.sh' && '$RemotePath/configure-dingtalk-callback.sh' '$Server' '$RemotePath'"
} '钉钉 HTTP 回调 Nginx 配置失败。'
}
Remove-OldProjectImages -CurrentImage $fullImage Remove-OldProjectImages -CurrentImage $fullImage
Write-Host "发布完成:$fullImage -> $AppUrl" Write-Host "发布完成:$fullImage -> $AppUrl"
} }

View File

@ -0,0 +1,183 @@
<?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),
};
};
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace Tests\Unit\Deployment;
use PHPUnit\Framework\TestCase;
class DingTalkCallbackNginxTest extends TestCase
{
public function test_plain_http_is_limited_to_the_registered_dingtalk_callback(): void
{
$config = file_get_contents(base_path('deploy/host-nginx-8442-dingtalk.conf'));
$this->assertStringContainsString('error_page 497 = @plain_http_request;', $config);
$this->assertStringContainsString('if ($uri != /auth/dingtalk/callback)', $config);
$this->assertStringContainsString('proxy_set_header X-Forwarded-Proto http;', $config);
$this->assertStringContainsString('return 308 https://218.93.190.75:8442$request_uri;', $config);
}
public function test_configuration_script_validates_and_restores_on_failure(): void
{
$script = file_get_contents(base_path('deploy/configure-dingtalk-callback.sh'));
$this->assertStringContainsString('nginx -t', $script);
$this->assertStringContainsString('the previous configuration was restored', $script);
$this->assertStringContainsString('DINGTALK_HTTP_CALLBACK_READY=', $script);
}
}