改进 token 认证

This commit is contained in:
Twilight 2024-07-24 00:40:56 +08:00
parent ecf813f1f6
commit f7ebda06f7
25 changed files with 677 additions and 102 deletions

View File

@ -10,10 +10,7 @@ class AssistantController extends Controller
/**
* Display a listing of the resource.
*/
public function index(Request $request)
{
}
public function index(Request $request) {}
/**
* Store a newly created resource in storage.

View File

@ -3,6 +3,7 @@
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Logic\OpenIDLogic;
use App\Models\User;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
@ -18,11 +19,7 @@ class AuthController extends Controller
{
public string $scopes = 'profile email realname openid';
protected string $auth_url;
protected string $token_url;
protected string $user_url;
protected OpenIDLogic $openIDLogic;
protected string $callback_url;
@ -33,24 +30,7 @@ class AuthController extends Controller
*/
public function __construct()
{
$cache_key = 'oauth_discovery';
if (cache()->has($cache_key)) {
$oauth_discovery = cache()->get($cache_key);
} else {
// lock 防止重复请求
$oauth_discovery = cache()->remember($cache_key, 3600, function () {
$client = new Client();
$response = $client->get(config('oauth.discovery'));
return json_decode($response->getBody(), true);
});
}
$this->auth_url = $oauth_discovery['authorization_endpoint'];
$this->token_url = $oauth_discovery['token_endpoint'];
$this->user_url = $oauth_discovery['userinfo_endpoint'];
$this->callback_url = route('oauth.callback');
$this->openIDLogic = app(OpenIDLogic::class);
}
public function redirect(Request $request)
@ -65,7 +45,7 @@ public function redirect(Request $request)
'state' => $state,
]);
return redirect()->to($this->auth_url.'?'.$query);
return redirect()->to($this->openIDLogic->auth_url.'?'.$query);
}
/**
@ -87,7 +67,7 @@ public function callback(Request $request)
$http = new Client;
try {
$authorize = $http->post($this->token_url, [
$authorize = $http->post($this->openIDLogic->token_url, [
'form_params' => [
'grant_type' => 'authorization_code',
'client_id' => config('oauth.client_id'),
@ -102,7 +82,7 @@ public function callback(Request $request)
$authorize = json_decode($authorize->getBody());
$oauth_user = $http->get($this->user_url, [
$oauth_user = $http->get($this->openIDLogic->user_url, [
'headers' => [
'Accept' => 'application/json',
'Authorization' => 'Bearer '.$authorize->access_token,

View File

@ -8,9 +8,7 @@
use cebe\openapi\exceptions\TypeErrorException;
use cebe\openapi\exceptions\UnresolvableReferenceException;
use cebe\openapi\json\InvalidJsonPointerSyntaxException;
use cebe\openapi\Reader;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class ToolController extends Controller
{
@ -28,18 +26,18 @@ public function index()
* @throws UnresolvableReferenceException
* @throws InvalidJsonPointerSyntaxException
*/
public function getOpenAPI() {
$url = "http://127.0.0.1:8081/openapi.yml";
public function getOpenAPI()
{
$url = 'http://127.0.0.1:8081/openapi.yml';
$file = file_get_contents($url);
// $openAPI = new OpenAPI();
//
// $document = $openAPI->parse($file);
//
//
// dd($document->openapi());
// $openAPI = new OpenAPI();
//
// $document = $openAPI->parse($file);
//
//
// dd($document->openapi());
}
/**

View File

@ -0,0 +1,60 @@
<?php
namespace App\Http\Middleware;
use App\Http\Controllers\Web\AuthController;
use App\Models\User;
use Closure;
use Exception;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use stdClass;
use Symfony\Component\HttpFoundation\Response;
class JWTMiddleware
{
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
*/
public function handle(Request $request, Closure $next, string $token_type = 'id_token'): Response
{
try {
$jwks = (new AuthController)->getJwks();
} catch (Exception $e) {
return response()->json(['error' => 'Failed to fetch JWKS data'], 500);
}
$keys = JWK::parseKeySet($jwks);
$jwt = $request->bearerToken();
if (empty($jwt)) {
return response()->json(['error' => 'No token provided'], 401);
}
$headers = new stdClass();
try {
// 解码并验证 JWT
$decoded = JWT::decode($jwt, $keys, $headers);
if ($headers->typ != $token_type) {
return response()->json(['error' => 'Invalid token type, must be '.$token_type.', got '.$headers->typ], 401);
}
$user = User::where('external_id', $decoded->sub)->firstOrCreate([
'external_id' => $decoded->sub,
'name' => $decoded->name,
]);
Auth::guard('api')->loginUsingId($user->id, true);
} catch (Exception $e) {
return response()->json(['error' => 'Invalid token, '.$e->getMessage()], 401);
}
return $next($request);
}
}

View File

@ -2,7 +2,4 @@
namespace App\LLM;
class Base
{
}
class Base {}

56
app/Logic/OpenIDLogic.php Normal file
View File

@ -0,0 +1,56 @@
<?php
namespace App\Logic;
use GuzzleHttp\Client;
class OpenIDLogic
{
public string $auth_url;
public string $token_url;
public string $user_url;
public string $jwks_url;
public array $jwks = [];
/**
* Create a new class instance.
*/
public function __construct()
{
$http = new Client();
$cache_key = 'oauth_discovery';
if (cache()->has($cache_key)) {
$oauth_discovery = cache()->get($cache_key);
} else {
// lock 防止重复请求
$oauth_discovery = cache()->remember($cache_key, 3600, function () use ($http) {
$response = $http->get(config('oauth.discovery'));
return json_decode($response->getBody(), true);
});
}
$this->auth_url = $oauth_discovery['authorization_endpoint'];
$this->token_url = $oauth_discovery['token_endpoint'];
$this->user_url = $oauth_discovery['userinfo_endpoint'];
$this->jwks_url = $oauth_discovery['jwks_uri'];
$cache_key = 'oauth_discovery_jwks';
if (cache()->has($cache_key)) {
$this->jwks = cache()->get($cache_key);
} else {
$this->jwks = cache()->remember($cache_key, 3600, function () use ($http) {
$response = $http->get($this->jwks_url);
return json_decode($response->getBody(), true);
});
}
}
}

View File

@ -2,10 +2,36 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Assistant extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
'user_id',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function tools(): BelongsToMany
{
return $this->belongsToMany(Tool::class, 'assistant_tools');
}
public function chats(): HasMany
{
return $this->hasMany(Chat::class);
}
public function chatHistories(): HasMany
{
return $this->hasMany(ChatHistory::class);
}
}

View File

@ -2,10 +2,12 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class AssistantTool extends Model
{
use HasFactory;
protected $fillable = [
'assistant_id',
'tool_id',
];
}

31
app/Models/Chat.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Chat extends Model
{
protected $fillable = [
'name',
'assistant_id',
'user_id',
];
public function assistant(): BelongsTo
{
return $this->belongsTo(Assistant::class);
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function histories(): HasMany
{
return $this->hasMany(ChatHistory::class);
}
}

View File

@ -2,10 +2,22 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ChatHistory extends Model
{
use HasFactory;
protected $fillable = [
'chat_id',
'content',
'role',
'input_tokens',
'output_tokens',
'total_tokens',
];
public function chat(): BelongsTo
{
return $this->belongsTo(Chat::class);
}
}

View File

@ -2,10 +2,41 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Http;
class Tool extends Model
{
use HasFactory;
protected $fillable = [
'name',
'description',
'url',
'api_key',
'callback_url',
'user_id',
];
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function functions(): HasMany
{
return $this->hasMany(ToolFunction::class);
}
public function fetchFunctions(): void
{
$url = $this->url;
$json = Http::get($url);
$json = $json->json();
$this->callback_url = $json['callback_url'];
}
}

View File

@ -2,10 +2,41 @@
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ToolFunction extends Model
{
use HasFactory;
protected $fillable = [
'tool_id',
'name',
'description',
'parameters',
'required',
];
public function tool(): BelongsTo
{
return $this->belongsTo(Tool::class);
}
public function getParametersAttribute($value)
{
return json_decode($value, true);
}
public function getRequiredAttribute($value)
{
return json_decode($value, true);
}
public function setParametersAttribute($value): void
{
$this->attributes['parameters'] = json_encode($value);
}
public function setRequiredAttribute($value): void
{
$this->attributes['required'] = json_encode($value);
}
}

View File

@ -18,30 +18,31 @@ class User extends Authenticatable
*/
protected $fillable = [
'name',
'email',
'password',
'external_id',
// 'email',
// 'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
// /**
// * The attributes that should be hidden for serialization.
// *
// * @var array<int, string>
// */
// protected $hidden = [
// 'password',
// 'remember_token',
// ];
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
// /**
// * Get the attributes that should be cast.
// *
// * @return array<string, string>
// */
// protected function casts(): array
// {
// return [
// 'email_verified_at' => 'datetime',
// 'password' => 'hashed',
// ];
// }
}

View File

@ -2,7 +2,15 @@
namespace App\Providers;
use App\Logic\OpenIDLogic;
use App\Models\User;
use Exception;
use Firebase\JWT\JWK;
use Firebase\JWT\JWT;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use stdClass;
class AppServiceProvider extends ServiceProvider
{
@ -19,6 +27,43 @@ public function register(): void
*/
public function boot(): void
{
//
$this->setJWTGuard();
}
private function setJWTGuard(): void
{
Auth::viaRequest('jwt', function (Request $request) {
$logic = app(OpenIDLogic::class);
$keys = JWK::parseKeySet($logic->jwks);
$jwt = $request->bearerToken();
if (empty($jwt)) {
return response()->json(['error' => 'No token provided'], 401);
}
$headers = new stdClass();
try {
$decoded = JWT::decode($jwt, $keys, $headers);
$request->attributes->add(['token_type' => $headers->typ]);
} catch (Exception $e) {
return response()->json(['error' => 'Invalid token, '.$e->getMessage()], 401);
}
if (! in_array($decoded->aud, config('oauth.trusted_aud'))) {
return response()->json(['error' => 'The application rejected the token, token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')], 401);
}
if (config('oauth.force_aud') && $decoded->aud != config('oauth.client_id')) {
return response()->json(['error' => 'The token not match the application, '.' token aud is '.$decoded->aud.', app aud is '.config('oauth.client_id')], 401);
}
return User::where('external_id', $decoded->sub)->firstOrCreate([
'external_id' => $decoded->sub,
'name' => $decoded->name,
]);
});
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Repositories\Tool;
use Exception;
class Tool
{
public string $name;
public string $url;
public string $description;
public string $api_key;
public string $user_id;
/**
* @throws Exception
*/
public function parse(array $data): void
{
// 验证数据
if (! $this->validate($data)) {
throw new Exception('Invalid data');
}
$this->name = $data['name'];
$this->url = $data['url'];
$this->description = $data['description'];
$this->api_key = $data['api_key'];
$this->user_id = $data['user_id'];
}
public function validate(array $data): bool
{
// all fields are required
if (empty($data['name']) ||
empty($data['url']) ||
empty($data['description']) ||
empty($data['api_key']) ||
empty($data['user_id'])) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,14 @@
<?php
namespace App\Repositories\Tool;
class ToolFunction
{
/**
* Create a new class instance.
*/
public function __construct()
{
//
}
}

View File

@ -8,7 +8,9 @@
"php": "^8.2",
"apiboard/php-openapi": "^2.1",
"cebe/php-openapi": "^1.7",
"firebase/php-jwt": "^6.10",
"laravel/framework": "^11.9",
"laravel/sanctum": "^4.0",
"laravel/tinker": "^2.9"
},
"require-dev": {

129
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "5d6d988753b4895d093e83b35b767cfb",
"content-hash": "1934fe35f6ca19ea328e64d7a0773787",
"packages": [
{
"name": "apiboard/php-openapi",
@ -618,6 +618,69 @@
],
"time": "2023-10-06T06:47:41+00:00"
},
{
"name": "firebase/php-jwt",
"version": "v6.10.1",
"source": {
"type": "git",
"url": "https://github.com/firebase/php-jwt.git",
"reference": "500501c2ce893c824c801da135d02661199f60c5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/firebase/php-jwt/zipball/500501c2ce893c824c801da135d02661199f60c5",
"reference": "500501c2ce893c824c801da135d02661199f60c5",
"shasum": ""
},
"require": {
"php": "^8.0"
},
"require-dev": {
"guzzlehttp/guzzle": "^7.4",
"phpspec/prophecy-phpunit": "^2.0",
"phpunit/phpunit": "^9.5",
"psr/cache": "^2.0||^3.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0"
},
"suggest": {
"ext-sodium": "Support EdDSA (Ed25519) signatures",
"paragonie/sodium_compat": "Support EdDSA (Ed25519) signatures when libsodium is not present"
},
"type": "library",
"autoload": {
"psr-4": {
"Firebase\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Neuman Vong",
"email": "neuman+pear@twilio.com",
"role": "Developer"
},
{
"name": "Anant Narayanan",
"email": "anant@php.net",
"role": "Developer"
}
],
"description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.",
"homepage": "https://github.com/firebase/php-jwt",
"keywords": [
"jwt",
"php"
],
"support": {
"issues": "https://github.com/firebase/php-jwt/issues",
"source": "https://github.com/firebase/php-jwt/tree/v6.10.1"
},
"time": "2024-05-18T18:05:11+00:00"
},
{
"name": "fruitcake/php-cors",
"version": "v1.3.0",
@ -1491,6 +1554,70 @@
},
"time": "2024-06-17T13:58:22+00:00"
},
{
"name": "laravel/sanctum",
"version": "v4.0.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/sanctum.git",
"reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/sanctum/zipball/9cfc0ce80cabad5334efff73ec856339e8ec1ac1",
"reference": "9cfc0ce80cabad5334efff73ec856339e8ec1ac1",
"shasum": ""
},
"require": {
"ext-json": "*",
"illuminate/console": "^11.0",
"illuminate/contracts": "^11.0",
"illuminate/database": "^11.0",
"illuminate/support": "^11.0",
"php": "^8.2",
"symfony/console": "^7.0"
},
"require-dev": {
"mockery/mockery": "^1.6",
"orchestra/testbench": "^9.0",
"phpstan/phpstan": "^1.10",
"phpunit/phpunit": "^10.5"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Sanctum\\SanctumServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Sanctum\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
"keywords": [
"auth",
"laravel",
"sanctum"
],
"support": {
"issues": "https://github.com/laravel/sanctum/issues",
"source": "https://github.com/laravel/sanctum"
},
"time": "2024-04-10T19:39:58+00:00"
},
{
"name": "laravel/serializable-closure",
"version": "v1.3.3",

83
config/sanctum.php Normal file
View File

@ -0,0 +1,83 @@
<?php
use Laravel\Sanctum\Sanctum;
return [
/*
|--------------------------------------------------------------------------
| Stateful Domains
|--------------------------------------------------------------------------
|
| Requests from the following domains / hosts will receive stateful API
| authentication cookies. Typically, these should include your local
| and production domains which access your API via a frontend SPA.
|
*/
'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
'%s%s',
'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
Sanctum::currentApplicationUrlWithPort()
))),
/*
|--------------------------------------------------------------------------
| Sanctum Guards
|--------------------------------------------------------------------------
|
| This array contains the authentication guards that will be checked when
| Sanctum is trying to authenticate a request. If none of these guards
| are able to authenticate the request, Sanctum will use the bearer
| token that's present on an incoming request for authentication.
|
*/
'guard' => ['web'],
/*
|--------------------------------------------------------------------------
| Expiration Minutes
|--------------------------------------------------------------------------
|
| This value controls the number of minutes until an issued token will be
| considered expired. This will override any values set in the token's
| "expires_at" attribute, but first-party sessions are not affected.
|
*/
'expiration' => null,
/*
|--------------------------------------------------------------------------
| Token Prefix
|--------------------------------------------------------------------------
|
| Sanctum can prefix new tokens in order to take advantage of numerous
| security scanning initiatives maintained by open source platforms
| that notify developers if they commit tokens into repositories.
|
| See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
|
*/
'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
/*
|--------------------------------------------------------------------------
| Sanctum Middleware
|--------------------------------------------------------------------------
|
| When authenticating your first-party SPA with Sanctum you may need to
| customize some of the middleware Sanctum uses while processing the
| request. You may change the middleware listed below as required.
|
*/
'middleware' => [
'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
],
];

View File

@ -14,27 +14,27 @@ public function up(): void
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->unsignedBigInteger('external_id')->index();
// $table->string('email')->unique();
// $table->timestamp('email_verified_at')->nullable();
// $table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
// Schema::create('password_reset_tokens', function (Blueprint $table) {
// $table->string('email')->primary();
// $table->string('token');
// $table->timestamp('created_at')->nullable();
// });
//
// Schema::create('sessions', function (Blueprint $table) {
// $table->string('id')->primary();
// $table->foreignId('user_id')->nullable()->index();
// $table->string('ip_address', 45)->nullable();
// $table->text('user_agent')->nullable();
// $table->longText('payload');
// $table->integer('last_activity')->index();
// });
}
/**

View File

@ -21,7 +21,6 @@ public function up(): void
$table->unsignedBigInteger('user_id')->index();
$table->foreign('user_id')->references('id')->on('users');
$table->timestamps();
});
}

View File

@ -22,7 +22,6 @@ public function up(): void
// index
$table->index(['assistant_id', 'user_id']);
$table->timestamps();
});
}

View File

@ -18,7 +18,7 @@ public function up(): void
$table->text('content');
$table->string('role');
// $table->string('model');
// $table->string('model');
// input tokens
$table->integer('input_tokens')->default(null);
@ -31,7 +31,6 @@ public function up(): void
// index
$table->index(['chat_id', 'role']);
$table->timestamps();
});
}

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('tools', function (Blueprint $table) {
$table->string('callback_url')->after('api_key');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('tools', function (Blueprint $table) {
$table->dropColumn('callback_url');
});
}
};

8
routes/api.php Normal file
View File

@ -0,0 +1,8 @@
<?php
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::get('/user', function (Request $request) {
return $request->user();
})->middleware('auth:api');