Improve oAuth authorize screen, and only assign admin scopes to admin accounts

pull/7340/head
Daniel Supernault 1 week ago
parent 7c412d7e70
commit 933e24c9ba
No known key found for this signature in database
GPG Key ID: 23740873EE6F76A1

@ -20,6 +20,7 @@ class User extends Authenticatable implements OAuthenticatable
protected function casts(): array
{
return [
'is_admin' => 'boolean',
'deleted_at' => 'datetime',
'email_verified_at' => 'datetime',
'2fa_setup_at' => 'datetime',
@ -41,9 +42,16 @@ class User extends Authenticatable implements OAuthenticatable
* @var array
*/
protected $hidden = [
'email', 'password', 'is_admin', 'remember_token',
'email_verified_at', '2fa_enabled', '2fa_secret',
'2fa_backup_codes', '2fa_setup_at', 'deleted_at',
'email',
'password',
'is_admin',
'remember_token',
'email_verified_at',
'2fa_enabled',
'2fa_secret',
'2fa_backup_codes',
'2fa_setup_at',
'deleted_at',
'updated_at',
];

@ -0,0 +1,47 @@
<?php
namespace App\Passport;
use App\User;
use Illuminate\Support\Str;
use Laravel\Passport\Bridge\ScopeRepository as BaseScopeRepository;
use League\OAuth2\Server\Entities\ClientEntityInterface;
class ScopeRepository extends BaseScopeRepository
{
/**
* Scopes that only an admin account can be issued.
*/
public static function isAdminScope(string $scope): bool
{
return Str::startsWith($scope, 'admin:');
}
/**
* Called by the OAuth server at token issuance for every grant type.
* The parent handles the usual checks (known scope, client allowed to
* request it, no "*" on the auth code flow); this layer additionally
* drops admin:* unless the token is being issued to an admin user.
*/
public function finalizeScopes(
array $scopes,
string $grantType,
ClientEntityInterface $clientEntity,
string|int|null $userIdentifier = null,
?string $authCodeId = null
): array {
$scopes = parent::finalizeScopes($scopes, $grantType, $clientEntity, $userIdentifier, $authCodeId);
$user = $userIdentifier !== null ? User::find($userIdentifier) : null;
if ($user && $user->is_admin) {
return $scopes;
}
// No user (client_credentials) or a non-admin user: never issue admin scopes.
return collect($scopes)
->reject(fn ($scope) => self::isAdminScope($scope->getIdentifier()))
->values()
->all();
}
}

@ -3,6 +3,7 @@
namespace App\Providers;
use App\Passport\CachedPersonalAccessClientRepository;
use App\Passport\ScopeRepository;
use Laravel\Passport\Bridge;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\Passport;
@ -26,7 +27,7 @@ class PassportServiceProvider extends \Laravel\Passport\PassportServiceProvider
return tap(new AuthorizationServer(
$this->app->make(Bridge\ClientRepository::class),
$this->app->make(Bridge\AccessTokenRepository::class),
$this->app->make(Bridge\ScopeRepository::class),
$this->app->make(ScopeRepository::class),
$this->makeCryptKey('private'),
Passport::tokenEncryptionKey($this->app->make('encrypter')),
$responseType ?? Passport::$authorizationServerResponseType

@ -1,5 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
@ -7,93 +8,340 @@
<title>{{ config_cache('app.name') }} - Authorization</title>
<link href="{{ mix('/css/app.css') }}" rel="stylesheet">
<style>
.passport-authorize .container {
margin-top: 30px;
.passport-authorize {
background-color: #f5f6f8;
min-height: 100vh;
}
.passport-authorize .authorize-wrap {
max-width: 460px;
margin: 0 auto;
padding: 40px 16px 32px;
}
.passport-authorize .authorize-card {
border-radius: 14px;
}
.passport-authorize .authorize-card .card-body {
padding: 2rem;
}
/* App -> account handoff */
.passport-authorize .handoff {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
}
.passport-authorize .handoff-tile {
width: 64px;
height: 64px;
flex: 0 0 64px;
}
.passport-authorize .handoff-app {
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background-color: #343a40;
color: #fff;
font-size: 1.5rem;
font-weight: 700;
}
.passport-authorize .handoff-avatar {
border-radius: 50%;
object-fit: cover;
background-color: #fff;
border: 1px solid #e9ecef;
}
.passport-authorize .handoff-link {
position: relative;
width: 64px;
display: flex;
align-items: center;
justify-content: center;
color: #adb5bd;
}
.passport-authorize .handoff-link::before {
content: '';
position: absolute;
left: 6px;
right: 6px;
top: 50%;
border-top: 2px dashed #dee2e6;
}
.passport-authorize .scopes {
margin-top: 20px;
.passport-authorize .handoff-link i {
position: relative;
padding: 0 6px;
background-color: #fff;
font-size: 0.875rem;
}
.passport-authorize .buttons {
margin-top: 25px;
/* Scope list */
.passport-authorize .scope-list {
border: 1px solid #e9ecef;
border-radius: 10px;
overflow: hidden;
}
.passport-authorize .scope-item {
display: flex;
align-items: center;
padding: 12px 14px;
border-top: 1px solid #e9ecef;
}
.passport-authorize .scope-list>.scope-item:first-child {
border-top: 0;
}
.passport-authorize .scope-icon {
flex: 0 0 38px;
width: 38px;
height: 38px;
margin-right: 12px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
background-color: #eef1f4;
color: #495057;
}
.passport-authorize .scope-icon-warning {
background-color: #fff3cd;
color: #856404;
}
.passport-authorize .scope-icon-danger {
background-color: #f8d7da;
color: #842029;
}
.passport-authorize .scope-body {
flex: 1 1 auto;
min-width: 0;
}
.passport-authorize .scope-desc {
margin: 0;
font-size: 0.9375rem;
line-height: 1.3;
}
.passport-authorize .scope-id {
display: block;
margin-top: 2px;
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.75rem;
color: #868e96;
}
/* Expander for extra scopes (no JS needed) */
.passport-authorize .scope-more>summary {
list-style: none;
cursor: pointer;
user-select: none;
padding: 10px 14px;
border-top: 1px solid #e9ecef;
text-align: center;
font-size: 0.875rem;
font-weight: 600;
color: #007bff;
}
.passport-authorize .btn {
width: 125px;
.passport-authorize .scope-more>summary::-webkit-details-marker {
display: none;
}
.passport-authorize .btn-approve {
margin-right: 15px;
.passport-authorize .scope-more>summary:hover {
background-color: #f8f9fa;
}
.passport-authorize .scope-more>summary:focus-visible {
outline: 2px solid #007bff;
outline-offset: -2px;
}
.passport-authorize .scope-more>summary .fa-chevron-down {
margin-left: 6px;
font-size: 0.75rem;
transition: transform 0.15s ease;
}
.passport-authorize .scope-more[open]>summary .fa-chevron-down {
transform: rotate(180deg);
}
.passport-authorize .scope-more:not([open]) .when-open,
.passport-authorize .scope-more[open] .when-closed {
display: none;
}
@media (prefers-reduced-motion: reduce) {
.passport-authorize .scope-more>summary .fa-chevron-down {
transition: none;
}
}
/* Actions */
.passport-authorize form {
display: inline;
margin: 0;
}
.passport-authorize .btn-block {
padding-top: 0.6rem;
padding-bottom: 0.6rem;
}
</style>
</head>
<body class="passport-authorize">
<div class="container">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="text-center mb-5">
<img src="/img/pixelfed-icon-grey.svg">
@php
$user = request()->user();
$scopeIcons = [
'read' => 'fas fa-eye',
'write' => 'fas fa-pen',
'follow' => 'fas fa-user-plus',
'push' => 'fas fa-bell',
'admin:read' => 'fas fa-server',
'admin:read:domain_blocks' => 'fas fa-globe',
'admin:write' => 'fas fa-tools',
'admin:write:domain_blocks' => 'fas fa-gavel',
'security:read' => 'fas fa-shield-alt',
'security:write' => 'fas fa-key',
];
$requestedScopes = collect($scopes);
$grantableScopes = $user->is_admin
? $requestedScopes
: $requestedScopes->reject(fn ($scope) => \App\Passport\ScopeRepository::isAdminScope($scope->id));
$droppedAdminScopes = $requestedScopes->count() - $grantableScopes->count();
$scopeLevel = function ($id) {
if (\App\Passport\ScopeRepository::isAdminScope($id)) {
return 'danger';
}
if (in_array($id, ['write', 'security:write'])) {
return 'warning';
}
return 'default';
};
$levelRank = ['danger' => 0, 'warning' => 1, 'default' => 2];
$sortedScopes = $grantableScopes
->sortBy(fn ($scope) => $levelRank[$scopeLevel($scope->id)])
->values();
$visibleCount = 3;
$hiddenCount = max($sortedScopes->count() - $visibleCount, 0);
$moreLabel = 'Show ' . $hiddenCount . ' more ' . \Illuminate\Support\Str::plural('permission', $hiddenCount);
@endphp
<div class="authorize-wrap">
<div class="text-center mb-4">
<img src="/img/pixelfed-icon-grey.svg" width="48" height="48" alt="{{ config_cache('app.name') }}">
</div>
<div class="card authorize-card shadow-sm border-0">
<div class="card-body">
<div class="handoff" aria-hidden="true">
<div class="handoff-tile handoff-app">{{ mb_strtoupper(mb_substr($client->name, 0, 1)) }}</div>
<div class="handoff-link"><i class="fas fa-link"></i></div>
<img class="handoff-tile handoff-avatar" src="{{ $user->profile->avatarUrl() }}" alt="">
</div>
<p class="text-center h3 font-weight-light mb-3">Authorize {{ $client->name }}</p>
<div class="card card-default shadow-none border">
<div class="card-body">
<div class="media">
<img src="/img/icon/alert-circle.svg" class="mr-3" width="32" height="32">
<div class="media-body">
<p class="my-0"><span class="font-weight-bold">{{ $client->name }}</span></p>
<p class="mb-0 text-muted small">wants access to your <strong>{{request()->user()->username}}</strong> account</p>
</div>
</div>
<hr>
@if (count($scopes) > 0)
<div class="scopes">
@foreach ($scopes as $scope)
<div class="media mb-3">
<img src="/img/icon/unlock.svg" class="mr-3" width="32" height="32">
<div class="media-body">
<p class="my-0"><span class="font-weight-bold">{{ $scope->id }}</span></p>
<p class="mb-0 text-muted small">{{$scope->description}}</p>
</div>
</div>
@endforeach
</div>
<h1 class="h4 font-weight-bold text-center mb-2">Authorize {{ $client->name }}</h1>
<p class="text-muted text-center mb-4">
{{ $client->name }} wants to access your
<strong>{{ '@' . $user->username }}</strong> account.
</p>
@if ($sortedScopes->count() > 0)
<p class="small text-muted mb-2">This app will be able to:</p>
<div class="scope-list">
@foreach ($sortedScopes as $scope)
@if ($loop->index === $visibleCount)
<details class="scope-more">
<summary>
<span class="when-closed">{{ $moreLabel }}</span>
<span class="when-open">Show fewer permissions</span>
<i class="fas fa-chevron-down"></i>
</summary>
@endif
<div class="buttons">
<form method="post" action="{{ route('passport.authorizations.approve') }}">
{{ csrf_field() }}
<input type="hidden" name="state" value="{{ $request->state }}">
<input type="hidden" name="client_id" value="{{ $client->id }}">
<input type="hidden" name="auth_token" value="{{ $authToken }}">
<button type="submit" class="btn btn-success font-weight-bold btn-approve">Authorize</button>
</form>
<form method="post" action="{{ route('passport.authorizations.deny') }}">
{{ csrf_field() }}
{{ method_field('DELETE') }}
<input type="hidden" name="state" value="{{ $request->state }}">
<input type="hidden" name="client_id" value="{{ $client->id }}">
<input type="hidden" name="auth_token" value="{{ $authToken }}">
<button class="btn btn-outline-danger font-weight-bold">Cancel</button>
</form>
@php $level = $scopeLevel($scope->id); @endphp
<div class="scope-item">
<div class="scope-icon {{ $level !== 'default' ? 'scope-icon-' . $level : '' }}">
<i class="{{ $scopeIcons[$scope->id] ?? 'fas fa-unlock' }}"></i>
</div>
<div class="scope-body">
<p class="scope-desc">{{ $scope->description }}</p>
<span class="scope-id">{{ $scope->id }}</span>
</div>
</div>
<hr>
<p class="mb-0 text-center small text-muted">Click <a href="{{ route('logout') }}" class="font-weight-bold" onclick="event.preventDefault();document.getElementById('logout_auth').submit();">here</a> to log out of this account.</p>
<form id="logout_auth" action="{{ route('logout') }}" method="POST" style="display: none;">
@csrf
@endforeach
@if ($hiddenCount > 0)
</details>
@endif
</div>
@endif
@if ($droppedAdminScopes > 0)
<p class="small text-muted mt-2 mb-0">
This app also asked for admin permissions, which don't apply to your account.
</p>
@endif
<div class="row no-gutters mt-4">
<div class="col-6 pr-2">
<form method="post" action="{{ route('passport.authorizations.deny') }}">
@csrf
@method('DELETE')
<input type="hidden" name="state" value="{{ $request->state }}">
<input type="hidden" name="client_id" value="{{ $client->id }}">
<input type="hidden" name="auth_token" value="{{ $authToken }}">
<button type="submit" class="btn btn-outline-secondary btn-block font-weight-bold">Cancel</button>
</form>
</div>
<div class="col-6 pl-2">
<form method="post" action="{{ route('passport.authorizations.approve') }}">
@csrf
<input type="hidden" name="state" value="{{ $request->state }}">
<input type="hidden" name="client_id" value="{{ $client->id }}">
<input type="hidden" name="auth_token" value="{{ $authToken }}">
<button type="submit" class="btn btn-primary btn-block font-weight-bold">Authorize</button>
</form>
</div>
</div>
<p class="small text-muted text-center mt-3 mb-0">
You can revoke this access at any time from your account settings.
</p>
</div>
</div>
<p class="small text-muted text-center mt-4 mb-0">
Signed in as <strong>{{ '@' . $user->username }}</strong>.
Not you?
<a href="{{ route('logout') }}" class="font-weight-bold" onclick="event.preventDefault();document.getElementById('logout_auth').submit();">Log out</a>
</p>
<form id="logout_auth" action="{{ route('logout') }}" method="POST" class="d-none">
@csrf
</form>
</div>
</body>
</html>
</html>
Loading…
Cancel
Save