diff --git a/app/Http/Controllers/Auth/DingTalkLogin.php b/app/Http/Controllers/Auth/DingTalkLogin.php new file mode 100644 index 00000000..c881139d --- /dev/null +++ b/app/Http/Controllers/Auth/DingTalkLogin.php @@ -0,0 +1,130 @@ +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')); + } +} diff --git a/app/Http/Controllers/Auth/Login.php b/app/Http/Controllers/Auth/Login.php index 07453c7e..8392e78b 100644 --- a/app/Http/Controllers/Auth/Login.php +++ b/app/Http/Controllers/Auth/Login.php @@ -4,6 +4,7 @@ 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; @@ -75,6 +76,11 @@ 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', ''); @@ -88,7 +94,7 @@ class Login extends Controller 'status' => null, 'success' => true, 'error' => false, - 'message' => trans('auth.login_redirect'), + 'message' => $dingtalkBound ? trans('auth.dingtalk.bound') : trans('auth.login_redirect'), 'data' => null, 'redirect' => url($path), ]); @@ -101,7 +107,7 @@ class Login extends Controller 'status' => null, 'success' => true, 'error' => false, - 'message' => trans('auth.login_redirect'), + 'message' => $dingtalkBound ? trans('auth.dingtalk.bound') : trans('auth.login_redirect'), 'data' => null, 'redirect' => redirect()->intended($url)->getTargetUrl(), ]); diff --git a/app/Http/Requests/Auth/Login.php b/app/Http/Requests/Auth/Login.php index 27b87f7b..6a7ddd33 100644 --- a/app/Http/Requests/Auth/Login.php +++ b/app/Http/Requests/Auth/Login.php @@ -26,6 +26,7 @@ class Login extends FormRequest return [ 'email' => 'required|email', 'password' => 'required', + 'dingtalk_binding' => 'nullable|string|max:255', ]; } } diff --git a/app/Models/Auth/DingTalkIdentity.php b/app/Models/Auth/DingTalkIdentity.php new file mode 100644 index 00000000..3459a534 --- /dev/null +++ b/app/Models/Auth/DingTalkIdentity.php @@ -0,0 +1,28 @@ + 'datetime', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/Auth/DingTalkOAuthState.php b/app/Models/Auth/DingTalkOAuthState.php new file mode 100644 index 00000000..0cc587ff --- /dev/null +++ b/app/Models/Auth/DingTalkOAuthState.php @@ -0,0 +1,25 @@ + 'datetime', + 'consumed_at' => 'datetime', + ]; +} diff --git a/app/Models/Auth/User.php b/app/Models/Auth/User.php index 2c85f01c..9346684d 100644 --- a/app/Models/Auth/User.php +++ b/app/Models/Auth/User.php @@ -78,6 +78,11 @@ class User extends Authenticatable implements HasLocalePreference return $this->belongsToMany('App\Models\Common\Company', 'App\Models\Auth\UserCompany'); } + public function dingtalkIdentity() + { + return $this->hasOne(DingTalkIdentity::class, 'user_id'); + } + public function contact() { return $this->hasOne('App\Models\Common\Contact', 'user_id', 'id'); diff --git a/app/Services/Auth/DingTalkIdentityBinder.php b/app/Services/Auth/DingTalkIdentityBinder.php new file mode 100644 index 00000000..0f155cb0 --- /dev/null +++ b/app/Services/Auth/DingTalkIdentityBinder.php @@ -0,0 +1,52 @@ +where('binding_token_hash', hash('sha256', $token)) + ->lockForUpdate() + ->first(); + + if (! $state || $state->expires_at->isPast() || ! $state->union_id) { + return false; + } + + $unionOwner = DingTalkIdentity::query()->where('union_id', $state->union_id)->first(); + if ($unionOwner && (int) $unionOwner->user_id !== (int) $user->id) { + return false; + } + + $userIdentity = DingTalkIdentity::query()->where('user_id', $user->id)->first(); + if ($userIdentity && $userIdentity->union_id !== $state->union_id) { + return false; + } + + DingTalkIdentity::query()->updateOrCreate( + ['user_id' => $user->id], + [ + 'union_id' => $state->union_id, + 'open_id' => $state->open_id, + 'nickname' => $state->nickname, + ] + ); + + $state->forceFill(['binding_token_hash' => null])->save(); + + return true; + }); + } +} diff --git a/app/Services/Auth/DingTalkOAuthClient.php b/app/Services/Auth/DingTalkOAuthClient.php new file mode 100644 index 00000000..16c832de --- /dev/null +++ b/app/Services/Auth/DingTalkOAuthClient.php @@ -0,0 +1,116 @@ +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; + } +} diff --git a/config/dingtalk.php b/config/dingtalk.php new file mode 100644 index 00000000..1b798326 --- /dev/null +++ b/config/dingtalk.php @@ -0,0 +1,20 @@ + (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), +]; diff --git a/database/migrations/2026_09_03_000000_create_dingtalk_login_tables.php b/database/migrations/2026_09_03_000000_create_dingtalk_login_tables.php new file mode 100644 index 00000000..77a8d7d4 --- /dev/null +++ b/database/migrations/2026_09_03_000000_create_dingtalk_login_tables.php @@ -0,0 +1,47 @@ +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'); + } +}; diff --git a/deploy/configure-dingtalk-callback.sh b/deploy/configure-dingtalk-callback.sh new file mode 100644 index 00000000..b8627dca --- /dev/null +++ b/deploy/configure-dingtalk-callback.sh @@ -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" diff --git a/deploy/host-nginx-8442-dingtalk.conf b/deploy/host-nginx-8442-dingtalk.conf new file mode 100644 index 00000000..3a0992ac --- /dev/null +++ b/deploy/host-nginx-8442-dingtalk.conf @@ -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; + } +} diff --git a/docker-compose.local.yml b/docker-compose.local.yml index ca5554a3..121a9284 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -28,6 +28,7 @@ services: - ./app:/var/www/html/app - ./config:/var/www/html/config - ./database:/var/www/html/database + - ./deploy:/var/www/html/deploy - ./modules:/var/www/html/modules - ./resources:/var/www/html/resources - ./routes:/var/www/html/routes diff --git a/resources/lang/en-GB/auth.php b/resources/lang/en-GB/auth.php index 8a823068..f07cf20c 100644 --- a/resources/lang/en-GB/auth.php +++ b/resources/lang/en-GB/auth.php @@ -48,6 +48,15 @@ return [ 'throttle' => 'Too many login attempts. Please try again in :seconds seconds.', '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' => [ '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.', diff --git a/resources/lang/zh-CN/auth.php b/resources/lang/zh-CN/auth.php index 6a3b0f50..3d8bb186 100644 --- a/resources/lang/zh-CN/auth.php +++ b/resources/lang/zh-CN/auth.php @@ -48,6 +48,15 @@ return [ 'throttle' => '尝试登录次数过多。请在 :seconds 秒后重试。', 'disabled' => '此账号已被停用。请联系系统管理员。', + 'dingtalk' => [ + 'login' => '使用钉钉登录', + 'or' => '或', + 'unavailable' => '钉钉登录尚未启用,请暂时使用邮箱密码登录。', + 'failed' => '钉钉登录未完成,请重新发起授权。', + 'confirm_binding' => '首次使用钉钉登录,请输入现有财务系统账号和密码完成绑定。', + 'bound' => '钉钉账号绑定成功,正在为您跳转……', + ], + 'notification' => [ 'message_1' => '您收到此邮件是因为我们收到了您账户的密码重置请求。', 'message_2' => '如果您未请求重置密码,则无需进一步操作。', diff --git a/resources/views/auth/login/create.blade.php b/resources/views/auth/login/create.blade.php index 0e7c364b..ec189814 100644 --- a/resources/views/auth/login/create.blade.php +++ b/resources/views/auth/login/create.blade.php @@ -24,7 +24,23 @@ v-cloak > + @if (session('dingtalk_error')) +