S3みたいなストレージサーバーっぽいものを自前で用意する③【Digest認証実装】
こんにちは。のんです。
前回に引き続き自前でストレージサーバーを開発していこうと思います。
GitHubプロジェクトはこちら
前回作成したAuthMiddleware
にDigest認証を実装する
Digest認証について
Basic認証はBase64でエンコードされているとはいえ、パスワードを送ってしまうのでデコードされたらバレちゃう→ダメじゃん
って流れからパスワードを含めた情報を md5
や sha256
でハッシュ化して送ろうと考案された。この辺は散々他のWEBサイトで解説されているので簡単に説明しました。
API認証でこのDigest認証を採用する可能性があった(仕事で使うかもしれない)から勉強するために採用しました。
wikiによれば
- クライアントは認証が必要なページをリクエストする。しかし、通常ここではユーザ名とパスワードを送っていない。なぜならばクライアントはそのページが認証を必要とするか否かを知らないためである。
- サーバは401レスポンスコードを返し、認証領域 (realm) や認証方式(Digest)に関する情報をクライアントに返す。このとき、ランダムな文字列(nonce)とサーバーがサポートしている qop (quality of protection) を示す引用符で囲まれた1つまたは複数のトークンも返される。
- それを受けたクライアントは、認証領域(通常は、アクセスしているサーバやシステムなどの簡単な説明)をユーザに提示して、ユーザ名とパスワードの入力を求める。ユーザはここでキャンセルすることもできる。
- ユーザによりユーザ名とパスワードが入力されると、クライアントはnonceとは別のランダムな文字列(cnonce)を生成する。そして、ユーザ名とパスワードとこれら2つのランダムな文字列などを使ってハッシュ文字列(response)を生成する。
- クライアントはサーバから送られた認証に関する情報(ユーザ名, realm, nc(nonce count), nonce, cnonce, qop)とともに、responseをサーバに送信する。
- サーバ側では、クライアントから送られてきたランダムな文字列(nonce、cnonce)などとサーバに格納されているハッシュ化されたパスワードから、正解のハッシュを計算する。
- この計算値とクライアントから送られてきたresponseとが一致する場合は、認証が成功し、サーバはコンテンツを返す。不一致の場合は再び401レスポンスコードが返され、それによりクライアントは再びユーザにユーザ名とパスワードの入力を求める。
とある。
APIには
- ページが無い
- 認証が必要であることが明示的にわかっている
ため、1, 2, 3の手順は必要ない。
このため、 AuthMiddleware
には4以降のクライアントから送信された入力情報を検証する処理のみを実装する。
ざっくりとした実装と解説
<?php
declare(strict_types=1);
namespace Nonz250\Storage\App\Http\Auth;
// 省略
class AuthMiddleware implements MiddlewareInterface
{
private DigestAuthInterface $digestAuth;
public function __construct(DigestAuthInterface $digestAuth)
{
$this->digestAuth = $digestAuth;
}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
try {
$digests = $request->getHeader('Authorization') ?? [];
if (count($digests) === 0) {
throw new HttpUnauthorizedException('Please set `Authorization` header.');
}
} catch (HttpUnauthorizedException $e) {
// TODO: ログ記録
return $e->getApiProblemResponse();
}
try {
try {
$input = new DigestAuthInput($digests[0], $request->getMethod(), App::env('DIGEST_NONCE'));
$this->digestAuth->process($input);
} catch (InvalidArgumentException | DataNotFoundException $e) {
// TODO: ログ記録
throw new HttpBadRequestException($e->getMessage());
} catch (InvalidResponseException $e) {
// TODO: ログ記録
throw new HttpUnauthorizedException('Please check user.');
} catch (Throwable $e) {
// TODO: ログ記録
throw new HttpInternalErrorException($e->getMessage());
}
} catch (HttpException $e) {
// TODO: ログ記録
return $e->getApiProblemResponse();
}
return $handler->handle($request);
}
}
以上が大まかな実装です。
TODO
コメントが多いのは実装途中だからですね。ロギングをしなければエラーを拾うことができなくなってしまいます。未実装なのでメモを残しています。
それ以外のところを上から順に追っていきます。
try {
$digests = $request->getHeader('Authorization') ?? [];
if (count($digests) === 0) {
throw new HttpUnauthorizedException('Please set `Authorization` header.');
}
} catch (HttpUnauthorizedException $e) {
// TODO: ログ記録
return $e->getApiProblemResponse();
}
特に難しいことはしていません。読めばわかると思います。
Authorization
ヘッダがない場合にエラーレスポンスを返しています。
return $e->getApiProblemResponse();
の部分はまだ未解説の部分で、(たぶん)次回に解説するのでその時に。
Authorization
ヘッダがない場合というのは
- クライアントは認証が必要なページをリクエストする。しかし、通常ここではユーザ名とパスワードを送っていない。なぜならばクライアントはそのページが認証を必要とするか否かを知らないためである。
に反しているのでエラーを返しています。
つまり、「このAPIは認証が必須ですよ。」ということをお知らせしています。
次に実際にDigest認証をしているところを見てみます。
下記のように、検証をしている箇所はUseCase層に封じ込めています。
try {
try {
$input = new DigestAuthInput($digests[0], $request->getMethod(), App::env('DIGEST_NONCE'));
$this->digestAuth->process($input);
} catch (InvalidArgumentException | DataNotFoundException $e) {
// TODO: ログ記録
throw new HttpBadRequestException($e->getMessage());
} catch (InvalidResponseException $e) {
// TODO: ログ記録
throw new HttpUnauthorizedException('Please check user.');
} catch (Throwable $e) {
// TODO: ログ記録
throw new HttpInternalErrorException($e->getMessage());
}
} catch (HttpException $e) {
// TODO: ログ記録
return $e->getApiProblemResponse();
}
ほとんどがエラーハンドリングのためのコードですが、実際に検証処理を呼び出している箇所は下記ですね。
$input = new DigestAuthInput($digests[0], $request->getMethod(), App::env('DIGEST_NONCE'));
$this->digestAuth->process($input);
Digest認証に関するコードはこちら。
Auth
├ ClientRepositoryInterface.php
└ Command
└─ DigestAuth
├─ DigestAuth.php
├─ DigestAuthInput.php
├─ DigestAuthInputPort.php
└─ DigestAuthInterface.php
DigestAuthInput
クラスに
Authorization
ヘッダの値- HTTPリクエストメソッド
nonce
の値
の認証に必要な3つの値を渡し、このInputクラスを利用してDigest認証の検証プロセスを発火します。
ちなみに、 DigestAuthInput
の実装はこちら。
<?php
declare(strict_types=1);
namespace Nonz250\Storage\App\Domain\Auth\Command\DigestAuth;
class DigestAuthInput implements DigestAuthInputPort
{
private array $data = [];
private string $method;
private string $nonce;
public function __construct(string $value, string $method, string $nonce)
{
$this->parse($value);
$this->method = $method;
$this->nonce = $nonce;
}
public function userName(): string
{
return $this->data['username'] ?? '';
}
public function uri(): string
{
return $this->data['uri'] ?? '';
}
public function qop(): string
{
return $this->data['qop'] ?? '';
}
public function nc(): string
{
return $this->data['nc'] ?? '';
}
public function cnonce(): string
{
return $this->data['cnonce'] ?? '';
}
public function response(): string
{
return $this->data['response'] ?? '';
}
public function method(): string
{
return $this->method;
}
private function parse(string $value): void
{
preg_match_all(
'@(cnonce|nc|qop|response|username|uri)=(?:([\'"])([^\2]+?)\2|([^\s,]+))@',
$value,
$matches,
PREG_SET_ORDER
);
foreach ($matches as $match) {
$this->data[$match[1]] = $match[3] ?: $match[4];
}
}
public function nonce(): string
{
return $this->nonce;
}
}
解説するべき点は1点のみです。
preg_match_all(
'@(cnonce|nc|qop|response|username|uri)=(?:([\'"])([^\2]+?)\2|([^\s,]+))@',
$value,
$matches,
PREG_SET_ORDER
);
foreach ($matches as $match) {
$this->data[$match[1]] = $match[3] ?: $match[4];
}
Authorization
ヘッダの中身(つまりクライアントから送信された入力内容)から、cnonce値
, nc値
, qop値
, response値
, username値
, uri値
を取得します。
具体的な検証プロセスとその解説
DigestAuthInputPort
が実装された入力内容を利用した検証プロセスはこちら。
同様に上から追っていきます。
<?php
declare(strict_types=1);
namespace Nonz250\Storage\App\Domain\Auth\Command\DigestAuth;
// 省略
class DigestAuth implements DigestAuthInterface
{
private const REALM = 'Secret Zone';
private const SHA_256 = 'sha256';
private ClientRepositoryInterface $clientRepository;
public function __construct(ClientRepositoryInterface $clientRepository)
{
$this->clientRepository = $clientRepository;
}
public function process(DigestAuthInputPort $inputPort): void
{
$client = $this->clientRepository->findById(new ClientId($inputPort->userName()));
$userName = (string)$client->clientId();
$password = (string)$client->clientSecret();
$nonce = $inputPort->nonce();
// @see https://tex2e.github.io/rfc-translater/html/rfc7616.html
$A1 = hash(self::SHA_256, "$userName:" . self::REALM . ":$password");
$A2 = hash(self::SHA_256, "{$inputPort->method()}:{$inputPort->uri()}");
$validResponse = hash(self::SHA_256, "$A1:" . $nonce . ":{$inputPort->nc()}:{$inputPort->cnonce()}:{$inputPort->qop()}:$A2");
if ($validResponse !== $inputPort->response()) {
throw new InvalidResponseException();
}
}
}
以外とシンプルにまとまっています。
$client = $this->clientRepository->findById(new ClientId($inputPort->userName()));
まず、入力されたユーザー(クライアント)情報を取得します。このときにユーザーの存在チェックも行っています。
ClientRepogitory
の実装については実際にGithubのコードを見てみてください。
とは言ってもただのSQL実行機ですが...複雑なクエリを必要としないので、ORMやクエリビルダなどのツールは用意していません。(これもそのうち記事にしたいと思います。)
ともかく、 Repogitory
からユーザー情報を取得できたので、リクエストで入手した情報とサーバーにある情報を比べて比較していきます。
$userName = (string)$client->clientId();
$password = (string)$client->clientSecret();
$nonce = $inputPort->nonce();
見てわかるように、今後の処理が追いやすいように変数に格納しているだけです。
次のコードに行きます。
@see
でも書かれているように、Digest認証は RFC 7616
で決められています。
下のサイトは日本語訳されているサイトなので比較的読みやすいと思います。
ただ、もちろん
こちらのサイトの確認も推奨します。
さて、具体的な実装については 3.4.1章
に書かれていそうです。
前提として、このAPIのDigest認証は下記の仕様に従うものとします。
qop
値がauth
であること- ハッシュ化アルゴリズムは
SHA256
であること
とすると、
If the algorithm parameter's value is "
", e.g., "SHA-256", then A1 is: A1 = unq(username) ":" unq(realm) ":" passwd
where
passwd = < user's password >
と実装されるようです。
上記を参考にして、まず A1
から
$A1 = hash(self::SHA_256, "$userName:" . self::REALM . ":$password");
ユーザー名
、 realm値
、 パスワード
を :
で結合したものを MD5
や SHA256
でハッシュ化します。
次に A2
です。
If the qop parameter's value is "auth" or is unspecified, then A2 is:
A2 = Method ":" request-uri
If the qop value is "auth-int", then A2 is:
A2 = Method ":" request-uri ":" H(entity-body)
auth
を想定していますので、 A2 = Method ":" request-uri
ですね。
$A2 = hash(self::SHA_256, "{$inputPort->method()}:{$inputPort->uri()}");
Httpリクエストメソッド
と リクエストUri
を :
で結合したものを MD5
や SHA256
でハッシュ化します。
最後に A1
と A2
からレスポンス値の生成します。
このレスポンス値がクライアントで生成されたと同じであれば認証成功となります。
If the qop value is "auth" or "auth-int":
response = <"> < KD ( H(A1), unq(nonce) ":" nc ":" unq(cnonce) ":" unq(qop) ":" H(A2) ) <">
See below for the definitions for A1 and A2.
日本語の文法的に最後になってしまいました。これまで作成した A1
と A2
からレスポンス値の生成方法は、 A1
、 nonce値
、 nc値
、 cnonce値
、 qop値(ここではauth)
と A2
を :
で結合して MD5
や SHA256
でハッシュ化します。
$validResponse = hash(self::SHA_256, "$A1:" . $nonce . ":{$inputPort->nc()}:{$inputPort->cnonce()}:{$inputPort->qop()}:$A2");
こうすることで、クライアント側で生成された response値
と同じものが作成できたはずです。
それを実際に検証し、成功すればOK。失敗すれば例外をスローします。
if ($validResponse !== $inputPort->response()) {
throw new InvalidResponseException();
}
これで検証プロセスの実装が完了しました。
まとめ
- クライアント側でユーザー情報など各種値と、決められた手順でハッシュ化された
response値
をAuthorization
ヘッダにセット - サーバーに送信。
- サーバー側でユーザー情報の有無確認
- クライアントとは違うリソース
- (重要: クライアントから取得した情報を利用しても意味がない。DBなどで保存されているクライアントとは別のリソース)を使い、同じ手順で
response値
を生成
- (重要: クライアントから取得した情報を利用しても意味がない。DBなどで保存されているクライアントとは別のリソース)を使い、同じ手順で
response値
の比較
という手順。
おまけ: エラーハンドリングについて
- ValueObjectなど引数のエラー
- BadRequest
- ユーザー情報の有無や認証エラー
- Unauthorized
- その他
- InternalServerError
という感じ。
最後に
ぶっちゃけこのコードもまだまだ改善点はありますね。
qop
は auth
のみを前提にしていますし、ハッシュ化のアルゴリズムも <algorithm> -sess
の考慮がされていません。
想定されていないことなら、それをエラーレスポンスとしてユーザーに教えてあげる必要がありますよね。でもそれがない。というのが今後の課題になりそうです。
とはいえ、結局自分しか使わないサービスになりそうなのでその辺は...実装するかな?w
次回は予定通り RFC 7807
について記事にしたいと思います。
APIを実装する上でエラーレスポンスをどう返すかというのをルール化したものですね。
この話はどちらかというと採用したライブラリや、独自例外クラスの記事になりそうです。
賛否あるかと思いますが、温かい目で見ていただければ幸いです。
また記事にします。
その時はよしなに。
.