Files
xianyan/docs/toolsapi/application/api/controller/UserSecurity.php
Developer 72f64f9ca9 feat: 新增文件传输助手功能及相关组件
新增文件传输助手功能,包含设备发现、配对、传输等核心模块。主要变更包括:
1. 新增局域网、蓝牙、NFC等多种设备发现方式
2. 实现基于WebRTC、TCP、USB等多种传输协议
3. 添加相关权限管理及状态监控
4. 完善UI界面及交互流程
5. 更新依赖库及版本号至4.19.0

同时优化部分现有功能:
1. 聊天会话增加隐藏功能
2. 完善本地通知权限处理
3. 修复部分已知问题
2026-05-10 02:48:52 +08:00

825 lines
28 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 v9.0.0 新增qrcodeLogin二维码登录; login/receiptLogin记录设备信息和在线状态
*/
class UserSecurity extends Api
{
protected $noNeedLogin = ['login', 'mobilelogin', 'register', 'resetpwd', 'tokenLogin', 'receiptLogin', 'sendEms', 'checkEms', 'qrcodeGenerate', 'qrcodePoll', 'qrcodeCancel'];
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 支持用户名/邮箱+密码登录,无需回执,登录后记录设备信息
* @lastUpdate v9.0.0 登录后记录设备信息和在线状态
*/
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) {
$this->recordLoginDevice($this->auth->id);
$this->updateOnlineStatus($this->auth->id);
$data = ['userinfo' => $this->auth->getUserinfo()];
$this->success(__('Logged in successful'), $data);
} else {
$this->error($this->auth->getError());
}
}
/**
* @name 回执登录
* @desc 客户端验证邮箱/手机后,用回执替代密码登录
* @lastUpdate v9.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) {
$this->recordLoginDevice($user->id);
$this->updateOnlineStatus($user->id);
$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);
}
/**
* @name 生成二维码
* @desc 生成二维码登录标识返回code用于轮询
* @lastUpdate v9.0.0 新增
*/
public function qrcodeGenerate()
{
$code = md5(uniqid('qr', true) . mt_rand() . time());
$expireTime = time() + 300;
db('qrcode_login')->insert([
'code' => $code,
'status' => 'pending',
'user_id' => 0,
'token' => '',
'ip' => $this->request->ip(),
'expire_time' => $expireTime,
'createtime' => time(),
'updatetime' => time(),
]);
$this->success('二维码已生成', [
'code' => $code,
'expire_time' => $expireTime,
'expire_seconds' => 300,
'qrcode_url' => $this->request->domain() . '/qrcode?code=' . $code,
]);
}
/**
* @name 扫码确认
* @desc 已登录用户扫码后确认登录,需登录
* @lastUpdate v9.0.0 新增
*/
public function qrcodeConfirm()
{
$code = $this->request->post('code', '', 'trim');
if (!$code) {
$this->error('code必填');
}
$qr = db('qrcode_login')->where('code', $code)->find();
if (!$qr) {
$this->error('二维码不存在');
}
if ($qr['status'] !== 'pending' && $qr['status'] !== 'scanned') {
$this->error('二维码状态无效');
}
if ($qr['expire_time'] < time()) {
db('qrcode_login')->where('code', $code)->update(['status' => 'expired', 'updatetime' => time()]);
$this->error('二维码已过期');
}
$userId = $this->auth->id;
$deviceInfo = json_encode([
'ip' => $this->request->ip(),
'platform' => $this->request->post('platform', '', 'trim'),
'device_name' => $this->request->post('device_name', '', 'trim'),
'app_name' => $this->request->post('app_name', '', 'trim'),
]);
db('qrcode_login')->where('code', $code)->update([
'status' => 'confirmed',
'user_id' => $userId,
'device_info' => $deviceInfo,
'updatetime' => time(),
]);
$this->success('确认登录成功');
}
/**
* @name 轮询二维码状态
* @desc 客户端轮询二维码状态confirmed时返回Token
* @lastUpdate v9.0.0 新增
*/
public function qrcodePoll()
{
$code = $this->request->param('code', '', 'trim');
if (!$code) {
$this->error('code必填');
}
$qr = db('qrcode_login')->where('code', $code)->find();
if (!$qr) {
$this->error('二维码不存在');
}
if ($qr['expire_time'] < time() && $qr['status'] !== 'confirmed') {
db('qrcode_login')->where('code', $code)->update(['status' => 'expired', 'updatetime' => time()]);
$this->success('', ['status' => 'expired', 'message' => '二维码已过期']);
}
if ($qr['status'] === 'pending') {
$this->success('', ['status' => 'pending', 'message' => '等待扫码']);
}
if ($qr['status'] === 'scanned') {
$this->success('', ['status' => 'scanned', 'message' => '已扫码,等待确认']);
}
if ($qr['status'] === 'expired') {
$this->success('', ['status' => 'expired', 'message' => '二维码已过期']);
}
if ($qr['status'] === 'cancelled') {
$this->success('', ['status' => 'cancelled', 'message' => '已取消']);
}
if ($qr['status'] === 'confirmed') {
$user = \app\common\model\User::get($qr['user_id']);
if (!$user || $user->status != 'normal') {
$this->error('用户异常');
}
$ret = $this->auth->direct($user->id);
if ($ret) {
$token = $this->auth->getToken();
db('qrcode_login')->where('code', $code)->update([
'token' => $token,
'updatetime' => time(),
]);
$this->recordLoginDevice($user->id);
$this->updateOnlineStatus($user->id);
$this->success('登录成功', [
'status' => 'confirmed',
'token' => $token,
'userinfo' => $this->auth->getUserinfo(),
]);
} else {
$this->error('登录失败');
}
}
$this->error('未知状态');
}
/**
* @name 取消二维码
* @desc 取消二维码登录
* @lastUpdate v9.0.0 新增
*/
public function qrcodeCancel()
{
$code = $this->request->post('code', '', 'trim');
if (!$code) {
$this->error('code必填');
}
$qr = db('qrcode_login')->where('code', $code)->find();
if (!$qr) {
$this->error('二维码不存在');
}
db('qrcode_login')->where('code', $code)->update(['status' => 'cancelled', 'updatetime' => time()]);
$this->success('已取消');
}
/**
* @name 记录登录设备
* @desc 登录成功后记录设备信息到user_device表
* @lastUpdate v9.0.0 新增
*/
private function recordLoginDevice($userId)
{
$deviceName = $this->request->post('device_name', '', 'trim');
$deviceModel = $this->request->post('device_model', '', 'trim');
$platform = $this->request->post('platform', '', 'trim');
$appName = $this->request->post('app_name', '', 'trim');
$deviceId = $this->request->post('device_id', '', 'trim');
$userAgent = $this->request->header('user-agent', '');
$ip = $this->request->ip();
$now = time();
if ($deviceId) {
$exists = db('user_device')->where('user_id', $userId)->where('device_id', $deviceId)->find();
if ($exists) {
db('user_device')->where('id', $exists['id'])->update([
'device_name' => $deviceName ?: $exists['device_name'],
'device_model' => $deviceModel ?: $exists['device_model'],
'platform' => $platform ?: $exists['platform'],
'app_name' => $appName ?: $exists['app_name'],
'ip' => $ip,
'last_active_time' => $now,
'is_online' => 1,
'user_agent' => substr($userAgent, 0, 500),
'updatetime' => $now,
]);
return;
}
}
db('user_device')->insert([
'user_id' => $userId,
'device_name' => $deviceName,
'device_model' => $deviceModel,
'platform' => $platform,
'app_name' => $appName,
'ip' => $ip,
'device_id' => $deviceId ?: md5($userId . $ip . $userAgent . $now),
'last_active_time' => $now,
'is_online' => 1,
'user_agent' => substr($userAgent, 0, 500),
'createtime' => $now,
'updatetime' => $now,
]);
}
/**
* @name 更新在线状态
* @desc 更新用户最后活跃时间和在线状态
* @lastUpdate v9.0.0 新增
*/
private function updateOnlineStatus($userId)
{
db('user')->where('id', $userId)->update([
'last_active_time' => time(),
'is_online' => 1,
]);
}
}