Skip to content

Commit

Permalink
WIP: system-wide api tokens (#165)
Browse files Browse the repository at this point in the history
This is a ton of work. A new system user is generated while running the migrations. System tokes are bound to that user. Api calls need to be properly authorized, which feels really hacky at the moment. I only implemented link api tests for now.
  • Loading branch information
Kovah committed Sep 29, 2022
1 parent b38bb3b commit b2705de
Show file tree
Hide file tree
Showing 44 changed files with 847 additions and 78 deletions.
3 changes: 3 additions & 0 deletions app/Enums/ActivityLog.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ class ActivityLog

public const USER_API_TOKEN_GENERATED = 'user.api_token_regenerated';
public const USER_API_TOKEN_REVOKED = 'user.api_token_revoked';

public const SYSTEM_API_TOKEN_GENERATED = 'system.api_token_regenerated';
public const SYSTEM_API_TOKEN_REVOKED = 'system.api_token_revoked';
}
40 changes: 40 additions & 0 deletions app/Enums/ApiToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,44 @@ class ApiToken
{
public const ABILITY_USER_ACCESS = 'user_access';
public const ABILITY_SYSTEM_ACCESS = 'system_access';
public const ABILITY_SYSTEM_ACCESS_PRIVATE = 'system_access_private';

public const ABILITY_LINKS_READ = 'links.read';
public const ABILITY_LINKS_CREATE = 'links.create';
public const ABILITY_LINKS_UPDATE = 'links.update';
public const ABILITY_LINKS_DELETE = 'links.delete';

public const ABILITY_LISTS_READ = 'lists.read';
public const ABILITY_LISTS_CREATE = 'lists.create';
public const ABILITY_LISTS_UPDATE = 'lists.update';
public const ABILITY_LISTS_DELETE = 'lists.delete';

public const ABILITY_TAGS_READ = 'tags.read';
public const ABILITY_TAGS_CREATE = 'tags.create';
public const ABILITY_TAGS_UPDATE = 'tags.update';
public const ABILITY_TAGS_DELETE = 'tags.delete';

public const ABILITY_NOTES_READ = 'notes.read';
public const ABILITY_NOTES_CREATE = 'notes.create';
public const ABILITY_NOTES_UPDATE = 'notes.update';
public const ABILITY_NOTES_DELETE = 'notes.delete';

public static array $systemTokenAbilities = [
self::ABILITY_LINKS_READ,
self::ABILITY_LINKS_CREATE,
self::ABILITY_LINKS_UPDATE,
self::ABILITY_LINKS_DELETE,
self::ABILITY_LISTS_READ,
self::ABILITY_LISTS_CREATE,
self::ABILITY_LISTS_UPDATE,
self::ABILITY_LISTS_DELETE,
self::ABILITY_TAGS_READ,
self::ABILITY_TAGS_CREATE,
self::ABILITY_TAGS_UPDATE,
self::ABILITY_TAGS_DELETE,
self::ABILITY_NOTES_READ,
self::ABILITY_NOTES_CREATE,
self::ABILITY_NOTES_UPDATE,
self::ABILITY_NOTES_DELETE,
];
}
4 changes: 4 additions & 0 deletions app/Helper/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ function getPaginationLimit(): mixed

$default = config('linkace.default.pagination');

if (auth()->id() === 0) {
return $default;
}

if (request()->is('guest/*')) {
return guestsettings('listitem_count') ?: $default;
}
Expand Down
2 changes: 2 additions & 0 deletions app/Http/Controllers/API/LinkCheckController.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public function __invoke(Request $request): JsonResponse
{
$searchedUrl = $request->input('url', false);

$this->authorize('viewAny', Link::class);

if (!$searchedUrl) {
return response()->json(['linksFound' => false]);
}
Expand Down
5 changes: 3 additions & 2 deletions app/Http/Controllers/API/LinkController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Http\Controllers\API;

use App\Enums\ApiToken;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Traits\ChecksOrdering;
use App\Http\Requests\Models\LinkStoreRequest;
Expand All @@ -18,7 +19,7 @@ class LinkController extends Controller
public function __construct()
{
$this->allowedOrderBy = Link::$allowOrderBy;
$this->authorizeResource(Link::class, 'link');
$this->authorizeResource(Link::class . 'Api', 'link');
}

public function index(Request $request): JsonResponse
Expand All @@ -29,7 +30,7 @@ public function index(Request $request): JsonResponse
$this->checkOrdering();

$links = Link::query()
->visibleForUser()
->visibleForUser(privateSystemAccess: $request->user()->tokenCan(ApiToken::ABILITY_SYSTEM_ACCESS_PRIVATE))
->orderBy($this->orderBy, $this->orderDir)
->paginate(getPaginationLimit());

Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/API/ListController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class ListController extends Controller
public function __construct()
{
$this->allowedOrderBy = LinkList::$allowOrderBy;
$this->authorizeResource(LinkList::class, 'list');
$this->authorizeResource(LinkList::class . 'Api', 'list');
}

public function index(Request $request): JsonResponse
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/API/NoteController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class NoteController extends Controller
{
public function __construct()
{
$this->authorizeResource(Note::class, 'note');
$this->authorizeResource(Note::class . 'Api', 'note');
}

public function store(NoteStoreRequest $request): JsonResponse
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/API/TagController.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class TagController extends Controller
public function __construct()
{
$this->allowedOrderBy = Tag::$allowOrderBy;
$this->authorizeResource(Tag::class, 'tag');
$this->authorizeResource(Tag::class . 'Api', 'tag');
}

public function index(Request $request): JsonResponse
Expand Down
65 changes: 65 additions & 0 deletions app/Http/Controllers/Admin/ApiTokenController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Enums\ActivityLog;
use App\Enums\ApiToken;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\CreateSystemApiTokenRequest;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Laravel\Sanctum\PersonalAccessToken;

class ApiTokenController extends Controller
{
public function index()
{
return view('admin.api-tokens.index', [
'tokens' => User::getSystemUser()->tokens()->get(),
]);
}

public function show(PersonalAccessToken $token)
{
return view('admin.api-tokens.show', [
'token' => $token,
]);
}

public function store(CreateSystemApiTokenRequest $request): RedirectResponse
{
$abilities = $request->validated('abilities');

if ($request->get('private_access', false)) {
$abilities[] = ApiToken::ABILITY_SYSTEM_ACCESS_PRIVATE;
} else {
$abilities[] = ApiToken::ABILITY_SYSTEM_ACCESS;
}

$token = User::getSystemUser()->createToken($request->validated('token_name'), $abilities);

activity()
->by($request->user())
->withProperty('token_id', $token->accessToken->id)
->log(ActivityLog::SYSTEM_API_TOKEN_GENERATED);

session()->flash('new_token', $token->plainTextToken);

return redirect()->route('system.api-tokens.show', ['api_token' => $token->accessToken]);
}

public function destroy(Request $request, PersonalAccessToken $token): RedirectResponse
{
$this->authorize('delete', $token);

$token->delete();

activity()
->by($request->user())
->log(ActivityLog::SYSTEM_API_TOKEN_REVOKED);

flash()->warning(trans('auth.api_tokens.revoke_successful'));
return redirect()->route('system.api-tokens.index');
}
}
4 changes: 4 additions & 0 deletions app/Http/Middleware/Authenticate.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ public function handle($request, Closure $next, ...$guards)
{
$this->authenticate($request, $guards);

if (!$request->is('api/*') && $request->user()->isSystemUser()) {
abort(403, trans('user.system_user_locked'));
}

if ($request->user()->isBlocked()) {
abort(403, trans('user.block_warning'));
}
Expand Down
34 changes: 34 additions & 0 deletions app/Http/Requests/Admin/CreateSystemApiTokenRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace App\Http\Requests\Admin;

use App\Rules\ApiTokenAbilityRule;
use Illuminate\Database\Query\Builder;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class CreateSystemApiTokenRequest extends FormRequest
{
public function rules(): array
{
return [
'token_name' => [
'required',
'alpha_dash',
'min:3',
'max:100',
Rule::unique('personal_access_tokens', 'name')->where(function (Builder $query) {
return $query->whereNull('tokenable_id');
}),
],
'abilities' => [
'required',
new ApiTokenAbilityRule(),
],
'private_access' => [
'sometimes',
'accepted',
],
];
}
}
6 changes: 5 additions & 1 deletion app/Models/ScopesVisibility.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,16 @@

trait ScopesVisibility
{
public function scopeVisibleForUser(Builder $query, int $userId = null): Builder
public function scopeVisibleForUser(Builder $query, int $userId = null, bool $privateSystemAccess = false): Builder
{
if (is_null($userId) && auth()->check()) {
$userId = auth()->id();
}

if ($userId === 0) {
return $privateSystemAccess ? $query : $query->whereNot('visibility', ModelAttribute::VISIBILITY_PRIVATE);
}

// Entity must be either public or internal, or have a private status together with the matching user id
return $query->where(function (Builder $query) use ($userId) {
$query->where('visibility', ModelAttribute::VISIBILITY_PUBLIC)
Expand Down
21 changes: 21 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
Expand Down Expand Up @@ -77,11 +78,26 @@ class User extends Authenticatable implements Auditable

public array $auditModifiers = [];

/*
* ========================================================================
* SCOPES
*/

public function scopeNotSystem(Builder $query): Builder
{
return $query->whereNot('id', 0);
}

/*
* ========================================================================
* METHODS
*/

public static function getSystemUser(): User
{
return self::whereId(0)->first();
}

public function isBlocked(): bool
{
return $this->blocked_at !== null;
Expand All @@ -91,4 +107,9 @@ public function isCurrentlyLoggedIn(): bool
{
return $this->is(auth()->user());
}

public function isSystemUser(): bool
{
return $this->id === 0;
}
}
65 changes: 65 additions & 0 deletions app/Policies/Api/LinkApiPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

namespace App\Policies\Api;

use App\Enums\ApiToken;
use App\Enums\ModelAttribute;
use App\Models\Link;
use App\Models\User;
use Illuminate\Auth\Access\HandlesAuthorization;

class LinkApiPolicy
{
use HandlesAuthorization;

public function viewAny(User $user): bool
{
if ($user->isSystemUser()) {
return $user->tokenCan(ApiToken::ABILITY_LINKS_READ);
}
return true;
}

public function view(User $user, Link $link): bool
{
if ($user->isSystemUser()) {
$canViewPrivate = $user->tokenCan(ApiToken::ABILITY_SYSTEM_ACCESS_PRIVATE);
return $link->is_private ? $canViewPrivate : $user->tokenCan(ApiToken::ABILITY_LINKS_READ);
}
return $this->userCanAccessLink($user, $link);
}

public function create(User $user): bool
{
return true;
}

public function update(User $user, Link $link): bool
{
return $this->userCanAccessLink($user, $link);
}

public function delete(User $user, Link $link): bool
{
return $link->user->is($user);
}

public function restore(User $user, Link $link): bool
{
return $link->user->is($user);
}

public function forceDelete(User $user, Link $link): bool
{
return $link->user->is($user);
}

// Link must be either owned by user, or be not private
protected function userCanAccessLink(User $user, Link $link): bool
{
if ($link->user_id === $user->id) {
return true;
}
return $link->visibility !== ModelAttribute::VISIBILITY_PRIVATE;
}
}
Loading

0 comments on commit b2705de

Please sign in to comment.