浏览代码

Add User management features to back-end

Bubka 1 年之前
父节点
当前提交
96f883d19a

+ 200 - 0
app/Api/v1/Controllers/UserManagerController.php

@@ -0,0 +1,200 @@
+<?php
+
+namespace App\Api\v1\Controllers;
+
+use App\Api\v1\Requests\UserManagerStoreRequest;
+use App\Api\v1\Requests\UserManagerUpdateRequest;
+use App\Api\v1\Resources\UserManagerResource;
+use App\Http\Controllers\Controller;
+use App\Models\User;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\Hash;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Password;
+use Laravel\Passport\TokenRepository;
+
+class UserManagerController extends Controller
+{
+    /**
+     * Display all users.
+     *
+     * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection
+     */
+    public function index(Request $request)
+    {
+        return UserManagerResource::collection(User::all());
+    }
+
+    /**
+     * Get a user
+     *
+     * @return \App\Api\v1\Resources\UserManagerResource
+     */
+    public function show(User $user)
+    {
+        return new UserManagerResource($user);
+    }
+
+    /**
+     * Reset user's password 
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function resetPassword(Request $request, User $user)
+    {
+        Log::info(sprintf('Password reset for User ID #%s requested by User ID #%s', $user->id, $request->user()->id));
+
+        $credentials = [
+            'token'    => $this->broker()->createToken($user),
+            'email'    => $user->email,
+            'password' => $user->password,
+        ];
+
+        $response = $this->broker()->reset(
+            $credentials, function ($user) {
+                $user->resetPassword();
+                $user->save();
+            }
+        );
+
+        if ($response == Password::PASSWORD_RESET) {
+            Log::info(sprintf('Temporary password set for User ID #%s', $user->id));
+    
+            $response = $this->broker()->sendResetLink(
+                ['email' => $credentials['email']]
+            );
+        }
+        else {
+            return response()->json([
+                'message' => 'bad request',
+                'reason'  => is_string($response) ? __($response) : __('errors.no_pwd_reset_for_this_user_type')
+            ], 400);
+        }
+
+        return $response == Password::RESET_LINK_SENT
+                    ? new UserManagerResource($user)
+                    : response()->json([
+                        'message' => 'bad request',
+                        'reason'  => __($response)
+                    ], 400);
+    }
+
+    /**
+     * Store a newly created user in storage.
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function store(UserManagerStoreRequest $request)
+    {
+        $validated = $request->validated();
+
+        $user = User::create([
+            'name'      => $validated['name'],
+            'email'     => $validated['email'],
+            'password'  => Hash::make($validated['password']),
+        ]);
+
+        Log::info(sprintf('User ID #%s created by user ID #%s', $user->id, $request->user()->id));
+
+        if ($validated['is_admin']) {
+            $user->promoteToAdministrator();
+            $user->save();
+            Log::notice(sprintf('User ID #%s set as administrator at creation by user ID #%s', $user->id, $request->user()->id));
+        }
+
+        $user->refresh();
+
+        return (new UserManagerResource($user))
+            ->response()
+            ->setStatusCode(201);
+    }
+
+    /**
+     * Purge user's PATs.
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function revokePATs(Request $request, User $user, TokenRepository $tokenRepository)
+    {
+        Log::info(sprintf('Deletion of all personal access tokens for User ID #%s requested by User ID #%s', $user->id, $request->user()->id));
+
+        $tokens = $tokenRepository->forUser($user->getAuthIdentifier());
+
+        $tokens->load('client')->filter(function ($token) {
+            return $token->client->personal_access_client && ! $token->revoked;
+        })->each(function ($token) {
+            $token->revoke();
+        });
+
+        Log::info(sprintf('All personal access tokens for User ID #%s have been revoked', $user->id));
+
+        return response()->json(null, 204);
+    }
+
+    /**
+     * Purge user's webauthn credentials.
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function revokeWebauthnCredentials(Request $request, User $user)
+    {
+        Log::info(sprintf('Deletion of all security devices for User ID #%s requested by User ID #%s', $user->id, $request->user()->id));
+
+        $user->flushCredentials();
+
+        // WebauthnOnly user options need to be reset to prevent impossible login when
+        // no more registered device exists.
+        // See #110
+        if (blank($user->webAuthnCredentials()->WhereEnabled()->get())) {
+            $user['preferences->useWebauthnOnly'] = false;
+            $user->save();
+            Log::notice(sprintf('No more Webauthn credential for user ID #%s, useWebauthnOnly user preference reset to false', $user->id));
+        }
+
+        Log::info(sprintf('All security devices for User ID #%s have been revoked', $user->id));
+
+        return response()->json(null, 204);
+    }
+
+    /**
+     * Remove the specified user from storage.
+     *
+     * @return \Illuminate\Http\JsonResponse
+     */
+    public function destroy(Request $request, User $user)
+    {
+        // This will delete the user and all its 2FAs & Groups thanks to the onCascadeDelete constrains.
+        // Deletion will not be done (and returns False) if the user is the only existing admin (see UserObserver clas)
+        return $user->delete() === false
+            ? response()->json([
+                'message' => __('errors.cannot_delete_the_only_admin'),
+            ], 403)
+            : response()->json(null, 204);
+    }
+
+    /**
+     * Update a user
+     *
+     * @return \App\Api\v1\Resources\UserManagerResource
+     */
+    public function update(UserManagerUpdateRequest $request, User $user)
+    {
+        $user->promoteToAdministrator($request->validated('is_admin'));
+        $user->save();
+
+        Log::info(sprintf('User ID #%s set is_admin=%s for User ID #%s', $request->user()->id, $user->isAdministrator(), $user->id));
+
+        return new UserManagerResource($user);
+    }
+
+    /**
+     * Get the broker to be used during password reset.
+     *
+     * @return \Illuminate\Contracts\Auth\PasswordBroker|\Illuminate\Auth\Passwords\PasswordBroker
+     */
+    protected function broker()
+    {
+        return Password::broker();
+    }
+
+}

+ 34 - 0
app/Api/v1/Requests/UserManagerStoreRequest.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Api\v1\Requests;
+
+use App\Http\Requests\UserStoreRequest;
+use Illuminate\Support\Facades\Auth;
+
+class UserManagerStoreRequest extends UserStoreRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return Auth::user()->isAdministrator();
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return array_merge(
+            parent::rules(),
+            [
+                'is_admin' => 'required|boolean',
+            ],
+        );
+    }
+}

+ 31 - 0
app/Api/v1/Requests/UserManagerUpdateRequest.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Api\v1\Requests;
+
+use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Support\Facades\Auth;
+
+class UserManagerUpdateRequest extends FormRequest
+{
+    /**
+     * Determine if the user is authorized to make this request.
+     *
+     * @return bool
+     */
+    public function authorize()
+    {
+        return Auth::user()->isAdministrator();
+    }
+
+    /**
+     * Get the validation rules that apply to the request.
+     *
+     * @return array
+     */
+    public function rules()
+    {
+        return [
+            'is_admin' => 'required|boolean',
+        ];
+    }
+}

+ 99 - 0
app/Api/v1/Resources/UserManagerResource.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace App\Api\v1\Resources;
+
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\DB;
+use Laravel\Passport\TokenRepository;
+
+/**
+ * @property mixed $id
+ * @property string $name
+ * @property string $email
+ * @property string $oauth_provider
+ * @property \Illuminate\Support\Collection<array-key, mixed> $preferences
+ * @property string $is_admin
+ * @property string $last_seen_at
+ * @property string $created_at
+ * @property string $updated_at
+ * @property int|null $twofaccounts_count
+ */
+class UserManagerResource extends UserResource
+{
+    /**
+     * The "data" wrapper that should be applied.
+     *
+     * @var string|null
+     */
+    public static $wrap = 'info';
+
+    /**
+     * Create a new resource instance.
+     *
+     * @param  mixed  $resource
+     * @return void
+     */
+    public function __construct($resource)
+    {
+        $this->resource = $resource;
+        $password_reset = null;
+        
+        // Password reset token
+        $resetToken = DB::table(config('auth.passwords.users.table'))->where(
+            'email', $this->resource->getEmailForPasswordReset()
+        )->first();
+
+        if ($resetToken) {
+            $password_reset = $this->tokenExpired($resetToken->created_at)
+                ? 0
+                : $resetToken->created_at;
+        }
+
+        // Personal Access Tokens (PATs)
+        $tokenRepository = App::make(TokenRepository::class);
+        $tokens = $tokenRepository->forUser($this->resource->getAuthIdentifier());
+
+        $PATs_count = $tokens->load('client')->filter(function ($token) {
+            return $token->client->personal_access_client && ! $token->revoked;
+        })->count();
+
+        $this->with = [
+            'password_reset'               => $password_reset,
+            'valid_personal_access_tokens' => $PATs_count,
+            'webauthn_credentials'         => $this->resource->webAuthnCredentials()->count()
+        ];
+    }
+    
+
+    /**
+     * Determine if the token has expired.
+     *
+     * @param  string  $createdAt
+     * @return bool
+     */
+    protected function tokenExpired($createdAt)
+    {
+        // See Illuminate\Auth\Passwords\DatabaseTokenRepository
+        return Carbon::parse($createdAt)->addSeconds(config('auth.passwords.users.expires', 60)*60)->isPast();
+    }
+
+    /**
+     * Transform the resource into an array.
+     *
+     * @param  \Illuminate\Http\Request  $request
+     * @return array
+     */
+    public function toArray($request)
+    {
+        return array_merge(
+            parent::toArray($request),
+            [
+                'twofaccounts_count' => is_null($this->twofaccounts_count) ? 0 : $this->twofaccounts_count,
+                'last_seen_at' => Carbon::parse($this->last_seen_at)->toDateString(),
+                'created_at'   => Carbon::parse($this->created_at)->toDateString(),
+                'updated_at'   => Carbon::parse($this->updated_at)->toDateString(),
+            ]
+        );
+    }
+}

+ 14 - 0
app/Models/User.php

@@ -3,10 +3,12 @@
 namespace App\Models;
 
 use App\Models\Traits\WebAuthnManageCredentials;
+use Illuminate\Auth\Events\PasswordReset;
 use Illuminate\Auth\Notifications\ResetPassword;
 use Illuminate\Database\Eloquent\Factories\HasFactory;
 use Illuminate\Foundation\Auth\User as Authenticatable;
 use Illuminate\Notifications\Notifiable;
+use Illuminate\Support\Facades\Hash;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Str;
 use Laragear\WebAuthn\WebAuthnAuthentication;
@@ -108,6 +110,18 @@ class User extends Authenticatable implements WebAuthnAuthenticatable
         $this->is_admin = $promote;
     }
 
+    /**
+     * Reset user password with a 12 chars random string.
+     *
+     * @return void
+     */
+    public function resetPassword()
+    {
+        $this->password = Hash::make(Str::password(12));
+
+        event(new PasswordReset($this));
+    }
+
     /**
      * Send the password reset notification.
      *

+ 7 - 1
routes/api/v1.php

@@ -6,6 +6,7 @@ use App\Api\v1\Controllers\QrCodeController;
 use App\Api\v1\Controllers\SettingController;
 use App\Api\v1\Controllers\TwoFAccountController;
 use App\Api\v1\Controllers\UserController;
+use App\Api\v1\Controllers\UserManagerController;
 use Illuminate\Support\Facades\Date;
 use Illuminate\Support\Facades\Route;
 
@@ -31,7 +32,7 @@ Route::group(['middleware' => 'auth:api-guard'], function () {
     Route::get('user/preferences/{preferenceName}', [UserController::class, 'showPreference'])->name('user.preferences.show');
     Route::get('user/preferences', [UserController::class, 'allPreferences'])->name('user.preferences.all');
     Route::put('user/preferences/{preferenceName}', [UserController::class, 'setPreference'])->name('user.preferences.set');
-
+    
     Route::delete('twofaccounts', [TwoFAccountController::class, 'batchDestroy'])->name('twofaccounts.batchDestroy');
     Route::patch('twofaccounts/withdraw', [TwoFAccountController::class, 'withdraw'])->name('twofaccounts.withdraw');
     Route::post('twofaccounts/reorder', [TwoFAccountController::class, 'reorder'])->name('twofaccounts.reorder');
@@ -59,6 +60,11 @@ Route::group(['middleware' => 'auth:api-guard'], function () {
  * Routes protected by the api authentication guard and restricted to administrators
  */
 Route::group(['middleware' => ['auth:api-guard', 'admin']], function () {
+    Route::patch('users/{user}/password/reset', [UserManagerController::class, 'resetPassword'])->name('users.password.reset');
+    Route::delete('users/{user}/pats', [UserManagerController::class, 'revokePATs'])->name('users.revoke.pats');
+    Route::delete('users/{user}/credentials', [UserManagerController::class, 'revokeWebauthnCredentials'])->name('users.revoke.credentials');
+    Route::apiResource('users', UserManagerController::class);
+
     Route::get('settings/{settingName}', [SettingController::class, 'show'])->name('settings.show');
     Route::get('settings', [SettingController::class, 'index'])->name('settings.index');
     Route::post('settings', [SettingController::class, 'store'])->name('settings.store');