سی ام اس نویسسی ام اس نویسیاد بگیر، سیستم خودتو بنویس
مجلهتماس با ما
سی ام اس نویسسی ام اس نویس

CMS نویس مرجعی برای آموزش برنامه نویسی وب و PHP، توسعه افزونه‌های اختصاصی وردپرس، طراحی ورک‌فلو و اتوماسیون با n8n و توسعه CMS های اختصاصی است. هدف ما ارائه آموزش‌های کاربردی و راهکارهای حرفه‌ای برای توسعه‌دهندگان، کسب‌وکارها و علاقه‌مندان به دنیای برنامه‌نویسی است.

پیوندهای سریع
محصولات فیزیکیمحصولات دیجیتالدوره‌های آموزشی
سی ام اس نویس
مجله سی ام اس نویستماس با ماپنل کاربری
سی ام اس نویس — تمامی حقوق برای سی ام اس نویس محفوظ است. © 1406 تماس با ما09307711941
پیاده‌سازی SSO با OpenID Connect در PHP؛ ساخت OIDC Client از صفر تا Production

پیاده‌سازی SSO با OpenID Connect در PHP؛ ساخت OIDC Client از صفر تا Production

ممدیر کل سیستم ۳۰ شهریور ۱۴۰۵ ۲۲ دقیقه مطالعه ۳ بازدید
پیاده‌سازی SSO با OpenID Connect در PHP؛ ساخت OIDC Client از صفر تا Production

فرض کنید یک مجموعه نرم‌افزاری چندین سرویس مختلف دارد:


app.example.com

panel.example.com

shop.example.com

اگر هر سرویس سیستم Login مستقل داشته باشد، کاربر مجبور است برای هر برنامه حساب جداگانه داشته باشد یا حداقل فرآیند احراز هویت در چند نقطه تکرار شود.

راهکار استاندارد برای چنین معماری‌ای استفاده از Single Sign-On یا SSO است.

در مقاله‌های قبلی با OAuth 2.0، ساخت Authorization Server و سپس ساخت OpenID Connect Provider آشنا شدیم.

حالا یک مرحله جلوتر می‌رویم و سمت دیگر این معماری را می‌سازیم:

> OpenID Connect Client

در این مقاله یک Client در PHP طراحی می‌کنیم که بتواند:

  • کاربر را به Provider منتقل کند
  • Authorization Code دریافت کند
  • PKCE را اجرا کند
  • State را بررسی کند
  • Nonce را مدیریت کند
  • Authorization Code را به Token تبدیل کند
  • ID Token را اعتبارسنجی کند
  • JWKS Provider را دریافت کند
  • اطلاعات کاربر را از UserInfo بگیرد
  • حساب OIDC را به User داخلی متصل کند
  • Session محلی ایجاد کند
  • Logout را مدیریت کند

در پایان، معماری SSO برای یک پروژه PHP را از دید Client به‌صورت کامل‌تر درک خواهیم کرد.

> نکته مهم: کدهای آموزشی این مقاله برای درک معماری هستند. در Production بهتر است اعتبارسنجی JWT/JOSE، Discovery و سایر اجزای حساس با کتابخانه‌های استاندارد و تست‌شده انجام شوند و رمزنگاری به‌صورت دستی پیاده‌سازی نشود.


# SSO چیست؟

SSO مخفف:


Single Sign-On

است.

یعنی کاربر یک‌بار در سیستم مرکزی Authentication وارد می‌شود و می‌تواند به چند Application مختلف دسترسی داشته باشد.

مثلاً:


                 ┌─────────────────────┐

                 │  OpenID Provider    │

                 │ auth.example.com    │

                 └──────────┬──────────┘

                            │

                ┌───────────┼───────────┐

                ▼           ▼           ▼

             App A       App B       App C

هر Application یک OIDC Client است.


# Provider و Client چه تفاوتی دارند؟

در معماری OIDC:

Provider

مسئول Authentication و صدور Token است.

مثلاً:


auth.example.com

Client

برنامه‌ای است که می‌خواهد کاربر را Authenticate کند.

مثلاً:


app.example.com

پس:


Provider

    │

    │ Authentication

    ▼

Client


# جریان کلی Login

فرآیند Login با OIDC معمولاً چنین است:


User

 │

 ▼

Application

 │

 │ Redirect

 ▼

OIDC Provider

 │

 │ Login

 ▼

Authorization Code

 │

 ▼

Application Callback

 │

 │ Token Request

 ▼

Token Endpoint

 │

 ▼

ID Token + Access Token

 │

 ▼

Validate ID Token

 │

 ▼

Find/Create Local User

 │

 ▼

Create Local Session

نکته مهم این است که Password کاربر در اختیار Application قرار نمی‌گیرد.

کاربر فقط در Provider احراز هویت می‌شود.


# مثال واقعی

فرض کنید:


Provider:

https://auth.example.com



Client:

https://app.example.com

کاربر وارد:


https://app.example.com/login

می‌شود.

Application کاربر را به:


https://auth.example.com/oauth/authorize

می‌فرستد.

Provider Login را انجام می‌دهد.

سپس کاربر به:


https://app.example.com/oauth/callback

برمی‌گردد.


# ساختار پروژه Client

یک ساختار ساده:


oidc-client/

│

├── config/

│   └── oidc.php

│

├── src/

│   ├── OidcClient.php

│   ├── TokenValidator.php

│   └── UserMapper.php

│

├── public/

│   ├── login.php

│   ├── callback.php

│   ├── logout.php

│   └── index.php

│

└── vendor/


# Configuration

یک فایل:


config/oidc.php

ایجاد می‌کنیم:


<?php



return [

    'issuer' => 'https://auth.example.com',



    'client_id' => 'app-client',



    'client_secret' => 'YOUR_CLIENT_SECRET',



    'redirect_uri' =>

        'https://app.example.com/oauth/callback',



    'scope' =>

        'openid profile email',

];

در Production نباید Secret را مستقیماً داخل Repository قرار دهیم.

بهتر است از Environment Variable استفاده کنیم:


OIDC_ISSUER=https://auth.example.com

OIDC_CLIENT_ID=app-client

OIDC_CLIENT_SECRET=...

OIDC_REDIRECT_URI=https://app.example.com/oauth/callback


# Discovery

اولین کار Client این است که اطلاعات Provider را از Discovery دریافت کند.

آدرس:


https://auth.example.com/.well-known/openid-configuration

ممکن است Response چیزی شبیه این باشد:


{

    "issuer": "https://auth.example.com",

    "authorization_endpoint":

        "https://auth.example.com/oauth/authorize",

    "token_endpoint":

        "https://auth.example.com/oauth/token",

    "userinfo_endpoint":

        "https://auth.example.com/oidc/userinfo",

    "jwks_uri":

        "https://auth.example.com/.well-known/jwks.json"

}

Client با این اطلاعات می‌فهمد Endpointهای Provider کجا هستند.


# چرا Discovery مهم است؟

بدون Discovery مجبور بودیم Endpointها را به‌صورت دستی وارد Configuration کنیم:


authorization_endpoint

token_endpoint

userinfo_endpoint

jwks_uri

اما با Discovery فقط Issuer را می‌دانیم:


https://auth.example.com

و بقیه اطلاعات را از Provider دریافت می‌کنیم.


# دریافت Discovery با PHP

برای نمونه آموزشی:


<?php



$issuer = 'https://auth.example.com';



$url = rtrim($issuer, '/') .

    '/.well-known/openid-configuration';



$response = file_get_contents($url);



$config = json_decode(

    $response,

    true,

    512,

    JSON_THROW_ON_ERROR

);



var_dump($config);

در Production بهتر است از HTTP Client مناسب با:

  • Timeout
  • TLS validation
  • Error handling
  • Retry policy مناسب

استفاده شود.


# بررسی Issuer

یکی از نکات مهم این است که Client نباید هر Discovery Response را قبول کند.

مثلاً:


if ($config['issuer'] !== $issuer) {

    throw new RuntimeException(

        'Invalid issuer'

    );

}

این بررسی باعث می‌شود Provider مورد انتظار با Provider دیگری اشتباه نشود.


# ساخت Login URL

حالا باید کاربر را به Authorization Endpoint بفرستیم.

برای این کار چند مقدار تصادفی تولید می‌کنیم.


# ساخت State


$state = bin2hex(random_bytes(32));

State باید در Session محلی ذخیره شود:


$_SESSION['oidc_state'] = $state;

چرا؟

چون هنگام Callback باید بررسی کنیم State برگشتی همان State درخواست اولیه است.


# ساخت Nonce


$nonce = bin2hex(random_bytes(32));



$_SESSION['oidc_nonce'] = $nonce;

Nonce در OIDC برای اتصال ID Token به Authentication Request استفاده می‌شود.


# ساخت PKCE

ابتدا:


$codeVerifier = rtrim(

    strtr(

        base64_encode(random_bytes(32)),

        '+/',

        '-_'

    ),

    '='

);

سپس:


$codeChallenge = rtrim(

    strtr(

        base64_encode(

            hash(

                'sha256',

                $codeVerifier,

                true

            )

        ),

        '+/',

        '-_'

    ),

    '='

);

و:


$_SESSION['oidc_code_verifier']

    = $codeVerifier;


# ساخت Authorization URL

پارامترهای اصلی:


$params = [

    'response_type' => 'code',



    'client_id' => $config['client_id'],



    'redirect_uri' =>

        $config['redirect_uri'],



    'scope' =>

        'openid profile email',



    'state' => $state,



    'nonce' => $nonce,



    'code_challenge' =>

        $codeChallenge,



    'code_challenge_method' =>

        'S256',

];

سپس:


$url =

    $provider['authorization_endpoint']

    . '?'

    . http_build_query($params);

و:


header('Location: ' . $url);

exit;


# Login.php

یک نسخه ساده:


<?php



session_start();



$config = require __DIR__ .

    '/../config/oidc.php';



$discoveryUrl =

    rtrim($config['issuer'], '/') .

    '/.well-known/openid-configuration';



$provider = json_decode(

    file_get_contents($discoveryUrl),

    true,

    512,

    JSON_THROW_ON_ERROR

);



$state = bin2hex(

    random_bytes(32)

);



$nonce = bin2hex(

    random_bytes(32)

);



$codeVerifier = rtrim(

    strtr(

        base64_encode(

            random_bytes(32)

        ),

        '+/',

        '-_'

    ),

    '='

);



$codeChallenge = rtrim(

    strtr(

        base64_encode(

            hash(

                'sha256',

                $codeVerifier,

                true

            )

        ),

        '+/',

        '-_'

    ),

    '='

);



$_SESSION['oidc_state'] = $state;

$_SESSION['oidc_nonce'] = $nonce;

$_SESSION['oidc_code_verifier'] =

    $codeVerifier;



$params = [

    'response_type' => 'code',

    'client_id' => $config['client_id'],

    'redirect_uri' =>

        $config['redirect_uri'],

    'scope' =>

        'openid profile email',

    'state' => $state,

    'nonce' => $nonce,

    'code_challenge' =>

        $codeChallenge,

    'code_challenge_method' =>

        'S256',

];



$url =

    $provider['authorization_endpoint']

    . '?'

    . http_build_query($params);



header('Location: ' . $url);

exit;


# کاربر چه چیزی می‌بیند؟

وقتی کاربر روی Login کلیک می‌کند:


app.example.com/login

        │

        ▼

auth.example.com/oauth/authorize

در Provider:


┌─────────────────────────────┐

│ ورود به حساب                │

│                             │

│ ایمیل                       │

│ [.........................] │

│                             │

│ رمز عبور                    │

│ [.........................] │

│                             │

│         ورود                │

└─────────────────────────────┘

پسورد فقط در Provider وارد می‌شود.


# Callback چیست؟

بعد از Authentication، Provider کاربر را به Redirect URI برمی‌گرداند:


https://app.example.com/oauth/callback

مثلاً:


/oauth/callback?code=ABC123&state=XYZ

حالا مهم‌ترین قسمت Client شروع می‌شود.


# مرحله اول Callback: بررسی Error

ابتدا بررسی کنیم آیا Provider خطایی ارسال کرده است یا خیر:


if (isset($_GET['error'])) {



    throw new RuntimeException(

        $_GET['error']

    );

}

در برنامه واقعی بهتر است Error را به شکل کنترل‌شده مدیریت کنیم و مقدار خام آن را مستقیماً به کاربر نمایش ندهیم.


# مرحله دوم: بررسی State

State ذخیره‌شده:


$expectedState =

    $_SESSION['oidc_state'] ?? null;

State برگشتی:


$receivedState =

    $_GET['state'] ?? null;

مقایسه:


if (

    !$expectedState ||

    !$receivedState ||

    !hash_equals(

        $expectedState,

        $receivedState

    )

) {

    throw new RuntimeException(

        'Invalid state'

    );

}

این مرحله بسیار مهم است.


# چرا State مهم است؟

فرض کنید مهاجم تلاش کند Response مربوط به یک Authorization Request دیگر را وارد Session کاربر کند.

State به Client کمک می‌کند Response را به Request اصلی متصل کند.

به همین دلیل نباید State را حذف کنیم.


# مرحله سوم: دریافت Authorization Code


$code = $_GET['code'] ?? null;



if (!$code) {

    throw new RuntimeException(

        'Authorization code missing'

    );

}


# مرحله چهارم: Token Request

Client حالا Code را به Token Endpoint می‌فرستد.

مثلاً:


POST /oauth/token

با:


grant_type=authorization_code

client_id=app-client

code=ABC123

redirect_uri=https://app.example.com/oauth/callback

code_verifier=...


# نمونه ارسال Token Request

برای آموزش می‌توانیم از cURL استفاده کنیم:


$postData = [

    'grant_type' =>

        'authorization_code',



    'client_id' =>

        $config['client_id'],



    'client_secret' =>

        $config['client_secret'],



    'code' =>

        $code,



    'redirect_uri' =>

        $config['redirect_uri'],



    'code_verifier' =>

        $_SESSION['oidc_code_verifier'],

];



$ch = curl_init(

    $provider['token_endpoint']

);



curl_setopt_array($ch, [

    CURLOPT_POST => true,



    CURLOPT_POSTFIELDS =>

        http_build_query($postData),



    CURLOPT_RETURNTRANSFER => true,



    CURLOPT_HTTPHEADER => [

        'Content-Type: application/x-www-form-urlencoded',

        'Accept: application/json',

    ],



    CURLOPT_TIMEOUT => 10,

]);



$response = curl_exec($ch);



if ($response === false) {

    throw new RuntimeException(

        curl_error($ch)

    );

}



curl_close($ch);



$tokens = json_decode(

    $response,

    true,

    512,

    JSON_THROW_ON_ERROR

);


# Token Response

Provider ممکن است چنین Responseای برگرداند:


{

    "access_token": "ACCESS_TOKEN",

    "token_type": "Bearer",

    "expires_in": 3600,

    "refresh_token": "REFRESH_TOKEN",

    "id_token": "ID_TOKEN"

}

در اینجا:


access_token

برای Resource Server است.

و:


id_token

برای Authentication است.


# ID Token را فوراً قبول نکنید

یکی از خطرناک‌ترین اشتباهات:


$payload = decodeJwt($tokens['id_token']);



$_SESSION['user'] = $payload;

این کافی نیست.

باید اول:


Signature

Issuer

Audience

Expiration

Nonce

Algorithm

بررسی شوند.


# دریافت JWKS

Client از Discovery مقدار:


jwks_uri

را دریافت کرده است.

مثلاً:


https://auth.example.com/.well-known/jwks.json

حالا:


$jwks = json_decode(

    file_get_contents(

        $provider['jwks_uri']

    ),

    true,

    512,

    JSON_THROW_ON_ERROR

);


# پیدا کردن Key بر اساس kid

فرض کنیم Header JWT:


{

    "alg": "RS256",

    "kid": "key-2026-01"

}

باشد.

Client باید در JWKS دنبال:


kid = key-2026-01

بگردد.

مثلاً:


function findJwk(

    array $jwks,

    string $kid

): array {



    foreach ($jwks['keys'] as $key) {



        if (

            isset($key['kid']) &&

            hash_equals(

                $key['kid'],

                $kid

            )

        ) {

            return $key;

        }

    }



    throw new RuntimeException(

        'Signing key not found'

    );

}


# اعتبارسنجی Signature

در این قسمت باید JWT با Public Key متناظر اعتبارسنجی شود.

این بخش را نباید با توابع ساده و ناقص خودمان برای Production پیاده کنیم.

یک کتابخانه JOSE/JWT معتبر باید:


JWT

 ↓

Header

 ↓

kid

 ↓

JWKS

 ↓

Public Key

 ↓

Signature Verification

را انجام دهد.


# بررسی Issuer

بعد از اعتبارسنجی Signature:


if (

    $claims['iss'] !==

    $config['issuer']

) {

    throw new RuntimeException(

        'Invalid issuer'

    );

}


# بررسی Audience

باید بررسی کنیم ID Token برای Client خودمان صادر شده است:


if (

    $claims['aud'] !==

    $config['client_id']

) {

    throw new RuntimeException(

        'Invalid audience'

    );

}

در بعضی پیاده‌سازی‌ها aud می‌تواند آرایه باشد؛ بنابراین Validator واقعی باید هر دو حالت استاندارد را به‌درستی مدیریت کند.


# بررسی Expiration


if (

    !isset($claims['exp']) ||

    $claims['exp'] < time()

) {

    throw new RuntimeException(

        'ID Token expired'

    );

}

در Validator واقعی باید Clock Skew مجاز نیز در طراحی در نظر گرفته شود.


# بررسی Nonce

Nonce اولیه:


$expectedNonce =

    $_SESSION['oidc_nonce'];

Nonce داخل ID Token:


$tokenNonce =

    $claims['nonce'] ?? null;

مقایسه:


if (

    !$tokenNonce ||

    !hash_equals(

        $expectedNonce,

        $tokenNonce

    )

) {

    throw new RuntimeException(

        'Invalid nonce'

    );

}


# چرا Nonce اینقدر مهم است؟

چون Client باید مطمئن شود ID Token مربوط به همان Authentication Request است که خودش ایجاد کرده است.

در OIDC:


Authentication Request

        │

        │ nonce

        ▼

Provider

        │

        │ ID Token

        ▼

Client

        │

        ▼

Compare nonce


# دریافت UserInfo

بعد از دریافت Access Token می‌توانیم UserInfo را درخواست کنیم.


$ch = curl_init(

    $provider['userinfo_endpoint']

);



curl_setopt_array($ch, [

    CURLOPT_RETURNTRANSFER => true,



    CURLOPT_HTTPHEADER => [

        'Authorization: Bearer ' .

            $tokens['access_token'],



        'Accept: application/json',

    ],



    CURLOPT_TIMEOUT => 10,

]);



$response = curl_exec($ch);



curl_close($ch);



$userInfo = json_decode(

    $response,

    true,

    512,

    JSON_THROW_ON_ERROR

);

مثلاً:


{

    "sub": "user_123",

    "name": "Ali",

    "email": "user@example.com",

    "email_verified": true

}


# ID Token یا UserInfo؟

هر دو می‌توانند اطلاعاتی درباره کاربر داشته باشند، اما کاربردشان متفاوت است.

برای Authentication ابتدا ID Token را اعتبارسنجی می‌کنیم.

سپس در صورت نیاز می‌توانیم UserInfo را برای دریافت اطلاعات پروفایل درخواست کنیم.

نباید صرفاً یک Email دریافتی از UserInfo را بدون اعتبارسنجی Token و بدون اتصال صحیح Identity به حساب داخلی معتبر فرض کنیم.


# اتصال کاربر OIDC به کاربر داخلی

فرض کنید در دیتابیس Application جدول:


users

داریم.

و جدول Identity:


CREATE TABLE user_identities (

    id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,



    user_id BIGINT UNSIGNED NOT NULL,



    issuer VARCHAR(255) NOT NULL,



    subject VARCHAR(255) NOT NULL,



    created_at DATETIME NOT NULL,



    UNIQUE KEY unique_oidc_identity (

        issuer,

        subject

    )

);

پس هویت خارجی کاربر را با:


issuer

+

subject

نگهداری می‌کنیم.


# چرا issuer + subject؟

فرض کنید دو Provider داریم:


https://auth.example.com

https://login.example.org

و هر دو کاربری با:


sub = user_123

دارند.

اگر فقط sub را ذخیره کنیم، ممکن است دو هویت مختلف را اشتباه یکی کنیم.

اما:


issuer + sub

هویت را مشخص می‌کند.


# فرآیند پیدا کردن User

به‌صورت مفهومی:


ID Token

   │

   ├── iss

   └── sub

        │

        ▼

user_identities

        │

   ┌────┴────┐

   │         │

 exists     not exists

   │         │

   ▼         ▼

Login     Create/Link


# ساخت حساب کاربر

اگر Identity وجود نداشته باشد و سیاست Application اجازه ساخت خودکار حساب بدهد:


OIDC Identity

      │

      ▼

Create User

      │

      ▼

Create user_identity

      │

      ▼

Login

اما بهتر است این رفتار قابل تنظیم باشد.

در برخی سیستم‌ها باید کاربر ابتدا توسط مدیر ایجاد شده باشد.


# اتصال بر اساس Email

گاهی می‌خواهیم اگر Email کاربر OIDC با Email یک User داخلی برابر بود، حساب‌ها را به هم متصل کنیم.

اما این کار باید با احتیاط انجام شود.

مثلاً صرفاً:


email == email

نباید همیشه به معنای مالکیت قطعی حساب داخلی تلقی شود.

اگر Provider اطلاعات email_verified را ارائه می‌دهد، باید وضعیت آن نیز بررسی شود و سیاست Linking حساب به‌صورت شفاف تعریف شود.

برای سیستم‌های حساس، Account Linking بهتر است نیازمند تأیید مجدد کاربر باشد.


# ایجاد Session محلی

بعد از اینکه Identity معتبر شد:


session_regenerate_id(true);



$_SESSION['user_id'] =

    $localUser['id'];



$_SESSION['authenticated'] =

    true;

حالا Application Session خودش را دارد.

نکته مهم:


OIDC Token

    ≠

Application Session


# چرا Session محلی بسازیم؟

لازم نیست در هر Request به Provider مراجعه کنیم.

بهتر است:


Login

  │

  ▼

OIDC

  │

  ▼

Local Session

  │

  ▼

Application Requests

داشته باشیم.

در نتیجه کاربر در Application با Session داخلی مدیریت می‌شود.


# Cookie Session

Session Cookie باید با تنظیمات مناسب امنیتی استفاده شود.

مثلاً:


session_set_cookie_params([

    'secure' => true,

    'httponly' => true,

    'samesite' => 'Lax',

]);

در محیط Production و با توجه به معماری دقیق Domainها، مقدار SameSite باید بر اساس نیاز واقعی انتخاب شود.


# پاک کردن اطلاعات موقت OIDC

بعد از Login موفق:


unset(

    $_SESSION['oidc_state'],

    $_SESSION['oidc_nonce'],

    $_SESSION['oidc_code_verifier']

);

اطلاعات موقتی که دیگر مورد نیاز نیستند نباید بی‌دلیل در Session باقی بمانند.


# Logout محلی

در ساده‌ترین حالت:


<?php



session_start();



$_SESSION = [];



if (ini_get('session.use_cookies')) {



    $params =

        session_get_cookie_params();



    setcookie(

        session_name(),

        '',

        time() - 42000,

        $params['path'],

        $params['domain'],

        $params['secure'],

        $params['httponly']

    );

}



session_destroy();



header('Location: /');



exit;

این فقط Local Logout است.


# Local Logout با Provider Logout فرق دارد

فرض کنید:


Application

    ↓

Logout

اما Session کاربر در Provider همچنان فعال است.

اگر کاربر دوباره روی Login کلیک کند:


Application

    ↓

Provider

    ↓

Provider Session موجود است

    ↓

Login بدون فرم

ممکن است دوباره وارد Application شود.

بنابراین در معماری SSO باید از ابتدا مشخص کنیم:


Local Logout

یا:


Provider Logout

یا هر دو مورد را می‌خواهیم.


# RP-Initiated Logout

OpenID Connect می‌تواند فرآیند Logout را نیز استاندارد کند.

Client می‌تواند کاربر را به Logout Endpoint Provider منتقل کند.

پارامترهایی مانند:


id_token_hint

post_logout_redirect_uri

state

ممکن است در این فرآیند استفاده شوند.

اگر Provider از این قابلیت پشتیبانی کند، Application می‌تواند Logout مرکزی را نیز آغاز کند.


# Post Logout Redirect

مانند Redirect URI در Login، مقصد بعد از Logout نیز باید Allowlist شود.

مثلاً:


https://app.example.com/

نباید اجازه دهیم:


?post_logout_redirect_uri=https://evil.example

بدون اعتبارسنجی پذیرفته شود.


# مدیریت Refresh Token

اگر Client یک Refresh Token دریافت کند، باید آن را مانند یک Credential حساس در نظر بگیریم.

نباید:


در HTML

در URL

در JavaScript

در Log

در LocalStorage بدون بررسی معماری

قرار گیرد.

در یک Server-side PHP Application معمولاً نگهداری آن در سمت Server گزینه مناسب‌تری نسبت به ارسال آن به Browser است.


# Refresh کردن Access Token

وقتی Access Token منقضی شود:


Access Token

     │

     ▼

Expired

     │

     ▼

Refresh Token

     │

     ▼

Token Endpoint

     │

     ├── New Access Token

     └── New Refresh Token

اگر Provider از Refresh Token Rotation استفاده کند، Refresh Token قبلی نیز باید طبق سیاست Provider باطل شود.


# معماری کامل SSO

حالا کل سیستم ما چنین چیزی می‌شود:


                    ┌───────────────────┐

                    │     Browser       │

                    └─────────┬─────────┘

                              │

                              ▼

                    ┌───────────────────┐

                    │   PHP Application │

                    │      OIDC Client  │

                    └─────────┬─────────┘

                              │

                     Authorization

                              │

                              ▼

                    ┌───────────────────┐

                    │ OpenID Provider   │

                    │                   │

                    │ Login             │

                    │ Consent           │

                    │ Token             │

                    │ JWKS              │

                    │ UserInfo          │

                    └─────────┬─────────┘

                              │

                              ▼

                    ┌───────────────────┐

                    │   Local Session   │

                    └───────────────────┘


# اگر چند Application داشته باشیم چه؟

فرض کنید:


app.example.com

panel.example.com

shop.example.com

هر سه Client جداگانه‌ای هستند:


Client A

Client B

Client C

اما همه به:


auth.example.com

متصل هستند.

کاربر:


Login → App A

و سپس:


App B → Provider → Session موجود → Login

را تجربه می‌کند.


# امنیت SSO در چند Application

در سیستم چند Client، هر Client باید:

  • client_id خودش را داشته باشد
  • redirect_uri خودش را داشته باشد
  • Scopeهای مورد نیاز خودش را داشته باشد
  • Secret خودش را داشته باشد، در صورت confidential بودن
  • Session خودش را مدیریت کند

نباید یک Client Secret را بین چند Application مختلف به اشتراک بگذاریم مگر اینکه معماری مشخصاً چنین چیزی را ایجاب کند.


# Public Client و Confidential Client

یکی از مفاهیم مهم OIDC این است که همه Clientها یکسان نیستند.

Confidential Client

مثلاً یک PHP Server-side Application.

این Application می‌تواند Secret را روی Server نگه دارد.

Public Client

مثل بعضی SPAها یا Native Apps.

این نوع Client نمی‌تواند Secret را به شکل قابل اعتماد محرمانه نگه دارد.

به همین دلیل PKCE اهمیت بیشتری پیدا می‌کند.


# چرا PHP Server-side برای OIDC ساده‌تر است؟

در PHP Server-side:


Browser

   │

   ▼

PHP Server

   │

   ├── Client Secret

   ├── Code Verifier

   ├── Access Token

   └── Refresh Token

می‌تواند در سمت Server مدیریت شود.

در نتیجه اطلاعات حساس لازم نیست در Browser نگهداری شوند.


# حملات و اشتباهات رایج

1. حذف State

نباید Authorization Response را بدون بررسی State قبول کنیم.


2. حذف PKCE

حتی اگر Client Secret داریم، PKCE یک لایه امنیتی مهم برای Authorization Code Flow است.


3. عدم بررسی Nonce

در OIDC باید Nonce صحیح مدیریت شود.


4. عدم بررسی Audience

ممکن است Token معتبر باشد اما برای Client دیگری صادر شده باشد.


5. اعتماد به Email بدون سیاست Account Linking

Email به‌تنهایی نباید باعث اتصال خودکار دو حساب شود مگر اینکه سیاست و سطح اعتماد Provider مشخص باشد.


6. Decode کردن JWT بدون Verify

این کار به تنهایی Authentication محسوب نمی‌شود.


7. قبول هر JWKS

JWKS باید از Issuer مورد انتظار دریافت شود و زنجیره اعتماد صحیح باشد.


8. ذخیره Token در URL

Access Token و Refresh Token نباید در URL قرار بگیرند.


9. قرار دادن Token در Log

مثلاً:


Log::info($accessToken);

نباید انجام شود.


10. عدم استفاده از HTTPS

Authentication بدون HTTPS در محیط Production مناسب نیست.


# ساخت یک OIDC Client Class

بعد از اینکه مفهوم را فهمیدیم، بهتر است منطق را از فایل‌های PHP جدا کنیم.

مثلاً:


class OidcClient

{

    public function __construct(

        private array $config

    ) {}



    public function getAuthorizationUrl(): string

    {

        // Discovery

        // State

        // Nonce

        // PKCE

        // URL generation

    }



    public function exchangeCode(

        string $code,

        string $codeVerifier

    ): array {

        // Token Request

    }



    public function validateIdToken(

        string $idToken,

        string $nonce

    ): array {

        // JWT validation

    }



    public function getUserInfo(

        string $accessToken

    ): array {

        // UserInfo

    }

}

این ساختار در پروژه‌های بزرگ بسیار قابل نگهداری‌تر است.


# ساختار نهایی پیشنهادی


src/

│

├── Oidc/

│   ├── Discovery.php

│   ├── Authorization.php

│   ├── TokenClient.php

│   ├── IdTokenValidator.php

│   ├── JwksClient.php

│   └── UserInfoClient.php

│

├── Auth/

│   ├── UserMapper.php

│   └── SessionManager.php

│

└── Security/

    ├── StateManager.php

    └── PkceManager.php

این تفکیک باعث می‌شود هر بخش مسئولیت مشخصی داشته باشد.


# تست فرآیند Login

برای تست کامل:

مرحله اول

به:


/app/login

بروید.

مرحله دوم

بررسی کنید Browser به:


/auth/authorize

منتقل شده است.

مرحله سوم

در Provider Login کنید.

مرحله چهارم

بررسی کنید به:


/app/oauth/callback

برگشته‌اید.

مرحله پنجم

Callback باید Code دریافت کند.

مرحله ششم

Code به Token Endpoint ارسال شود.

مرحله هفتم

ID Token اعتبارسنجی شود.

مرحله هشتم

User داخلی پیدا یا ایجاد شود.

مرحله نهم

Local Session ایجاد شود.


# چه چیزهایی را باید در لاگ ثبت کنیم؟

برای Debug می‌توانیم اطلاعات غیرحساس را ثبت کنیم:


OIDC login started

OIDC callback received

Client ID

Issuer

Token endpoint status

User internal ID

Authentication success/failure

اما موارد زیر نباید Log شوند:


Password

Access Token

Refresh Token

ID Token کامل

Authorization Code

Client Secret

Private Key

Code Verifier

در محیط Production حتی بعضی از موارد غیرحساس نیز باید با توجه به نیاز واقعی Logging محدود شوند.


# چک‌لیست Production

قبل از Production کردن OIDC Client:

  • [ ] HTTPS فعال است
  • [ ] Discovery اعتبارسنجی می‌شود
  • [ ] Issuer بررسی می‌شود
  • [ ] State تصادفی است
  • [ ] State در Callback بررسی می‌شود
  • [ ] Nonce تصادفی است
  • [ ] Nonce در ID Token بررسی می‌شود
  • [ ] PKCE فعال است
  • [ ] Code Verifier به‌صورت امن نگهداری می‌شود
  • [ ] Redirect URI دقیق است
  • [ ] Authorization Code فقط یک‌بار مصرف می‌شود
  • [ ] ID Token Signature بررسی می‌شود
  • [ ] Algorithm Allowlist شده
  • [ ] kid از JWT با JWKS تطبیق داده می‌شود
  • [ ] Issuer بررسی می‌شود
  • [ ] Audience بررسی می‌شود
  • [ ] Expiration بررسی می‌شود
  • [ ] Subject پایدار است
  • [ ] User Identity با issuer + sub ذخیره می‌شود
  • [ ] Session بعد از Authentication با session_regenerate_id() بازسازی می‌شود
  • [ ] Tokenها در URL قرار نمی‌گیرند
  • [ ] Tokenها در Log ذخیره نمی‌شوند
  • [ ] Client Secret فقط سمت Server نگهداری می‌شود
  • [ ] Refresh Token امن نگهداری می‌شود
  • [ ] Logout به‌درستی طراحی شده
  • [ ] Post Logout Redirect Allowlist شده
  • [ ] Rate Limiting برای Login/Callback در نظر گرفته شده
  • [ ] کتابخانه JWT/JOSE معتبر استفاده شده است

# جمع‌بندی

در این مقاله از سمت Provider به سمت Client حرکت کردیم و یک معماری کامل‌تر برای SSO در PHP ساختیم.

فرآیند اصلی به این شکل است:


User

 ↓

PHP Application

 ↓

OIDC Authorization Endpoint

 ↓

Login

 ↓

Authorization Code

 ↓

Callback

 ↓

Token Endpoint

 ↓

ID Token + Access Token

 ↓

Signature Validation

 ↓

Issuer / Audience / Exp / Nonce

 ↓

UserInfo

 ↓

issuer + sub

 ↓

Local User

 ↓

Local Session

نکته مهم این است که OIDC فقط یک Redirect ساده برای Login نیست.

برای پیاده‌سازی صحیح باید چند لایه امنیتی را همزمان در نظر بگیریم:


OAuth 2.0

+

PKCE

+

State

+

Nonce

+

JWT Validation

+

JWKS

+

Session Security

+

Secure Account Linking

=

OIDC Client

با داشتن Provider و Client، حالا معماری SSO ما تقریباً کامل شده است.

در مرحله بعد می‌توانیم یک بخش بسیار مهم دیگر را به این زنجیره اضافه کنیم: مدیریت حرفه‌ای کلیدهای JWT و JWKS در PHP؛ شامل ساخت RSA Key Pair، kid، Key Rotation، انتشار چند کلید همزمان در JWKS، اعتبارسنجی کلیدها و نحوه تغییر کلید بدون از کار افتادن Login کاربران.

نظرات (0)

نظرات پس از تأیید مدیر نمایش داده می‌شوند

هنوز نظری ثبت نشده است؛ اولین نفر باشید!

مقالات مرتبط

ساخت CMS با Laravel 12؛ آموزش ساخت سیستم مدیریت محتوا از صفر

ساخت CMS با Laravel 12؛ آموزش ساخت سیستم مدیریت محتوا از صفر

۲۴ دقیقه
ساخت فروشگاه اینترنتی با Laravel 12؛ آموزش پروژه‌محور از صفر تا طراحی هسته فروشگاه

ساخت فروشگاه اینترنتی با Laravel 12؛ آموزش پروژه‌محور از صفر تا طراحی هسته فروشگاه

۲۲ دقیقه
ساخت Marketplace با Laravel؛ آموزش طراحی فروشگاه چندفروشندگی

ساخت Marketplace با Laravel؛ آموزش طراحی فروشگاه چندفروشندگی

۲۲ دقیقه
مدیریت Queueهای سنگین در Laravel؛ آموزش پردازش کارهای طولانی

مدیریت Queueهای سنگین در Laravel؛ آموزش پردازش کارهای طولانی

۲۲ دقیقه