신규 Laravel 애플리케이션을 구축하는데, DB 설계를 기다릴 여유 없이 클라이언트를 위한 사용자 인증 API를 일단 만들어야 한다.
실제로 해결해야 하는 과제는 크게 다음 2가지.
2번은, 이를테면 1번에서 식별되는 사용자가 지금 접속된 '세션'과의 관계를 맺어야 한다든가, XSS("짝퉁사이트공격")으로부터 보호돼야 한다든가 하는 부분이다.
1번은 Custom User Provider 도입으로 해결한다.
2번은 Custom Auth Guard를 만들려다가... 그냥 동봉돼 있던 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_session
과 XSRF-TOKEN
쿠키이기 때문이다.
이제부터 보내는 요청에는 이 토큰을 값으로 하는
X-XSRF-TOKEN
헤더가 포함돼야 합니다. 이 작업은 보통 Axios 및 Angular HttpClient와 같은 라이브러리가 알아서 해 줍니다. 출처
이제 이 X-XSRF-TOKEN
을 사용해야 한다. 그러려면:
Guard
가 새로 하나 필요하다.
그런데 실은 아까 그 프로바이더가 런타임에 올린 sanctum
가드가 바로 그걸 수행한다.
그래서 각 라우트 정의에서 auth:sanctum
미들웨어를 설정해 두기만 하면, 그 토큰을 통한 사용자 식별을 할 수 있게 된다.
config/auth.php
에서 "sanctum"을 찾아볼 수 없는데도 앱이 정상 작동하는 것은 이 때문이다.
각 '가드'는 반드시 하나의 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',
]);
}
}
이렇게만 놓고 보면 마치 보안상 문제가 있는 것처럼 보인다.
하지만 실제로는, SPA 클라이언트를 통한다는 맥락에서 이 전개에는 별다른 문제가 없다.
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()
를 하지만, 그게 이렇게도 쓰일 수 있는 거구나 하는 생각까지는 미처 못 했다.혹시 PHP로 새 애플리케이션을 구현해야 하는데, 인증을 직접 구현하고 싶은 / 그래야 할 거 같다는 생각이 든다면, 이 경험을 참고하시면 좋을 거 같다. 전체 코드는 싱거울 정도로 단출하지만, 여기까지 오는 데 풀타임 근무 시간을 족히 1주일은 버린 거 같다. 원래는 이렇게 구현할 생각까지는 아니었거든. 더 간단하면서도 더 정확하고 합리적인 authentication 전개가 있을 거라고, 그걸 내가 만들 수 있을 거라고 생각했다.
해보니 그게 아니었다. 만들면 만들수록 허술해지더군. 가드를 구현한답시고 하는데 이게 전혀 가드가 안 됨을 느껴서, 약간 지는 기분으로 sanctum을 채택했고, 지금 돌이켜 보면 내가 어디서 뭘 틀리고 있었는지 조금은 알겠다 싶다.
참고로 그 '원래 계획'의 전체 내용은 다음과 같다.
POST /login
을 곧바로 요청할 수 있다.API-KEY
헤더에 그 난수값을 담아 원하는 라우트를 곧바로 요청할 수 있다.지금까지 살펴본 Sanctum의 전개와 비교하여, 원래 계획대로 구현했었다면 과연 어떤 함정이 기다리고 있었을지 생각해 보자.