From db24dade1964fe33ffd352cf845ee303a8f03fc2 Mon Sep 17 00:00:00 2001 From: ccrice Date: Thu, 25 Sep 2025 12:43:29 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(api):=E4=BF=AE=E6=AD=A3=20API=20?= =?UTF-8?q?=E4=BB=A4=E7=89=8C=E9=AA=8C=E8=AF=81=E9=80=BB=E8=BE=91-=20?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E4=BB=A4=E7=89=8C=E9=AA=8C=E8=AF=81=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=EF=BC=8C=E4=BF=AE=E5=A4=8D=E4=B8=8D=E4=BC=A0=E5=85=A5?= =?UTF-8?q?token=E4=B8=8D=E8=BF=9B=E8=A1=8Ctoken=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Action.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Action.php b/Action.php index 8cc4f87..8ccd5e2 100644 --- a/Action.php +++ b/Action.php @@ -208,7 +208,7 @@ private function checkState($route) $this->throwError('This API has been disabled.', 403); } $token = $this->request->getHeader('token'); - if (!empty($token) && $token != $this->config->apiToken) { + if ($this->config->apiToken && $token != $this->config->apiToken) { $this->throwError('apiToken is invalid', 403); } } From ae7c69f02c8068f31980b4fda3fbb2930e8b2651 Mon Sep 17 00:00:00 2001 From: ccrice Date: Thu, 25 Sep 2025 13:16:57 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(api):=20=E4=BF=AE=E5=A4=8D=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E4=B8=AD=E4=B8=8D=E6=98=BE=E7=A4=BAapi=E6=8F=8F?= =?UTF-8?q?=E8=BF=B0=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Action.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Action.php b/Action.php index 8ccd5e2..fc2cee9 100644 --- a/Action.php +++ b/Action.php @@ -832,6 +832,8 @@ public function userListAction() /** * 发表文章 + * + * @return void */ public function postArticleAction() { @@ -919,6 +921,8 @@ public function postArticleAction() /** * 新增标签/分类 + * + * @return void */ public function addMetasAction() { From c2b1011b87d2fe48877928029c8366d37d625ddf Mon Sep 17 00:00:00 2001 From: ccrice Date: Thu, 25 Sep 2025 15:06:07 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(api):=20=E6=B7=BB=E5=8A=A0=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=99=BB=E5=BD=95=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Action.php | 139 +++++++++++++++++++++++++++++++++++++++++++++++++- Plugin.php | 28 ++++++++++ README.md | 25 +++++++++ composer.json | 3 +- 4 files changed, 193 insertions(+), 2 deletions(-) diff --git a/Action.php b/Action.php index fc2cee9..48aefee 100644 --- a/Action.php +++ b/Action.php @@ -95,7 +95,7 @@ private function sendCORS() { $httpOrigin = $this->request->getServer('HTTP_ORIGIN'); $this->response->setHeader('Access-Control-Allow-Credentials', 'true'); - $allowedHttpOrigins = explode("\n", str_replace("\r", "", $this->config->origin)); + $allowedHttpOrigins = explode("\n", str_replace("\r", "", $this->config->origin ?? '')); if (!$httpOrigin) { return; @@ -948,6 +948,141 @@ public function addMetasAction() $this->throwData($res); } + /** + * 用户登录接口 + * + * @return void + */ + public function loginAction() + { + $this->lockMethod('post'); + $this->checkState('login'); + $this->checkLoginAttempts(); + $name = $this->getParams('name', ''); + $remember = $this->getParams('remember', false); + $secretKey = $this->config->secretKey ?? ''; + $timestamp = $this->getParams('timestamp', '');; + $encryptedPassword = $this->getParams('password', ''); + + if (empty($timestamp)||empty($encryptedPassword)) { + $this->throwError('参数错误', 400); + } + if (abs(time() - $timestamp) > 30) { + $this->throwError('请求过期', 400); + } + // 计算 dynamicKey + $dynamicKey = hash_hmac('sha256', $timestamp, $secretKey); + // 解密密码 + $data = base64_decode($encryptedPassword); + $method = 'AES-128-CBC'; + $ivLength = openssl_cipher_iv_length($method); + $iv = substr($data, 0, $ivLength); + $encrypted = substr($data, $ivLength); + + $password = openssl_decrypt( + $encrypted, + $method, + $dynamicKey, + OPENSSL_RAW_DATA, + $iv + ); + //解密错误 + if ($password === false) { + $this->throwError('参数错误', 400); + } + if (empty($name) || empty($password)) { + $this->throwError('用户名和密码不能为空', 400); + } + // 使用 OpenSSL 或 PHP 库解密 + + $user = $this->widget('Widget_User'); + + try { + $result = $user->login($name, $password, false, $remember ? 30 * 24 * 3600 : 0); + + if (!$result) { + $this->recordFailedLogin($name); + $this->throwError('用户名或密码错误', 401); + } + $this->clearFailedLoginAttempts($name); + $cookies = array(); + $prefix = $this->widget('Widget_Options')->cookiePrefix; + + $uidCookie = Typecho_Cookie::get($prefix . '__typecho_uid'); + $authCodeCookie = Typecho_Cookie::get($prefix . '__typecho_authCode'); + + if ($uidCookie && $authCodeCookie) { + $cookies[$prefix . '__typecho_uid'] = $uidCookie; + $cookies[$prefix . '__typecho_authCode'] = $authCodeCookie; + } + + $this->throwData(array( + 'message' => '登录成功', + 'cookies' => $cookies, + 'user' => array( + 'uid' => $user->uid, + 'name' => $user->name, + 'screenName' => $user->screenName, + 'mail' => $user->mail + ) + )); + + } catch (Exception $e) { + $this->recordFailedLogin($name); + $this->throwError('登录失败: ' . $e->getMessage(), 401); + } + } + + /** + * 检查登录尝试次数 + */ + private function checkLoginAttempts() + { + $ip = $this->request->getServer('REMOTE_ADDR'); + $timeout = $this->config->banTimeOut ?? 900; + $this->db->query($this->db->sql() + ->delete('table.login_attempts') + ->where('created < ?', time() - $timeout)); + + $attemptCount = $this->db->fetchObject($this->db->select(array('COUNT(*)' => 'count')) + ->from('table.login_attempts') + ->where('ip = ?', $ip) + ->where('created > ?', time() - $timeout))->count; + + $maxAttempts = $this->config->attemptCount ?? 5; + + if ($attemptCount >= $maxAttempts) { + $this->throwError('登录失败次数过多', 429); + } + } + + /** + * 记录失败的登录尝试 + */ + private function recordFailedLogin($username) + { + $ip = $this->request->getServer('REMOTE_ADDR'); + + $this->db->query($this->db->insert('table.login_attempts')->rows(array( + 'ip' => $ip, + 'username' => $username ?: '', + 'created' => time() + ))); + } + + /** + * 清除登录失败记录 + */ + private function clearFailedLoginAttempts($username) + { + $ip = $this->request->getServer('REMOTE_ADDR'); + + $this->db->query($this->db->sql() + ->delete('table.login_attempts') + ->where('ip = ?', $ip)); + } + + /** * 插件更新接口 * @@ -1159,4 +1294,6 @@ private function refreshMetas(array $midArray) ->where('mid = ?', $tag['mid'])); } } + + } diff --git a/Plugin.php b/Plugin.php index bdf8e3d..5042be8 100644 --- a/Plugin.php +++ b/Plugin.php @@ -23,12 +23,29 @@ class Restful_Plugin implements Typecho_Plugin_Interface */ public static function activate() { + $db = Typecho_Db::get(); + $prefix = $db->getPrefix(); $routes = call_user_func(array(self::ACTION_CLASS, 'getRoutes')); foreach ($routes as $route) { Helper::addRoute($route['name'], $route['uri'], self::ACTION_CLASS, $route['action']); } Typecho_Plugin::factory('Widget_Feedback')->comment = array(__CLASS__, 'comment'); + // 创建登录尝试记录表 + $sql = "CREATE TABLE IF NOT EXISTS `{$prefix}login_attempts` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `ip` varchar(50) NOT NULL DEFAULT '', + `username` varchar(100) NOT NULL DEFAULT '', + `created` int(11) NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `ip` (`ip`), + KEY `created` (`created`) + ) ENGINE=MyISAM DEFAULT CHARSET=utf8mb4;"; + + try { + $db->query($sql, Typecho_Db::WRITE); + } catch (Exception $e) { + } return '_(:з」∠)_'; } @@ -96,6 +113,17 @@ public static function config(Typecho_Widget_Helper_Form $form) $apiToken = new Typecho_Widget_Helper_Form_Element_Text('apiToken', null, '123456', _t('APITOKEN'), _t('api请求需要携带的token,设置为空就不校验。')); $form->addInput($apiToken); + /* 登录尝试次数 */ + $attemptCount = new Typecho_Widget_Helper_Form_Element_Text('attemptCount', null, 5, _t('登录api的登录尝试次数'), _t('登录api的登录尝试次数,超过后将被封禁。')); + $form->addInput($attemptCount); + + /* 登录失败封建时间 */ + $banTimeOut = new Typecho_Widget_Helper_Form_Element_Text('banTimeOut', null, 900, _t('超过登录尝试次数后封禁时间(秒)'), _t('设置超过登录尝试次数后被封禁的时间,单位为秒。')); + $form->addInput($banTimeOut); + /* 登录密码加密盐 */ + $secretKey = new Typecho_Widget_Helper_Form_Element_Text('secretKey', null, 'Q23Ch5rHYXFPere06VeyBD9u1W0DDj', _t('登录密码加密盐'), _t('请务必修改本参数,以防止跨站攻击。')); + $form->addInput($secretKey); + /* 高敏接口是否校验登录用户 */ $validateLogin = new Typecho_Widget_Helper_Form_Element_Radio('validateLogin', array( 0 => _t('否'), diff --git a/README.md b/README.md index 9cc88f1..7564622 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,31 @@ PS: mid是因为typecho分类跟标签是同一个表。 | type | string | 类型(category/tag) | 必须 | | slug | string | 别名 | 可选 | +### 登录账号 + +`POST /api/login` + +| 参数 | 类型 | 描述 | | +|-----------|--------|----------------|----| +| name | string | 用户名 | 必须 | +| password | string | 加密后的密码 | 必须 | +| timestamp | string | 当前 Unix 时间戳(秒) | 必须 | +| remember | bool | 是否记住登录 | 可选 | + +PS:为了防止重放攻击和中间人攻击,密码需要进行加密后进行传输。 +登录成功将返回typecho的cookie可以直接使用 + +密码使用使用 AES-128-CBC 模式加密, + +#### 密码加密方法 +1. 使用 HMAC-SHA256 算法根据timestamp生成动态密钥 +2. 使用 AES-128-CBC 模式加密密码 + 1. 将 动态密钥 转换为 16 字节长度 + 2. 生成 16 字节随机 IV + 3. 使用 AES-128-CBC 加密密码(key为16字节长度动态密钥,iv为随机IV) + 4. 将IV与加密后端密码组合为一个字符串 + 5. 将组合后的字符串进行base64编码 + ## 其它 ### 自定义 URI 前缀 diff --git a/composer.json b/composer.json index ea95b09..1a04273 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ }, "require": { "php": ">=5.3.0", - "ext-curl": "*" + "ext-curl": "*", + "ext-openssl": "*" }, "require-dev": { "catfan/medoo": "~1.5",