diff --git a/app/Enums/ActivityLog.php b/app/Enums/ActivityLog.php index b3c6f2bc..a4216b81 100644 --- a/app/Enums/ActivityLog.php +++ b/app/Enums/ActivityLog.php @@ -7,4 +7,5 @@ class ActivityLog public const SYSTEM_CRON_TOKEN_REGENERATED = 'system.cron_token_regenerated'; public const USER_API_TOKEN_GENERATED = 'user.api_token_regenerated'; + public const USER_API_TOKEN_REVOKED = 'user.api_token_revoked'; } diff --git a/app/Enums/ApiToken.php b/app/Enums/ApiToken.php new file mode 100644 index 00000000..40f223af --- /dev/null +++ b/app/Enums/ApiToken.php @@ -0,0 +1,9 @@ +, \Psr\Log\LogLevel::*> + */ + protected $levels = [ + // + ]; + /** * A list of the exception types that are not reported. * - * @var array + * @var array> */ protected $dontReport = [ // ]; /** - * A list of the inputs that are never flashed for validation exceptions. + * A list of the inputs that are never flashed to the session on validation exceptions. * - * @var array + * @var array */ protected $dontFlash = [ + 'current_password', 'password', 'password_confirmation', ]; /** - * Report or log an exception. - * - * @param Throwable $exception - * @throws Throwable - */ - public function report(Throwable $exception): void - { - if ($this->shouldReport($exception) && app()->bound('sentry')) { - app('sentry')->captureException($exception); - } - - parent::report($exception); - } - - /** - * Render an exception into an HTTP response. + * Register the exception handling callbacks for the application. * - * @param Request $request - * @param Throwable $exception - * @return Response - * @throws Throwable + * @return void */ - public function render($request, Throwable $exception): Response + public function register() { - return parent::render($request, $exception); + $this->reportable(function (Throwable $e) { + if (app()->bound('sentry')) { + app('sentry')->captureException($e); + } + }); } } diff --git a/app/Http/Controllers/App/ApiTokenController.php b/app/Http/Controllers/App/ApiTokenController.php new file mode 100644 index 00000000..28d2ba0b --- /dev/null +++ b/app/Http/Controllers/App/ApiTokenController.php @@ -0,0 +1,47 @@ + $request->user()->tokens()->get(), + ]); + } + + public function store(CreateApiTokenRequest $request): RedirectResponse + { + $token = $request->user()->createToken($request->validated('token_name'), [ApiToken::ABILITY_USER_ACCESS]); + + activity() + ->by($request->user()) + ->withProperty('token_id', $token->accessToken->id) + ->log(ActivityLog::USER_API_TOKEN_GENERATED); + + return redirect()->route('api-tokens.index')->with('new_token', $token->plainTextToken); + } + + public function destroy(Request $request, PersonalAccessToken $token): RedirectResponse + { + $this->authorize('delete', $token); + + $token->delete(); + + activity() + ->by($request->user()) + ->log(ActivityLog::USER_API_TOKEN_REVOKED); + + flash()->warning(trans('auth.api_tokens.revoke_successful')); + return redirect()->route('api-tokens.index'); + } +} diff --git a/app/Http/Controllers/App/UserSettingsController.php b/app/Http/Controllers/App/UserSettingsController.php index a0bfdcf7..992d3cc8 100644 --- a/app/Http/Controllers/App/UserSettingsController.php +++ b/app/Http/Controllers/App/UserSettingsController.php @@ -87,24 +87,4 @@ public function changeUserPassword(Request $request): RedirectResponse flash(trans('settings.password_updated'), 'success'); return redirect()->back(); } - - /** - * Generate a new API token for the current user. - * - * @param Request $request - * @return JsonResponse - */ - public function generateApiToken(Request $request): JsonResponse - { - $new_token = Str::random(32); - - $request->user()->api_token = $new_token; - $request->user()->save(); - - activity()->by(auth()->user())->log(ActivityLog::USER_API_TOKEN_GENERATED); - - return response()->json([ - 'new_token' => $new_token, - ]); - } } diff --git a/app/Http/Requests/Auth/CreateApiTokenRequest.php b/app/Http/Requests/Auth/CreateApiTokenRequest.php new file mode 100644 index 00000000..4d940c9e --- /dev/null +++ b/app/Http/Requests/Auth/CreateApiTokenRequest.php @@ -0,0 +1,25 @@ + [ + 'required', + 'alpha_dash', + 'min:3', + 'max:100', + Rule::unique('personal_access_tokens', 'name')->where(function (Builder $query) { + return $query->where('tokenable_id', request()->user()->id); + }), + ], + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 1253cb8b..d2b7d745 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -8,6 +8,7 @@ use Illuminate\Notifications\Notifiable; use Illuminate\Support\Carbon; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; use OwenIt\Auditing\Auditable as AuditableTrait; use OwenIt\Auditing\Contracts\Auditable; use Spatie\Permission\Traits\HasRoles; @@ -30,11 +31,12 @@ class User extends Authenticatable implements Auditable { use AuditableTrait; - use Notifiable; + use HasApiTokens; use HasFactory; use HasRoles; - use TwoFactorAuthenticatable; + use Notifiable; use SoftDeletes; + use TwoFactorAuthenticatable; protected $fillable = [ 'name', diff --git a/app/Policies/ApiTokenPolicy.php b/app/Policies/ApiTokenPolicy.php new file mode 100644 index 00000000..2e45532c --- /dev/null +++ b/app/Policies/ApiTokenPolicy.php @@ -0,0 +1,47 @@ +tokenable->is($user); + } + + public function restore(User $user, PersonalAccessToken $personalAccessToken): bool + { + return false; + } + + public function forceDelete(User $user, PersonalAccessToken $personalAccessToken): bool + { + return false; + } +} diff --git a/app/Policies/LinkPolicy.php b/app/Policies/LinkPolicy.php index b22c9d53..5d6f3006 100644 --- a/app/Policies/LinkPolicy.php +++ b/app/Policies/LinkPolicy.php @@ -33,17 +33,17 @@ public function update(User $user, Link $link): bool public function delete(User $user, Link $link): bool { - return $this->userCanAccessLink($user, $link); + return $link->user->is($user); } public function restore(User $user, Link $link): bool { - return $this->userCanAccessLink($user, $link); + return $link->user->is($user); } public function forceDelete(User $user, Link $link): bool { - return $this->userCanAccessLink($user, $link); + return $link->user->is($user); } // Link must be either owned by user, or be not private diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php index 467ab23a..e60c53b8 100644 --- a/app/Providers/AuthServiceProvider.php +++ b/app/Providers/AuthServiceProvider.php @@ -6,11 +6,13 @@ use App\Models\LinkList; use App\Models\Note; use App\Models\Tag; +use App\Policies\ApiTokenPolicy; use App\Policies\LinkListPolicy; use App\Policies\LinkPolicy; use App\Policies\NotePolicy; use App\Policies\TagPolicy; use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider; +use Laravel\Sanctum\PersonalAccessToken; class AuthServiceProvider extends ServiceProvider { @@ -24,6 +26,7 @@ class AuthServiceProvider extends ServiceProvider LinkList::class => LinkListPolicy::class, Note::class => NotePolicy::class, Tag::class => TagPolicy::class, + PersonalAccessToken::class => ApiTokenPolicy::class, ]; /** diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 516c7b7e..79faf8a0 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider; use Illuminate\Support\Facades\Route; +use Laravel\Sanctum\PersonalAccessToken; class RouteServiceProvider extends ServiceProvider { @@ -25,7 +26,7 @@ class RouteServiceProvider extends ServiceProvider */ public function boot(): void { - // + Route::model('api_token', PersonalAccessToken::class); parent::boot(); } diff --git a/config/auth.php b/config/auth.php index 963c5ee7..d8c6cee7 100644 --- a/config/auth.php +++ b/config/auth.php @@ -40,12 +40,6 @@ 'driver' => 'session', 'provider' => 'users', ], - - 'api' => [ - 'driver' => 'token', - 'provider' => 'users', - 'hash' => false, - ], ], /* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 449c2d87..846f822c 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -19,7 +19,6 @@ public function definition(): array 'name' => $this->faker->userName(), 'email' => $this->faker->unique()->safeEmail(), 'password' => '$2y$10$9.preebMjZ.8obdvk5ZVdOCw7Cq1EJm6i1B1RJevxCXYW0lUiwDJG', // secretpassword - 'api_token' => Str::random(32), ]; } } 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 00000000..3ce00023 --- /dev/null +++ b/database/migrations/2019_12_14_000001_create_personal_access_tokens_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->morphs('tokenable'); + $table->string('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('personal_access_tokens'); + } +} diff --git a/lang/en_US/audit.php b/lang/en_US/audit.php index b664eb4d..11ee96d2 100644 --- a/lang/en_US/audit.php +++ b/lang/en_US/audit.php @@ -17,7 +17,8 @@ 'cron_token_regenerated' => 'System: Cron Token was re-generated', ], 'user_settings' => [ - 'api_token_regenerated' => 'User: API Token was re-generated', + 'api_token_generated' => 'User: API Token was generated', + 'api_token_revoken' => 'User: API Token was revoked', ], ], ]; diff --git a/lang/en_US/auth.php b/lang/en_US/auth.php index 12aba3e8..3f79a5b3 100644 --- a/lang/en_US/auth.php +++ b/lang/en_US/auth.php @@ -27,4 +27,17 @@ 'two_factor_check' => 'Please enter the one-time-password provided by your Two Factor Authentication app now.', 'two_factor_with_recovery' => 'Authenticate with Recovery Code', + 'api_tokens' => 'API Tokens', + 'api_tokens.no_tokens_found' => 'No API Tokens found.', + 'api_tokens.generate' => 'Generate a new API Token', + 'api_tokens.generate_short' => 'Generate Token', + 'api_tokens.generate_help' => 'API tokens are used to authenticate yourself when using the LinkAce API.', + 'api_tokens.generated_successfully' => 'Your API token was generated successfully: :token', + 'api_tokens.generated_help' => 'Please store this token in a safe place. It is not possible to recover your token if you lose it.', + 'api_tokens.name' => 'Token name', + 'api_tokens.name_help' => 'Choose a name for your token. The name can only contain alpha-numeric characters, dashes, and underscores. Helpful if you want to create separate tokens for different use cases or applications.', + 'api_tokens.revoke' => 'Revoke token', + 'api_tokens.revoke_confirm' => 'Do you really want to revoke this token? This step cannot be undone and the token cannot be recovered.', + 'api_tokens.revoke_successful' => 'The token was revoked successfully.', + ]; diff --git a/lang/en_US/linkace.php b/lang/en_US/linkace.php index 961a99da..0f064366 100644 --- a/lang/en_US/linkace.php +++ b/lang/en_US/linkace.php @@ -24,6 +24,8 @@ 'created_at' => 'Created at', 'updated_at' => 'Updated at', 'last_update' => 'Last Update', + 'last_used' => 'Last used', + 'never_used' => 'Never used', 'blocked' => 'Blocked', 'blocked_at' => 'Blocked at', 'deleted' => 'Deleted', diff --git a/lang/en_US/settings.php b/lang/en_US/settings.php index 3c4b2dfa..5d629630 100644 --- a/lang/en_US/settings.php +++ b/lang/en_US/settings.php @@ -74,13 +74,6 @@ 'two_factor_recovery_codes_view' => 'View Recovery Codes', 'two_factor_regenerate_recovery_codes' => 'Generate new Recovery Codes', - 'api_token' => 'API Token', - 'api_token_generate' => 'Generate Token', - 'api_token_generate_confirm' => 'Do you really want to generate a new token?', - 'api_token_help' => 'The API token can be used to access LinkAce from other application or scripts.', - 'api_token_generate_info' => 'Caution: If you already have an API token, generating a new one will break all existing integrations!', - 'api_token_generate_failure' => 'A new API token could not be generated. Please check your browser console and application logs for more information.', - 'page_title' => 'Page Title', 'guest_access' => 'Enable Guest Access', 'guest_access_help' => 'If enabled, guest will be able to see all links that are not private.', diff --git a/resources/assets/js/app.js b/resources/assets/js/app.js index 26015e70..e6f0344d 100644 --- a/resources/assets/js/app.js +++ b/resources/assets/js/app.js @@ -7,7 +7,6 @@ import BookmarkTimer from './components/BookmarkTimer'; import TagsSelect from './components/TagsSelect'; import SimpleSelect from './components/SimpleSelect'; import ShareToggleAll from './components/ShareToggleAll'; -import GenerateApiToken from './components/GenerateApiToken'; import GenerateCronToken from './components/GenerateCronToken'; import UpdateCheck from './components/UpdateCheck'; import Import from './components/Import'; @@ -21,7 +20,6 @@ function registerViews () { register('.tag-select', TagsSelect); register('.simple-select', SimpleSelect); register('.share-toggle', ShareToggleAll); - register('.api-token', GenerateApiToken); register('.cron-token', GenerateCronToken); register('.update-check', UpdateCheck); register('.import-form', Import); diff --git a/resources/assets/js/components/GenerateApiToken.js b/resources/assets/js/components/GenerateApiToken.js deleted file mode 100644 index d73ee58f..00000000 --- a/resources/assets/js/components/GenerateApiToken.js +++ /dev/null @@ -1,61 +0,0 @@ -import { debounce } from '../lib/helper'; - -export default class GenerateApiToken { - - constructor ($el) { - this.$el = $el; - - this.$input = $el.querySelector('.api-token-input'); - this.$btn = $el.querySelector('.api-token-generate'); - this.$failureMsg = $el.querySelector('.api-token-generate-failure'); - - this.$btn.addEventListener('click', this.onButtonClick.bind(this)); - } - - onButtonClick () { - this.$btn.disabled = true; - - this.fetchNewToken(); - } - - fetchNewToken () { - - const fetchURL = window.appData.routes.fetch.generateApiToken; - - fetch(fetchURL, { - method: 'POST', - credentials: 'same-origin', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ - _token: window.appData.user.token - }) - }).then((response) => { - return response.json(); - }).then(response => { - this.handleResponse(response); - }).catch(() => { - this.showFailureMsg(); - }); - - } - - handleResponse (response) { - - if (typeof response.new_token !== 'undefined') { - debounce(() => { - this.$input.value = response.new_token; - }, 1000); - - window.setTimeout(() => { - this.$btn.disabled = false; - }, 5000); - } else { - this.showFailureMsg(); - } - - } - - showFailureMsg () { - this.$failureMsg.classList.remove('d-none'); - } -} diff --git a/resources/views/app/api-tokens/index.blade.php b/resources/views/app/api-tokens/index.blade.php new file mode 100644 index 00000000..0076cc14 --- /dev/null +++ b/resources/views/app/api-tokens/index.blade.php @@ -0,0 +1,88 @@ +@extends('layouts.app') + +@section('content') + + @if(session()->has('new_token')) +
+

+ + @lang('auth.api_tokens.generated_successfully', ['token' => session()->get('new_token')]) + +

+

@lang('auth.api_tokens.generated_help')

+
+ @endif + +
+
+ @lang('auth.api_tokens') +
+
+ +
+ + + + + + + + @forelse($tokens as $token) + + + + + + + @empty + + + + @endforelse +
@lang('auth.api_tokens.name')@lang('linkace.created_at')@lang('linkace.last_used')
{{ $token->name }}{{ $token->created_at }}{{ $token->last_used ?: trans('linkace.never_used') }} +
+ @csrf + @method('DELETE') + +
+
@lang('auth.api_tokens.no_tokens_found')
+
+ +
+
+ +
+
+ @lang('auth.api_tokens.generate') +
+
+ +

@lang('auth.api_tokens.generate_help')

+ +
+ @csrf +
+ + +

@lang('auth.api_tokens.name_help')

+ @if ($errors->has('token_name')) + + @endif +
+ + +
+ +
+
+ +@endsection diff --git a/resources/views/app/settings/partials/api.blade.php b/resources/views/app/settings/partials/api.blade.php deleted file mode 100644 index 2b9126e3..00000000 --- a/resources/views/app/settings/partials/api.blade.php +++ /dev/null @@ -1,25 +0,0 @@ -
-
- @lang('settings.api_token') -
-
- -

@lang('settings.api_token_help')

- -
- - -
- -

- @lang('settings.api_token_generate_failure') -

- -

@lang('settings.api_token_generate_info')

- -
-
diff --git a/resources/views/app/settings/user.blade.php b/resources/views/app/settings/user.blade.php index 78dc8999..e9ba674c 100644 --- a/resources/views/app/settings/user.blade.php +++ b/resources/views/app/settings/user.blade.php @@ -4,8 +4,6 @@ @include('app.settings.partials.bookmarklet') - @include('app.settings.partials.api') - @include('app.settings.partials.account-settings') @include('app.settings.partials.change-pw') diff --git a/resources/views/models/links/show.blade.php b/resources/views/models/links/show.blade.php index 091a6fef..aedf41c6 100644 --- a/resources/views/models/links/show.blade.php +++ b/resources/views/models/links/show.blade.php @@ -66,10 +66,9 @@ class="btn btn-sm btn-outline-danger cursor-pointer">
diff --git a/resources/views/partials/header.blade.php b/resources/views/partials/header.blade.php index 6bb3eeac..4f0e8e88 100644 --- a/resources/views/partials/header.blade.php +++ b/resources/views/partials/header.blade.php @@ -24,7 +24,6 @@ 'existingLinks' => route('fetch-existing-links'), 'htmlForUrl' => route('fetch-html-for-url'), 'updateCheck' => route('fetch-update-check'), - 'generateApiToken' => route('generate-api-token'), 'generateCronToken' => route('generate-cron-token'), ] ] diff --git a/resources/views/partials/nav-user.blade.php b/resources/views/partials/nav-user.blade.php index a3f35a5c..e5e4224e 100644 --- a/resources/views/partials/nav-user.blade.php +++ b/resources/views/partials/nav-user.blade.php @@ -13,6 +13,9 @@ @lang('settings.settings') + + @lang('auth.api_tokens') + @lang('linkace.logout') diff --git a/routes/api.php b/routes/api.php index 020028a2..e1b8205c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,75 +22,73 @@ | */ -Route::prefix('v1')->group(function () { - Route::middleware('auth:api')->group(function () { +Route::prefix('v1')->middleware(['auth:sanctum'])->group(function () { - Route::get('links/check', LinkCheckController::class) - ->name('api.links.check'); + Route::get('links/check', LinkCheckController::class) + ->name('api.links.check'); - Route::apiResource('links', LinkController::class) - ->names([ - 'index' => 'api.links.index', - 'show' => 'api.links.show', - 'store' => 'api.links.store', - 'update' => 'api.links.update', - 'destroy' => 'api.links.destroy', - ]); + Route::apiResource('links', LinkController::class) + ->names([ + 'index' => 'api.links.index', + 'show' => 'api.links.show', + 'store' => 'api.links.store', + 'update' => 'api.links.update', + 'destroy' => 'api.links.destroy', + ]); - Route::get('links/{link}/notes', LinkNotesController::class) - ->name('api.links.notes'); + Route::get('links/{link}/notes', LinkNotesController::class) + ->name('api.links.notes'); - Route::apiResource('lists', ListController::class) - ->names([ - 'index' => 'api.lists.index', - 'show' => 'api.lists.show', - 'store' => 'api.lists.store', - 'update' => 'api.lists.update', - 'destroy' => 'api.lists.destroy', - ]); + Route::apiResource('lists', ListController::class) + ->names([ + 'index' => 'api.lists.index', + 'show' => 'api.lists.show', + 'store' => 'api.lists.store', + 'update' => 'api.lists.update', + 'destroy' => 'api.lists.destroy', + ]); - Route::get('lists/{list}/links', ListLinksController::class) - ->name('api.lists.links'); + Route::get('lists/{list}/links', ListLinksController::class) + ->name('api.lists.links'); - Route::apiResource('tags', TagController::class) - ->names([ - 'index' => 'api.tags.index', - 'show' => 'api.tags.show', - 'store' => 'api.tags.store', - 'update' => 'api.tags.update', - 'destroy' => 'api.tags.destroy', - ]); + Route::apiResource('tags', TagController::class) + ->names([ + 'index' => 'api.tags.index', + 'show' => 'api.tags.show', + 'store' => 'api.tags.store', + 'update' => 'api.tags.update', + 'destroy' => 'api.tags.destroy', + ]); - Route::get('tags/{tag}/links', TagLinksController::class) - ->name('api.tags.links'); + Route::get('tags/{tag}/links', TagLinksController::class) + ->name('api.tags.links'); - Route::apiResource('notes', NoteController::class) - ->names([ - 'store' => 'api.notes.store', - 'update' => 'api.notes.update', - 'destroy' => 'api.notes.destroy', - ]) - ->except(['index', 'show']); + Route::apiResource('notes', NoteController::class) + ->names([ + 'store' => 'api.notes.store', + 'update' => 'api.notes.update', + 'destroy' => 'api.notes.destroy', + ]) + ->except(['index', 'show']); - Route::get('search/links', [SearchController::class, 'searchLinks']) - ->name('api.search.links'); - Route::get('search/tags', [SearchController::class, 'searchByTags']) - ->name('api.search.tags'); - Route::get('search/lists', [SearchController::class, 'searchByLists']) - ->name('api.search.lists'); + Route::get('search/links', [SearchController::class, 'searchLinks']) + ->name('api.search.links'); + Route::get('search/tags', [SearchController::class, 'searchByTags']) + ->name('api.search.tags'); + Route::get('search/lists', [SearchController::class, 'searchByLists']) + ->name('api.search.lists'); - Route::get('trash/links', [TrashController::class, 'getLinks']) - ->name('api.trash.links'); - Route::get('trash/lists', [TrashController::class, 'getLists']) - ->name('api.trash.lists'); - Route::get('trash/tags', [TrashController::class, 'getTags']) - ->name('api.trash.tags'); - Route::get('trash/notes', [TrashController::class, 'getNotes']) - ->name('api.trash.notes'); + Route::get('trash/links', [TrashController::class, 'getLinks']) + ->name('api.trash.links'); + Route::get('trash/lists', [TrashController::class, 'getLists']) + ->name('api.trash.lists'); + Route::get('trash/tags', [TrashController::class, 'getTags']) + ->name('api.trash.tags'); + Route::get('trash/notes', [TrashController::class, 'getNotes']) + ->name('api.trash.notes'); - Route::delete('trash/clear', [TrashController::class, 'clear']) - ->name('api.trash.clear'); - Route::patch('trash/restore', [TrashController::class, 'restore']) - ->name('api.trash.restore'); - }); + Route::delete('trash/clear', [TrashController::class, 'clear']) + ->name('api.trash.clear'); + Route::patch('trash/restore', [TrashController::class, 'restore']) + ->name('api.trash.restore'); }); diff --git a/routes/web.php b/routes/web.php index 1407682e..99528b50 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,6 +2,7 @@ use App\Http\Controllers\Admin\SystemSettingsController; use App\Http\Controllers\Admin\UserManagementController; +use App\Http\Controllers\App\ApiTokenController; use App\Http\Controllers\App\AuditController; use App\Http\Controllers\App\BookmarkletController; use App\Http\Controllers\App\DashboardController; @@ -125,8 +126,9 @@ ->name('save-settings-app'); Route::post('settings/change-password', [UserSettingsController::class, 'changeUserPassword']) ->name('change-user-password'); - Route::post('settings/generate-api-token', [UserSettingsController::class, 'generateApiToken']) - ->name('generate-api-token'); + + Route::resource('settings/api-tokens', ApiTokenController::class) + ->only(['index', 'store', 'destroy']); Route::post('fetch/tags', [FetchController::class, 'getTags']) ->name('fetch-tags'); diff --git a/tests/Controller/API/ApiTestCase.php b/tests/Controller/API/ApiTestCase.php index ed688839..46a3af50 100644 --- a/tests/Controller/API/ApiTestCase.php +++ b/tests/Controller/API/ApiTestCase.php @@ -2,6 +2,7 @@ namespace Tests\Controller\API; +use App\Enums\ApiToken; use App\Models\User; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Notification; @@ -12,12 +13,14 @@ abstract class ApiTestCase extends TestCase { protected User $user; + protected string $accessToken; protected function setUp(): void { parent::setUp(); $this->user = User::first() ?: User::factory()->create(); + $this->accessToken = $this->user->createToken('api-test', [ApiToken::ABILITY_USER_ACCESS])->plainTextToken; Queue::fake(); Notification::fake(); @@ -41,7 +44,7 @@ protected function setUp(): void */ public function getJsonAuthorized(string $uri, array $headers = []): TestResponse { - $headers['Authorization'] = 'Bearer ' . $this->user->api_token; + $headers['Authorization'] = 'Bearer ' . $this->accessToken; return $this->getJson($uri, $headers); } @@ -55,7 +58,7 @@ public function getJsonAuthorized(string $uri, array $headers = []): TestRespons */ public function postJsonAuthorized(string $uri, array $data = [], array $headers = []): TestResponse { - $headers['Authorization'] = 'Bearer ' . $this->user->api_token; + $headers['Authorization'] = 'Bearer ' . $this->accessToken; return $this->postJson($uri, $data, $headers); } @@ -69,7 +72,7 @@ public function postJsonAuthorized(string $uri, array $data = [], array $headers */ public function patchJsonAuthorized(string $uri, array $data = [], array $headers = []): TestResponse { - $headers['Authorization'] = 'Bearer ' . $this->user->api_token; + $headers['Authorization'] = 'Bearer ' . $this->accessToken; return $this->patchJson($uri, $data, $headers); } @@ -83,7 +86,7 @@ public function patchJsonAuthorized(string $uri, array $data = [], array $header */ public function deleteJsonAuthorized(string $uri, array $data = [], array $headers = []): TestResponse { - $headers['Authorization'] = 'Bearer ' . $this->user->api_token; + $headers['Authorization'] = 'Bearer ' . $this->accessToken; return $this->deleteJson($uri, $data, $headers); } } diff --git a/tests/Controller/App/ApiTokenControllerTest.php b/tests/Controller/App/ApiTokenControllerTest.php new file mode 100644 index 00000000..17f200f3 --- /dev/null +++ b/tests/Controller/App/ApiTokenControllerTest.php @@ -0,0 +1,67 @@ +create(); + $this->actingAs($user); + } + + public function testTokenOverview(): void + { + $this->get('settings/api-tokens')->assertOk()->assertSee('API Tokens'); + } + + public function testTokenCreation(): void + { + $this->post('settings/api-tokens', [ + 'token_name' => 'invalid name', + ])->assertSessionHasErrors(['token_name']); + + $this->post('settings/api-tokens', [ + 'token_name' => 'validToken', + ]) + ->assertRedirect('settings/api-tokens') + ->assertSessionHas('new_token'); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'name' => 'validToken', + 'tokenable_type' => User::class, + 'tokenable_id' => 1, + ]); + + $this->post('settings/api-tokens', [ + 'token_name' => 'validToken', + ])->assertSessionHasErrors(['token_name']); + } + + public function testTokenDeletion(): void + { + $this->post('settings/api-tokens', [ + 'token_name' => 'validToken', + ]); + + $this->delete('settings/api-tokens/1')->assertRedirect('settings/api-tokens'); + + $this->assertDatabaseCount('personal_access_tokens', 0); + } + + public function testTokenDeletionForForeignToken(): void + { + $user = User::factory()->create(); + $user->createToken('foreignToken'); + + $this->delete('settings/api-tokens/1')->assertForbidden(); + } +}