Laravel 앱에 로그인 추가
이 퀵스타트에서는 표준 OpenID Connect 클라이언트 facile-it/php-openid-client를 사용해 Laravel 13 앱에 KeenAuth 로그인을 추가합니다. KeenAuth에는 아직 PHP SDK가 없으며, 필요하지도 않습니다. KeenAuth는 표준 OpenID Connect 제공자이므로 표준 OpenID Connect 클라이언트 라이브러리로 연동할 수 있습니다. 앱은 OAuth 클라이언트이며 모든 토큰을 서버 측 세션에 보관합니다. 브라우저에는 암호화된 세션 ID 쿠키만 전달됩니다. 이 가이드에서는 로그인 라우트를 추가하고, 페이지를 보호하고, 사용자의 액세스 토큰으로 보호된 API를 호출하고, 로그아웃한 뒤 앱을 오프라인에서 테스트하는 과정을 다룹니다.
앱에는 다음 라우트가 있습니다.
| 라우트 | 역할 |
|---|---|
GET / |
공개 홈 페이지: 로그인 버튼, 또는 사용자 이름과 로그아웃 버튼 |
GET /dashboard |
세션이 필요합니다. 사용자 정보와 API가 반환한 메시지를 표시합니다 |
POST /login |
로그인을 시작합니다. Laravel이 CSRF 토큰을 확인합니다 |
GET /auth/callback |
응답을 확인하고 코드를 교환한 뒤 세션을 시작합니다 |
POST /logout |
여기서와 KeenAuth에서 세션을 종료합니다 |
시작하기 전에
- KeenAuth 환경. 발급자(issuer)는
https://your-environment.eu.keenauth.app형식입니다. - 환경의 웹 애플리케이션(애플리케이션 생성). 클라이언트 시크릿(
client_secret_basic)이 필요합니다.development환경에서는 리디렉션 URIhttp://localhost/auth/callback과 로그아웃 후 리디렉션 URIhttp://localhost/를 등록하십시오. 루프백 URI는 포트 없이 등록되며,8000과 같은 모든 포트와 일치합니다. - 환경에 등록된 API(API 생성).
messages:read스코프가 있어야 합니다. 예를 들어https://api.example.com과 같은identifier가 해당 API 액세스 토큰의 대상(audience)입니다. Express 퀵스타트에서 이러한 토큰을 받는 API를 만듭니다. - PHP 8.3 이상 및 Composer 2.
1. 설치
composer require facile-it/php-openid-client:1.0.0 guzzlehttp/guzzle:8.3.1
이 라이브러리에는 PSR-18 HTTP 클라이언트가 필요하며, Guzzle이 그중 하나입니다. 예제는 composer.json에서 모든 패키지를 정확한 버전으로 고정하고, composer.lock은 각 패키지를 커밋 단위로 고정합니다. 설치는 composer install --no-plugins --no-scripts로 진행하므로 설치 중에는 어떤 패키지 코드도 실행되지 않습니다.
2. 구성
앱은 config/keenauth.php를 통해 환경 변수에서만 설정을 읽습니다. Laravel이 로드하는 .env에 값을 넣고, 이 파일은 절대 커밋하지 마십시오.
| 변수 | 필수 여부 | 의미 |
|---|---|---|
KEENAUTH_ISSUER |
예 | 환경의 발급자, https |
KEENAUTH_CLIENT_ID |
예 | 웹 애플리케이션의 client_id |
KEENAUTH_CLIENT_SECRET |
예 | 해당 클라이언트 시크릿 |
APP_BASE_URL |
예 | 앱의 공개 URL. 리디렉션 URI는 이 값으로 만들어집니다. localhost, 127.0.0.1 또는 [::1]에서만 http를 사용할 수 있습니다 |
API_URL |
예 | 앱이 호출하는 API. 예: http://localhost:8080 |
KEENAUTH_API_AUDIENCE |
예 | KeenAuth에 등록된 해당 API의 식별자 |
KEENAUTH_ID_TOKEN_ALG |
아니요 | 환경이 ID 토큰에 서명하는 알고리즘: RS256(기본값), PS256, ES256 또는 EdDSA |
APP_KEY |
예 | Laravel 자체 키로, 세션 쿠키를 암호화합니다. php artisan key:generate로 생성하십시오 |
Config::fromArray가 이 값들을 검사합니다. 누락되었거나 사용할 수 없는 값은 이를 필요로 하는 첫 요청에서 중단되며, 어떤 오류 메시지에도 값은 포함되지 않습니다. HMAC 알고리즘은 거부되므로 클라이언트 시크릿으로 ID 토큰에 서명할 수 없습니다:
$alg = $values['id_token_alg'] ?? 'RS256';
if (!in_array($alg, ['RS256', 'PS256', 'ES256', 'EdDSA'], true)) {
// Never an HMAC algorithm: the client secret must not be able to sign an ID token.
throw new InvalidArgumentException('KEENAUTH_ID_TOKEN_ALG must be RS256, PS256, ES256 or EdDSA');
}
3. OpenID Connect 클라이언트 만들기
App\KeenAuth\KeenAuth는 디스커버리(<issuer>/.well-known/openid-configuration)로 클라이언트를 만들고, 메타데이터와 키 세트를 1시간 동안 캐시합니다:
$issuer = (new IssuerBuilder())
->setMetadataProviderBuilder((new MetadataProviderBuilder())->setHttpClient($this->http)
->setCache($this->cache)->setCacheTtl(3600))
->setJwksProviderBuilder((new JwksProviderBuilder())->withHttpClient($this->http)
->withCache($this->cache)->withCacheTtl(3600))
->build($this->config->issuer);
->setClientMetadata(ClientMetadata::fromArray([
'client_id' => $this->config->clientId,
'client_secret' => $this->config->clientSecret,
'token_endpoint_auth_method' => 'client_secret_basic',
'redirect_uris' => [$this->config->redirectUri()],
// The verifier accepts only this algorithm, never an HMAC one.
'id_token_signed_response_alg' => $this->config->idTokenAlg,
]))
서비스 프로바이더는 이 클라이언트를 싱글톤으로, HTTP 클라이언트는 테스트에서 교체할 수 있도록 별도의 바인딩으로 등록합니다.
4. 로그인 시작
로그인할 때마다 새로운 state, nonce, PKCE 검증자를 만들고 세션에 보관합니다:
public static function newTransaction(): array
{
return [
'state' => self::base64url(random_bytes(32)),
'nonce' => self::base64url(random_bytes(32)),
'code_verifier' => self::base64url(random_bytes(32)),
];
}
인가 요청은 검증자의 S256 챌린지와 API 식별자를 resource(RFC 8707)로 보냅니다. resource가 없으면 액세스 토큰은 해당 API를 대상으로 하지 않으므로 API가 이를 거부합니다.
return $this->service()->getAuthorizationUri($this->client(), [
// messages:read is a scope of your API; the example API requires it on /api/messages.
'scope' => 'openid profile email messages:read',
'state' => $transaction['state'],
'nonce' => $transaction['nonce'],
// PKCE (RFC 7636): S256 of the verifier that only this session knows.
'code_challenge' => self::base64url(hash('sha256', $transaction['code_verifier'], true)),
'code_challenge_method' => 'S256',
// RFC 8707: an access token for your API.
'resource' => $this->config->apiAudience,
]);
로그인은 CSRF 토큰과 함께 /login으로 제출되는 폼입니다. 따라서 다른 사이트가 사용자를 대신해 로그인을 시작할 수 없습니다:
<form action="/login" method="post">
@csrf
<button type="submit">Sign in</button>
</form>
5. 콜백 처리
라이브러리는 state 검사를 앱에 맡깁니다. 콜백은 세션에서 트랜잭션을 꺼내므로 state는 한 번만 사용할 수 있으며, 상수 시간으로 비교합니다. 환경은 모든 인가 응답에 자신을 식별하는 값을 포함하므로(RFC 9207), 콜백은 iss가 없거나 다른 발급자를 가리키는 응답도 거부합니다:
$transaction = $request->session()->pull('keenauth.transaction');
$state = $request->query('state');
if (!is_array($transaction) || !is_string($state) || !hash_equals($transaction['state'], $state)) {
return $this->failure('invalid_state');
}
// RFC 9207: the response names its issuer, so a response from another provider is refused.
$iss = $request->query('iss');
if ($keenauth->issuerInResponse() && $iss !== $keenauth->config->issuer) {
return $this->failure('invalid_issuer');
}
콜백에서는 코드만 받습니다. 라이브러리는 이 코드를 PKCE 검증자와 함께 교환하고, ID 토큰을 검사합니다. 환경의 키로 서명을 확인하며, iss, aud, exp, iat, 알고리즘, 그리고 이번 로그인의 nonce를 검증합니다.
return $this->service()->callback($this->client(), ['code' => $code], $this->config->redirectUri(),
AuthSession::fromArray($transaction));
그다음 앱은 새 세션 ID를 시작하고, 사용자와 토큰을 서버 측 세션에 보관합니다:
// A new session id after sign-in prevents session fixation.
$request->session()->regenerate(destroy: true);
세션 설정은 config/session.php에 있습니다. file 드라이버를 사용하며, 쿠키는 암호화되고 HttpOnly이며 SameSite=Lax입니다. SESSION_SECURE_COOKIE=false를 설정하지 않는 한 Secure가 적용되며, http://localhost에서는 이 설정이 필요합니다. 서버가 여러 대라면 redis 또는 database 드라이버를 사용하십시오.
6. 페이지 보호 및 API 호출
사용자가 필요한 라우트에는 keenauth 미들웨어를 사용합니다. 예제에는 리프레시 토큰이 없으므로 액세스 토큰이 만료되면 사용자는 다시 로그인해야 합니다.
Route::get('/dashboard', [DashboardController::class, 'show'])->middleware('keenauth');
대시보드는 액세스 토큰을 베어러 토큰으로 사용해 API를 호출합니다. 토큰은 서버에 머무르며, 브라우저는 HTML만 받습니다.
$response = $http->sendRequest(new HttpRequest('GET', $keenauth->config->apiUrl . '/api/messages', [
'Authorization' => 'Bearer ' . $request->session()->get('keenauth.access_token'),
'Accept' => 'application/json',
]));
7. 로그아웃
로그아웃은 CSRF 토큰과 함께 보내는 POST입니다. 앱은 로컬 세션을 종료하고, id_token_hint, client_id, post_logout_redirect_uri와 함께 브라우저를 환경의 로그아웃 엔드포인트(OpenID Connect RP-Initiated Logout)로 보냅니다. 이 엔드포인트가 브라우저를 앱으로 되돌려 보냅니다:
$idToken = $request->session()->get('keenauth.id_token');
$request->session()->invalidate();
$request->session()->regenerateToken();
8. 앱 실행
Express 퀵스타트의 API(또는 이 토큰을 받는 다른 API)를 시작한 뒤 앱을 실행합니다:
php artisan serve
http://localhost:8000을 열고 로그인을 선택한 다음 KeenAuth에서 로그인하여 대시보드를 여십시오.
9. 네트워크 없이 테스트
테스트에는 네트워크나 KeenAuth 계정이 필요하지 않습니다. 가짜 PSR-18 클라이언트가 HTTP 클라이언트를 대신하여 환경(디스커버리, 테스트 실행용으로 만든 키 세트, 토큰 엔드포인트)과 API 역할을 수행합니다. Laravel은 testing 환경에서 CSRF 검사를 건너뛰지만 이 테스트는 해당 검사를 확인하므로, 테스트는 APP_ENV=ci로 실행됩니다:
public function test_callback_with_an_unknown_state_is_refused_before_any_token_request(): void
{
$this->startSignIn();
$this->returnFromKeenAuth(['state' => 'not-the-state'])->assertStatus(401)->assertSee('invalid_state');
vendor/bin/phpunit
php ../scripts/php_deps.php # exact pins, commits and licences
테스트는 다음을 확인합니다. 구성이 검증되는지(그리고 어떤 오류에도 시크릿이 포함되지 않는지). 로그인에 CSRF 토큰이 필요하고 PKCE S256, state, nonce, resource를 보내는지. 코드가 일치하는 검증자와 클라이언트 인증으로 교환되는지. 액세스 토큰이 Authorization 헤더로 API에 전달되며 쿠키에는 절대 저장되지 않는지. 알 수 없거나 재사용된 state, 없거나 잘못된 iss, 다른 클라이언트용 또는 다른 nonce의 ID 토큰, 인가 오류가 세션 없이 거부되는지. 세션이 없는 페이지가 홈 페이지로 리디렉션되는지. 로그아웃에 CSRF 토큰이 필요하고 id_token_hint와 함께 로그아웃 엔드포인트로 이동하는지.
실제 브라우저로 하는 전체 로그인에는 KeenAuth 환경이 필요합니다. 이는 오프라인 테스트에 포함되지 않습니다.
프록시 또는 로드 밸런서 뒤에서
APP_BASE_URL을 앱의 공개 URL로 설정하십시오. 리디렉션 URI는 이 값으로 만들어지며, Host 헤더로 만들어지지 않습니다. 프록시가 TLS를 종료한다면 해당 프록시만 신뢰하도록 Laravel의 신뢰 프록시를 구성하십시오.
현재 제한 사항
- 리프레시 토큰과 자동 로그인(silent sign-in)을 지원하지 않습니다. 액세스 토큰이 만료되면 사용자가 다시 로그인해야 합니다.
- 클라이언트는 클라이언트 시크릿으로 인증합니다. 라이브러리는
private_key_jwt도 지원하지만 예제에서는 다루지 않습니다.
다음 단계
- Express API 보호하기: 이 앱이 호출하는 API.
- 토큰 검증: API 미들웨어 아래에서 동작하는 검증기.