From 0d0e0ae2a94558b28aadb517e5e99e156614cb9f Mon Sep 17 00:00:00 2001 From: "kouhei.takemoto" Date: Wed, 16 Sep 2020 08:08:22 +0900 Subject: [PATCH 1/4] =?UTF-8?q?Item=E3=82=92AuthItem=E3=81=AB=E3=83=AA?= =?UTF-8?q?=E3=83=8D=E3=83=BC=E3=83=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Authentication.php | 10 +++---- src/Authentication/{Item.php => AuthItem.php} | 6 ++-- src/Authentication/Database.php | 28 +++++++++---------- src/Authentication/Protocol.php | 8 +++--- tests/AuthenticationTest.php | 4 +-- tests/Sample/Business/Formmap/Login.php | 4 +-- 6 files changed, 30 insertions(+), 30 deletions(-) rename src/Authentication/{Item.php => AuthItem.php} (83%) diff --git a/src/Authentication.php b/src/Authentication.php index 02900b2..d661da3 100644 --- a/src/Authentication.php +++ b/src/Authentication.php @@ -11,7 +11,7 @@ namespace Citrus; use Citrus\Authentication\Database; -use Citrus\Authentication\Item; +use Citrus\Authentication\AuthItem; use Citrus\Authentication\Protocol; use Citrus\Configure\Configurable; use Citrus\Database\Connection\Connection; @@ -70,10 +70,10 @@ public function loadConfigures(array $configures = []): Configurable /** * 認証処理 * - * @param Item $item + * @param AuthItem $item * @return bool true:認証成功, false:認証失敗 */ - public function authorize(Item $item): bool + public function authorize(AuthItem $item): bool { if (true === is_null($this->protocol)) { @@ -106,10 +106,10 @@ public function deAuthorize(): bool * 認証のチェック * 認証できていれば期間の延長 * - * @param Item|null $item + * @param AuthItem|null $item * @return bool true:チェック成功, false:チェック失敗 */ - public function isAuthenticated(Item $item = null): bool + public function isAuthenticated(AuthItem $item = null): bool { if (true === is_null($this->protocol)) { diff --git a/src/Authentication/Item.php b/src/Authentication/AuthItem.php similarity index 83% rename from src/Authentication/Item.php rename to src/Authentication/AuthItem.php index 10dc0ea..ef80672 100644 --- a/src/Authentication/Item.php +++ b/src/Authentication/AuthItem.php @@ -15,7 +15,7 @@ /** * 認証アイテム */ -class Item extends Columns +class AuthItem extends Columns { /** @var string user id */ public $user_id; @@ -26,6 +26,6 @@ class Item extends Columns /** @var string token */ public $token; - /** @var string keep at */ - public $keep_at; + /** @var string expired at */ + public $expired_at; } diff --git a/src/Authentication/Database.php b/src/Authentication/Database.php index a083a35..ec8bc50 100644 --- a/src/Authentication/Database.php +++ b/src/Authentication/Database.php @@ -60,10 +60,10 @@ public function __construct(Connection $connection) /** * 認証処理 * - * @param Item $item + * @param AuthItem $item * @return bool true:認証成功, false:認証失敗 */ - public function authorize(Item $item): bool + public function authorize(AuthItem $item): bool { // ログインID、パスワード のどちらかが null もしくは 空文字 だった場合は認証失敗 if (true === Strings::isEmpty($item->user_id) || true === Strings::isEmpty($item->password)) @@ -75,10 +75,10 @@ public function authorize(Item $item): bool $table_name = Authentication::$AUTHORIZE_TABLE_NAME; // 対象ユーザーがいるか? - $condition = new Item(); + $condition = new AuthItem(); $condition->user_id = $item->user_id; - /** @var Item $result */ - $result = (new Builder($this->connection))->select($table_name, $condition)->execute(Item::class)->one(); + /** @var AuthItem $result */ + $result = (new Builder($this->connection))->select($table_name, $condition)->execute(AuthItem::class)->one(); // いなければ認証失敗 if (true === is_null($result)) { @@ -93,11 +93,11 @@ public function authorize(Item $item): bool // 認証情報の保存 $item->token = Authentication::generateToken(); - $item->keep_at = Authentication::generateKeepAt(); + $item->expired_at = Authentication::generateKeepAt(); $item->password = null; // データベースに現在のトークンと保持期間の保存 - $condition = new Item(); + $condition = new AuthItem(); $condition->rowid = $result->rowid; $condition->rev = $result->rev; (new Builder($this->connection))->update($table_name, $item, $condition)->execute(); @@ -127,10 +127,10 @@ public function deAuthorize(): bool * 認証のチェック * 認証できていれば期間の延長 * - * @param Item|null $item + * @param AuthItem|null $item * @return bool true:チェック成功, false:チェック失敗 */ - public function isAuthenticated(Item $item = null): bool + public function isAuthenticated(AuthItem $item = null): bool { // 指定されない場合はsessionから取得 if (true === is_null($item)) @@ -171,18 +171,18 @@ public function isAuthenticated(Item $item = null): bool $table_name = Authentication::$AUTHORIZE_TABLE_NAME; // まだ認証済みなので、認証期間の延長 - $authentic = new Item(); - $authentic->keep_at = Authentication::generateKeepAt(); - $condition = new Item(); + $authentic = new AuthItem(); + $authentic->expired_at = Authentication::generateKeepAt(); + $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 Item $item */ + /** @var AuthItem $item */ $item = Session::$session->call(Authentication::SESSION_KEY); - $item->keep_at = $authentic->keep_at; + $item->expired_at = $authentic->expired_at; Session::$session->add(Authentication::SESSION_KEY, $item); Session::commit(); diff --git a/src/Authentication/Protocol.php b/src/Authentication/Protocol.php index cdd3c3a..548cd21 100644 --- a/src/Authentication/Protocol.php +++ b/src/Authentication/Protocol.php @@ -18,10 +18,10 @@ abstract class Protocol /** * 認証処理 * - * @param Item $item + * @param AuthItem $item * @return bool true:認証成功, false:認証失敗 */ - abstract public function authorize(Item $item): bool; + abstract public function authorize(AuthItem $item): bool; /** @@ -37,8 +37,8 @@ abstract public function deAuthorize(): bool; * 認証のチェック * 認証できていれば期間の延長 * - * @param Item|null $item + * @param AuthItem|null $item * @return bool true:チェック成功, false:チェック失敗 */ - abstract public function isAuthenticated(Item $item = null): bool; + abstract public function isAuthenticated(AuthItem $item = null): bool; } diff --git a/tests/AuthenticationTest.php b/tests/AuthenticationTest.php index d51345f..67d8638 100644 --- a/tests/AuthenticationTest.php +++ b/tests/AuthenticationTest.php @@ -12,7 +12,7 @@ use Citrus\Authentication; use Citrus\Authentication\Database; -use Citrus\Authentication\Item; +use Citrus\Authentication\AuthItem; use Citrus\Configure\ConfigureException; use Citrus\Database\Connection\Connection; use Citrus\Database\DSN; @@ -121,8 +121,8 @@ public function authorize_認証を通す() $authentication = Authentication::sharedInstance()->loadConfigures($this->configures); // 認証処理 - $authItem = new Item(); $authItem->user_id = 1; + $authItem = new AuthItem(); $authItem->password = 'hogehoge'; $is_auth = $authentication->authorize($authItem); $this->assertTrue($is_auth); diff --git a/tests/Sample/Business/Formmap/Login.php b/tests/Sample/Business/Formmap/Login.php index 14bc776..bbc8d82 100644 --- a/tests/Sample/Business/Formmap/Login.php +++ b/tests/Sample/Business/Formmap/Login.php @@ -8,13 +8,13 @@ * @license http://www.besidesplus.net/ */ -use Citrus\Authentication\Item; +use Citrus\Authentication\AuthItem; use Citrus\Formmap\ElementType; return [ 'Login' => [ 'login' => [ - 'class' => Item::class, + 'class' => AuthItem::class, 'elements' => [ 'user_id' => [ 'form_type' => ElementType::FORM_TYPE_TEXT, From a6e51ac732b03e415210f1d2f70f5238d900c60a Mon Sep 17 00:00:00 2001 From: "kouhei.takemoto" Date: Wed, 16 Sep 2020 08:09:50 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E3=82=AB=E3=83=A9=E3=83=A0=E5=90=8D?= =?UTF-8?q?=E3=81=AE=E5=A4=89=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Authentication/Database.php | 18 +++++++++--------- tests/AuthenticationTest.php | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Authentication/Database.php b/src/Authentication/Database.php index ec8bc50..8bcbffd 100644 --- a/src/Authentication/Database.php +++ b/src/Authentication/Database.php @@ -24,7 +24,7 @@ user_id CHARACTER VARYING(32) NOT NULL, password CHARACTER VARYING(64) NOT NULL, token TEXT, - keep_at TIMESTAMP WITHOUT TIME ZONE, + expired_at TIMESTAMP WITHOUT TIME ZONE, status INTEGER DEFAULT 0 NOT NULL, created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT current_timestamp NOT NULL, updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT current_timestamp NOT NULL, @@ -145,24 +145,24 @@ public function isAuthenticated(AuthItem $item = null): bool return false; } // ユーザーIDとトークン、認証期間があるか - if (true === is_null($item->user_id) or true === is_null($item->token) or true === is_null($item->keep_at)) + 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)、もしくはタイムアウト(keep_at=%s)', + Logger::debug('ログアウト:ユーザIDが無い(user_id=%s)、もしくはトークンが無い(token=%s)、もしくはタイムアウト(expired_at=%s)', $item->user_id, $item->token, - $item->keep_at + $item->expired_at ); return false; } // すでに認証期間が切れている - $keep_timestamp = strtotime($item->keep_at); - $now_timestamp = time(); - if ($keep_timestamp < $now_timestamp) + $expired_ts = strtotime($item->expired_at); + $now_ts = time(); + if ($expired_ts < $now_ts) { Logger::debug('ログアウト:タイムアウト(%s) < 現在時間(%s)', - $keep_timestamp, - $now_timestamp + $expired_ts, + $now_ts ); return false; } diff --git a/tests/AuthenticationTest.php b/tests/AuthenticationTest.php index 67d8638..d1f1314 100644 --- a/tests/AuthenticationTest.php +++ b/tests/AuthenticationTest.php @@ -74,7 +74,7 @@ public function setUp(): void // データ生成 $pdo = new \PDO(sprintf('sqlite:%s', $this->sqlite_file)); - $pdo->query('CREATE TABLE users (user_id INT, password TEXT, token TEXT, keep_at TEXT, status INT, created_at TEXT, updated_at TEXT, rowid INT, rev INT);'); + $pdo->query('CREATE TABLE users (user_id INT, password TEXT, token TEXT, expired_at TEXT, status INT, created_at TEXT, updated_at TEXT, rowid INT, rev INT);'); $pdo->query('INSERT INTO users VALUES (1, "'. password_hash('hogehoge', PASSWORD_DEFAULT) .'", "", "", 0, "2019-01-01", "2019-01-01", 1, 1);'); $dsn = DSN::getInstance()->loadConfigures($this->configures); From b49527d921e8eb6a343852dda3c13494b544d3b7 Mon Sep 17 00:00:00 2001 From: "kouhei.takemoto" Date: Wed, 16 Sep 2020 08:16:29 +0900 Subject: [PATCH 3/4] =?UTF-8?q?contract=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- composer.json | 4 +- src/Controller/AuthResponse.php | 73 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 src/Controller/AuthResponse.php diff --git a/composer.json b/composer.json index 0a5b00d..91ca569 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ ], "require": { "citrus-framework/configure": "^1.0", + "citrus-framework/contract": "^1.0", "citrus-framework/formmap": "^1.0", "citrus-framework/http": "^1.0", "citrus-framework/logger": "^1.0", @@ -22,7 +23,8 @@ "ext-mbstring": "*", "ext-posix": "*", "ext-json": "*", - "ext-pdo": "*" + "ext-pdo": "*", + "ext-openssl": "*" }, "require-dev": { "php": "^7.3", diff --git a/src/Controller/AuthResponse.php b/src/Controller/AuthResponse.php new file mode 100644 index 0000000..42f28b7 --- /dev/null +++ b/src/Controller/AuthResponse.php @@ -0,0 +1,73 @@ + + * @license http://www.besidesplus.net/ + */ + +namespace CitronIssue\Business\Extend; + +use CitronIssue\Business\Service\Auth\AuthItem; +use Citrus\Http\Server\Response; +use Citrus\Variable\Binders; + +/** + * 認証用レスポンス + */ +class AuthResponse extends Response +{ + use Binders; + + /** @var String 認証用トークン */ + public $token; + + /** @var array 認証用アイテム */ + public $user; + + + + /** + * token返却用レスポンスの生成 + * + * @param string $token 認証トークン + * @return $this + */ + public static function withToken(string $token): self + { + $self = new self(); + $self->token = $token; + $self->remove([ + 'result', + 'items', + 'messages', + 'user', + ]); + return $self; + } + + + + /** + * user返却用レスポンスの生成 + * + * @param AuthItem $item 認証アイテム + * @return $this + */ + public static function withItem(AuthItem $item): self + { + $self = new self(); + $self->user = [ + 'user_id' => $item->user_id, + ]; + $self->remove([ + 'result', + 'items', + 'messages', + 'token', + ]); + return $self; + } +} From baef9c9bb4ab4bdd69015bb0d471fa6b35cb3272 Mon Sep 17 00:00:00 2001 From: "kouhei.takemoto" Date: Wed, 16 Sep 2020 08:17:37 +0900 Subject: [PATCH 4/4] =?UTF-8?q?JWT=E3=81=AE=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/Authentication.php | 2 +- src/Authentication/Database.php | 2 +- src/Authentication/JWT.php | 438 ++++++++++++++++++++++++++++ src/Authentication/JWTException.php | 20 ++ src/Controller/AuthController.php | 105 +++++++ src/Controller/AuthResponse.php | 4 +- tests/AuthenticationTest.php | 2 +- 7 files changed, 568 insertions(+), 5 deletions(-) create mode 100644 src/Authentication/JWT.php create mode 100644 src/Authentication/JWTException.php create mode 100644 src/Controller/AuthController.php 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);