diff --git a/src/Authentication.php b/src/Authentication.php index d661da3..a44166d 100644 --- a/src/Authentication.php +++ b/src/Authentication.php @@ -10,8 +10,8 @@ namespace Citrus; -use Citrus\Authentication\Database; use Citrus\Authentication\AuthItem; +use Citrus\Authentication\Database; use Citrus\Authentication\Protocol; use Citrus\Configure\Configurable; use Citrus\Database\Connection\Connection; diff --git a/src/Authentication/Database.php b/src/Authentication/Database.php index 8bcbffd..5182ee3 100644 --- a/src/Authentication/Database.php +++ b/src/Authentication/Database.php @@ -66,7 +66,7 @@ public function __construct(Connection $connection) public function authorize(AuthItem $item): bool { // ログインID、パスワード のどちらかが null もしくは 空文字 だった場合は認証失敗 - if (true === Strings::isEmpty($item->user_id) || true === Strings::isEmpty($item->password)) + if (true === Strings::isEmpty($item->user_id) or true === Strings::isEmpty($item->password)) { return false; } diff --git a/src/Authentication/JWT.php b/src/Authentication/JWT.php new file mode 100644 index 0000000..3e639b3 --- /dev/null +++ b/src/Authentication/JWT.php @@ -0,0 +1,438 @@ + + * @license http://www.besidesplus.net/ + */ + +namespace Citrus\Authentication; + +use Citrus\Authentication; +use Citrus\CitrusException; +use Citrus\Collection; +use Citrus\Database\Connection\Connection; +use Citrus\Database\DatabaseException; +use Citrus\Intersection; +use Citrus\Logger; +use Citrus\Query\Builder; +use Citrus\Session; +use Citrus\Variable\Strings; + +/** + * JWT認証 + * @see https://jwt.io/ + */ +class JWT extends Protocol +{ + /** @var string HMAC using SHA-256 hash */ + public const HS256 = 'HS256'; + + /** @var string HMAC using SHA-384 hash */ + public const HS384 = 'HS384'; + + /** @var string HMAC using SHA-512 hash */ + public const HS512 = 'HS512'; + + /** @var string RSA using SHA-256 hash */ + public const RS256 = 'RS256'; + + /** @var string RSA using SHA-384 hash */ + public const RS384 = 'RS384'; + + /** @var string RSA using SHA-512 hash */ + public const RS512 = 'RS512'; + + /** @var string RSA method */ + private const METHOD_RSA = 'openssl_sign'; + + /** @var string HMAC method */ + private const METHOD_HMAC = 'hash_hmac'; + + /** + * @var array アルゴリズムリスト + */ + public static $ALGORITHM_METHODS = [ + self::HS256 => ['hash' => 'SHA256', 'method' => self::METHOD_HMAC], + self::HS384 => ['hash' => 'SHA384', 'method' => self::METHOD_HMAC], + self::HS512 => ['hash' => 'SHA512', 'method' => self::METHOD_HMAC], + self::RS256 => ['hash' => OPENSSL_ALGO_SHA256, 'method' => self::METHOD_RSA], + self::RS384 => ['hash' => OPENSSL_ALGO_SHA384, 'method' => self::METHOD_RSA], + self::RS512 => ['hash' => OPENSSL_ALGO_SHA512, 'method' => self::METHOD_RSA], + ]; + + /** @var Connection */ + public $connection; + + /** @var string 秘密鍵 */ + private static $SECRET_KEY = '9b3DdFJYdIP2Cf6OVPrkhBQUpAjHb3Z2G86rw6HSIJg='; + + /** @var string アルゴリズム */ + private static $ALGORITHM = self::HS256; + + /** @var int 認証有効期限(秒) */ + private static $EXPIRE_SEC = (24 * 60 * 60); + + + + /** + * constructor. + * + * @param Connection $connection + */ + public function __construct(Connection $connection) + { + $this->connection = $connection; + } + + + /** + * JWTエンコード処理してトークンを得る + * + * @param array $add_payloads 追加ペイロード + * @return string JWTトークン + */ + public function encode(array $add_payloads): string + { + // アルゴリズム + $algorithm = self::$ALGORITHM; + // 署名文字列 + $secret = self::$SECRET_KEY; + // 現時刻(秒) + $now = time(); + + // 要素 + $elements = []; + + // ヘッダー + $elements[] = self::base64encode(json_encode([ + 'alg' => $algorithm, + 'typ' => 'JWT', + ])); + + // ペイロード + $payloads = Collection::stream([ + // 発行者識別子 + 'iss' => 'CitrusIssue3', + // JWTの有効期限 (現在時刻 + 有効期限) + 'exp' => $this->generateExpiredAt($now), + // JWTが有効となる開始日時 + 'ndf' => $now, + // JWTの発行日時 + 'iat' => $now, + ])->betterMerge($add_payloads)->toList(); + $elements[] = self::base64encode(json_encode($payloads)); + + // 署名 + $elements[] = self::base64encode( + self::signing(implode('.', $elements), $algorithm, $secret) + ); + + return implode('.', $elements); + } + + + + /** + * JWTトークンをデコードしてペイロードを得る + * + * @param string $jwt_token JWTトークン + * @return array ペイロード配列 + * @throws JWTException + */ + public function decode(string $jwt_token): array + { + // アルゴリズム + $algorithm = self::$ALGORITHM; + // 署名文字列 + $secret = self::$SECRET_KEY; + // 現時刻(秒) + $now = time(); + + // トークン配列の分割 + $tokens = explode('.', $jwt_token); + if (3 != count($tokens)) + { + throw new JWTException('トークン要素数が不足しています'); + } + + // ヘッダーチェック + $header = json_decode(self::base64decode($tokens[0]), true); + // 認証アルゴリズムが指定したものではない + if ($algorithm !== $header['alg']) + { + throw new JWTException('認証アルゴリズムが一致しません'); + } + // 認証アルゴリズムが指定したものではない + if ('JWT' !== $header['typ']) + { + throw new JWTException('認証タイプが一致しません'); + } + + // ペイロードチェック + $payload = json_decode(self::base64decode($tokens[1]), true); + // 有効期限設定が無い、もしくは現在時刻より以前に設定されている + if (false === isset($payload['exp']) or $payload['exp'] < $now) + { + throw new JWTException('有効期限切れの認証トークンです'); + } + + // 署名チェック + $signature = self::base64decode($tokens[2]); + $verifying_token = sprintf('%s.%s', $tokens[0], $tokens[1]); + // 署名が有効ではない + if (false === self::verifySignature($verifying_token, $signature, $secret, $algorithm)) + { + throw new JWTException('署名が有効ではありません'); + } + + return $payload; + } + + + + /** + * BASE64エンコード + * + * @param string $message 対象文字列 + * @return string + */ + public static function base64encode(string $message): string + { + return str_replace('=', '', strtr(base64_encode($message), '+/', '-_')); + } + + + + /** + * BASE64デコード + * + * @param string $message 対象文字列 + * @return string + */ + public static function base64decode(string $message): string + { + // 字詰めの必要はあるか + $remainder = strlen($message) % 4; + if (0 < $remainder) + { + $message .= str_repeat('=', (4 - $remainder)); + } + return base64_decode(strtr($message, '-_', '+/')); + } + + + + /** + * 有効期限を取得 + * + * @param int $timestamp 起点になるUNIXタイムスタンプ + * @return int 有効期限のUNIXタイムスタンプの取得 + */ + public function generateExpiredAt(int $timestamp): int + { + return ($timestamp + self::$EXPIRE_SEC); + } + + + + /** + * {@inheritdoc} + * @throws DatabaseException + */ + public function authorize(AuthItem $item): bool + { + // ログインID、パスワード のどちらかが null もしくは 空文字 だった場合は認証失敗 + if (true === Strings::isEmpty($item->user_id) or true === Strings::isEmpty($item->password)) + { + return false; + } + + // 対象テーブル + $table_name = Authentication::$AUTHORIZE_TABLE_NAME; + + // 対象ユーザーがいるか? + $condition = new AuthItem(); + $condition->user_id = $item->user_id; + /** @var AuthItem $result */ + $result = (new Builder($this->connection))->select($table_name, $condition)->execute(AuthItem::class)->one(); + + // いなければ認証失敗 + if (true === is_null($result)) + { + return false; + } + + // パスワード照合 + if (false === password_verify($item->password, $result->password)) + { + return false; + } + + // 認証情報の保存 + $item->token = $this->encode(['user_id' => $item->user_id]); + $item->expired_at = date('Y-m-d H:i:s', $this->generateExpiredAt(time())); + $item->password = null; + // データベースに現在のトークンと保持期間の保存 + $condition = new AuthItem(); + $condition->rowid = $result->rowid; + $condition->rev = $result->rev; + (new Builder($this->connection))->update($table_name, $item, $condition)->execute(); + Session::$session->add(Authentication::SESSION_KEY, $item); + Session::commit(); + + return true; + } + + + + /** + * {@inheritdoc} + */ + public function deAuthorize(): bool + { + Session::$session->remove(Authentication::SESSION_KEY); + Session::commit(); + + return true; + } + + + + /** + * 認証のチェック + * 認証できていれば期間の延長 + * + * @param AuthItem|null $item + * @return bool true:チェック成功, false:チェック失敗 + * @throws DatabaseException + */ + public function isAuthenticated(AuthItem $item = null): bool + { + // 指定されない場合はsessionから取得 + if (true === is_null($item)) + { + $item = Session::$session->call(Authentication::SESSION_KEY); + } + // 認証itemが無い + if (true === is_null($item)) + { + Logger::debug('ログアウト:認証Itemが無い'); + Logger::debug(Session::$session); + return false; + } + // ユーザーIDとトークン、認証期間があるか + if (true === is_null($item->user_id) or true === is_null($item->token) or true === is_null($item->expired_at)) + { + Logger::debug('ログアウト:ユーザIDが無い(user_id=%s)、もしくはトークンが無い(token=%s)、もしくはタイムアウト(expired_at=%s)', + $item->user_id, + $item->token, + $item->expired_at + ); + return false; + } + + // すでに認証期間が切れている + $expired_ts = strtotime($item->expired_at); + $now_ts = time(); + if ($expired_ts < $now_ts) + { + Logger::debug('ログアウト:タイムアウト(%s) < 現在時間(%s)', + $expired_ts, + $now_ts + ); + return false; + } + + // 対象テーブル + $table_name = Authentication::$AUTHORIZE_TABLE_NAME; + + // まだ認証済みなので、認証期間の延長 + $authentic = new AuthItem(); + $authentic->expired_at = date('Y-m-d H:i:s', $this->generateExpiredAt(time())); + $condition = new AuthItem(); + $condition->user_id = $item->user_id; + $condition->token = $item->token; + // 更新 + $result = (new Builder($this->connection))->update($table_name, $authentic, $condition)->execute(); + + // 時間を延長 + /** @var AuthItem $item */ + $item = (new Builder($this->connection))->select($table_name, $condition)->execute(AuthItem::class)->one(); + Session::$session->add(Authentication::SESSION_KEY, $item); + Session::commit(); + + return ($result > 0); + } + + + + /** + * 署名の生成 + * + * @param string $unsigned_token 未署名トークン + * @param string $algorithm アルゴリズム + * @param string $secret 署名文字列 + * @return string + */ + private static function signing(string $unsigned_token, string $algorithm, string $secret): string + { + // メソッド + $method = self::$ALGORITHM_METHODS[$algorithm]['method']; + // ハッシュ + $hash = self::$ALGORITHM_METHODS[$algorithm]['hash']; + + /** @var string $signature 署名 */ + $signature = Intersection::fetch($method, [ + // HMAC + self::METHOD_HMAC => function () use ($hash, $unsigned_token, $secret) { + return hash_hmac($hash, $unsigned_token, $secret, true); + }, + // RSA + self::METHOD_RSA => function () use ($hash, $unsigned_token, $secret) { + $signature = ''; + $success = openssl_sign($unsigned_token, $signature, $secret, $hash); + if (false === $success) + { + throw new CitrusException('OpenSSL signing Error'); + } + return $signature; + }, + ], true); + + return ($signature ?: ''); + } + + + + /** + * 署名の確認 + * + * @param string $verifying_token 確認したいトークン + * @param string $signature 署名 + * @param string $secret シークレットキー + * @param string $algorithm アルゴリズム + * @return bool true:確認OK,false:確認NG + */ + private static function verifySignature(string $verifying_token, string $signature, string $secret, string $algorithm): bool + { + // メソッド + $method = self::$ALGORITHM_METHODS[$algorithm]['method']; + // ハッシュ + $hash = self::$ALGORITHM_METHODS[$algorithm]['hash']; + + // 署名の確認ができたかどうかを返却 + return Intersection::fetch($method, [ + // HMAC + self::METHOD_HMAC => function () use ($verifying_token, $signature, $secret, $hash) { + return (true === hash_equals($signature, hash_hmac($hash, $verifying_token, $secret, true))); + }, + // RSA + self::METHOD_RSA => function () use ($verifying_token, $signature, $secret, $hash) { + return (1 === openssl_verify($verifying_token, $signature, $secret, $hash)); + }, + ], true); + } +} diff --git a/src/Authentication/JWTException.php b/src/Authentication/JWTException.php new file mode 100644 index 0000000..c4f94f9 --- /dev/null +++ b/src/Authentication/JWTException.php @@ -0,0 +1,20 @@ + + * @license http://www.citrus.tk/ + */ + +namespace Citrus\Authentication; + +use Citrus\CitrusException; + +/** + * JWT認証用例外 + */ +class JWTException extends CitrusException +{ +} diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php new file mode 100644 index 0000000..f39ac9f --- /dev/null +++ b/src/Controller/AuthController.php @@ -0,0 +1,105 @@ + + * @license http://www.besidesplus.net/ + */ + +namespace Citrus\Controller; + +use Citrus\Authentication; +use Citrus\Authentication\AuthItem; +use Citrus\Authentication\JWT; +use Citrus\Authentication\JWTException; +use Citrus\Contract; +use Citrus\Database\Connection\ConnectionPool; +use Citrus\Http\Server\Request; +use Citrus\Http\Server\Response; +use Citrus\Session; + +/** + * 認証処理 + */ +class AuthController extends ApiController +{ + /** + * サインイン + * + * @param Request $request + * @return Response + */ + public function signin(Request $request): Response + { + /** @var AuthItem $user */ + $user = Contract::sharedInstance()->autoParse(); + // 認証処理 + $is_authenticated = (new JWT(ConnectionPool::callDefault()))->authorize($user); + + // 認証失敗 + if (false === $is_authenticated) + { + header('HTTP/1.0 401 Unauthorized'); + exit; + } + + /** @var AuthItem $item 成功したらトークン取得 */ + $item = Session::$session->call(Authentication::SESSION_KEY); + return AuthResponse::withToken($item->token); + } + + + + /** + * ユーザー情報 + * + * @param Request $request + * @return Response + */ + public function user(Request $request): Response + { + $jwt = new JWT(ConnectionPool::callDefault()); + + // Bearer 文字列の取得 + $headers = getallheaders(); + $authorization = explode(' ', $headers['Authorization'])[1]; + $payload = $jwt->decode($authorization); + + $item = new AuthItem(); + $item->user_id = $payload['user_id']; + $item->expired_at = date('Y-m-d H:i:s', $payload['exp']); + $item->token = $authorization; + $jwt->isAuthenticated($item); + + /** @var AuthItem $item 成功したらトークン取得 */ + $item = Session::$session->call(Authentication::SESSION_KEY); + $item->remove([ + 'password', + ]); + return AuthResponse::withItem($item); + } + + + /** + * 認証チェック + */ + public function verify(): void + { + try + { + // Bearer 文字列の取得 + $headers = getallheaders(); + $authorization = explode(' ', $headers['Authorization'])[1]; + // decodeすることでExceptionチェックする + (new JWT(ConnectionPool::callDefault()))->decode($authorization); + } + catch (JWTException $e) + { + // 有効期限が切れたらException + header('HTTP/1.0 401 Unauthorized'); + exit; + } + } +} diff --git a/src/Controller/AuthResponse.php b/src/Controller/AuthResponse.php index 42f28b7..6e7c388 100644 --- a/src/Controller/AuthResponse.php +++ b/src/Controller/AuthResponse.php @@ -8,9 +8,9 @@ * @license http://www.besidesplus.net/ */ -namespace CitronIssue\Business\Extend; +namespace Citrus\Controller; -use CitronIssue\Business\Service\Auth\AuthItem; +use Citrus\Authentication\AuthItem; use Citrus\Http\Server\Response; use Citrus\Variable\Binders; diff --git a/tests/AuthenticationTest.php b/tests/AuthenticationTest.php index d1f1314..9010192 100644 --- a/tests/AuthenticationTest.php +++ b/tests/AuthenticationTest.php @@ -121,8 +121,8 @@ public function authorize_認証を通す() $authentication = Authentication::sharedInstance()->loadConfigures($this->configures); // 認証処理 - $authItem->user_id = 1; $authItem = new AuthItem(); + $authItem->user_id = '1'; $authItem->password = 'hogehoge'; $is_auth = $authentication->authorize($authItem); $this->assertTrue($is_auth);