Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 142 additions & 1 deletion Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -832,6 +832,8 @@ public function userListAction()

/**
* 发表文章
*
* @return void
*/
public function postArticleAction()
{
Expand Down Expand Up @@ -919,6 +921,8 @@ public function postArticleAction()

/**
* 新增标签/分类
*
* @return void
*/
public function addMetasAction()
{
Expand All @@ -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));
}


/**
* 插件更新接口
*
Expand Down Expand Up @@ -1155,4 +1294,6 @@ private function refreshMetas(array $midArray)
->where('mid = ?', $tag['mid']));
}
}


}
28 changes: 28 additions & 0 deletions Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 '_(:з」∠)_';
}

Expand Down Expand Up @@ -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('否'),
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 前缀
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
},
"require": {
"php": ">=5.3.0",
"ext-curl": "*"
"ext-curl": "*",
"ext-openssl": "*"
},
"require-dev": {
"catfan/medoo": "~1.5",
Expand Down