diff --git a/Action.php b/Action.php index 8ccd5e2..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; @@ -832,6 +832,8 @@ public function userListAction() /** * 发表文章 + * + * @return void */ public function postArticleAction() { @@ -919,6 +921,8 @@ public function postArticleAction() /** * 新增标签/分类 + * + * @return void */ public function addMetasAction() { @@ -944,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)); + } + + /** * 插件更新接口 * @@ -1155,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",