📄 Package Laravel xác thực OAuth2 (SSO Client)

🔖 Mô tả chung

Mục tiêu: Xây dựng một Laravel package đóng vai trò như một client thư viện OAuth2, phục vụ việc xác thực người dùng thông qua hệ thống SSO (Single Sign-On).

Đối tượng sử dụng: Các hệ thống Laravel khác (gọi là client app) muốn xác thực người dùng thông qua hệ thống SSO Provider trung tâm.

Kết quả mong đợi: Một package Laravel hoàn chỉnh, dễ cài đặt và tích hợp, cho phép client app:

  • Redirect người dùng tới SSO để đăng nhập
  • Nhận token và thông tin người dùng
  • Tự động xử lý xác thực và lưu thông tin đăng nhập
  • Gọi được API với token đã nhận

⚙️ Kỹ thuật sử dụng

  • Chuẩn xác thực: OAuth2 Authorization Code Flow
  • Laravel version: Tương thích Laravel 9 trở lên

📦 Cài đặt

A. Cài đặt Package

composer require thk-hd/sso-client

B. Chạy Install Command (Khuyến nghị)

php artisan sso-client:install

Command này sẽ tự động:

  • ✅ Publish config file
  • ✅ Tạo routes file với đầy đủ routes
  • ✅ Thêm biến môi trường vào .env
  • ✅ Load routes file
  • ✅ Register middleware
  • ✅ Configure admin check

C. Setup thủ công (nếu không dùng install command)

php artisan vendor:publish --tag=sso-client-config

⚙️ Cấu hình

Environment Variables

Thêm vào file .env:

SSO_SERVER_URL=http://127.0.0.1:8001
SSO_CLIENT_ID=xxxxxxxxxxxxxxxxxx
SSO_CLIENT_SECRET=xxxxxxxxxxxxxxxxxx
SSO_REDIRECT_URI=http://localhost:8000/callback
SSO_MENUS_URI=/api/menus
SSO_NAVIGATION_ENABLED=true
SSO_REMOTE_LOGOUT_SECRET=your-strong-secret-token-here
SSO_REMOTE_LOGOUT_ENABLED=true

Tạo Remote Logout Secret

# Cách 1: Dùng PHP
php -r "echo bin2hex(random_bytes(32));"

# Cách 2: Dùng OpenSSL
openssl rand -hex 32

🚀 Sử dụng nhanh

1. Sử dụng Install Command

php artisan sso-client:install

Sau đó load routes file trong routes/web.php:

require __DIR__.'/sso-client.php';

2. Register Middleware

Trong bootstrap/app.php:

use THKHD\SsoClient\Http\Middleware\AdminMiddleware;
use THKHD\SsoClient\Http\Middleware\PermissionMiddleware;
use THKHD\SsoClient\Http\Middleware\RefreshNavigationMiddleware;

->withMiddleware(function (Middleware $middleware) {
    $middleware->web([RefreshNavigationMiddleware::class]);
    $middleware->alias([
        'admin' => AdminMiddleware::class,
        'permission' => PermissionMiddleware::class,
    ]);
})

3. Configure Admin Check

Trong AppServiceProvider::boot():

config(['sso-client.admin_check' => fn($user) => $user->role === 'admin']);

🔧 Các Method có sẵn

Authentication Methods

buildAuthorizationUrl(string $state, array $extraParams = [])

Tạo URL để redirect đến SSO

getAccessToken(string $code)

Lấy access token từ authorization code

getUser(string $accessToken)

Lấy thông tin user từ SSO server

validateState(?string $sessionState, ?string $requestState)

Validate state parameter để chống CSRF

Token Management

saveSSOToken(string $token)

Lưu token vào session

getSSOToken()

Lấy token từ session

clearSSOToken()

Xóa token khỏi session

revokeToken(string $accessToken)

Revoke token trên SSO server

Navigation Menu

storeNavigationMenu(string $accessToken, ?string $lang = 'en')

Lấy và lưu navigation menu từ SSO

getNavigationMenu()

Lấy navigation menu từ session

clearNavigationMenu()

Xóa navigation menu khỏi session

User Management

createOrUpdateUser(array $userData, ?callable $callback = null)

Tạo hoặc cập nhật user từ SSO data

forceLogout(string|int $identifier)

Force logout user theo email hoặc user_id

🛡️ Middleware

RefreshNavigationMiddleware

Tự động refresh navigation menu từ SSO và đảm bảo token hợp lệ. Middleware sẽ tự động skip nếu:

  • User chưa đăng nhập (guest)
  • Route nằm trong config('sso-client.middleware.skip_routes')
  • navigation_enabled = false
$middleware->web([RefreshNavigationMiddleware::class]);

PermissionMiddleware

Kiểm tra quyền truy cập dựa trên permissions từ SSO session

Route::middleware(['auth', 'permission:app.dashboard'])->group(function () {
    // Protected routes
});

AdminMiddleware

Kiểm tra user có phải admin không

Route::middleware(['auth', 'admin'])->group(function () {
    // Admin routes
});

ValidateSSOSecretMiddleware

Xác thực secret token cho remote logout endpoint

Route::middleware([ValidateSSOSecretMiddleware::class])->group(function () {
    Route::post('remote-logout', [SSOAuthenticateController::class, 'forceLogout']);
});

🔐 Remote Logout (Force Logout từ SSO Server)

Mô tả

Tính năng cho phép SSO server gọi endpoint để force logout user đang đăng nhập trên client app. Hữu ích khi:

  • SSO server thay đổi quyền của user
  • SSO server yêu cầu bắt buộc logout (bảo mật, v.v.)
  • User bị vô hiệu hóa trên SSO server

SSO_REMOTE_LOGOUT_SECRET là gì?

SSO_REMOTE_LOGOUT_SECRET là một secret token dùng để xác thực các request từ SSO server khi gọi endpoint remote logout. Đây là một chuỗi bí mật được chia sẻ giữa SSO server và client app.

Mục đích:
  • Bảo mật: Chỉ SSO server biết secret mới có thể gọi endpoint force logout
  • Ngăn chặn: Tránh người lạ gọi endpoint và logout user bất hợp pháp
  • Audit: Log tất cả requests để theo dõi và audit

Cách tạo Secret

# Cách 1: Dùng PHP
php -r "echo bin2hex(random_bytes(32));"

# Cách 2: Dùng OpenSSL
openssl rand -hex 32

# Cách 3: Dùng Laravel Tinker
php artisan tinker
>>> bin2hex(random_bytes(32))

SSO Server gọi endpoint

Option 1: Dùng Header

curl -X POST https://your-app.com/remote-logout \
  -H "X-SSO-Secret: your-strong-secret-token-here" \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com"}'

Option 2: Dùng Query Parameter

curl -X POST "https://your-app.com/remote-logout?secret=your-strong-secret-token-here" \
  -H "Content-Type: application/json" \
  -d '{"user_id": 123}'

Response

Success:

{
  "success": true,
  "message": "User logged out successfully",
  "identifier": "user@example.com"
}

Lưu Secret trong Database của SSO Server

Có thể lưu secret vào DB của SSO server không?Có, và đây là cách tốt!

Khi quản lý nhiều client apps, SSO server nên lưu secret của từng client vào database để:

  • Quản lý tập trung: Tất cả secrets ở một nơi
  • Dễ rotate: Có thể đổi secret mà không cần cập nhật code
  • Audit trail: Theo dõi lịch sử thay đổi secret
  • Multi-tenant: Mỗi client có secret riêng, độc lập

Cấu trúc Database đề xuất:

CREATE TABLE sso_clients (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    client_id VARCHAR(255) UNIQUE NOT NULL,
    client_secret VARCHAR(255) NOT NULL,
    name VARCHAR(255) NOT NULL,
    redirect_uri TEXT,
    remote_logout_secret VARCHAR(255) NOT NULL,
    remote_logout_url VARCHAR(500),
    remote_logout_enabled BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

Laravel Model (SSO Server):

use Illuminate\Database\Eloquent\Casts\Encrypted;

class SSOClient extends Model
{
    protected $casts = [
        'remote_logout_secret' => Encrypted::class,
        'remote_logout_enabled' => 'boolean',
    ];

    public function forceLogoutUser($userId, $userEmail = null)
    {
        if (!$this->remote_logout_enabled) {
            return ['success' => false, 'message' => 'Remote logout disabled'];
        }

        $response = Http::withHeaders([
            'X-SSO-Secret' => $this->remote_logout_secret,
        ])->post($this->remote_logout_url, [
            'user_id' => $userId,
            'email' => $userEmail,
        ]);

        return $response->json();
    }
}

📝 Ví dụ sử dụng

Ví dụ đầy đủ

use THKHD\SsoClient\Facades\SSOClient;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;

// 1. Redirect to SSO
public function redirectToSSO(Request $request)
{
    $state = Str::random(40);
    $request->session()->put('sso_state', $state);
    $redirectUrl = SSOClient::buildAuthorizationUrl($state);
    return redirect($redirectUrl);
}

// 2. Handle callback
public function handleCallback(Request $request)
{
    $sessionState = $request->session()->pull('sso_state');
    $requestState = $request->query('state');
    
    if (!SSOClient::validateState($sessionState, $requestState)) {
        abort(403, 'Invalid state');
    }
    
    try {
        $tokenData = SSOClient::getAccessToken($request->code);
        $accessToken = $tokenData['access_token'];
        $userInfo = SSOClient::getUser($accessToken);
        
        $user = SSOClient::createOrUpdateUser($userInfo);
        
        // Store navigation menu (chỉ khi navigation_enabled = true)
        if (config('sso-client.navigation_enabled', true)) {
            $locale = $request->session()->get('locale', 'en');
            SSOClient::storeNavigationMenu($accessToken, $locale);
        }
        
        Auth::login($user, true);
        $request->session()->put('sso_token', $accessToken);
        $request->session()->put('sso_permissions', $userInfo['permissions'] ?? []);
        
        return redirect()->intended('/');
    } catch (\Exception $e) {
        logger()->error('SSO authentication failed', ['error' => $e->getMessage()]);
        return redirect()->route('login')->with('error', $e->getMessage());
    }
}