]> BookStack Code Mirror - bookstack/commitdiff
Built out interfaces & endpoints for API token managment
authorDan Brown <redacted>
Sun, 29 Dec 2019 17:03:52 +0000 (17:03 +0000)
committerDan Brown <redacted>
Sun, 29 Dec 2019 17:03:52 +0000 (17:03 +0000)
14 files changed:
app/Api/ApiToken.php
app/Auth/UserRepo.php
app/Http/Controllers/UserApiTokenController.php
database/migrations/2019_12_29_120917_add_api_auth.php
resources/lang/en/settings.php
resources/sass/_forms.scss
resources/views/form/date.blade.php [new file with mode: 0644]
resources/views/form/text.blade.php
resources/views/users/api-tokens/create.blade.php [new file with mode: 0644]
resources/views/users/api-tokens/delete.blade.php [new file with mode: 0644]
resources/views/users/api-tokens/edit.blade.php [new file with mode: 0644]
resources/views/users/api-tokens/form.blade.php [new file with mode: 0644]
resources/views/users/edit.blade.php
routes/web.php

index 838e70abbb1baa6ba36ececa56fa1788e254ce39..e7101387f00192f0dd34246eb82031de927af28b 100644 (file)
@@ -5,5 +5,7 @@ use Illuminate\Database\Eloquent\Model;
 class ApiToken extends Model
 {
     protected $fillable = ['name', 'expires_at'];
 class ApiToken extends Model
 {
     protected $fillable = ['name', 'expires_at'];
-
+    protected $casts = [
+        'expires_at' => 'datetime:Y-m-d'
+    ];
 }
 }
index a903e2c38324e27c1d646655d9758d8e1febb471..e082b2dd50bbbad68bea82bfed57fd19a3af857d 100644 (file)
@@ -194,6 +194,7 @@ class UserRepo
     public function destroy(User $user)
     {
         $user->socialAccounts()->delete();
     public function destroy(User $user)
     {
         $user->socialAccounts()->delete();
+        $user->apiTokens()->delete();
         $user->delete();
         
         // Delete user profile images
         $user->delete();
         
         // Delete user profile images
index 3853520118d1311e86d66b9eb4c06c85f6b1b37b..3bfb0175ec513726f938beb24ff3d84396353265 100644 (file)
@@ -1,6 +1,11 @@
 <?php namespace BookStack\Http\Controllers;
 
 <?php namespace BookStack\Http\Controllers;
 
+use BookStack\Api\ApiToken;
+use BookStack\Auth\User;
 use Illuminate\Http\Request;
 use Illuminate\Http\Request;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Str;
 
 class UserApiTokenController extends Controller
 {
 
 class UserApiTokenController extends Controller
 {
@@ -10,11 +15,121 @@ class UserApiTokenController extends Controller
      */
     public function create(int $userId)
     {
      */
     public function create(int $userId)
     {
+        // Ensure user is has access-api permission and is the current user or has permission to manage the current user.
         $this->checkPermission('access-api');
         $this->checkPermission('access-api');
+        $this->checkPermissionOrCurrentUser('manage-users', $userId);
 
 
-        // TODO - Form
-        return 'test';
+        $user = User::query()->findOrFail($userId);
+        return view('users.api-tokens.create', [
+            'user' => $user,
+        ]);
     }
 
     }
 
+    /**
+     * Store a new API token in the system.
+     */
+    public function store(Request $request, int $userId)
+    {
+        $this->checkPermission('access-api');
+        $this->checkPermissionOrCurrentUser('manage-users', $userId);
+
+        $this->validate($request, [
+            'name' => 'required|max:250',
+            'expires_at' => 'date_format:Y-m-d',
+        ]);
+
+        $user = User::query()->findOrFail($userId);
+        $secret = Str::random(32);
+        $expiry = $request->get('expires_at', (Carbon::now()->addYears(100))->format('Y-m-d'));
+
+        $token = (new ApiToken())->forceFill([
+            'name' => $request->get('name'),
+            'client_id' => Str::random(32),
+            'client_secret' => Hash::make($secret),
+            'user_id' => $user->id,
+            'expires_at' => $expiry
+        ]);
+
+        while (ApiToken::query()->where('client_id', '=', $token->client_id)->exists()) {
+            $token->client_id = Str::random(32);
+        }
+
+        $token->save();
+        // TODO - Notification and activity?
+        session()->flash('api-token-secret:' . $token->id, $secret);
+        return redirect($user->getEditUrl('/api-tokens/' . $token->id));
+    }
+
+    /**
+     * Show the details for a user API token, with access to edit.
+     */
+    public function edit(int $userId, int $tokenId)
+    {
+        [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
+        $secret = session()->pull('api-token-secret:' . $token->id, null);
+
+        return view('users.api-tokens.edit', [
+            'user' => $user,
+            'token' => $token,
+            'model' => $token,
+            'secret' => $secret,
+        ]);
+    }
+
+    /**
+     * Update the API token.
+     */
+    public function update(Request $request, int $userId, int $tokenId)
+    {
+        $this->validate($request, [
+            'name' => 'required|max:250',
+            'expires_at' => 'date_format:Y-m-d',
+        ]);
+
+        [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
+
+        $token->fill($request->all())->save();
+        // TODO - Notification and activity?
+        return redirect($user->getEditUrl('/api-tokens/' . $token->id));
+    }
+
+    /**
+     * Show the delete view for this token.
+     */
+    public function delete(int $userId, int $tokenId)
+    {
+        [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
+        return view('users.api-tokens.delete', [
+            'user' => $user,
+            'token' => $token,
+        ]);
+    }
+
+    /**
+     * Destroy a token from the system.
+     */
+    public function destroy(int $userId, int $tokenId)
+    {
+        [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
+        $token->delete();
+
+        // TODO - Notification and activity?, Might have text in translations already (user_api_token_delete_success)
+        return redirect($user->getEditUrl('#api_tokens'));
+    }
+
+    /**
+     * Check the permission for the current user and return an array
+     * where the first item is the user in context and the second item is their
+     * API token in context.
+     */
+    protected function checkPermissionAndFetchUserToken(int $userId, int $tokenId): array
+    {
+        $this->checkPermission('access-api');
+        $this->checkPermissionOrCurrentUser('manage-users', $userId);
+
+        $user = User::query()->findOrFail($userId);
+        $token = ApiToken::query()->where('user_id', '=', $user->id)->where('id', '=', $tokenId)->firstOrFail();
+        return [$user, $token];
+    }
 
 }
 
 }
index e80fe3ae411f7566037d4f4d72a699db5a202d0a..2af0b292eb05d87c88b0f61623fc647cdb354312 100644 (file)
@@ -18,7 +18,8 @@ class AddApiAuth extends Migration
         // Add API tokens table
         Schema::create('api_tokens', function(Blueprint $table) {
             $table->increments('id');
         // Add API tokens table
         Schema::create('api_tokens', function(Blueprint $table) {
             $table->increments('id');
-            $table->string('client_id')->index();
+            $table->string('name');
+            $table->string('client_id')->unique();
             $table->string('client_secret');
             $table->integer('user_id')->unsigned()->index();
             $table->timestamp('expires_at')->index();
             $table->string('client_secret');
             $table->integer('user_id')->unsigned()->index();
             $table->timestamp('expires_at')->index();
index bb750a7804d5bdb6973e89d48c3098259ef03902..a2148361af6709d1f1d87eed85c2ae1a4e1c6487 100755 (executable)
@@ -155,8 +155,26 @@ return [
     'users_api_tokens' => 'API Tokens',
     'users_api_tokens_none' => 'No API tokens have been created for this user',
     'users_api_tokens_create' => 'Create Token',
     'users_api_tokens' => 'API Tokens',
     'users_api_tokens_none' => 'No API tokens have been created for this user',
     'users_api_tokens_create' => 'Create Token',
+    'users_api_tokens_expires' => 'Expires',
 
     // API Tokens
 
     // API Tokens
+    'user_api_token_create' => 'Create API Token',
+    'user_api_token_name' => 'Name',
+    'user_api_token_name_desc' => 'Give your token a readable name as a future reminder of its intended purpose.',
+    'user_api_token_expiry' => 'Expiry Date',
+    'user_api_token_expiry_desc' => 'Set a date at which this token expires. After this date, requests made using this token will no longer work. Leaving this field blank will set an expiry 100 years into the future.',
+    'user_api_token_create_secret_message' => 'Immediately after creating this token a "client id"" & "client secret" will be generated and displayed. The client secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.',
+    'user_api_token' => 'API Token',
+    'user_api_token_client_id' => 'Client ID',
+    'user_api_token_client_id_desc' => 'This is a non-editable system generated identifier for this token which will need to be provided in API requests.',
+    'user_api_token_client_secret' => 'Client Secret',
+    'user_api_token_client_secret_desc' => 'This is a system generated secret for this token which will need to be provided in API requests. This will only be displayed this one time so copy this value to somewhere safe and secure.',
+    'user_api_token_created' => 'Token Created :timeAgo',
+    'user_api_token_updated' => 'Token Updated :timeAgo',
+    'user_api_token_delete' => 'Delete Token',
+    'user_api_token_delete_warning' => 'This will fully delete this API token with the name \':tokenName\' from the system.',
+    'user_api_token_delete_confirm' => 'Are you sure you want to delete this API token?',
+    'user_api_token_delete_success' => 'API token successfully deleted',
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
 
     //! If editing translations files directly please ignore this in all
     //! languages apart from en. Content will be auto-copied from en.
index 3e7ff60f357c9598f7f450a57371200dfc25405f..da0f7ef4c191be13f4989186ffe03e208f95ca57 100644 (file)
@@ -19,6 +19,9 @@
   &.disabled, &[disabled] {
     background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
   }
   &.disabled, &[disabled] {
     background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAcAAAAHCAYAAADEUlfTAAAAMUlEQVQIW2NkwAGuXbv2nxGbHEhCS0uLEUMSJgHShCKJLIEiiS4Bl8QmAZbEJQGSBAC62BuJ+tt7zgAAAABJRU5ErkJggg==);
   }
+  &[readonly] {
+    background-color: #f8f8f8;
+  }
   &:focus {
     border-color: var(--color-primary);
     outline: 1px solid var(--color-primary);
   &:focus {
     border-color: var(--color-primary);
     outline: 1px solid var(--color-primary);
diff --git a/resources/views/form/date.blade.php b/resources/views/form/date.blade.php
new file mode 100644 (file)
index 0000000..c2e70b9
--- /dev/null
@@ -0,0 +1,9 @@
+<input type="date" id="{{ $name }}" name="{{ $name }}"
+       @if($errors->has($name)) class="text-neg" @endif
+       placeholder="{{ $placeholder ?? 'YYYY-MM-DD' }}"
+       @if($autofocus ?? false) autofocus @endif
+       @if($disabled ?? false) disabled="disabled" @endif
+       @if(isset($model) || old($name)) value="{{ old($name) ?? $model->$name->format('Y-m-d') ?? ''}}" @endif>
+@if($errors->has($name))
+    <div class="text-neg text-small">{{ $errors->first($name) }}</div>
+@endif
index 4b3631a06566158d2286df4f0e662b0b4dfd376a..fabfab451680167937588b463be09735ed4fa933 100644 (file)
@@ -3,6 +3,7 @@
        @if(isset($placeholder)) placeholder="{{$placeholder}}" @endif
        @if($autofocus ?? false) autofocus @endif
        @if($disabled ?? false) disabled="disabled" @endif
        @if(isset($placeholder)) placeholder="{{$placeholder}}" @endif
        @if($autofocus ?? false) autofocus @endif
        @if($disabled ?? false) disabled="disabled" @endif
+       @if($readonly ?? false) readonly="readonly" @endif
        @if(isset($model) || old($name)) value="{{ old($name) ? old($name) : $model->$name}}" @endif>
 @if($errors->has($name))
     <div class="text-neg text-small">{{ $errors->first($name) }}</div>
        @if(isset($model) || old($name)) value="{{ old($name) ? old($name) : $model->$name}}" @endif>
 @if($errors->has($name))
     <div class="text-neg text-small">{{ $errors->first($name) }}</div>
diff --git a/resources/views/users/api-tokens/create.blade.php b/resources/views/users/api-tokens/create.blade.php
new file mode 100644 (file)
index 0000000..46c3e0b
--- /dev/null
@@ -0,0 +1,33 @@
+@extends('simple-layout')
+
+@section('body')
+
+    <div class="container small pt-xl">
+
+        <main class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('settings.user_api_token_create') }}</h1>
+
+            <form action="{{ $user->getEditUrl('/create-api-token') }}" method="post">
+                {!! csrf_field() !!}
+
+                <div class="setting-list">
+                    @include('users.api-tokens.form')
+
+                    <div>
+                        <p class="text-warn italic">
+                            {{ trans('settings.user_api_token_create_secret_message') }}
+                        </p>
+                    </div>
+                </div>
+
+                <div class="form-group text-right">
+                    <a href="{{ $user->getEditUrl('#api_tokens') }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <button class="button" type="submit">{{ trans('common.save') }}</button>
+                </div>
+
+            </form>
+
+        </main>
+    </div>
+
+@stop
diff --git a/resources/views/users/api-tokens/delete.blade.php b/resources/views/users/api-tokens/delete.blade.php
new file mode 100644 (file)
index 0000000..8fcfcda
--- /dev/null
@@ -0,0 +1,26 @@
+@extends('simple-layout')
+
+@section('body')
+    <div class="container small pt-xl">
+
+        <div class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('settings.user_api_token_delete') }}</h1>
+
+            <p>{{ trans('settings.user_api_token_delete_warning', ['tokenName' => $token->name]) }}</p>
+
+            <div class="grid half">
+                <p class="text-neg"><strong>{{ trans('settings.user_api_token_delete_confirm') }}</strong></p>
+                <div>
+                    <form action="{{ $user->getEditUrl('/api-tokens/' . $token->id) }}" method="POST" class="text-right">
+                        {!! csrf_field() !!}
+                        {!! method_field('delete') !!}
+
+                        <a href="{{ $user->getEditUrl('/api-tokens/' . $token->id) }}" class="button outline">{{ trans('common.cancel') }}</a>
+                        <button type="submit" class="button">{{ trans('common.confirm') }}</button>
+                    </form>
+                </div>
+            </div>
+
+        </div>
+    </div>
+@stop
diff --git a/resources/views/users/api-tokens/edit.blade.php b/resources/views/users/api-tokens/edit.blade.php
new file mode 100644 (file)
index 0000000..0ec9adb
--- /dev/null
@@ -0,0 +1,66 @@
+@extends('simple-layout')
+
+@section('body')
+
+    <div class="container small pt-xl">
+
+        <main class="card content-wrap auto-height">
+            <h1 class="list-heading">{{ trans('settings.user_api_token') }}</h1>
+
+            <form action="{{ $user->getEditUrl('/api-tokens/' . $token->id) }}" method="post">
+                {!! method_field('put') !!}
+                {!! csrf_field() !!}
+
+                <div class="setting-list">
+
+                    <div class="grid half gap-xl v-center">
+                        <div>
+                            <label class="setting-list-label">{{ trans('settings.user_api_token_client_id') }}</label>
+                            <p class="small">{{ trans('settings.user_api_token_client_id_desc') }}</p>
+                        </div>
+                        <div>
+                            @include('form.text', ['name' => 'client_id', 'readonly' => true])
+                        </div>
+                    </div>
+
+
+                    @if( $secret )
+                        <div class="grid half gap-xl v-center">
+                            <div>
+                                <label class="setting-list-label">{{ trans('settings.user_api_token_client_secret') }}</label>
+                                <p class="small text-warn">{{ trans('settings.user_api_token_client_secret_desc') }}</p>
+                            </div>
+                            <div>
+                                <input type="text" readonly="readonly" value="{{ $secret }}">
+                            </div>
+                        </div>
+                    @endif
+
+                    @include('users.api-tokens.form', ['model' => $token])
+                </div>
+
+                <div class="grid half gap-xl v-center">
+
+                    <div class="text-muted text-small">
+                        <span title="{{ $token->created_at }}">
+                            {{ trans('settings.user_api_token_created', ['timeAgo' => $token->created_at->diffForHumans()]) }}
+                        </span>
+                        <br>
+                        <span title="{{ $token->updated_at }}">
+                            {{ trans('settings.user_api_token_updated', ['timeAgo' => $token->created_at->diffForHumans()]) }}
+                        </span>
+                    </div>
+
+                    <div class="form-group text-right">
+                        <a href="{{  $user->getEditUrl('#api_tokens') }}" class="button outline">{{ trans('common.back') }}</a>
+                        <a href="{{  $user->getEditUrl('/api-tokens/' . $token->id . '/delete') }}" class="button outline">{{ trans('settings.user_api_token_delete') }}</a>
+                        <button class="button" type="submit">{{ trans('common.save') }}</button>
+                    </div>
+                </div>
+
+            </form>
+
+        </main>
+    </div>
+
+@stop
diff --git a/resources/views/users/api-tokens/form.blade.php b/resources/views/users/api-tokens/form.blade.php
new file mode 100644 (file)
index 0000000..d81a330
--- /dev/null
@@ -0,0 +1,21 @@
+
+
+<div class="grid half gap-xl v-center">
+    <div>
+        <label class="setting-list-label">{{ trans('settings.user_api_token_name') }}</label>
+        <p class="small">{{ trans('settings.user_api_token_name_desc') }}</p>
+    </div>
+    <div>
+        @include('form.text', ['name' => 'name'])
+    </div>
+</div>
+
+<div class="grid half gap-xl v-center">
+    <div>
+        <label class="setting-list-label">{{ trans('settings.user_api_token_expiry') }}</label>
+        <p class="small">{{ trans('settings.user_api_token_expiry_desc') }}</p>
+    </div>
+    <div class="text-right">
+        @include('form.date', ['name' => 'expires_at'])
+    </div>
+</div>
\ No newline at end of file
index b3f73773b96755995f3738aaa59ecbafa7fd6fd0..54e0ee21aeae91e1b3138163c4581a84acda6369 100644 (file)
@@ -90,7 +90,7 @@
 
         {{-- TODO - Review Control--}}
         @if(($currentUser->id === $user->id && userCan('access-api')) || userCan('manage-users'))
 
         {{-- TODO - Review Control--}}
         @if(($currentUser->id === $user->id && userCan('access-api')) || userCan('manage-users'))
-            <section class="card content-wrap auto-height">
+            <section class="card content-wrap auto-height" id="api_tokens">
                 <div class="grid half">
                     <div><h2 class="list-heading">{{ trans('settings.users_api_tokens') }}</h2></div>
                     <div class="text-right pt-xs">
                 <div class="grid half">
                     <div><h2 class="list-heading">{{ trans('settings.users_api_tokens') }}</h2></div>
                     <div class="text-right pt-xs">
                     </div>
                 </div>
                 @if (count($user->apiTokens) > 0)
                     </div>
                 </div>
                 @if (count($user->apiTokens) > 0)
-
+                    <table class="table">
+                        <tr>
+                            <th>{{ trans('common.name') }}</th>
+                            <th>{{ trans('settings.users_api_tokens_expires') }}</th>
+                            <th></th>
+                        </tr>
+                        @foreach($user->apiTokens as $token)
+                        <tr>
+                            <td>
+                                {{ $token->name }} <br>
+                                <span class="small text-muted italic">{{ $token->client_id }}</span>
+                            </td>
+                            <td>{{ $token->expires_at->format('Y-m-d') ?? '' }}</td>
+                            <td class="text-right">
+                                <a class="button outline small" href="{{ $user->getEditUrl('/api-tokens/' . $token->id) }}">{{ trans('common.edit') }}</a>
+                            </td>
+                        </tr>
+                        @endforeach
+                    </table>
                 @else
                     <p class="text-muted italic py-m">{{ trans('settings.users_api_tokens_none') }}</p>
                 @endif
                 @else
                     <p class="text-muted italic py-m">{{ trans('settings.users_api_tokens_none') }}</p>
                 @endif
index 2a0e85dfe09ca1094a45a18008037c2fc8c6888d..f38575b79ea61efba32bc8ffdd7be33e80d7ed5f 100644 (file)
@@ -189,6 +189,11 @@ Route::group(['middleware' => 'auth'], function () {
 
         // User API Tokens
         Route::get('/users/{userId}/create-api-token', 'UserApiTokenController@create');
 
         // User API Tokens
         Route::get('/users/{userId}/create-api-token', 'UserApiTokenController@create');
+        Route::post('/users/{userId}/create-api-token', 'UserApiTokenController@store');
+        Route::get('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@edit');
+        Route::put('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@update');
+        Route::get('/users/{userId}/api-tokens/{tokenId}/delete', 'UserApiTokenController@delete');
+        Route::delete('/users/{userId}/api-tokens/{tokenId}', 'UserApiTokenController@destroy');
 
         // Roles
         Route::get('/roles', 'PermissionController@listRoles');
 
         // Roles
         Route::get('/roles', 'PermissionController@listRoles');