diff --git a/README.md b/README.md index db865ae..5ed0714 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,18 @@ TwoNav 是一款开源免费的书签(导航)管理程序,界面简洁, * 支持加密链接 * 支持分享链接 * 支持二级分类 -* 支持用户分组 +* 支持用户分组/权限管理 * 支持Chrome/Firefox/Edge书签批量导入 -* 支持多种主题风格 +* 支持批量更新链接图标/标题/描述等信息 * 支持链接信息自动识别 * 支持API * 支持Docker部署 * 支持uTools插件 * 支持Chromium内核的[浏览器扩展] +* 支持简易文章管理 +* 支持更换各种模板/支持混搭,20+个主题模板 +* 安全性支持:更换登录入口/二级密码/OTP双重验证 +   diff --git a/system/Authenticator.php b/system/Authenticator.php new file mode 100644 index 0000000..4c29cb0 --- /dev/null +++ b/system/Authenticator.php @@ -0,0 +1,161 @@ +_getBase32LookupTable(); + if ($secretLength < 16 || $secretLength > 128) { + throw new Exception('Bad secret length'); + } + $secret = ''; + $rnd = false; + if (function_exists('random_bytes')) { + $rnd = random_bytes($secretLength); + } elseif (function_exists('mcrypt_create_iv')) { + $rnd = mcrypt_create_iv($secretLength, MCRYPT_DEV_URANDOM); + } elseif (function_exists('openssl_random_pseudo_bytes')) { + $rnd = openssl_random_pseudo_bytes($secretLength, $cryptoStrong); + if (!$cryptoStrong) { + $rnd = false; + } + } + if ($rnd !== false) { + for ($i = 0; $i < $secretLength; ++$i) { + $secret .= $validChars[ord($rnd[$i]) & 31]; + } + } else { + throw new Exception('No source of secure random'); + } + + return $secret; + } + public function getCode($secret, $timeSlice = null) + { + if ($timeSlice === null) { + $timeSlice = floor(time() / 30); + } + + $secretkey = $this->_base32Decode($secret); + $time = chr(0).chr(0).chr(0).chr(0).pack('N*', $timeSlice); + $hm = hash_hmac('SHA1', $time, $secretkey, true); + $offset = ord(substr($hm, -1)) & 0x0F; + $hashpart = substr($hm, $offset, 4); + $value = unpack('N', $hashpart); + $value = $value[1]; + $value = $value & 0x7FFFFFFF; + $modulo = pow(10, $this->_codeLength); + return str_pad($value % $modulo, $this->_codeLength, '0', STR_PAD_LEFT); + } + public function getQRCodeGoogleUrl($name, $secret, $title = null, $params = array()) + { + $width = !empty($params['width']) && (int) $params['width'] > 0 ? (int) $params['width'] : 200; + $height = !empty($params['height']) && (int) $params['height'] > 0 ? (int) $params['height'] : 200; + $level = !empty($params['level']) && array_search($params['level'], array('L', 'M', 'Q', 'H')) !== false ? $params['level'] : 'M'; + + $urlencoded = urlencode('otpauth://totp/'.$name.'?secret='.$secret.''); + if (isset($title)) { + $urlencoded .= urlencode('&issuer='.urlencode($title)); + } + + return "https://api.qrserver.com/v1/create-qr-code/?data=$urlencoded&size=${width}x${height}&ecc=$level"; + } + + public function verifyCode($secret, $code, $discrepancy = 1, $currentTimeSlice = null) + { + if ($currentTimeSlice === null) { + $currentTimeSlice = floor(time() / 30); + } + + if (strlen($code) != 6) { + return false; + } + + for ($i = -$discrepancy; $i <= $discrepancy; ++$i) { + $calculatedCode = $this->getCode($secret, $currentTimeSlice + $i); + if ($this->timingSafeEquals($calculatedCode, $code)) { + return true; + } + } + + return false; + } + + public function setCodeLength($length) + { + $this->_codeLength = $length; + + return $this; + } + + protected function _base32Decode($secret) + { + if (empty($secret)) { + return ''; + } + + $base32chars = $this->_getBase32LookupTable(); + $base32charsFlipped = array_flip($base32chars); + + $paddingCharCount = substr_count($secret, $base32chars[32]); + $allowedValues = array(6, 4, 3, 1, 0); + if (!in_array($paddingCharCount, $allowedValues)) { + return false; + } + for ($i = 0; $i < 4; ++$i) { + if ($paddingCharCount == $allowedValues[$i] && + substr($secret, -($allowedValues[$i])) != str_repeat($base32chars[32], $allowedValues[$i])) { + return false; + } + } + $secret = str_replace('=', '', $secret); + $secret = str_split($secret); + $binaryString = ''; + for ($i = 0; $i < count($secret); $i = $i + 8) { + $x = ''; + if (!in_array($secret[$i], $base32chars)) { + return false; + } + for ($j = 0; $j < 8; ++$j) { + $x .= str_pad(base_convert(@$base32charsFlipped[@$secret[$i + $j]], 10, 2), 5, '0', STR_PAD_LEFT); + } + $eightBits = str_split($x, 8); + for ($z = 0; $z < count($eightBits); ++$z) { + $binaryString .= (($y = chr(base_convert($eightBits[$z], 2, 10))) || ord($y) == 48) ? $y : ''; + } + } + + return $binaryString; + } + + protected function _getBase32LookupTable() + { + return array( + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', + 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', + 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', '2', '3', '4', '5', '6', '7', + '=', + ); + } + + private function timingSafeEquals($safeString, $userString) + { + if (function_exists('hash_equals')) { + return hash_equals($safeString, $userString); + } + $safeLen = strlen($safeString); + $userLen = strlen($userString); + + if ($userLen != $safeLen) { + return false; + } + + $result = 0; + + for ($i = 0; $i < $userLen; ++$i) { + $result |= (ord($safeString[$i]) ^ ord($userString[$i])); + } + return $result === 0; + } +} diff --git a/system/UseFew/Import_data.php b/system/UseFew/Import_data.php index 0eddf2c..40d0a5f 100644 --- a/system/UseFew/Import_data.php +++ b/system/UseFew/Import_data.php @@ -392,7 +392,7 @@ if($_GET['type'] == 'upload'){ } //数据库清除 if(!empty($_POST['TABLE'])){ - $TABLE = ["user_categorys","user_links","user_pwd_group","user_share","user_apply"]; + $TABLE = ["user_categorys","user_links","user_pwd_group","user_share","user_apply","user_article_list"]; foreach($_POST['TABLE'] as $key =>$value){ if(in_array($key,$TABLE)){ delete_db($key,['uid'=>UID]); @@ -418,7 +418,7 @@ if($_GET['type'] == 'upload'){ //文件删除 if(!empty($_POST['FILE'])){ - $FILE = ["MessageBoard","favicon"]; + $FILE = ["MessageBoard","favicon","upload"]; foreach($_POST['FILE'] as $key =>$value){ $path = DIR.'/data/user/'.U.'/'.$key; if(in_array($key,$FILE) && is_dir($path)){ diff --git a/system/UseFew/local_backup.php b/system/UseFew/local_backup.php index cc525e5..faef69d 100644 --- a/system/UseFew/local_backup.php +++ b/system/UseFew/local_backup.php @@ -47,7 +47,7 @@ if(!defined('DIR')){ $info['file_db'] = $info['backup_dir'] .'/'. $info['file'].'.db3'; $info['file_info'] = $info['backup_dir'] .'/'. $info['file'].'.info'; $info['file_gz'] = $info['backup_dir'] .'/'. $info['file'].'.tar'; - $info['table_arr'] = ['user_config','user_categorys','user_links','user_pwd_group','user_apply','user_share']; + $info['table_arr'] = ['user_config','user_categorys','user_links','user_pwd_group','user_apply','user_share','user_article_list']; $info['lock'] = DIR.'/data/user/'.U.'/lock.'.UID; if (!extension_loaded('phar')) { msg(-1,'不支持phar扩展'); @@ -167,7 +167,7 @@ if(!defined('DIR')){ } //遍历删除用户数据 - $info['table_arr'] = ['user_config','user_categorys','user_links','user_pwd_group','user_apply','user_share']; + $info['table_arr'] = ['user_config','user_categorys','user_links','user_pwd_group','user_apply','user_share','user_article_list']; foreach($info['table_arr'] as $table_name){ //删除数据 @@ -186,8 +186,15 @@ if(!defined('DIR')){ $where['name'] = $table_name; $where['LIMIT'] = [($page - 1) * $limit,$limit]; $datas = $MyDB->select('backup','data',$where); - foreach($datas as $data){ + foreach($datas as $key => $data){ $data = unserialize($data); + //处理null + foreach ($data as $key => $value) { + if ($value === null) { + $data[$key] = ''; + } + } + if(isset($data['id'])){ unset($data['id']); } diff --git a/system/api.php b/system/api.php index 70d65d9..1326b9a 100644 --- a/system/api.php +++ b/system/api.php @@ -951,7 +951,7 @@ function write_security_setting(){ 'login_page'=>['v'=>['admin','index','auto'],'msg'=>'登录成功参数错误'], 'Password2'=>['empty'=>true] ]; - + $LoginConfig = unserialize($USER_DB['LoginConfig']); foreach ($datas as $key => $data){ if($data['int']){ $LoginConfig[$key] = ($_POST[$key] >= $data['min'] && $_POST[$key] <= $data['max'])?intval($_POST[$key]):msg(-1,$data['msg']); @@ -1211,6 +1211,65 @@ function write_user_password(){ msg(1,'修改成功'); } + +//读双重验证 +function read_totp(){ + global $USER_DB; + if($USER_DB['Password'] !== Get_MD5_Password($_POST['Password'],$USER_DB['RegTime'])){ + msg(-1102,'密码错误,请核对后再试!'); + } + $LoginConfig = unserialize($USER_DB['LoginConfig']); + if(empty($LoginConfig['totp_key'])){ + require DIR . '/system/Authenticator.php'; + $totp = new PHPGangsta_GoogleAuthenticator(); + $key = $totp->createSecret(); + msgA(['code'=>2,'msg'=>'未开启双重验证','key'=> $key ]); + } + msgA(['code'=>1,'msg'=>'已开启双重验证']); +} + +//写双重验证 +function write_totp(){ + global $USER_DB; + if($USER_DB['Password'] !== Get_MD5_Password($_POST['Password'],$USER_DB['RegTime'])){ + msg(-1102,'密码错误,请核对后再试!'); + } + + if($_GET['type'] === 'delete'){ //删除双重验证 + $LoginConfig = unserialize($USER_DB['LoginConfig']); + if(empty($LoginConfig['totp_key'])){ + msgA(['code'=>-1,'msg'=>'未开启双重验证',]); + } + $LoginConfig['totp_key'] = ''; + update_db("global_user", ["LoginConfig"=>$LoginConfig],["ID"=>UID],[1,'操作成功']); + }elseif($_GET['type'] === 'set'){ //设置双重验证 + //必填项验证 + if(empty($_POST['key'])){ + msgA(['code'=>-1,'msg'=>'Key不能为空']); + }elseif(empty($_POST['code'])){ + msgA(['code'=>-1,'msg'=>'验证码不能为空']); + } + $LoginConfig = unserialize($USER_DB['LoginConfig']); + if(!empty($LoginConfig['totp_key'])){ + msgA(['code'=>-1,'msg'=>'已开启双重验证,无法继续开启!']); + } + //载入totp库 + require DIR . '/system/Authenticator.php'; + $totp = new PHPGangsta_GoogleAuthenticator(); + $checkResult = $totp->verifyCode($_POST['key'], $_POST['code'], 2); + if(!$checkResult){ + msgA(['code'=>-1,'msg'=>'验证失败,请重试']); + } + //写入数据库 + $LoginConfig = unserialize($USER_DB['LoginConfig']); + $LoginConfig['totp_key'] = $_POST['key']; + update_db("global_user", ["LoginConfig"=>$LoginConfig],["ID"=>UID],[1,'操作成功']); + }else{ + msg(-1,'请求参数有误'); + } +} + + //查Token function read_token(){ global $USER_DB; diff --git a/system/login.php b/system/login.php index c8a191c..825b248 100644 --- a/system/login.php +++ b/system/login.php @@ -40,11 +40,25 @@ if(strlen($Password)!==32){ msg(-1,"浏览器UA长度异常,请更换浏览器!"); } +$LoginConfig = unserialize( $USER_DB['LoginConfig'] ); +//开启双重验证时验证OTP验证码 +if(!empty($LoginConfig['totp_key'])){ + if(empty($_POST['otp_code'])){ + msgA(['code'=>-1,'msg'=>'您已开启双重验证,请输入OTP验证码']); + } + require DIR . '/system/Authenticator.php'; + $totp = new PHPGangsta_GoogleAuthenticator(); + $checkResult = $totp->verifyCode($LoginConfig['totp_key'], $_POST['otp_code'], 2); + if(!$checkResult){ + msgA(['code'=>-1,'msg'=>'OTP验证码错误,请重试!']); + } +} + //计算请求密码和数据库的对比 if(Get_MD5_Password($Password,$USER_DB["RegTime"]) === $USER_DB["Password"]){ update_db("user_log", ["description" => "请求登录>登录成功"], ["id"=>$log_id]); Set_key($USER_DB); - $LoginConfig = unserialize( $USER_DB['LoginConfig'] ); + if(empty($LoginConfig['login_page']) || $LoginConfig['login_page'] == 'admin'){ $url = "./?c=admin&u={$USER_DB['User']}"; }elseif($LoginConfig['login_page'] == 'index'){ diff --git a/system/version.txt b/system/version.txt index ffde1d6..38e6765 100644 --- a/system/version.txt +++ b/system/version.txt @@ -1 +1 @@ -v2.0.33-20230802 \ No newline at end of file +v2.0.34-20230809 \ No newline at end of file diff --git a/templates/admin/page/SecuritySetting.php b/templates/admin/page/SecuritySetting.php index 664dfd6..e0ca7d9 100644 --- a/templates/admin/page/SecuritySetting.php +++ b/templates/admin/page/SecuritySetting.php @@ -1,4 +1,6 @@ - +
安全模式: 仅提供TwoNav自身的API接口,访客(未登录/Token为空)无法调用!
-兼容模式: 兼容部分OneNav的API接口,以便于其他插件调用!不允许访客调用!
-兼容模式+开放: 在兼容模式的基础上允许访客调用API获取共有数据!
+安全模式: 仅提供TwoNav自身的API接口,不兼容Onenav的API接口!
+兼容模式: 兼容部分OneNav的API接口,以便于其他插件调用!不支持访客调用!
如果你未使用相关扩展插件,则无需修改模式并将Token删除,以提高账号的安全性!
前言: 由于uTools扩展插件非TwoNav所开发适配,如存在Bug或无法使用属正常现象!
安装: 在uTools插件应用市场>搜索OneNav>点击获取
-设置S: 1.TwoNav后台>右上角账号>安全设置>API模式>设为<兼容模式>或<兼容模式+开放> 2.在本页面获取SecretKey ( 即插件设置中的API KEY )
+设置S: 1.TwoNav后台>右上角账号>安全设置>API模式>设为<兼容模式> 2.在本页面获取SecretKey ( 即插件设置中的API KEY )
设置C: 打开uTools中的OneNav,点击右下角小齿轮>输入网站地址/用户名/API KEY
1.备份数据库仅保存最近20份数据+
2.该功能仅辅助备份使用,无法确保100%数据安全,因此定期对整个站点打包备份仍然是必要的
1.备份数据库仅保存最近20份数据
2.该功能仅辅助备份使用,无法确保100%数据安全,因此定期对整个站点打包备份仍然是必要的
3.不支持将新版本备份回滚到旧版本中,不建议跨数据库类型回滚