WIP: Road to 1.0.0 #1

Draft
miguel456 wants to merge 123 commits from develop into master
11 changed files with 406 additions and 3 deletions
Showing only changes of commit 99779c9053 - Show all commits

25
app/ApiKey.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ApiKey extends Model
{
use HasFactory;
protected $fillable = [
'name',
'status',
'discriminator',
'last_used',
'secret',
'owner_user_id'
];
public function user()
{
return $this->belongsTo('App\User', 'id');
}
}

View File

@ -0,0 +1,103 @@
<?php
namespace App\Http\Controllers;
use App\ApiKey;
use App\Http\Requests\CreateApiKeyRequest;
use App\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class ApiKeyController extends Controller
{
/**
* Display a listing of the resource.
*
*/
public function index()
{
return view('dashboard.user.api.index')
->with('keys', Auth::user()->keys);
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
*/
public function store(CreateApiKeyRequest $request)
{
$discriminator = "#" . bin2hex(openssl_random_pseudo_bytes(7));
$secret = bin2hex(openssl_random_pseudo_bytes(32));
$key = ApiKey::create([
'name' => $request->keyName,
'discriminator' => $discriminator,
'secret' => Hash::make($secret),
'status' => 'active',
'owner_user_id' => Auth::user()->id
]);
if ($key)
{
$request->session()->flash('success', 'Key successfully registered!');
$request->session()->flash('finalKey', $discriminator . '.' . $secret);
return redirect()
->back();
}
return redirect()
->back()
->with('error', 'An error occurred whilst trying to create an API key.');
}
public function revokeKey(Request $request, ApiKey $key)
{
if (Auth::user()->is($key->user) || Auth::user()->hasRole('admin'))
{
if ($key->status == 'active')
{
$key->status = 'disabled';
$key->save();
}
else
{
return redirect()
->back()
->with('error', 'Key already revoked.');
}
return redirect()
->back()
->with('success', 'Key revoked. Apps using this key will stop working.');
}
return redirect()
->back()
->with('error', 'You do not have permission to modify this key.');
}
/**
* Remove the specified resource from storage.
*/
public function destroy($id)
{
$key = ApiKey::findOrFail($id);
if (Auth::user()->is($key->user) || Auth::user()->hasRole('admin'))
{
$key->delete();
return redirect()
->back()
->with('success', 'Key deleted successfully. Apps using this key will stop working.');
}
return redirect()
->back()
->with('error', 'You do not have permission to modify this key.');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CreateApiKeyRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*
* @return bool
*/
public function authorize()
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array
*/
public function rules()
{
return [
'keyName' => 'required|string'
];
}
}

View File

@ -2,6 +2,7 @@
namespace App\Providers; namespace App\Providers;
use App;
use App\Helpers\JSON; use App\Helpers\JSON;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;

View File

@ -92,6 +92,11 @@ class User extends Authenticatable implements MustVerifyEmail
return $this->hasMany('App\TeamFile', 'uploaded_by'); return $this->hasMany('App\TeamFile', 'uploaded_by');
} }
public function keys()
{
return $this->hasMany('App\ApiKey', 'owner_user_id');
}
// UTILITY LOGIC // UTILITY LOGIC
public function isBanned() public function isBanned()

View File

@ -268,6 +268,11 @@ return [
'icon' => 'fas fa-user-circle', 'icon' => 'fas fa-user-circle',
'url' => '/profile/settings/account', 'url' => '/profile/settings/account',
], ],
[
'text' => 'Programmatic Access',
'icon' => 'fas fa-wrench',
'route' => 'keys.index'
],
[ [
'header' => 'h_app_management', 'header' => 'h_app_management',
'can' => ['applications.view.all', 'applications.vote'], 'can' => ['applications.view.all', 'applications.vote'],

View File

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class ApiKeys extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
// API keys can only access resources the owner's account can access
Schema::create('api_keys', function (Blueprint $table) {
$table->id();
$table->string('discriminator');
$table->string('secret');
$table->enum('status', ['disabled', 'active']);
$table->bigInteger('owner_user_id')->unsigned();
$table->foreign('owner_user_id')
->references('id')
->on('users')
->cascadeOnDelete()
->cascadeOnUpdate();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
//
}
}

View File

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

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddLastUsedToApiKeys extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('api_keys', function (Blueprint $table) {
$table->dateTime('last_used')->after('owner_user_id')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('api_keys', function (Blueprint $table) {
$table->dropColumn('last_used');
$table->dropTimestamps();
});
}
}

View File

@ -0,0 +1,118 @@
@extends('adminlte::page')
@section('title', config('app.name') . ' | ' . __('API Key Management'))
@section('content_header')
<h4>Profile / Settings / API Keys</h4>
@stop
@section('js')
<x-global-errors></x-global-errors>
@stop
@section('content')
<x-modal id="createKeyModal" modal-label="createKeyModalLabel" modal-title="New API Key" include-close-button="true">
<form id="createKey" method="post" action="{{ route('keys.store') }}">
@csrf
<div class="form-group">
<label for="name">Give your new API key a name to easily identify it.</label>
<input type="text" name="keyName" class="form-control" id="name" required>
</div>
</form>
<x-slot name="modalFooter">
<button onclick="$('#createKey').submit()" type="button" class="btn btn-success"><i class="fas fa-key"></i> Register new key</button>
</x-slot>
</x-modal>
<div class="row">
<div class="col">
<div class="alert alert-warning">
<p><i class="fas fa-exclamation-triangle"></i> <b>Friendly reminder: </b> API keys can access your whole account and the resources it has access to. Please treat them like a password. If they are leaked, please revoke them.</p>
</div>
</div>
</div>
@if (session()->has('finalKey'))
<div class="row">
<div class="col">
<div class="alert alert-success">
<p><i class="fas fa-key"></i> This is your API key: {{ session('finalKey') }}</p>
<p>Please copy it <b>now</b> as it'll only appear once.</p>
</div>
</div>
</div>
@endif
<div class="row">
<div class="col">
<x-card id="keyListing" card-title="Manage API Keys" footer-style="text-center">
<x-slot name="cardHeader"></x-slot>
@if(!$keys->isEmpty())
<table class="table table-borderless">
<thead>
<tr>
<th>Key name</th>
<th>Status</th>
<th>Last Used</th>
<th>Last Modified</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach($keys as $key)
<tr>
<td>{{ $key->name }}</td>
<td><span class="badge badge-{{ ($key->status == 'disabled') ? 'danger' : 'primary' }}">{{ ($key->status == 'disabled') ? 'Revoked' : 'Active' }}</span></td>
<td><span class="badge badge-{{ ($key->last_used == null) ? 'danger' : 'primary' }}">{{ ($key->last_used == null) ? 'No recent activity' : $key->last_used }}</span></td>
<td><span class="badge badge-primary">{{ $key->updated_at }}</span></td>
<td>
@if ($key->status == 'active')
<form class="d-inline-block" action="{{ route('revokeKey', ['key' => $key->id]) }}" method="post">
@csrf
@method('PATCH')
<button type="submit" class="btn btn-danger btn-sm ml-2"><i class="fas fa-lock"></i> Revoke</button>
</form>
@endif
<form class="d-inline-block" action="{{ route('keys.destroy', ['key' => $key->id]) }}" method="post">
@csrf
@method('DELETE')
<button type="submit" class="btn btn-danger btn-sm ml-2"><i class="fas fa-trash"></i> Delete</button>
</form>
</td>
</tr>
@endforeach
</tbody>
</table>
@else
<div class="alert alert-success">
<p><i class="fa fa-info-circle"></i> You don't have any API keys yet.</p>
</div>
@endif
<x-slot name="cardFooter">
<button onclick="$('#createKeyModal').modal('show')" type="button" class="btn btn-secondary"><i class="fas fa-plus"></i> New API Key</button>
</x-slot>
</x-card>
</div>
</div>
@stop
@section('footer')
@include('breadcrumbs.dashboard.footer')
@stop

View File

@ -19,6 +19,7 @@
* along with Raspberry Staff Manager. If not, see <https://www.gnu.org/licenses/>. * along with Raspberry Staff Manager. If not, see <https://www.gnu.org/licenses/>.
*/ */
use App\Http\Controllers\ApiKeyController;
use App\Http\Controllers\ApplicationController; use App\Http\Controllers\ApplicationController;
use App\Http\Controllers\AppointmentController; use App\Http\Controllers\AppointmentController;
use App\Http\Controllers\Auth\TwofaController; use App\Http\Controllers\Auth\TwofaController;
@ -68,11 +69,11 @@ Route::group(['prefix' => LaravelLocalization::setLocale(), 'middleware' => ['lo
->name('processDeleteConfirmation'); ->name('processDeleteConfirmation');
Route::group(['middleware' => ['auth', 'forcelogout', 'passwordexpiration', '2fa', 'verified']], function () { Route::group(['middleware' => ['auth', 'forcelogout', 'passwordexpiration', '2fa', 'verified']], function () {
Route::group(['middleware' => ['passwordredirect']], function(){ Route::group(['middleware' => ['passwordredirect']], function(){
Route::get('/dashboard', [DashboardController::class, 'index']) Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard') ->name('dashboard')
->middleware('eligibility'); ->middleware('eligibility');
@ -107,7 +108,7 @@ Route::group(['prefix' => LaravelLocalization::setLocale(), 'middleware' => ['lo
Route::get('team/files/{teamFile}/download', [TeamFileController::class, 'download']) Route::get('team/files/{teamFile}/download', [TeamFileController::class, 'download'])
->name('downloadTeamFile'); ->name('downloadTeamFile');
}); });
Route::group(['prefix' => '/applications', 'middleware' => ['passwordredirect']], function () { Route::group(['prefix' => '/applications', 'middleware' => ['passwordredirect']], function () {
@ -163,6 +164,13 @@ Route::group(['prefix' => LaravelLocalization::setLocale(), 'middleware' => ['lo
->name('showProfileSettings') ->name('showProfileSettings')
->middleware('passwordredirect'); ->middleware('passwordredirect');
Route::resource('keys', ApiKeyController::class)
->middleware('passwordredirect');
Route::patch('keys/revoke/{key}', [ApiKeyController::class, 'revokeKey'])
->name('revokeKey')
->middleware('passwordredirect');
Route::patch('/settings/save', [ProfileController::class, 'saveProfile']) Route::patch('/settings/save', [ProfileController::class, 'saveProfile'])
->name('saveProfileSettings') ->name('saveProfileSettings')
->middleware('passwordredirect'); ->middleware('passwordredirect');