のんラボ

S3みたいなストレージサーバーっぽいものを自前で用意する③【Digest認証実装】

2022/03/26 2022/04/10 S3みたいなストレージサーバーっぽいものを自前で用意する③【Digest認証実装】

S3みたいなストレージサーバーっぽいものを自前で用意する③【Digest認証実装】

こんにちは。のんです。

前回に引き続き自前でストレージサーバーを開発していこうと思います。

GitHubプロジェクトはこちら

前回作成したAuthMiddleware にDigest認証を実装する

Digest認証について

Basic認証はBase64でエンコードされているとはいえ、パスワードを送ってしまうのでデコードされたらバレちゃう→ダメじゃん
って流れからパスワードを含めた情報を md5sha256 でハッシュ化して送ろうと考案された。この辺は散々他のWEBサイトで解説されているので簡単に説明しました。

API認証でこのDigest認証を採用する可能性があった(仕事で使うかもしれない)から勉強するために採用しました。

wikiによれば

  1. クライアントは認証が必要なページをリクエストする。しかし、通常ここではユーザ名とパスワードを送っていない。なぜならばクライアントはそのページが認証を必要とするか否かを知らないためである。
  2. サーバは401レスポンスコードを返し、認証領域 (realm) や認証方式(Digest)に関する情報をクライアントに返す。このとき、ランダムな文字列(nonce)とサーバーがサポートしている qop (quality of protection) を示す引用符で囲まれた1つまたは複数のトークンも返される。
  3. それを受けたクライアントは、認証領域(通常は、アクセスしているサーバやシステムなどの簡単な説明)をユーザに提示して、ユーザ名とパスワードの入力を求める。ユーザはここでキャンセルすることもできる。
  4. ユーザによりユーザ名とパスワードが入力されると、クライアントはnonceとは別のランダムな文字列(cnonce)を生成する。そして、ユーザ名とパスワードとこれら2つのランダムな文字列などを使ってハッシュ文字列(response)を生成する。
  5. クライアントはサーバから送られた認証に関する情報(ユーザ名, realm, nc(nonce count), nonce, cnonce, qop)とともに、responseをサーバに送信する。
  6. サーバ側では、クライアントから送られてきたランダムな文字列(nonce、cnonce)などとサーバに格納されているハッシュ化されたパスワードから、正解のハッシュを計算する。
  7. この計算値とクライアントから送られてきたresponseとが一致する場合は、認証が成功し、サーバはコンテンツを返す。不一致の場合は再び401レスポンスコードが返され、それによりクライアントは再びユーザにユーザ名とパスワードの入力を求める。

とある。

APIには

  • ページが無い
  • 認証が必要であることが明示的にわかっている

ため、1, 2, 3の手順は必要ない

このため、 AuthMiddleware には4以降のクライアントから送信された入力情報を検証する処理のみを実装する。

ざっくりとした実装と解説

copied.<?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 コメントが多いのは実装途中だからですね。ロギングをしなければエラーを拾うことができなくなってしまいます。未実装なのでメモを残しています。


それ以外のところを上から順に追っていきます。

copied.try {
    $digests = $request->getHeader('Authorization') ?? [];
    if (count($digests) === 0) {
        throw new HttpUnauthorizedException('Please set `Authorization` header.');
    }
} catch (HttpUnauthorizedException $e) {
    // TODO: ログ記録
    return $e->getApiProblemResponse();
}

特に難しいことはしていません。読めばわかると思います。
Authorization ヘッダがない場合にエラーレスポンスを返しています。

copied.return $e->getApiProblemResponse();

の部分はまだ未解説の部分で、(たぶん)次回に解説するのでその時に。

Authorization ヘッダがない場合というのは

  1. クライアントは認証が必要なページをリクエストする。しかし、通常ここではユーザ名とパスワードを送っていない。なぜならばクライアントはそのページが認証を必要とするか否かを知らないためである。

に反しているのでエラーを返しています。

つまり、「このAPIは認証が必須ですよ。」ということをお知らせしています。


次に実際にDigest認証をしているところを見てみます。

下記のように、検証をしている箇所はUseCase層に封じ込めています。

copied.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();
}

ほとんどがエラーハンドリングのためのコードですが、実際に検証処理を呼び出している箇所は下記ですね。

copied.$input = new DigestAuthInput($digests[0], $request->getMethod(), App::env('DIGEST_NONCE'));
$this->digestAuth->process($input);

Digest認証に関するコードはこちら。

copied.Auth
├ ClientRepositoryInterface.php
└ Command
   └─ DigestAuth
        ├─ DigestAuth.php
        ├─ DigestAuthInput.php
        ├─ DigestAuthInputPort.php
        └─ DigestAuthInterface.php

DigestAuthInput クラスに

  • Authorization ヘッダの値
  • HTTPリクエストメソッド
  • nonce の値

の認証に必要な3つの値を渡し、このInputクラスを利用してDigest認証の検証プロセスを発火します。

ちなみに、 DigestAuthInput の実装はこちら。

copied.<?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点のみです。

copied.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 が実装された入力内容を利用した検証プロセスはこちら。

同様に上から追っていきます。

copied.<?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();
        }
    }
}

以外とシンプルにまとまっています。

copied.$client = $this->clientRepository->findById(new ClientId($inputPort->userName()));

まず、入力されたユーザー(クライアント)情報を取得します。このときにユーザーの存在チェックも行っています。

ClientRepogitory の実装については実際にGithubのコードを見てみてください。

とは言ってもただのSQL実行機ですが...複雑なクエリを必要としないので、ORMやクエリビルダなどのツールは用意していません。(これもそのうち記事にしたいと思います。)

ともかく、 Repogitory からユーザー情報を取得できたので、リクエストで入手した情報とサーバーにある情報を比べて比較していきます。

copied.$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:

copied.A1 = unq(username) ":" unq(realm) ":" passwd

where

copied.passwd   = < user's password >

と実装されるようです。

上記を参考にして、まず A1 から

copied.$A1 = hash(self::SHA_256, "$userName:" . self::REALM . ":$password");

ユーザー名realm値パスワード: で結合したものを MD5SHA256 でハッシュ化します。

次に A2 です。

If the qop parameter's value is "auth" or is unspecified, then A2 is:

copied.A2 = Method ":" request-uri

If the qop value is "auth-int", then A2 is:

copied.A2 = Method ":" request-uri ":" H(entity-body)

auth を想定していますので、 A2 = Method ":" request-uri ですね。

copied.$A2 = hash(self::SHA_256, "{$inputPort->method()}:{$inputPort->uri()}");

HttpリクエストメソッドリクエストUri: で結合したものを MD5SHA256 でハッシュ化します。

最後に A1A2 からレスポンス値の生成します。

このレスポンス値がクライアントで生成されたと同じであれば認証成功となります。

If the qop value is "auth" or "auth-int":

copied.response = <"> < KD ( H(A1), unq(nonce)
    ":" nc
    ":" unq(cnonce)
    ":" unq(qop)
    ":" H(A2)
) <">

See below for the definitions for A1 and A2.

日本語の文法的に最後になってしまいました。これまで作成した A1A2 からレスポンス値の生成方法は、 A1nonce値nc値cnonce値qop値(ここではauth)A2: で結合して MD5SHA256 でハッシュ化します。

copied.$validResponse = hash(self::SHA_256, "$A1:" . $nonce . ":{$inputPort->nc()}:{$inputPort->cnonce()}:{$inputPort->qop()}:$A2");

こうすることで、クライアント側で生成された response値 と同じものが作成できたはずです。

それを実際に検証し、成功すればOK。失敗すれば例外をスローします。

copied.if ($validResponse !== $inputPort->response()) {
    throw new InvalidResponseException();
}

これで検証プロセスの実装が完了しました。

まとめ

  1. クライアント側でユーザー情報など各種値と、決められた手順でハッシュ化された response値Authorization ヘッダにセット
  2. サーバーに送信。
  3. サーバー側でユーザー情報の有無確認
  4. クライアントとは違うリソース
    1. 重要: クライアントから取得した情報を利用しても意味がない。DBなどで保存されているクライアントとは別のリソース)を使い、同じ手順で response値 を生成
  5. response値 の比較

という手順。

おまけ: エラーハンドリングについて

  • ValueObjectなど引数のエラー
    • BadRequest
  • ユーザー情報の有無や認証エラー
    • Unauthorized
  • その他
    • InternalServerError

という感じ。

最後に

ぶっちゃけこのコードもまだまだ改善点はありますね。

qopauth のみを前提にしていますし、ハッシュ化のアルゴリズムも <algorithm> -sess の考慮がされていません。

想定されていないことなら、それをエラーレスポンスとしてユーザーに教えてあげる必要がありますよね。でもそれがない。というのが今後の課題になりそうです。

とはいえ、結局自分しか使わないサービスになりそうなのでその辺は...実装するかな?w

次回は予定通り RFC 7807 について記事にしたいと思います。

APIを実装する上でエラーレスポンスをどう返すかというのをルール化したものですね。

この話はどちらかというと採用したライブラリや、独自例外クラスの記事になりそうです。

賛否あるかと思いますが、温かい目で見ていただければ幸いです。

また記事にします。

その時はよしなに。

.