laravel-json-api / laravel

JSON:API for Laravel applications
MIT License
549 stars 43 forks source link

Dummy app: User authentication #82

Open 0x7357 opened 3 years ago

0x7357 commented 3 years ago

I'd love to see an implementation of user authentication in your dummy app (I did some research and in 2018 you already wanted to add an authentication to the demo app).

Since I'm quite new to JSON APIs and usually love "logical and 'correct' stuff" and since you seem to know anything about JSON APIs... I beg you: help me here, please: . :-)

(And don't forget: you already added authorization to the dummy app - I guess it isn't far away from having a simple and working authentication.)

Thank you!

lindyhopchris commented 3 years ago

Thanks for raising this. The reason I haven't done something like this is it is really dependent on how you authenticate users to your API, which can vary drastically from app to app. E.g. are you using tokens? are you use Sanctum for an SPA? Etc etc. Not only does it depend on how you want to do it in the backend (the Laravel app), it also depends on how you expect API clients to authenticate, which can vary from app to app. You may for example need to support multiple authentication strategies in complex applications.

The reality is there's lots of different approaches you can use, so I don't think there's a definitive answer. Which makes adding it to the demo app difficult because I wouldn't want to recommend one approach over any other approach.

0x7357 commented 3 years ago

Legit answer. But since the API seems to be an encapsulated environment, Auth does not seem to be a part of the jsonapi itself. And a "best practice hint" would be awesome (at least for me).

/api/v1/auth or /api/auth? I'd say the 2nd example seems to be more logical because you could authenticate yourself via /api/auth for /api/v2/..., too.

Edit: You are using $request->user() (or similar in your dummy app) and this already seems to be a result of something like Auth. Where does the user entity comes from?

lindyhopchris commented 3 years ago

Yeah so we use Laravel Fortify - which adds routes that are set up both for HTML and JSON requests, e.g. at the /login endpoint. So we just use those as it meets our requirements and keeps the login/logout capability unified (rather than adding a seperate API login). In this scenario I wouldn't recommend writing a JSON:API resource for login, because submitting login credentials doesn't really constitute a resource - so that's why we don't build JSON:API specific login routes. We do however add a route that returns the JSON:API users resource that represents the current logged in user.

I'd do it differently though if I was using some sort of token authentication that was specific to the API. Then I would use something like /api/auth endpoints that are specific to authenticating to the API. I might be tempted to have a JSON:API auth-tokens resource for which a POST request created a token. But haven't actually had to build anything like that yet as none of the production apps I work on have done authentication like that.

lindyhopchris commented 3 years ago

It's the kind of thing that would be good for a blog - describing different approaches. At the moment though I just don't have time to do something like that! (Time is always my enemy!)

0x7357 commented 3 years ago

I have taken a short look to Fortify and Sanctum. I usually prefer JWT, but it seems JWT isn't as common as API Keys. I like the approach, that API keys' permissions is stored in the database, but a JWT already can contain all the permissions. Do you know any solution for the use of JWT and JSON:API?

lindyhopchris commented 3 years ago

It totally depends what you're expecting for the API client. In our case it's a SPA so Sanctum is the perfect fit.

For JWT I've previously used this: https://github.com/tymondesigns/jwt-auth

0x7357 commented 3 years ago

Can you show me how you've set up Sanctum with a JSON Api? Sanctum kinda fits to my needs and I've currently read bad stuff about JWT. :-D

lindyhopchris commented 3 years ago

@DannyEndert sorry don't have a lot of time to spend sharing all the code, particularly as the application is not open source.

Effectively I authenticate using the /login route added by Fortify. Then for Sanctum, I just use auth:sanctum middleware for the API routes. Like this:

JsonApiRoute::server('dancer@v1')
    ->prefix('v1')
    ->name('v1.')
    ->withoutMiddleware(SubstituteBindings::class)
    ->middleware('auth:sanctum')
    ->resources(function ($server) {
         // API resource routes here
    });

It's as straightforward as that. If you're stuck on something specific, let me know.

MeiKatz commented 1 year ago

I like to raise this question again since it has not be fully answered for me. I currently try to build the following: send username and password to /api/v1/access-tokens and like to get back an access token. Yes, I know that those are two different resource schemas and that is part of the problem. Lately I tried to create an additional resource type LoginCredential with username, password and id in the schema. But then I have a different route url.

Any idea how I can solve this? Or any way I can change the resource type/schema on the fly? I used the last three days to work through the code but haven't find any solution for now.

asugai commented 1 year ago

@MeiKatz - I'm sure you've solved this by now, but my solution was to create a custom controller and reply with custom JSONAPI responses.

This is my controller in app\Http\Controllers\Api\V1\AuthController.php:

<?php

namespace App\Http\Controllers\Api\v1;

use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Facades\Validator;
use LaravelJsonApi\Core\Exceptions\JsonApiException;
use LaravelJsonApi\Core\Responses\DataResponse;
use LaravelJsonApi\Core\Responses\MetaResponse;

class AuthController extends Controller
{
    public function login(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'data.attributes.email' => 'required|email',
            'data.attributes.password' => 'required|min:8',
            'data.meta.device_name' => 'required',
        ]);

        if ($validator->fails()) {
            return app(\LaravelJsonApi\Validation\Factory::class)
                ->createErrors($validator);
        }

        $user = User::where('email', $request->input('data.attributes.email'))->first();

        if (! $user || ! Hash::check($request->input('data.attributes.password'), $user->password)) {
            throw JsonApiException::error([
                'status' => 400,
                'detail' => 'The provided credentials are incorrect.',
            ]);
        }

        return DataResponse::make($user)->withServer('v1');
    }

    public function forgot(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'data.attributes.email' => 'required|email',
        ]);

        if ($validator->fails()) {
            return app(\LaravelJsonApi\Validation\Factory::class)
                ->createErrors($validator);
        }

        if ($user = User::where('email', $request->input('data.attributes.email'))->first()) {
            $token = Password::createToken($user);
            $user->sendPasswordResetNotification($token);
        }

        return response()->json([
            'jsonapi' => [
                'version' => '1.0',
            ],
        ])->header('Content-Type', 'application/vnd.api+json');
    }

    public function reset(Request $request)
    {
        $validator = Validator::make($request->all(), [
            'data.attributes.email' => 'required|email',
            'data.attributes.password' => 'required|min:8',
            'data.meta.device_name' => 'required',
        ]);

        if ($validator->fails()) {
            return app(\LaravelJsonApi\Validation\Factory::class)
                ->createErrors($validator);
        }

        $status = Password::reset(
            [
                'email' => $request->input('data.attributes.email'),
                'password' => $request->input('data.attributes.password'),
                'token' => $request->bearerToken(),
            ],
            function ($user, $password) {
                $user->update(['password' => Hash::make($password)]);
                event(new PasswordReset($user));
            }
        );

        if ($status !== Password::PASSWORD_RESET) {
            throw JsonApiException::error([
                'status' => 400,
                'detail' => 'The provided credentials are incorrect.',
            ]);
        }

        $user = User::where('email', $request->input('data.attributes.email'))->first();

        return DataResponse::make($user)->withServer('v1');
    }
}

and here are my routes in app/routes/api.php:

Route::prefix('/v1')->group(function () {
    Route::post('/auth/login', [AuthController::class, 'login']);
    Route::post('/auth/forgot', [AuthController::class, 'forgot']);
    Route::post('/auth/reset', [AuthController::class, 'reset']);
});

In my app\JsonApi\V1\Users\UserResourse.php file I added the following meta so the login will reply with a token:

public function meta($request): iterable
{
    if ($request->method() === 'POST') {
        return [
            'token' => $this->createToken('app')->plainTextToken,
        ];
    }
    return [];
}

This also allows me to send back a token on User creation which is done through a normal JsonApi endpoint.

Pok09 commented 1 year ago

@asugai thanks, it's was very usefull