Laravel Passport 패스워드 그랜트로 API 토큰 발급하기

5/31/2017 ·impact

Laravel Passport 패스워드 그랜트로 API 토큰 발급하기

Laravel Passport를 붙이면 모바일 앱이 이메일과 비밀번호만으로 액세스 토큰을 받아오는 부분에서 가장 먼저 막힙니다. 인가 코드 리다이렉트를 건너뛰는 대신 클라이언트 ID와 시크릿을 요청에 실어야 하는데, 이 값을 앱 배포물에 넣고 싶지는 않습니다. 그래서 서버 안에 얇은 컨트롤러를 하나 두고, 거기서 클라이언트 정보를 채운 다음 패스포트가 이미 등록해 둔 토큰 라우트로 넘기는 방식을 씁니다. 아래는 Laravel 5.4에서 그 프록시 컨트롤러를 작성한 코드이고, 각 파라미터가 공식 문서의 어떤 규격에 대응하는지 함께 정리했습니다.

의존성 선언과 라우트 두 줄

// composer.json
"laravel/passport" : "^2.0"

^2.0은 이 예제가 선언한 버전입니다. laravel.com의 Laravel 5.4 패스포트 문서 설치 절은 composer require laravel/passport=~4.0을 안내하므로, 5.4에 새로 붙이는 상황이라면 문서가 안내하는 ~4.0을 따르면 됩니다. 설치 절은 이어서 config/app.php의 providers 배열에 Laravel\Passport\PassportServiceProvider::class를 등록하고 php artisan migrate를 돌리라고 안내합니다. 패스포트가 자체 마이그레이션 디렉터리를 프레임워크에 등록하기 때문에, 클라이언트와 액세스 토큰을 담을 테이블은 이 단계에서 생깁니다.

// api.php
Route::post('auth/token', 'Restful\TokenController@authenticate');
Route::post('auth/refresh', 'Restful\TokenController@refreshToken');

Laravel 5.4 라우팅 문서에 따르면 routes/api.php에 정의한 라우트는 상태를 갖지 않고 api 미들웨어 그룹이 붙습니다. 그리고 패스포트 문서는 /oauth/token 라우트가 Passport::routes() 메서드로 이미 등록되므로 직접 정의할 필요가 없다고 못 박습니다. 위 두 줄은 그 라우트를 대체하는 게 아니라, 앱이 부르기 편한 이름으로 한 겹 감싸 주는 역할입니다. 앱은 auth/tokenauth/refresh만 알면 되고 클라이언트 시크릿은 서버에 남습니다.

토큰을 대신 받아오는 컨트롤러

class TokenController extends Controller
{
 /**
 * @var object
 */
 private $client;

 /**
 * DefaultController constructor.
 */
 public function __construct()
 {
 // Get grant type : password...
 $this->client = DB::table('oauth_clients')->where('id', 2)->first();
 }

 /**
 * @param Request $request
 * @return mixed
 */
 protected function authenticate(Request $request)
 {
 $request->request->add([
 'username' => $request->username,
 'password' => $request->password,
 'grant_type' => 'password',
 'client_id' => $this->client->id,
 'client_secret' => $this->client->secret,
 'scope' => '*'
 ]);

 $proxy = Request::create(
 'oauth/token',
 'POST'
 );

 return Route::dispatch($proxy);
 }

 /**
 * @param Request $request
 * @return mixed
 */
 protected function refreshToken(Request $request)
 {
 $request->request->add([
 'grant_type' => 'refresh_token',
 'refresh_token' => $request->refresh_token,
 'client_id' => $this->client->id,
 'client_secret' => $this->client->secret,
 ]);

 $proxy = Request::create(
 '/oauth/token',
 'POST'
 );

 return Route::dispatch($proxy);
 }
}

authenticate()가 요청 바디에 채워 넣는 키는 패스포트 문서의 패스워드 그랜트 요청 예제와 이름이 그대로 일치합니다. 문서는 grant_typepassword로 두고 client_id, client_secret, username, password, scope/oauth/token에 POST하라고 설명합니다. 문서 설명대로 이 그랜트는 모바일 애플리케이션 같은 자사 클라이언트가 이메일 주소나 사용자명과 비밀번호로 액세스 토큰을 얻게 해주는 방식이고, 사용자를 인가 코드 리다이렉트 흐름 전체로 보내지 않아도 됩니다.

scope에 들어간 *도 문서에 근거가 있습니다. 애플리케이션이 지원하는 모든 스코프를 토큰에 부여하고 싶을 때 *를 요청하며, 이 스코프가 붙은 토큰의 can 메서드는 항상 true를 반환합니다. 문서는 이 스코프가 password 그랜트로 발급된 토큰에만 부여될 수 있다고 명시합니다. 요청이 성공하면 /oauth/tokenaccess_token, refresh_token, expires_in을 담은 JSON을 돌려주고, expires_in은 액세스 토큰 만료까지 남은 초입니다.

oauth_clients에서 id 2를 꺼내는 이유

생성자는 oauth_clients 테이블에서 id가 2인 행을 읽어 클라이언트 정보로 씁니다. 이 숫자의 출처는 설치 명령입니다. 패스포트 문서는 php artisan passport:install이 액세스 토큰 생성에 필요한 암호화 키를 만들고, 여기에 더해 personal access 클라이언트와 password grant 클라이언트를 생성한다고 설명합니다. 패키지의 InstallCommand 소스를 보면 호출 순서가 passport:keys, passport:client --personal, passport:client --password입니다. 새로 설치한 데이터베이스라면 패스워드 그랜트 클라이언트가 두 번째로 들어갑니다.

거꾸로 말하면 이 하드코딩은 설치 직후의 생성 순서에 기대고 있습니다. 시드에서 클라이언트를 더 만들었거나 passport:client --password를 별도로 돌려 추가했다면 숫자가 어긋납니다. 문서도 --password 옵션으로 패스워드 그랜트 클라이언트를 따로 만들 수 있다고 안내하면서, 이미 passport:install을 실행했다면 이 명령을 다시 돌릴 필요는 없다고 덧붙입니다. 운영 중인 프로젝트라면 id 값을 그대로 믿기보다 실제 테이블에서 어느 행이 패스워드 그랜트 클라이언트인지 확인하고 쓰는 편이 낫습니다.

배포할 때 걸리는 게 하나 더 있습니다. 문서는 운영 서버에 패스포트를 처음 올릴 때 php artisan passport:keys를 실행해야 할 수 있고, 생성된 키는 보통 소스 컨트롤에 두지 않는다고 안내합니다. 이미지를 구워 배포하는 구성이라면 이 키를 어디에 보관하고 배포 시점에 어떻게 넣어 줄지를 따로 정해야 합니다.

빈 프록시 요청인데 토큰이 나오는 이유

Request::create('oauth/token', 'POST')로 만든 $proxy에는 방금 채워 넣은 파라미터가 들어 있지 않습니다. 값을 넣은 대상은 컨트롤러가 주입받은 현재 요청이지 새로 만든 요청 객체가 아니기 때문입니다. 그런데도 이 코드가 토큰을 돌려준다면, 패스포트가 파라미터를 읽어 가는 경로가 $proxy가 아니라는 뜻입니다. 소스를 세 군데만 따라가면 경로가 드러납니다.

먼저 패스포트의 AccessTokenController는 발급 메서드를 issueToken(ServerRequestInterface $request)로 선언합니다. PSR-7 인터페이스입니다. 이 인터페이스의 바인딩은 Laravel 5.4의 RoutingServiceProvider가 등록하는데, 구현이 (new DiactorosFactory)->createRequest($app->make('request'))입니다. 컨테이너에 들어 있는 request 인스턴스를 PSR-7으로 변환해서 넘기는 셈입니다.

다음으로 Router::dispatch()는 넘겨받은 요청을 $this->currentRequest에 대입한 뒤 dispatchToRoute()로 보냅니다. 컨테이너의 request 인스턴스를 새 요청으로 갈아끼우지는 않습니다. 두 조각을 이어 붙이면, 토큰 컨트롤러가 실제로 읽는 값은 $request->request->add()로 우리가 수정해 둔 원래 요청 쪽입니다. $proxy/oauth/token 라우트를 찾게 만드는 역할을 맡습니다.

액션이 protected로 선언된 것도 눈에 걸립니다. 이쪽도 소스에 답이 있습니다. ControllerDispatcher::dispatch()는 컨트롤러에 callAction이 있으면 그 메서드를 호출하고, Controller::callAction()은 클래스 내부에서 call_user_func_array([$this, $method], $parameters)를 실행합니다. 호출이 클래스 스코프 안에서 일어나므로 protected 메서드에 접근할 수 있습니다.

리프레시 토큰과 만료 시각 설정

refreshToken()은 같은 구조에서 파라미터만 바뀝니다. grant_typerefresh_token으로 두고 클라이언트 정보와 함께 보냅니다. 패스포트 문서의 갱신 예제도 grant_type, refresh_token, client_id, client_secret을 form_params로 보내며 키 이름이 동일합니다. 문서 예제에는 scope가 함께 들어 있지만 위 코드에는 없습니다. 응답은 발급 때와 같은 형태로 access_token, refresh_token, expires_in을 담은 JSON입니다.

갱신 라우트를 언제 부를지는 토큰 수명 설정에 달려 있습니다. 문서의 Token Lifetimes 절은 패스포트가 기본적으로 갱신할 필요가 없는 긴 수명의 액세스 토큰을 발급한다고 설명합니다. 수명을 줄이려면 AuthServiceProviderboot()에서 두 메서드를 호출합니다.

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

 Passport::routes();

 Passport::tokensExpireIn(Carbon::now()->addDays(15));

 Passport::refreshTokensExpireIn(Carbon::now()->addDays(30));
}

문서 예제의 값은 위와 같이 15일과 30일입니다. 발급된 토큰으로 API를 보호할 때는 config/auth.php에서 api 가드의 driverpassport로 바꾸고 라우트에 auth:api 미들웨어를 붙입니다. API를 호출하는 쪽은 Authorization 헤더에 Bearer 토큰을 실어 보냅니다. 앞단이 SPA라면 401 응답을 받았을 때 auth/refresh를 호출하고 원래 요청을 다시 보내는 처리를 클라이언트 쪽에 두게 됩니다. 화면 단의 라우팅 규칙은 React Router 사용법 정리: 중첩 라우팅과 URL 파라미터에 정리해 두었고, 파이썬으로 비슷한 형태의 API 서버를 세우는 예제는 FastAPI로 만드는 간단한 Todo List 앱에 있습니다.

League OAuth2 Server패스포트가 토큰 발급에 사용하는 하위 라이브러리

laravel/passport InstallCommand클라이언트 생성 순서를 눈으로 확인할 수 있는 소스

이 설정을 옮겨 쓰기 전에

패스워드 그랜트는 모바일 앱 같은 자사 클라이언트가 아이디와 비밀번호로 직접 토큰을 받아야 할 때 고르는 방식입니다. 서드파티 클라이언트에 열어 줄 API라면 인가 코드 그랜트가 맞습니다. 프록시 컨트롤러를 끼울 때는 값을 채우는 대상이 $proxy가 아니라 컨트롤러가 주입받은 현재 요청이라는 점만 지키면 됩니다.

마지막으로 남는 문제는 id 2입니다. passport:install이 클라이언트를 만드는 순서에 기댄 값이라, 그 순서가 지금 쓰는 버전에서도 같다는 보장이 없으니, 실제 oauth_clients 테이블에서 패스워드 그랜트 클라이언트의 id를 확인해 쓰는 편이 안전합니다.

여기에 AuthServiceProvider에서 tokensExpireInrefreshTokensExpireIn까지 지정하면 토큰 수명을 기본값에 맡기지 않아도 됩니다.

출처: Laravel 5.4 API Authentication (Passport) / Laravel 5.4 Routing / laravel/passport InstallCommand / laravel/passport AccessTokenController / laravel/framework Router.php / laravel/framework RoutingServiceProvider.php / laravel/framework ControllerDispatcher.php