Multi-guards authentication with LIT (Laravel 8 Inertia Tailwind) stack (1/2)

Laravel 8 의 Fortify 는 headless Auth 모듈이며, 단 하나의 User 모델 만을 지원하기 때문에 multiple authentication 을 위하여 Trait 기반으로 작성된 laravel/ui 를 사용할 때 처럼 여러개의 사용자 모델을 만들 수 없다. 이 글에서는 api 가드(guard)가 사용하는 User 모델 이외에 web 가드(guard)를 사용하는 Member 모델을 생성하고 spatie/permissions 패키지를 이용하여 관리자, 파트너 로 명명된 각기 다른 사용자들의 로그인 처리를 어떻게 구현했는지 설명한다.

Understanding the Big Picture

아래의 Laravel 8 튜토리얼 비디오를 보면 Jetstream 을 이용한 multi-guards 구현에 대한 유사 use-case 를 설명한다. 차이점이라면 내 프로젝트 use-case 의 경우, User 모델은 api 가드를 사용하고, Member 모델은 web 가드를 사용하기에 인증에 필요한 유저모델이 2개인 반면, 이 튜토리얼의 경우 하나의 User 모델을 api 가드 와 web 가드에서 사용하고, role/permission 기능을 이용하여 authorization 관리를 한다는 점에서 차이가 있다. 또한, 나는 Inertia 사용했고, 이 튜토리얼은 Livewire 를 사용한다는 차이가 있지만, 큰 그림을 이해하는데 좋은 레퍼런스가 될 것 같아서 소개한다.

The use-case

위에서 언급한 내 프로젝트의 use-case 를 살펴보면 다음과 같다. 우선 User 모델을 사용하는 앱은 웹 접속이 필요없는 모바일 전용 앱이다. 사용자 인증은 tymondesigns/jwt-auth 를 사용한 api 가드를 사용한다. web 가드의 경우 Fortify 와 Jetstream 을 사용한 웹써비스를 위해서 만들었고, 이 web 가드는 별도의 Member 라는 모델을 사용한다.

  1. barun.test
  2. www.barun.test (redirect to barun.test)
  3. partner.barun.test
  4. admin.barun.test

1번 주소는 사용자 인증기능 없는 모바일 앱의 다운로드 홍보 페이지이고, 2번 주소는 단순히 DNS (Route 53) 에서 ALB 로 연결한 alias A 레코드를 사용해서, 1번 주소로 리다이렉션 되도록 만든 WWW 주소이며, 3번 주소는 파트너 회원(partner role 을 갖는 Member 회원) 전용 앱이고, 4번 주소는 관리자 회원 (admin role 을 갖는 Memeber) 전용 앱이다. 다시 말해 Member 회원은 role 에 따라, 파트너로 접속할 수 도, 어드민으로 접속할 수 도 있다.

Laravel Fortify

Fortify 는 사용자 등록에 필요한 로그인, 가입, 비밀번호 초기화, 이메일 확인, 비밀번호 갱신, 프로파일 정보 갱신, 2팩터인증 등 사용자 인증을 위한 백앤드 로직 패키지이다. 단 하나의 가드만 지원하여, 여러개의 모델을 사용할 수 없다. config/fortify.php 를 살펴보면 아래와 같다.

<?php

use App\Providers\RouteServiceProvider;
use Laravel\Fortify\Features;

return [

    'guard' => 'web',
    'passwords' => 'members',
    'username' => 'email',
    'email' => 'email',
    'home' => RouteServiceProvider::HOME,

    'prefix' => '',
    'domain' => null,

    'middleware' => ['web'],

    'limiters' => [],

    'views' => true, // register all the view routes

    'features' => [
        Features::registration(),
        Features::resetPasswords(),
        Features::emailVerification(),
        Features::updateProfileInformation(),
        Features::updatePasswords(),
        // Features::twoFactorAuthentication([
        //     'confirmPassword' => true,
        // ]),
    ],

];

Fortify 를 설치하면 인증기능을 담당하는 일련의 Actions, Contracts, Http\Controllers, Http\Requests, Http\Responses, Rules 가 생성되는데, 라우터를 걸친 Controllers 가 Requests 를 받아 Actions 을 수행하여 Responses 를 리턴하는 일반적인 패턴이다.

Subdomain Routing

우리가 원하는 multi 인증을 위해서는 RouteServiceProvider 에서 subdomain 별로 각기 다른 Controllers 로 라우팅되도록 했다. 즉, admin.barun.test 로 접속하면 app/Http/Controllers/Admin/... 로 연결하고, partner.barun.test 로 접속하면 app/Http/Controllers/Partner/... 로 연결한다. RouteServiceProvider 는 아래처럼 설정했다.

참고로 config('app.domain') 은 http scheme 을 제외한 도메인명 즉, barun.test 을 값으로 갖는다.

public function boot()
    {
        $this->configureRateLimiting();

        $this->routes(function () {
            // api.barun.test
            Route::middleware('api')
                ->name('api.')
                ->domain('api.'.config('app.domain'))
                ->group(base_path('routes/api.php'));

            // admin.barun.test
            Route::middleware(['web', 'role:admin'])
                ->name('admin.')
                ->domain('admin.'.config('app.domain'))
                ->group(base_path('routes/web-admin.php'));

            // partner.barun.test
            Route::middleware('web')
                ->name('partner.')
                ->domain('partner.'.config('app.domain'))
                ->group(base_path('routes/web-partner.php'));

            // barun.test
            Route::middleware('web')
                ->group(base_path('routes/web-base.php'));
        });
    }

App\Http\Controllers\Admin 와 App\Http\Controllers\Partner 로 나뉘었기 때문에 Actions, Requests, Responses 들도 Admin 과 Partner 로 role 별 클래스를 만들어서 처리할 수 도 있고, 아니면 하나의 클래스에서 인증 회원의 role 을 체크하여 conditionally 처리할 수 도 있다. 하지만, Response 들은 대개 Action 의 결과를 인자로 받아 리턴되므로, 인자를 갖는 형태로 되어 있고, 인자(params)를 갖는 contextual binding 은 anti-pattern 으로 라라벨에서 지원을 하지 않으므로, 아래와 같이 바인딩 후,

$this->app->singleton(
    \Laravel\Fortify\Contracts\LoginResponse::class,
    \App\Http\Responses\LoginResponse::class
);

아래 처럼 실제 concrete class 의 메서드 안에서 컨디션을 체크하는 것이 Fortify 가 제공하는 코드 커스터마이징 코스트가 최소화될 수 있는 방법이다.

<?php

namespace App\Http\Responses;

use Illuminate\Http\JsonResponse;
use Laravel\Fortify\Contracts\PasswordResetResponse as PasswordResetResponseContract;

class PasswordResetResponse implements PasswordResetResponseContract
{
    protected $status;

    public function __construct(string $status)
    {
        $this->status = $status;
    }

    public function toResponse($request)
    {
        if ($request->wantsJson()) {
            return new JsonResponse(['message' => trans($this->status)], 200);
        }

        if ($request->isAdmin()) {
            return redirect()
                ->route('admin.login')
                ->with('status', trans($this->status));
        }

        return redirect()
            ->route('partner.login')
            ->with('status', trans($this->status));
    }
}

따라서, 나의 최종적인 FortifyServiceProvider 의 모습은 아래와 같다.

class FortifyServiceProvider extends ServiceProvider
{
    public function register()
    {
        if (request()->isAdmin()) {
            config(['fortify.domain' => 'admin.'.config('app.domain')]);
        } else if (request()->isPartner()) {
            config(['fortify.domain' => 'partner.'.config('app.domain')]);
        }

        $this->registerFortifyResponsesForTwoDistinctRoles();
    }

    public function boot()
    {
        Fortify::ignoreRoutes();

        Fortify::createUsersUsing(CreateNewUser::class);
        Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
        Fortify::updateUserPasswordsUsing(UpdateUserPassword::class);
        Fortify::updateUserProfileInformationUsing(UpdateUserProfileInformation::class);

        RateLimiter::for('admin.login', function (Request $request) {
            return Limit::perMinute(5)->by($request->email.$request->ip());
        });

        RateLimiter::for('partner.login', function (Request $request) {
            return Limit::perMinute(5)->by($request->email.$request->ip());
        });

        if (Features::enabled(Features::twoFactorAuthentication())) {
             RateLimiter::for('two-factor', function (Request $request) {
                 return Limit::perMinute(5)->by($request->session()->get('login.id'));
             });
        }
    }

    protected function registerFortifyResponsesForTwoDistinctRoles()
    {
        $this->app->singleton(
            LoginResponseContract::class,
            LoginResponse::class
        );
        $this->app->singleton(
            PasswordConfirmedResponseContract::class,
            PasswordConfirmedResponse::class
        );
        $this->app->singleton(
            PasswordResetResponseContract::class,
            PasswordResetResponse::class
        );
        $this->app->singleton(
            RegisterResponseContract::class,
            RegisterResponse::class
        );

        if (Features::enabled(Features::twoFactorAuthentication())) {
            $this->app->singleton(
                FailedTwoFactorLoginResponseContract::class,
                FailedTwoFactorLoginResponse::class
            );
            $this->app->singleton(
                TwoFactorLoginResponseContract::class,
                TwoFactorLoginResponse::class
            );
        }
    }
}

Laravel Sanctum vs JWT

stateful 세션을 사용하는 Sanctum 은 미들웨어를 사용하여 API 와 Web 에서 공히 사용할 수 있어서, 실시간으로 사용자의 어떤 디바이스만 로그아웃 시키는 섬세한 인증관리가 가능하지만, stateful 이란 태생적 한계와 Laravel 6 부터 계속 키워왔던 코드 베이스에, tymondesigns/jwt-auth 를 이용하여 stateless 인증을 사용했던 바 (물론, jwt-auth 패키지도 blacklist 를 위한 state 관리가 필요하지만…) , 굳이 프로덕션 모바일 앱과의 연동이 잘되고 있는 jwt 인증부를 Sanctum 으로 변경할 필요성을 못느꼈다. 어쨌든 현재 프로젝트에서 jwt 드라이버를 사용하는 api 가드는 User 모델을 사용하고 web 가드는 Member 모델을 사용하는 구조이다. 이해를 돕기위해 config/auth.php 를 보면 아래와 같다.

<?php

return [

    'defaults' => [
        'guard' => 'api',
        'passwords' => 'users',
    ],

    'guards' => [
        'api' => [
            'driver' => 'jwt',
            'provider' => 'users',
            'hash' => false,
        ],
        'web' => [
            'driver' => 'session',
            'provider' => 'members',
        ],
    ],

    'providers' => [
        'users' => [
            'driver' => 'eloquent',
            'model' => App\Models\User::class,
        ],
        'members' => [
            'driver' => 'eloquent',
            'model' => App\Models\Member::class,
        ],
    ],

    'passwords' => [
        'users' => [
            'provider' => 'users',
            'table' => 'password_resets',
            'expire' => 60,
            'throttle' => 60,
        ],
        'members' => [
            'provider' => 'members',
            'table' => 'password_resets',
            'expire' => 30,
            'throttle' => 30,
        ],
    ],

    'password_timeout' => 10800,

];

Laravel Jetstream

Laravel Fortify 가 사용자 인증을 위한 백앤드 로직 패키지라면, Laravel Jetstream 은 사용자 인증을 위한 프론트앤드 스캐폴딩 패키지라 말할 수 있다. 프로트앤드 기술로는 Laravel 의 전통적인 Blade 템플릿 엔진과 미니멀 JS 프레임워크인 Alpine 을 사용하는 LiveWire 와 VueJS 를 사용하여 SPA 를 만들 수 있는 Inertia 가 있다. (물론 Inertia 는 프레임웍에 종속적이지 않아 React 나 Svelte 를 사용할 수 도 있지만 내 주력 프레임웍이 아니라서 패스.) 전자는 TALL 스택, 후자는 LIT 스택이라고도 불리운다.

나의 경우, VueJS 나 Nuxt 로 작업을 많이 해왔기에, 당연히 VueJS 패키지들을 자유롭게 import 할 수 있는 Inertia 에 좀더 매력을 느꼈다. 그에 반해 라라벨 커뮤니티에서만 사용하는 LiveWire 의 경우, 얼마나 많은 패키지를 쉽게 구할 수 있겠는가를 생각했을 때 답은 No brainer 이다. 물론 이런식의 dependency 를 갖는 모습에 반대하는 볼멘 목소리가 reddit 에 올라오기도 하지만, 개인적으론 매우 환영한다.

여담으로 Inertia 페이지를 가보면 Modern monolith 이라는 카피를 볼 수 있다. monolithic 아키텍쳐가 classic 하다(퇴물이라)고 회자될 때 전통적 all-in-one 식 개발 방식의 고수를 이끈 Jonathan Reinink 에게 경의를 표한다. 독립적인 프론트엔드 작업과 백앤드 API 작업을 동시에 해본 사람이라면 전통적인 SSR 방식의 빠른 초기 반응 속도의 장점 뿐만아니라, 하나의 코드 베이스로 동작하는 MVC 패턴의 생산성, 배포 및 모니터링 등 여러면에서 개이득이란 점 공감하리라.

Y’all know what KISS means. Keep it simple! suckerz…

어쨌든, 본론으로 들어가서, config/jetstream.php 을 보면 아래와 같다.

<?php

use Laravel\Jetstream\Features;

return [

    'stack' => 'inertia',

    'middleware' => ['web'],

    'features' => [
        Features::termsAndPrivacyPolicy(),
        Features::profilePhotos(),
        // Features::api(),
        // Features::teams(['invitations' => true]),
        Features::accountDeletion(),
    ],

    'profile_photo_disk' => 'public',

];

JetstreamServiceProvider 은 아래와 같은 설정으로 Inertia 를 통한 Vue 뷰들을 등록하는데, 관리자와 파트너는 완전히 다른 뷰를 사용하기에 resources/js/Pages 밑에 resources/js/Pages/Adminresources/js/Pages/Partner 를 생성하여 사용했다.

<?php

namespace App\Providers;

use App\Actions\Jetstream\DeleteUser;
use App\Http\Middleware\HandleInertiaRequests;
use App\Models\Member;
use Illuminate\Contracts\Http\Kernel;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\ServiceProvider;
use Inertia\Inertia;
use Laravel\Fortify\Fortify;
use Laravel\Jetstream\Jetstream;
use Laravel\Jetstream\Http\Middleware\ShareInertiaData;

class JetstreamServiceProvider extends ServiceProvider
{
    /**
     * Register any application services.
     *
     * @return void
     */
    public function register()
    {
        //
    }

    /**
     * Bootstrap any application services.
     *
     * @return void
     */
    public function boot()
    {
        $this->configurePermissions();

        Jetstream::useUserModel(Member::class);

        $this->bootInertiaViews();

        Jetstream::deleteUsersUsing(DeleteUser::class);
    }

    protected function configurePermissions()
    {
        Jetstream::defaultApiTokenPermissions(['read']);

        Jetstream::permissions([
            'create',
            'read',
            'update',
            'delete',
        ]);
    }

    protected function bootInertiaViews()
    {
        $kernel = $this->app->make(Kernel::class);

        $kernel->appendMiddlewareToGroup('web', ShareInertiaData::class);
        $kernel->appendToMiddlewarePriority(ShareInertiaData::class);

        if (class_exists(HandleInertiaRequests::class)) {
            $kernel->appendToMiddlewarePriority(HandleInertiaRequests::class);
        }

        Fortify::loginView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/Login", [
                'canResetPassword' => Route::has('password.request'),
                'status' => session('status'),
            ]);
        });

        Fortify::requestPasswordResetLinkView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/ForgotPassword", [
                'status' => session('status'),
            ]);
        });

        Fortify::resetPasswordView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/ResetPassword", [
                'email' => $request->input('email'),
                'token' => $request->route('token'),
            ]);
        });

        Fortify::registerView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/Register");
        });

        Fortify::verifyEmailView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/VerifyEmail", [
                'status' => session('status'),
            ]);
        });

        Fortify::twoFactorChallengeView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/TwoFactorChallenge");
        });

        Fortify::confirmPasswordView(function (Request $request) {
            $prefix = $request->isAdmin() ? 'Admin' : 'Partner';
            return Inertia::render("{$prefix}/Auth/ConfirmPassword");
        });
    }
}

Providers

추가적으로 이번 작업을 위해 변경된 Service Provider 들을 살펴보자. 우선 AppServiceProvider 는 subdomain routing 시 요청하는 호스트를 체크하기 위해 아래와 같이 매크로를 설정했고

    public function register()
    {
        Request::macro('isAdmin', function () {
            return $this->getHost() === 'admin.'.config('app.domain');
        });

        Request::macro('isPartner', function () {
            return $this->getHost() === 'partner.'.config('app.domain');
        });
    }

RouteServiceProvider 는 아래처럼 설정했다. 위 내용에서 config('app.domain') 은 http scheme 을 제외한 도메인명barun.test 을 값으로 갖는다. 즉, admin.barun.test 냐 partner.barun.test 냐 아니면 그냥 barun.test 냐 에 따른 subdomain 라우팅을 설정하기 위함이다. config/app.php 에 아래와 같이 정의 되어 있다.

    'url' => env('APP_URL'),
    'domain' => preg_replace('/https?:\/\//', null, env('APP_URL')),

admin.barun.test 의 라우트들에는 role:admin 이라는 미들웨어가 있는데, 아래와 같이 정의된 미들웨어다.

class CheckRole
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @param  string $role
     * @return mixed
     */
    public function handle(Request $request, Closure $next, string $role)
    {
        $user = $request->user();
        if ($user) {
            if ($role === 'admin' &amp;&amp; $user->hasRole('partner')) {
                abort(403);
            }
        }

        return $next($request);
    }
}

admin 만 가능한 라우트를 partner 가 감히 접근하게 되면 403 오류페이지를 리턴하는 미들웨어이다. 말이 나온김에 authorization 패키지는 spatie/laravel-permission 를 사용했고 DB 시딩은 아래 처럼 처리했다.

class RolesAndPermissionsSeeder extends Seeder
{
    public function run(): void
    {
        // Reset cached roles and permissions
        app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();

        // create permissions
        Permission::create(['name' => 'create.templates', 'guard_name' => 'web']);
        Permission::create(['name' => 'update.templates', 'guard_name' => 'web']);
        Permission::create(['name' => 'delete.templates', 'guard_name' => 'web']);
        Permission::create(['name' => 'approve.templates', 'guard_name' => 'web']);

        // create roles
        Role::create(['name' => 'admin', 'guard_name' => 'web']);
        // gets all permissions via Gate::before rule; see AuthServiceProvider

        $partnerRole = Role::create(['name' => 'partner', 'guard_name' => 'web']);
        $partnerRole->givePermissionTo(['create.templates', 'update.templates', 'delete.templates']);
    }
}

다음에 계속…

Leave a Reply