feat: add DingTalk account login
This commit is contained in:
parent
cb02ca8a36
commit
6995ad31c5
130
app/Http/Controllers/Auth/DingTalkLogin.php
Normal file
130
app/Http/Controllers/Auth/DingTalkLogin.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
]);
|
||||
|
||||
@ -26,6 +26,7 @@ class Login extends FormRequest
|
||||
return [
|
||||
'email' => 'required|email',
|
||||
'password' => 'required',
|
||||
'dingtalk_binding' => 'nullable|string|max:255',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
28
app/Models/Auth/DingTalkIdentity.php
Normal file
28
app/Models/Auth/DingTalkIdentity.php
Normal 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);
|
||||
}
|
||||
}
|
||||
25
app/Models/Auth/DingTalkOAuthState.php
Normal file
25
app/Models/Auth/DingTalkOAuthState.php
Normal 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',
|
||||
];
|
||||
}
|
||||
@ -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');
|
||||
|
||||
52
app/Services/Auth/DingTalkIdentityBinder.php
Normal file
52
app/Services/Auth/DingTalkIdentityBinder.php
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
116
app/Services/Auth/DingTalkOAuthClient.php
Normal file
116
app/Services/Auth/DingTalkOAuthClient.php
Normal 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
20
config/dingtalk.php
Normal 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),
|
||||
];
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
50
deploy/configure-dingtalk-callback.sh
Normal file
50
deploy/configure-dingtalk-callback.sh
Normal 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"
|
||||
76
deploy/host-nginx-8442-dingtalk.conf
Normal file
76
deploy/host-nginx-8442-dingtalk.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -48,6 +48,15 @@ return [
|
||||
'throttle' => '尝试登录次数过多。请在 :seconds 秒后重试。',
|
||||
'disabled' => '此账号已被停用。请联系系统管理员。',
|
||||
|
||||
'dingtalk' => [
|
||||
'login' => '使用钉钉登录',
|
||||
'or' => '或',
|
||||
'unavailable' => '钉钉登录尚未启用,请暂时使用邮箱密码登录。',
|
||||
'failed' => '钉钉登录未完成,请重新发起授权。',
|
||||
'confirm_binding' => '首次使用钉钉登录,请输入现有财务系统账号和密码完成绑定。',
|
||||
'bound' => '钉钉账号绑定成功,正在为您跳转……',
|
||||
],
|
||||
|
||||
'notification' => [
|
||||
'message_1' => '您收到此邮件是因为我们收到了您账户的密码重置请求。',
|
||||
'message_2' => '如果您未请求重置密码,则无需进一步操作。',
|
||||
|
||||
@ -24,7 +24,23 @@
|
||||
v-cloak
|
||||
></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">
|
||||
@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"
|
||||
@ -75,6 +91,21 @@
|
||||
</x-button>
|
||||
</div>
|
||||
</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-script folder="auth" file="common" />
|
||||
|
||||
@ -12,6 +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('forgot', 'Auth\Forgot@create')->name('forgot');
|
||||
Route::post('forgot', 'Auth\Forgot@store')->name('forgot.store');
|
||||
|
||||
@ -20,6 +20,7 @@ param(
|
||||
[int]$BuildCacheMaxAgeHours = 720,
|
||||
[switch]$BootstrapServer,
|
||||
[switch]$ConfigureHttps,
|
||||
[switch]$ConfigureDingTalkCallback,
|
||||
[switch]$AllowNonMain,
|
||||
[switch]$AllowMigrations
|
||||
)
|
||||
@ -403,6 +404,8 @@ try {
|
||||
deploy/config-db-init.sh `
|
||||
deploy/update-runtime-url.sh `
|
||||
deploy/setup-https.sh `
|
||||
deploy/configure-dingtalk-callback.sh `
|
||||
deploy/host-nginx-8442-dingtalk.conf `
|
||||
deploy/host-nginx-http.conf `
|
||||
deploy/host-nginx.conf `
|
||||
deploy/setup-incremental-registry.sh `
|
||||
@ -520,6 +523,12 @@ try {
|
||||
} '服务器 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
|
||||
Write-Host "发布完成:$fullImage -> $AppUrl"
|
||||
}
|
||||
|
||||
183
tests/Feature/Auth/DingTalkLoginTest.php
Normal file
183
tests/Feature/Auth/DingTalkLoginTest.php
Normal 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),
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
27
tests/Unit/Deployment/DingTalkCallbackNginxTest.php
Normal file
27
tests/Unit/Deployment/DingTalkCallbackNginxTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user