refactor: 更新URL哈希处理逻辑 feat: 添加聊天消息存储支持 docs: 更新API控制器基类文档 chore: 删除无用脚本文件 fix: 修复分类模型返回类型问题 feat: 添加回执登录功能 build: 更新依赖项配置 style: 统一HTML模板中的哈希ID引用格式 ci: 添加部署和检查脚本
597 lines
20 KiB
PHP
597 lines
20 KiB
PHP
<?php
|
||
|
||
namespace app\api\controller;
|
||
|
||
use app\common\controller\Api;
|
||
use app\common\library\Receipt;
|
||
use app\common\library\Ems;
|
||
use app\common\library\Sms;
|
||
use fast\Random;
|
||
use think\Config;
|
||
use think\Validate;
|
||
|
||
/**
|
||
* 用户安全接口
|
||
* @time 2026-04-29
|
||
* @name UserSecurity
|
||
* @description 用户安全相关API,含注册/登录/改密/改邮箱/重置密码/回执登录等
|
||
* @lastUpdate v8.0.0 去除邮箱验证码依赖,引入HMAC-SHA256回执验证机制,新增receiptLogin
|
||
*/
|
||
class UserSecurity extends Api
|
||
{
|
||
protected $noNeedLogin = ['login', 'mobilelogin', 'register', 'resetpwd', 'tokenLogin', 'receiptLogin', 'sendEms', 'checkEms'];
|
||
protected $noNeedRight = '*';
|
||
|
||
private static $rateLimitKey = 'api_rate_limit:';
|
||
private static $rateLimits = [
|
||
'login' => ['max' => 50, 'window' => 300],
|
||
'register' => ['max' => 30, 'window' => 3600],
|
||
'resetpwd' => ['max' => 20, 'window' => 3600],
|
||
'changepwd' => ['max' => 30, 'window' => 3600],
|
||
'changeemail' => ['max' => 30, 'window' => 3600],
|
||
'changemobile' => ['max' => 30, 'window' => 3600],
|
||
'sendEms' => ['max' => 30, 'window' => 300],
|
||
'tokenLogin' => ['max' => 80, 'window' => 300],
|
||
'receiptLogin' => ['max' => 50, 'window' => 300],
|
||
];
|
||
|
||
private static $testMode = false;
|
||
|
||
public function _initialize()
|
||
{
|
||
parent::_initialize();
|
||
if (!Config::get('fastadmin.usercenter')) {
|
||
$this->error(__('User center already closed'));
|
||
}
|
||
self::$testMode = Config::get('test_mode') ?: false;
|
||
}
|
||
|
||
private function checkRateLimit($action)
|
||
{
|
||
if (!isset(self::$rateLimits[$action])) {
|
||
return true;
|
||
}
|
||
$config = self::$rateLimits[$action];
|
||
$key = self::$rateLimitKey . $action . ':' . $this->request->ip();
|
||
$cache = cache($key);
|
||
$count = $cache ? intval($cache) : 0;
|
||
if ($count >= $config['max']) {
|
||
$this->error('请求过于频繁,请稍后再试', null, ['retry_after' => $config['window']]);
|
||
}
|
||
cache($key, $count + 1, $config['window']);
|
||
return true;
|
||
}
|
||
|
||
private function sanitizeString($value, $maxLength = 255)
|
||
{
|
||
if (!is_string($value)) {
|
||
return '';
|
||
}
|
||
$value = trim($value);
|
||
$value = strip_tags($value);
|
||
$value = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||
if (mb_strlen($value) > $maxLength) {
|
||
$value = mb_substr($value, 0, $maxLength);
|
||
}
|
||
return $value;
|
||
}
|
||
|
||
private function validateLength($value, $field, $min = 1, $max = 255)
|
||
{
|
||
$len = mb_strlen($value);
|
||
if ($len < $min || $len > $max) {
|
||
$this->error("{$field}长度须在{$min}-{$max}之间");
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private function detectMaliciousInput($value)
|
||
{
|
||
$patterns = [
|
||
'/<script\b[^>]*>/i',
|
||
'/javascript\s*:/i',
|
||
'/on\w+\s*=/i',
|
||
'/union\s+select/i',
|
||
'/insert\s+into/i',
|
||
'/delete\s+from/i',
|
||
'/update\s+\w+\s+set/i',
|
||
'/drop\s+table/i',
|
||
'/\.\.[\/\\\\]/i',
|
||
'/eval\s*\(/i',
|
||
'/exec\s*\(/i',
|
||
'/system\s*\(/i',
|
||
];
|
||
foreach ($patterns as $pattern) {
|
||
if (preg_match($pattern, $value)) {
|
||
$this->error('输入内容包含非法字符');
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
private function isTestUser($userId)
|
||
{
|
||
if (!self::$testMode) {
|
||
return false;
|
||
}
|
||
$user = db('user')->where('id', $userId)->find();
|
||
return $user && isset($user['is_test']) && $user['is_test'] == 1;
|
||
}
|
||
|
||
private function verifyReceipt($action, $payloadStr)
|
||
{
|
||
$receipt = $this->request->post('receipt', '', 'trim');
|
||
$sig = $this->request->post('sig', '', 'trim');
|
||
$result = Receipt::verify($receipt, $sig, $action, $payloadStr);
|
||
if (!$result['valid']) {
|
||
$this->error($result['msg']);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* @name 账号密码登录
|
||
* @desc 支持用户名/邮箱+密码登录,无需回执
|
||
*/
|
||
public function login()
|
||
{
|
||
$this->checkRateLimit('login');
|
||
$account = $this->request->post('account', '', 'trim');
|
||
$password = $this->request->post('password', '', 'trim');
|
||
if (!$account || !$password) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
$this->validateLength($account, '账号', 2, 50);
|
||
$this->validateLength($password, '密码', 6, 30);
|
||
$this->detectMaliciousInput($account);
|
||
$ret = $this->auth->login($account, $password);
|
||
if ($ret) {
|
||
$data = ['userinfo' => $this->auth->getUserinfo()];
|
||
$this->success(__('Logged in successful'), $data);
|
||
} else {
|
||
$this->error($this->auth->getError());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 回执登录
|
||
* @desc 客户端验证邮箱/手机后,用回执替代密码登录
|
||
* @since v8.0.0
|
||
*/
|
||
public function receiptLogin()
|
||
{
|
||
$this->checkRateLimit('receiptLogin');
|
||
$account = $this->request->post('account', '', 'trim');
|
||
$receipt = $this->request->post('receipt', '', 'trim');
|
||
$sig = $this->request->post('sig', '', 'trim');
|
||
|
||
if (!$account || !$receipt || !$sig) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
$this->validateLength($account, '账号', 2, 100);
|
||
$this->detectMaliciousInput($account);
|
||
|
||
$result = Receipt::verifyFlexible($receipt, $sig, 'receipt_login', $account);
|
||
if (!$result['valid']) {
|
||
$this->error($result['msg']);
|
||
}
|
||
|
||
$user = null;
|
||
if (Validate::is($account, "email")) {
|
||
$user = \app\common\model\User::getByEmail($account);
|
||
} elseif (Validate::regex($account, "^1\d{10}$")) {
|
||
$user = \app\common\model\User::getByMobile($account);
|
||
} else {
|
||
$user = \app\common\model\User::getByUsername($account);
|
||
}
|
||
|
||
if (!$user) {
|
||
$this->error(__('User not found'));
|
||
}
|
||
if ($user->status != 'normal') {
|
||
$this->error(__('Account is locked'));
|
||
}
|
||
|
||
$ret = $this->auth->direct($user->id);
|
||
if ($ret) {
|
||
$data = ['userinfo' => $this->auth->getUserinfo()];
|
||
$this->success(__('Logged in successful'), $data);
|
||
} else {
|
||
$this->error($this->auth->getError());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 手机号登录
|
||
* @desc 手机号+短信验证码登录,未注册自动注册
|
||
*/
|
||
public function mobilelogin()
|
||
{
|
||
$this->checkRateLimit('login');
|
||
$mobile = $this->request->post('mobile', '', 'trim');
|
||
$captcha = $this->request->post('captcha', '', 'trim');
|
||
if (!$mobile || !$captcha) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
if (!Validate::regex($mobile, "^1\d{10}$")) {
|
||
$this->error(__('Mobile is incorrect'));
|
||
}
|
||
$this->validateLength($captcha, '验证码', 4, 6);
|
||
if (self::$testMode && $captcha === '888888') {
|
||
$verified = true;
|
||
} else {
|
||
$verified = false;
|
||
try {
|
||
$verified = Sms::check($mobile, $captcha, 'mobilelogin');
|
||
} catch (\Exception $e) {
|
||
$this->error('短信验证服务暂不可用,请使用邮箱登录');
|
||
}
|
||
}
|
||
if (!$verified) {
|
||
$this->error(__('Captcha is incorrect'));
|
||
}
|
||
$user = \app\common\model\User::getByMobile($mobile);
|
||
if ($user) {
|
||
if ($user->status != 'normal') {
|
||
$this->error(__('Account is locked'));
|
||
}
|
||
$ret = $this->auth->direct($user->id);
|
||
} else {
|
||
$ret = $this->auth->register($mobile, Random::alnum(), '', $mobile, []);
|
||
}
|
||
if ($ret) {
|
||
try { Sms::flush($mobile, 'mobilelogin'); } catch (\Exception $e) {}
|
||
$data = ['userinfo' => $this->auth->getUserinfo()];
|
||
$this->success(__('Logged in successful'), $data);
|
||
} else {
|
||
$this->error($this->auth->getError());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name Token令牌登录
|
||
* @desc 使用已有Token直接登录
|
||
*/
|
||
public function tokenLogin()
|
||
{
|
||
$this->checkRateLimit('tokenLogin');
|
||
$token = $this->request->post('token', '', 'trim');
|
||
if (!$token) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
$this->validateLength($token, 'Token', 10, 128);
|
||
$tokenInfo = \app\common\library\Token::get($token);
|
||
if (!$tokenInfo) {
|
||
$this->error('Token无效或已过期');
|
||
}
|
||
$ret = $this->auth->direct($tokenInfo['user_id']);
|
||
if ($ret) {
|
||
$data = ['userinfo' => $this->auth->getUserinfo()];
|
||
$this->success(__('Logged in successful'), $data);
|
||
} else {
|
||
$this->error($this->auth->getError());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 用户注册
|
||
* @desc 注册新用户,需回执验证(客户端已验证邮箱),不再需要邮箱验证码
|
||
*/
|
||
public function register()
|
||
{
|
||
$this->checkRateLimit('register');
|
||
$username = $this->request->post('username', '', 'trim');
|
||
$password = $this->request->post('password', '', 'trim');
|
||
$email = $this->request->post('email', '', 'trim');
|
||
$mobile = $this->request->post('mobile', '', 'trim');
|
||
$mobileCode = $this->request->post('mobile_code', '', 'trim');
|
||
|
||
if (!$username || !$password || !$email) {
|
||
$this->error('用户名、密码和邮箱为必填项');
|
||
}
|
||
$this->validateLength($username, '用户名', 3, 30);
|
||
$this->validateLength($password, '密码', 6, 30);
|
||
$this->detectMaliciousInput($username);
|
||
if (!preg_match('/^[a-zA-Z0-9_\x{4e00}-\x{9fa5}]+$/u', $username)) {
|
||
$this->error('用户名只能包含字母、数字、下划线和中文');
|
||
}
|
||
if (!Validate::is($email, "email")) {
|
||
$this->error(__('Email is incorrect'));
|
||
}
|
||
$this->validateLength($email, '邮箱', 5, 100);
|
||
|
||
if (\app\common\model\User::getByEmail($email)) {
|
||
$this->error('该邮箱已被注册');
|
||
}
|
||
|
||
$this->verifyReceipt('register', $email);
|
||
|
||
if ($mobile) {
|
||
if (!Validate::regex($mobile, "^1\d{10}$")) {
|
||
$this->error(__('Mobile is incorrect'));
|
||
}
|
||
if ($mobileCode) {
|
||
if (!Sms::check($mobile, $mobileCode, 'register')) {
|
||
$this->error(__('Captcha is incorrect'));
|
||
}
|
||
}
|
||
}
|
||
|
||
$ret = $this->auth->register($username, $password, $email, $mobile, []);
|
||
if ($ret) {
|
||
$userId = $this->auth->id;
|
||
$verification = db('user')->where('id', $userId)->value('verification');
|
||
$verification = $verification ? json_decode($verification, true) : [];
|
||
$verification['email'] = 1;
|
||
db('user')->where('id', $userId)->update(['verification' => json_encode($verification)]);
|
||
$data = ['userinfo' => $this->auth->getUserinfo()];
|
||
$this->success(__('Sign up successful'), $data);
|
||
} else {
|
||
$this->error($this->auth->getError());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 退出登录
|
||
* @desc 清除Token,退出登录状态
|
||
*/
|
||
public function logout()
|
||
{
|
||
if (!$this->request->isPost()) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
$token = $this->request->header('token', '');
|
||
if ($token) {
|
||
\app\common\library\Token::delete($token);
|
||
}
|
||
$userId = $this->auth->id;
|
||
if ($userId) {
|
||
\app\common\library\Token::clear($userId);
|
||
}
|
||
$this->success(__('Logout successful'));
|
||
}
|
||
|
||
/**
|
||
* @name 修改密码
|
||
* @desc 修改密码需验证旧密码+回执验证(客户端已验证邮箱)
|
||
*/
|
||
public function changepwd()
|
||
{
|
||
$this->checkRateLimit('changepwd');
|
||
$user = $this->auth->getUser();
|
||
$oldpassword = $this->request->post('oldpassword', '', 'trim');
|
||
$newpassword = $this->request->post('newpassword', '', 'trim');
|
||
|
||
if (!$oldpassword || !$newpassword) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
$this->validateLength($oldpassword, '旧密码', 6, 30);
|
||
$this->validateLength($newpassword, '新密码', 6, 30);
|
||
|
||
if (!$this->isTestUser($user->id)) {
|
||
$email = $user->email;
|
||
if (!$email) {
|
||
$this->error('请先绑定邮箱后再修改密码');
|
||
}
|
||
$this->verifyReceipt('changepwd', $user->username);
|
||
}
|
||
|
||
$ret = $this->auth->changepwd($newpassword, $oldpassword);
|
||
if ($ret) {
|
||
$this->success(__('Change password successful'));
|
||
} else {
|
||
$this->error($this->auth->getError());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 重置密码
|
||
* @desc 通过回执验证重置密码(无需登录),客户端已验证邮箱/手机
|
||
*/
|
||
public function resetpwd()
|
||
{
|
||
$this->checkRateLimit('resetpwd');
|
||
$type = $this->request->post("type", "email", 'trim');
|
||
$mobile = $this->request->post("mobile", '', 'trim');
|
||
$email = $this->request->post("email", '', 'trim');
|
||
$newpassword = $this->request->post("newpassword", '', 'trim');
|
||
|
||
if (!$newpassword) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
$this->validateLength($newpassword, '新密码', 6, 30);
|
||
|
||
if ($type == 'mobile') {
|
||
if (!Validate::regex($mobile, "^1\d{10}$")) {
|
||
$this->error(__('Mobile is incorrect'));
|
||
}
|
||
$user = \app\common\model\User::getByMobile($mobile);
|
||
if (!$user) {
|
||
$this->error(__('User not found'));
|
||
}
|
||
$this->verifyReceipt('resetpwd', $mobile);
|
||
} else {
|
||
if (!Validate::is($email, "email")) {
|
||
$this->error(__('Email is incorrect'));
|
||
}
|
||
$user = \app\common\model\User::getByEmail($email);
|
||
if (!$user) {
|
||
$this->error(__('User not found'));
|
||
}
|
||
$this->verifyReceipt('resetpwd', $email);
|
||
}
|
||
|
||
$this->auth->direct($user->id);
|
||
$ret = $this->auth->changepwd($newpassword, '', true);
|
||
if ($ret) {
|
||
$this->success(__('Reset password successful'));
|
||
} else {
|
||
$this->error($this->auth->getError());
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 修改邮箱
|
||
* @desc 修改邮箱需回执验证(客户端已验证新邮箱)
|
||
*/
|
||
public function changeemail()
|
||
{
|
||
$this->checkRateLimit('changeemail');
|
||
$user = $this->auth->getUser();
|
||
$email = $this->request->post('email', '', 'trim');
|
||
|
||
if (!$email) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
if (!Validate::is($email, "email")) {
|
||
$this->error(__('Email is incorrect'));
|
||
}
|
||
$this->validateLength($email, '邮箱', 5, 100);
|
||
|
||
if (\app\common\model\User::where('email', $email)->where('id', '<>', $user->id)->find()) {
|
||
$this->error(__('Email already exists'));
|
||
}
|
||
|
||
$this->verifyReceipt('changeemail', $email);
|
||
|
||
$verification = $user->verification;
|
||
$verification->email = 1;
|
||
$user->verification = $verification;
|
||
$user->email = $email;
|
||
$user->save();
|
||
$this->success();
|
||
}
|
||
|
||
/**
|
||
* @name 修改手机号
|
||
* @desc 修改手机号需回执验证(客户端已验证新手机号)
|
||
*/
|
||
public function changemobile()
|
||
{
|
||
$this->checkRateLimit('changemobile');
|
||
$user = $this->auth->getUser();
|
||
$mobile = $this->request->post('mobile', '', 'trim');
|
||
|
||
if (!$mobile) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
if (!Validate::regex($mobile, "^1\d{10}$")) {
|
||
$this->error(__('Mobile is incorrect'));
|
||
}
|
||
|
||
if (\app\common\model\User::where('mobile', $mobile)->where('id', '<>', $user->id)->find()) {
|
||
$this->error(__('Mobile already exists'));
|
||
}
|
||
|
||
$this->verifyReceipt('changemobile', $mobile);
|
||
|
||
$verification = $user->verification;
|
||
$verification->mobile = 1;
|
||
$user->verification = $verification;
|
||
$user->mobile = $mobile;
|
||
$user->save();
|
||
$this->success();
|
||
}
|
||
|
||
/**
|
||
* @name 发送邮箱验证码(保留兼容)
|
||
* @desc 保留接口兼容旧客户端,新客户端建议使用回执验证
|
||
*/
|
||
public function sendEms()
|
||
{
|
||
$this->checkRateLimit('sendEms');
|
||
$email = $this->request->post('email', '', 'trim');
|
||
$event = $this->request->post('event', 'register', 'trim');
|
||
|
||
if (!$email) {
|
||
$this->error('邮箱不能为空');
|
||
}
|
||
if (!Validate::is($email, "email")) {
|
||
$this->error(__('Email is incorrect'));
|
||
}
|
||
|
||
$allowedEvents = ['register', 'changepwd', 'resetpwd', 'changeemail'];
|
||
if (!in_array($event, $allowedEvents)) {
|
||
$this->error('无效的事件类型');
|
||
}
|
||
|
||
$last = Ems::get($email, $event);
|
||
if ($last && time() - $last['createtime'] < 60) {
|
||
$this->error(__('发送频繁'));
|
||
}
|
||
|
||
$userinfo = \app\common\model\User::getByEmail($email);
|
||
if ($event == 'register' && $userinfo) {
|
||
$this->error(__('已被注册'));
|
||
} elseif ($event == 'changeemail' && $userinfo) {
|
||
$this->error(__('已被占用'));
|
||
} elseif (in_array($event, ['changepwd', 'resetpwd']) && !$userinfo) {
|
||
$this->error(__('未注册'));
|
||
}
|
||
|
||
$ret = Ems::send($email, null, $event);
|
||
if ($ret) {
|
||
$this->success(__('发送成功'));
|
||
} else {
|
||
$this->error(__('发送失败'));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 校验邮箱验证码(保留兼容)
|
||
* @desc 保留接口兼容旧客户端
|
||
*/
|
||
public function checkEms()
|
||
{
|
||
$email = $this->request->post('email', '', 'trim');
|
||
$captcha = $this->request->post('captcha', '', 'trim');
|
||
$event = $this->request->post('event', 'register', 'trim');
|
||
|
||
if (!$email || !$captcha) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
|
||
if (self::$testMode && $captcha === '888888') {
|
||
$this->success(__('验证码正确'));
|
||
}
|
||
|
||
$ret = Ems::check($email, $captcha, $event);
|
||
if ($ret) {
|
||
$this->success(__('验证码正确'));
|
||
} else {
|
||
$this->error(__('验证码不正确'));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @name 第三方登录
|
||
* @desc 第三方平台登录
|
||
*/
|
||
public function third()
|
||
{
|
||
$this->checkRateLimit('login');
|
||
$url = url('user/index');
|
||
$platform = $this->request->post("platform", '', 'trim');
|
||
$code = $this->request->post("code", '', 'trim');
|
||
$this->detectMaliciousInput($platform);
|
||
$this->detectMaliciousInput($code);
|
||
$config = get_addon_config('third');
|
||
if (!$config || !isset($config[$platform])) {
|
||
$this->error(__('Invalid parameters'));
|
||
}
|
||
$app = new \addons\third\library\Application($config);
|
||
$result = $app->{$platform}->getUserInfo(['code' => $code]);
|
||
if ($result) {
|
||
$loginret = \addons\third\library\Service::connect($platform, $result);
|
||
if ($loginret) {
|
||
$data = [
|
||
'userinfo' => $this->auth->getUserinfo(),
|
||
'thirdinfo' => $result
|
||
];
|
||
$this->success(__('Logged in successful'), $data);
|
||
}
|
||
}
|
||
$this->error(__('Operation failed'), $url);
|
||
}
|
||
}
|