Laravel에서 아직 DB가 없는데도 사용자를 인증하는 방법

엽토군·2023년 2월 24일
1

문제

신규 Laravel 애플리케이션을 구축하는데, DB 설계를 기다릴 여유 없이 클라이언트를 위한 사용자 인증 API를 일단 만들어야 한다.

분석

실제로 해결해야 하는 과제는 크게 다음 2가지.

  1. 조건이 맞기만 하면 (그리고, 그 조건이 맞을 때에만) '사용자'를 식별할 수 있어야 한다.
  2. 1번 과정이 정식 인증 절차로써 작동해야 한다.

2번은, 이를테면 1번에서 식별되는 사용자가 지금 접속된 '세션'과의 관계를 맺어야 한다든가, XSS("짝퉁사이트공격")으로부터 보호돼야 한다든가 하는 부분이다.

해결

1번은 Custom User Provider 도입으로 해결한다.
2번은 Custom Auth Guard를 만들려다가... 그냥 동봉돼 있던 Sanctum을 꺼내서 썼다.

전체 전개 해설

Laravel Sanctum을 가드로 사용하기

Laravel 9를 설치하면 laravel/sanctum이 동반 설치된다.
이후 라라벨은 기동되는 과정에서, 필요한 프로바이더를 불러오는 절차에 의해 SanctumServiceProvider 프로바이더를 서비스에 올린다.

config/app.php에서 "Sanctum"을 찾아볼 수 없는데도 앱이 정상 작동하는 것은 이 때문이다.

// vendor/laravel/sanctum/composer.json
{
    "extra": {
        "laravel": {
            "providers": [
                "Laravel\\Sanctum\\SanctumServiceProvider"
            ]
        }
    }
}

이 프로바이더는 사용자의 auth 및 sanctum config를 런타임 중에 확장하고, 그게 다 되면, 필요한 라우트, 가드, 미들웨어를 런타임에 올린다.

// SanctumServiceProvider::boot()
$this->defineRoutes();
$this->configureGuard();
$this->configureMiddleware();

이때 올라가는 라우트는 1개뿐이다. GET /sanctum/csrf-cookie
이 라우트는 의도적으로 HTTP 204 No Content(와 빈 본문)를 반환한다.
진짜 필요한 것은 이 응답의 쿠키, 특히 laravel_sessionXSRF-TOKEN 쿠키이기 때문이다.

이제부터 보내는 요청에는 이 토큰을 값으로 하는 X-XSRF-TOKEN 헤더가 포함돼야 합니다. 이 작업은 보통 Axios 및 Angular HttpClient와 같은 라이브러리가 알아서 해 줍니다. 출처

이제 이 X-XSRF-TOKEN을 사용해야 한다. 그러려면:

  1. 이걸 읽을 필요가 있는지 확인하고 (아니라면 통과하고)
  2. 맞다면 실제로 읽고 (문제가 있으면 중단하고)
  3. 그 값을 세션과 대조하고 (뭔가 이상하면 중단하고)
  4. 세션을 사용자에 대응시킬 수 있는

Guard가 새로 하나 필요하다.

그런데 실은 아까 그 프로바이더가 런타임에 올린 sanctum 가드가 바로 그걸 수행한다.
그래서 각 라우트 정의에서 auth:sanctum 미들웨어를 설정해 두기만 하면, 그 토큰을 통한 사용자 식별을 할 수 있게 된다.

config/auth.php에서 "sanctum"을 찾아볼 수 없는데도 앱이 정상 작동하는 것은 이 때문이다.

사용자 정의 User Provider 만들기

각 '가드'는 반드시 하나의 User 프로바이더가 필요하다.
당연한 것이다. 가드는 누군가를 사칭범으로부터 "가드"하려고 있는 거거든. 그 누군가를 부르는 용어가 "User"인 거라고 이해해야 한다.

User 프로바이더란, ID 및 토큰을 기준으로 Authenticable 인스턴스를 반환해 주는 구현체다.
가드는 '사용자 ID 식별'까지만 하고, 그 ID를 사용자 정보와 연관하는 작업은 오로지 User 프로바이더가 한다.
어느 가드가 어느 User 프로바이더를 쓸지는 config/auth.php에서 정의한다.

'guards' => [
    'web' => [
        'provider' => 'admin', // 이게 뭔지를 누군가는 알려줘야 한다
    ],
],
'providers' => [
    'admin' => [               // 다행히 여기서 알려준다
        'driver' => 'generic', // 근데 이게 뭔지는 아직 모른다
    ],
],

이걸 별도로 정의하지 않으면 라라벨 가드는 eloquent라는 이름의 User 프로바이더를 사용하고, App\Models\User 인스턴스를 반환하려고 시도한다.
그 프로바이더 말고, 별도의 User 프로바이더를 하나 새로 구현하고 적용했다.

namespace App\Providers;

use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use App\Services\Auth\GenericUserProvider;

class AuthServiceProvider extends ServiceProvider
{
    // 이 프로바이더가 앱 부트스트래핑 과정에서 런타임에 올라오므로
    public function boot(): void
    {
        // 이제 'generic' user provider가 뭔지 알 수 있다
        Auth::provider('generic', static function () {
            return new GenericUserProvider;
        });
    }
}

매뉴얼에 따르면 모든 UserProvider는 Authenticable을 제공할 수 있어야 한다.
그리고 그 깡통 구현체인 GenericUser라고 하는 구현체가 있다.
그래서 이를 확장한 AdminUser를 정의하고 이를 취급하는 User 프로바이더를 작성했다.

namespace App\Models\GenericUsers;

use Illuminate\Auth\GenericUser; // <- implements Authenticable
use Illuminate\Contracts\Support\Arrayable;

class AdminUser extends GenericUser implements Arrayable { /* 생략 */ }
namespace App\Services\Auth;

use Illuminate\Contracts\Auth\UserProvider;
use App\Models\GenericUsers\AdminUser;

class GenericUserProvider implements UserProvider
{
    private function getAdmin(): AdminUser
    {
        // 항상 다음 1개 자료만 나오는 DB를 조회하는 것처럼 꾸몄다
        return new AdminUser([
            'id' => 0,
            'account' => 'admin',
            'password' => 'istrator',
            'remember_token' => 'administrator',
        ]);
    }
}

회고

이렇게만 놓고 보면 마치 보안상 문제가 있는 것처럼 보인다.

  • "로그인" 과정이라는 게 1명밖에 없는 사용자의 하드코딩된 계정/암호를 형식적으로 확인하는 것뿐이지 않나?
  • 그게 통과하는 순간, 이후의 모든 인증에 사용 가능한 토큰이 탈취되는 것 아닌가?

하지만 실제로는, SPA 클라이언트를 통한다는 맥락에서 이 전개에는 별다른 문제가 없다.

  • 실제로 보안이 발휘되는 곳은 "로그인 과정"이 아니라 그 이후임.
  • "로그인 과정" 역시 CSRF 보호, Auth 파사드를 통한 서버 세션과의 연동 등의 보호 조치가 돼 있음.

로그인 요청이 성공하면 사용자 식별이 된 것이며, 이후에 애플리케이션으로 들어오는 요청들은 애플리케이션이 발급한 세션 쿠키에 의한 자동 사용자 식별이 됩니다. 이뿐만 아니라, 일단 /sanctum/csrf-cookie 라우트를 요청해 두었다면, 이후의 자바스크립트 HTTP 클라이언트가 보내는 요청은, XSRF-TOKEN 쿠키 값을 X-XSRF-TOKEN 헤더에 담아 보내는 한은 자동 CSRF 보호가 됩니다. 출처

(SPA 인증 전개에서) Sanctum은 어떤 종류의 토큰도 사용하지 않습니다. 대신, Sanctum은 라라벨에 내장(built-in) 된 쿠키 기반의 세션 인증 서비스를 사용합니다. 이것은 CSRF 보호, 세션 인증이라는 이점을 제공할 뿐만 아니라 XSS를 통한 인증 자격 증명(authentication credentials)의 유출을 방어합니다. 출처

이러한 이유로, 원래 계획 대신 Laravel Sanctum에 좀더 의존하는 방향으로 변경해 지금에 이른다.

교훈

흑흑.. 라라벨이 짱이야..

라라벨이 대단한 프레임워크라는 걸 새삼 다시 느꼈는데, '당신들이 주로 해결하고 싶은 문제가 무엇인지 이미 알고 있으며 그 해결도 어느 정도 제안할 수 있다'라는 PHP 정신이 그대로 살아 있는 설계임을 확인했다.

  • "도대체 GET /sanctum/csrf-cookie는 어딨는 거야??" 하고 찾다가 알게 된 라라벨의 서비스 프로바이더 부트스트래핑 개념이 그렇다. 모든 서비스 프로바이더가 register()를 하고 boot()를 하지만, 그게 이렇게도 쓰일 수 있는 거구나 하는 생각까지는 미처 못 했다.
  • 입력으로부터 ID를 얻기, ID로부터 사용자를 얻기, 사용자의 ID 대신 토큰을 가지고 사용자를 얻기... 등등이 전부 합리적인 수준으로 구획 구분이 되어 있다. 그리고 그 '사용자' 역시 이런 사용자 저런 사용자 공갈 사용자 등의 개념으로 더 쪼갤 수 있게 제공하고 있다. '응용'해서 '구현'하기만 하면 된다.

그래.. 바퀴를 다시 만들지 말자..

혹시 PHP로 새 애플리케이션을 구현해야 하는데, 인증을 직접 구현하고 싶은 / 그래야 할 거 같다는 생각이 든다면, 이 경험을 참고하시면 좋을 거 같다. 전체 코드는 싱거울 정도로 단출하지만, 여기까지 오는 데 풀타임 근무 시간을 족히 1주일은 버린 거 같다. 원래는 이렇게 구현할 생각까지는 아니었거든. 더 간단하면서도 더 정확하고 합리적인 authentication 전개가 있을 거라고, 그걸 내가 만들 수 있을 거라고 생각했다.

해보니 그게 아니었다. 만들면 만들수록 허술해지더군. 가드를 구현한답시고 하는데 이게 전혀 가드가 안 됨을 느껴서, 약간 지는 기분으로 sanctum을 채택했고, 지금 돌이켜 보면 내가 어디서 뭘 틀리고 있었는지 조금은 알겠다 싶다.

참고로 그 '원래 계획'의 전체 내용은 다음과 같다.

  1. 클라이언트는 (그게 누구든지) POST /login을 곧바로 요청할 수 있다.
  2. 서버는 클라이언트가 계정/암호를 맞히는 데 성공하(기만 하)면, 세션 ID 및 사용자 ID와 연계되는 임의의 난수를 생성하여
    • 서버단에 캐시(저장)하고
    • 클라이언트에게 반환한다.
  3. 클라이언트는 (그게 누구든지) API-KEY 헤더에 그 난수값을 담아 원하는 라우트를 곧바로 요청할 수 있다.
  4. 서버는 그 헤더의 난수값과 현재 요청자의 세션을 (어떻게든) 대조한다.
  5. 그 대조가 성공하(기만 하)면, 그 난수로 서버단에 캐시(저장)한 난수로부터 사용자 ID를 추출하여 사용자 정보를 조회해 필요한 작업을 한다.

지금까지 살펴본 Sanctum의 전개와 비교하여, 원래 계획대로 구현했었다면 과연 어떤 함정이 기다리고 있었을지 생각해 보자.

profile
4년차 PHP 개발자입니다.

0개의 댓글