Files
xianyan/docs/toolsapi/application/api/controller/UserSecurity.php
Developer 00ff5f152a feat: 添加清除结果功能到检查提供者
refactor: 更新URL哈希处理逻辑

feat: 添加聊天消息存储支持

docs: 更新API控制器基类文档

chore: 删除无用脚本文件

fix: 修复分类模型返回类型问题

feat: 添加回执登录功能

build: 更新依赖项配置

style: 统一HTML模板中的哈希ID引用格式

ci: 添加部署和检查脚本
2026-04-30 10:19:56 +08:00

597 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}