diff --git a/app/Console/Commands/AddSiteSettings.php b/app/Console/Commands/AddSiteSettings.php index 424fa26124..1bb1329c13 100644 --- a/app/Console/Commands/AddSiteSettings.php +++ b/app/Console/Commands/AddSiteSettings.php @@ -95,6 +95,10 @@ public function handle() { $this->addSiteSetting('allow_users_to_delete_profile_comments', 0, '0: Users cannot delete profile comments, 1: Users can delete profile comments.'); + $this->addSiteSetting('allow_users_to_generate_tokens', 0, '0: Users cannot generate their own API tokens, 1: Users can generate their own API tokens.'); + + $this->addSiteSetting('allow_token_generation_via_api', 0, '0: Tokens can not be generated via the API, 1: Users can directly generate tokens via username/password authentication with the API.'); + $this->line("\nSite settings up to date!"); } diff --git a/app/Http/Controllers/Admin/Users/UserController.php b/app/Http/Controllers/Admin/Users/UserController.php index 9b9842219b..bd1f818d7f 100644 --- a/app/Http/Controllers/Admin/Users/UserController.php +++ b/app/Http/Controllers/Admin/Users/UserController.php @@ -421,4 +421,41 @@ public function postReactivate(Request $request, UserService $service, $name) { return redirect()->back(); } + + /** + * Show a user's token revokation page. + * + * @param mixed $name + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function getRevokeTokensConfirmation($name) { + $user = User::where('name', $name)->with('settings')->first(); + + if (!$user) { + abort(404); + } + + return view('admin.users._user_revoke_token_confirmation', [ + 'user' => $user, + ]); + } + + public function postRevokeTokens(Request $request, UserService $service, $name) { + $user = User::where('name', $name)->first(); + + if (!$user) { + flash('Invalid user.')->error(); + } elseif (!Auth::user()->canEditRank($user->rank)) { + flash('You cannot edit the information of a user that has a higher rank than yourself.')->error(); + } elseif ($service->revokeTokens($user, Auth::user())) { + flash('Revoked all user API tokens successfully.')->success(); + } else { + foreach ($service->errors()->getMessages()['error'] as $error) { + flash($error)->error(); + } + } + + return redirect()->back(); + } } diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000000..3bf16eee4a --- /dev/null +++ b/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,41 @@ +json([ + 'message' => 'Token generation via API is not allowed.', + ], 403); + } + + $request->validate([ + 'email' => 'required|email', + 'password' => 'required', + 'token_name' => 'required', + ]); + + $user = User::where('email', $request->email)->first(); + + if (!$user || !Hash::check($request->password, $user->password)) { + throw ValidationException::withMessages([ + 'email' => ['The provided credentials are incorrect.'], + ]); + } + + // Delete any pre-existing tokens + $user->tokens()->delete(); + + return $user->createToken($request->token_name)->plainTextToken; + } +} diff --git a/app/Http/Controllers/Api/InfoController.php b/app/Http/Controllers/Api/InfoController.php new file mode 100644 index 0000000000..ff9ab97de2 --- /dev/null +++ b/app/Http/Controllers/Api/InfoController.php @@ -0,0 +1,21 @@ +id); + + if ($character) { + return response()->json($character); + } else { + return response()->json([ + 'message' => 'No character found.', + ], 404); + } + } +} diff --git a/app/Http/Controllers/Users/ApiController.php b/app/Http/Controllers/Users/ApiController.php new file mode 100644 index 0000000000..3791a56ec2 --- /dev/null +++ b/app/Http/Controllers/Users/ApiController.php @@ -0,0 +1,65 @@ +generateToken(Auth::user())) { + foreach ($service->errors()->getMessages()['error'] as $error) { + flash($error)->error(); + } + } + + return redirect()->back()->withInput(); + } + + /** + * Generates (or re-generates) an API token. + * + * @return \Illuminate\Contracts\Support\Renderable + */ + public function postRevokeToken(Request $request, UserService $service) { + if (!$service->revokeTokens(Auth::user())) { + foreach ($service->errors()->getMessages()['error'] as $error) { + flash($error)->error(); + } + } else { + flash('Token(s) revoked successfully.')->success(); + } + + return redirect()->back()->withInput(); + } + + /** + * Generates (or re-generates) an API token without CSRF protection. + * This token is different from the one provided to the user via settings. + * + * @return mixed + */ + public function getGenerateToken(Request $request, UserService $service) { + return $service->generateTokenAPI(); + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index f8f41cfba4..aa3eb75e83 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -38,8 +38,9 @@ class Kernel extends HttpKernel { ], 'api' => [ + \Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class, 'throttle:60,1', - 'bindings', + \Illuminate\Routing\Middleware\SubstituteBindings::class, ], ]; @@ -65,6 +66,8 @@ class Kernel extends HttpKernel { 'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class, 'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class, 'post.throttle' => Middleware\PostRequestThrottleMiddleware::class, + 'abilities' => \Laravel\Sanctum\Http\Middleware\CheckAbilities::class, + 'ability' => \Laravel\Sanctum\Http\Middleware\CheckForAnyAbility::class, ]; /** diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php index 4b2fc45403..e1eca9819c 100644 --- a/app/Http/Middleware/VerifyCsrfToken.php +++ b/app/Http/Middleware/VerifyCsrfToken.php @@ -18,6 +18,6 @@ class VerifyCsrfToken extends Middleware { * @var array */ protected $except = [ - // + 'account/api/token', ]; } diff --git a/app/Models/User/User.php b/app/Models/User/User.php index e89c18cd67..af49f563a0 100644 --- a/app/Models/User/User.php +++ b/app/Models/User/User.php @@ -27,9 +27,10 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Facades\Auth; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements MustVerifyEmail { - use Commenter, Notifiable, TwoFactorAuthenticatable; + use Commenter, HasApiTokens, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. diff --git a/app/Services/UserService.php b/app/Services/UserService.php index 751085bc30..66fd4e5d3a 100644 --- a/app/Services/UserService.php +++ b/app/Services/UserService.php @@ -14,6 +14,7 @@ use App\Models\User\User; use App\Models\User\UserUpdateLog; use Carbon\Carbon; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Hash; @@ -81,7 +82,8 @@ public function validator(array $data, $socialite = false) { 'agreement' => ['required', 'accepted'], 'password' => ($socialite ? [] : ['required']) + ['string', 'min:8', 'confirmed'], 'dob' => [ - 'required', function ($attribute, $value, $fail) { + 'required', + function ($attribute, $value, $fail) { $formatDate = Carbon::createFromFormat('Y-m-d', $value); $now = Carbon::now(); if ($formatDate->diffInYears($now) < 13) { @@ -89,17 +91,19 @@ public function validator(array $data, $socialite = false) { } }, ], - 'code' => ['string', function ($attribute, $value, $fail) { - if (!Settings::get('is_registration_open')) { - if (!$value) { - $fail('An invitation code is required to register an account.'); + 'code' => [ + 'string', + function ($attribute, $value, $fail) { + if (!Settings::get('is_registration_open')) { + if (!$value) { + $fail('An invitation code is required to register an account.'); + } + $invitation = Invitation::where('code', $value)->whereNull('recipient_id')->first(); + if (!$invitation) { + $fail('Invalid code entered.'); + } } - $invitation = Invitation::where('code', $value)->whereNull('recipient_id')->first(); - if (!$invitation) { - $fail('Invalid code entered.'); - } - } - }, + }, ], ] + (config('app.env') == 'production' && config('lorekeeper.extensions.use_recaptcha') ? [ 'g-recaptcha-response' => 'required|recaptchav3:register,0.5', @@ -415,7 +419,7 @@ public function updateUsername($username, $user) { if ($last_change && $last_change->created_at->diffInDays(Carbon::now()) < config('lorekeeper.settings.username_change_cooldown')) { throw new \Exception('You must wait ' .config('lorekeeper.settings.username_change_cooldown') - $last_change->created_at->diffInDays(Carbon::now()). - ' days before changing your username again.'); + ' days before changing your username again.'); } } @@ -696,4 +700,71 @@ public function reactivate($user, $staff = null) { return $this->rollbackReturn(false); } + + /** + * Generate a "public" API token for the user. + * + * @param User $user + * + * @return bool + */ + public function generateToken($user) { + try { + $user->tokens()->where('personal_access_tokens.name', 'token')->delete(); + + $token = $user->createToken('token')->plainTextToken; + + UserUpdateLog::create(['staff_id' => $user->id, 'user_id' => $user->id, 'data' => json_encode([]), 'type' => 'Generated API Token']); + + flash('Token created successfully:')->success(); + flash($token)->success(); + flash('Copy this down! It will NOT be shown again.')->warning(); + + return true; + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return false; + } + + /** + * Revokes all of a user's API tokens. + * + * @param User $user + * @param User $staff + * + * @return bool + */ + public function revokeTokens($user, $staff = null) { + try { + $user->tokens()->delete(); + + UserUpdateLog::create(['staff_id' => $staff ? $staff->id : $user->id, 'user_id' => $user->id, 'data' => json_encode([]), 'type' => 'Tokens Revoked']); + + return true; + } catch (\Exception $e) { + $this->setError('error', $e->getMessage()); + } + + return false; + } + + /** + * Generate a "hidden" API token for the user and return it as plain text. + * + * @return mixed + */ + public function generateTokenAPI() { + try { + Auth::user()->tokens()->where('personal_access_tokens.name', 'hidden')->delete(); + $token = Auth::user()->createToken('hidden')->plainTextToken; + + UserUpdateLog::create(['staff_id' => Auth::user()->id, 'user_id' => Auth::user()->id, 'data' => json_encode([]), 'type' => 'Generated API Token w/o CSRF']); + + return $token; + } catch (\Exception $e) { + return response()->json($e); + } + } } diff --git a/composer.json b/composer.json index 09654ecc0f..f73c6688b2 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "laravel/fortify": "^1.25", "laravel/framework": "^10.0", "laravel/helpers": "^1.4", + "laravel/sanctum": "^3.3", "laravel/socialite": "^5.2", "laravel/tinker": "^2.0", "laravelcollective/html": "^6.0", diff --git a/composer.lock b/composer.lock index dd0befa69e..e06309c578 100644 --- a/composer.lock +++ b/composer.lock @@ -2266,6 +2266,72 @@ }, "time": "2024-08-12T22:06:33+00:00" }, + { + "name": "laravel/sanctum", + "version": "v3.3.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "8c104366459739f3ada0e994bcd3e6fd681ce3d5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/8c104366459739f3ada0e994bcd3e6fd681ce3d5", + "reference": "8c104366459739f3ada0e994bcd3e6fd681ce3d5", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^9.21|^10.0", + "illuminate/contracts": "^9.21|^10.0", + "illuminate/database": "^9.21|^10.0", + "illuminate/support": "^9.21|^10.0", + "php": "^8.0.2" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "orchestra/testbench": "^7.28.2|^8.8.3", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + }, + "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": "2023-12-19T18:44:48+00:00" + }, { "name": "laravel/serializable-closure", "version": "v1.3.7", diff --git a/config/lorekeeper/admin_sidebar.php b/config/lorekeeper/admin_sidebar.php index 409c62f00d..86d6bacbda 100644 --- a/config/lorekeeper/admin_sidebar.php +++ b/config/lorekeeper/admin_sidebar.php @@ -31,6 +31,15 @@ ], ], ], + 'API' => [ + 'power' => 'api_access', + 'links' => [ + [ + 'name' => 'API Tokens', + 'url' => 'admin/api', + ], + ], + ], 'Reports' => [ 'power' => 'manage_reports', 'links' => [ diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000000..ae76c432e8 --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,83 @@ + 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' => App\Http\Middleware\EncryptCookies::class, + 'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class, + ], + +]; diff --git a/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php new file mode 100644 index 0000000000..73b0c178b7 --- /dev/null +++ b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php @@ -0,0 +1,30 @@ +id(); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/resources/views/account/settings.blade.php b/resources/views/account/settings.blade.php index ae2477da5a..5989fd4105 100644 --- a/resources/views/account/settings.blade.php +++ b/resources/views/account/settings.blade.php @@ -220,6 +220,42 @@ {!! Form::close() !!} @endif + + @if (Settings::get('allow_users_to_generate_tokens')) +
Here you may generate personal access (API) tokens for your account. It will have the same site permissions as you.
+You already have an API token generated. You may regenerate this token with the button below. It will create a new token, not show you your current one.
+ {!! Form::open(['url' => 'account/api/token']) !!} + {!! Form::submit('Regenerate Token', ['class' => 'btn btn-warning']) !!} + {!! Form::close() !!} +Click the below button to revoke (delete) your current API token. This can not be undone!
+ {!! Form::open(['url' => 'account/api/revoke']) !!} + {!! Form::submit('Revoke Token', ['class' => 'btn btn-danger']) !!} + {!! Form::close() !!} + +Clicking the below button will generate a token for your account. This token will only display ONCE. Be sure to copy it down!
+ {!! Form::open(['url' => 'account/api/token']) !!} + {!! Form::submit('Generate Token', ['class' => 'btn btn-primary']) !!} + {!! Form::close() !!} +