1.增加谷歌验证器
This commit is contained in:
@@ -6,8 +6,10 @@ namespace app\admin\controller;
|
|||||||
|
|
||||||
use ba\ClickCaptcha;
|
use ba\ClickCaptcha;
|
||||||
use ba\Random;
|
use ba\Random;
|
||||||
|
use app\admin\model\Admin;
|
||||||
use app\common\facade\Token;
|
use app\common\facade\Token;
|
||||||
use app\admin\model\AdminLog;
|
use app\admin\model\AdminLog;
|
||||||
|
use app\common\library\AdminTotp;
|
||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
use support\validation\Validator;
|
use support\validation\Validator;
|
||||||
use support\validation\ValidationException;
|
use support\validation\ValidationException;
|
||||||
@@ -16,7 +18,7 @@ use support\Response;
|
|||||||
|
|
||||||
class Index extends Backend
|
class Index extends Backend
|
||||||
{
|
{
|
||||||
protected array $noNeedLogin = ['logout', 'login'];
|
protected array $noNeedLogin = ['logout', 'login', 'totpVerify', 'totpBindInit', 'totpBindConfirm'];
|
||||||
protected array $noNeedPermission = ['index'];
|
protected array $noNeedPermission = ['index'];
|
||||||
|
|
||||||
public function index(Request $request): Response
|
public function index(Request $request): Response
|
||||||
@@ -102,26 +104,26 @@ class Index extends Backend
|
|||||||
|
|
||||||
AdminLog::instance($request)->setTitle(__('Login'));
|
AdminLog::instance($request)->setTitle(__('Login'));
|
||||||
|
|
||||||
$res = $this->auth->login($username, $password, (bool) $keep);
|
if (!$this->auth->verifyCredentials($username, $password)) {
|
||||||
if ($res === true) {
|
$msg = $this->auth->getError();
|
||||||
$userInfo = $this->auth->getInfo();
|
return $this->error($msg ?: __('Incorrect user name or password!'));
|
||||||
$adminId = $this->auth->id;
|
}
|
||||||
$keepTime = (int) config('buildadmin.admin_token_keep_time', 86400 * 3);
|
|
||||||
// 兜底:若 getInfo 未返回 token,在控制器层生成并入库(login 成功时必有 adminId)
|
if ($this->auth->hasTotpBound()) {
|
||||||
if (empty($userInfo['token']) && $adminId) {
|
$tempToken = AdminTotp::createPendingToken($this->auth->id, AdminTotp::TOKEN_TYPE_VERIFY);
|
||||||
$userInfo['token'] = Random::uuid();
|
return $this->success(__('Please enter Google Authenticator code'), [
|
||||||
Token::set($userInfo['token'], \app\admin\library\Auth::TOKEN_TYPE, $adminId, $keepTime);
|
'type' => $this->auth::NEED_TOTP,
|
||||||
}
|
'tempToken' => $tempToken,
|
||||||
if (empty($userInfo['refresh_token']) && $keep && $adminId) {
|
'username' => $this->auth->username,
|
||||||
$userInfo['refresh_token'] = Random::uuid();
|
|
||||||
Token::set($userInfo['refresh_token'], \app\admin\library\Auth::TOKEN_TYPE . '-refresh', $adminId, 2592000);
|
|
||||||
}
|
|
||||||
return $this->success(__('Login succeeded!'), [
|
|
||||||
'userInfo' => $userInfo
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
$msg = $this->auth->getError();
|
|
||||||
return $this->error($msg ?: __('Incorrect user name or password!'));
|
$tempToken = AdminTotp::createPendingToken($this->auth->id, AdminTotp::TOKEN_TYPE_BIND);
|
||||||
|
return $this->success(__('Please bind Google Authenticator'), [
|
||||||
|
'type' => $this->auth::NEED_BIND_TOTP,
|
||||||
|
'tempToken' => $tempToken,
|
||||||
|
'username' => $this->auth->username,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->success('', [
|
return $this->success('', [
|
||||||
@@ -129,6 +131,138 @@ class Index extends Backend
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function totpBindInit(Request $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) return $response;
|
||||||
|
|
||||||
|
if ($request->method() !== 'POST') {
|
||||||
|
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempToken = (string) $request->post('tempToken', '');
|
||||||
|
$adminId = AdminTotp::resolvePendingToken($tempToken, AdminTotp::TOKEN_TYPE_BIND);
|
||||||
|
if ($adminId <= 0) {
|
||||||
|
return $this->error(__('TOTP session expired, please login again'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->auth->loadAdminById($adminId)) {
|
||||||
|
return $this->error($this->auth->getError());
|
||||||
|
}
|
||||||
|
if ($this->auth->hasTotpBound()) {
|
||||||
|
return $this->error(__('Google Authenticator already bound'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$secret = AdminTotp::generateSecret();
|
||||||
|
$label = $this->auth->username;
|
||||||
|
$qrCode = AdminTotp::getQrDataUri($label, $secret);
|
||||||
|
|
||||||
|
return $this->success('', [
|
||||||
|
'secret' => $secret,
|
||||||
|
'qrCode' => $qrCode,
|
||||||
|
'username' => $label,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function totpBindConfirm(Request $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) return $response;
|
||||||
|
|
||||||
|
if ($request->method() !== 'POST') {
|
||||||
|
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempToken = (string) $request->post('tempToken', '');
|
||||||
|
$secret = (string) $request->post('secret', '');
|
||||||
|
$code = (string) $request->post('code', '');
|
||||||
|
$keep = (bool) $request->post('keep');
|
||||||
|
|
||||||
|
if ($tempToken === '' || $secret === '' || $code === '') {
|
||||||
|
return $this->error(__('Parameter %s can not be empty', ['']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminId = AdminTotp::resolvePendingToken($tempToken, AdminTotp::TOKEN_TYPE_BIND);
|
||||||
|
if ($adminId <= 0) {
|
||||||
|
return $this->error(__('TOTP session expired, please login again'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!AdminTotp::verifyCode($secret, $code)) {
|
||||||
|
return $this->error(__('Google Authenticator code error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->auth->loadAdminById($adminId)) {
|
||||||
|
return $this->error($this->auth->getError());
|
||||||
|
}
|
||||||
|
if ($this->auth->hasTotpBound()) {
|
||||||
|
AdminTotp::deletePendingToken($tempToken);
|
||||||
|
return $this->error(__('Google Authenticator already bound'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$encrypted = AdminTotp::encryptSecret($secret);
|
||||||
|
if ($encrypted === '') {
|
||||||
|
return $this->error(__('Google Authenticator bind failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
Admin::where('id', $adminId)->update([
|
||||||
|
'totp_secret' => $encrypted,
|
||||||
|
'totp_bind_time' => time(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AdminTotp::deletePendingToken($tempToken);
|
||||||
|
|
||||||
|
if (!$this->auth->finalizeLogin($keep)) {
|
||||||
|
return $this->error($this->auth->getError() ?: __('Google Authenticator bind failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildLoginSuccessResponse($keep, __('Google Authenticator bound successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function totpVerify(Request $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) return $response;
|
||||||
|
|
||||||
|
if ($request->method() !== 'POST') {
|
||||||
|
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tempToken = (string) $request->post('tempToken', '');
|
||||||
|
$code = (string) $request->post('code', '');
|
||||||
|
$keep = (bool) $request->post('keep');
|
||||||
|
|
||||||
|
if ($tempToken === '' || $code === '') {
|
||||||
|
return $this->error(__('Parameter %s can not be empty', ['']));
|
||||||
|
}
|
||||||
|
|
||||||
|
$adminId = AdminTotp::resolvePendingToken($tempToken, AdminTotp::TOKEN_TYPE_VERIFY);
|
||||||
|
if ($adminId <= 0) {
|
||||||
|
return $this->error(__('TOTP session expired, please login again'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->auth->loadAdminById($adminId)) {
|
||||||
|
return $this->error($this->auth->getError());
|
||||||
|
}
|
||||||
|
if (!$this->auth->hasTotpBound()) {
|
||||||
|
AdminTotp::deletePendingToken($tempToken);
|
||||||
|
return $this->error(__('Google Authenticator not bound'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$encrypted = $this->auth->getAdmin()->getData('totp_secret');
|
||||||
|
if (!AdminTotp::verifyStoredCode($encrypted, $code)) {
|
||||||
|
$this->auth->loginFailed();
|
||||||
|
return $this->error(__('Google Authenticator code error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminTotp::deletePendingToken($tempToken);
|
||||||
|
|
||||||
|
if (!$this->auth->finalizeLogin($keep)) {
|
||||||
|
return $this->error($this->auth->getError() ?: __('Login failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildLoginSuccessResponse($keep);
|
||||||
|
}
|
||||||
|
|
||||||
public function logout(Request $request): Response
|
public function logout(Request $request): Response
|
||||||
{
|
{
|
||||||
$response = $this->initializeBackend($request);
|
$response = $this->initializeBackend($request);
|
||||||
@@ -144,4 +278,22 @@ class Index extends Backend
|
|||||||
}
|
}
|
||||||
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
|
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function buildLoginSuccessResponse(bool $keep, ?string $message = null): Response
|
||||||
|
{
|
||||||
|
$userInfo = $this->auth->getInfo();
|
||||||
|
$adminId = $this->auth->id;
|
||||||
|
$keepTime = (int) config('buildadmin.admin_token_keep_time', 86400 * 3);
|
||||||
|
if (empty($userInfo['token']) && $adminId) {
|
||||||
|
$userInfo['token'] = Random::uuid();
|
||||||
|
Token::set($userInfo['token'], \app\admin\library\Auth::TOKEN_TYPE, $adminId, $keepTime);
|
||||||
|
}
|
||||||
|
if (empty($userInfo['refresh_token']) && $keep && $adminId) {
|
||||||
|
$userInfo['refresh_token'] = Random::uuid();
|
||||||
|
Token::set($userInfo['refresh_token'], \app\admin\library\Auth::TOKEN_TYPE . '-refresh', $adminId, 2592000);
|
||||||
|
}
|
||||||
|
return $this->success($message ?: __('Login succeeded!'), [
|
||||||
|
'userInfo' => $userInfo,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ use Throwable;
|
|||||||
use support\think\Db;
|
use support\think\Db;
|
||||||
use support\validation\Validator;
|
use support\validation\Validator;
|
||||||
use support\validation\ValidationException;
|
use support\validation\ValidationException;
|
||||||
|
use app\common\facade\Token;
|
||||||
|
use app\admin\model\AdminLog;
|
||||||
use app\common\controller\Backend;
|
use app\common\controller\Backend;
|
||||||
use app\admin\model\Admin as AdminModel;
|
use app\admin\model\Admin as AdminModel;
|
||||||
use support\Response;
|
use support\Response;
|
||||||
@@ -17,7 +19,7 @@ class Admin extends Backend
|
|||||||
{
|
{
|
||||||
protected ?object $model = null;
|
protected ?object $model = null;
|
||||||
|
|
||||||
protected array|string $preExcludeFields = ['create_time', 'update_time', 'password', 'salt', 'login_failure', 'last_login_time', 'last_login_ip', 'channel_id'];
|
protected array|string $preExcludeFields = ['create_time', 'update_time', 'password', 'salt', 'login_failure', 'last_login_time', 'last_login_ip', 'channel_id', 'totp_secret', 'totp_bind_time'];
|
||||||
|
|
||||||
protected array|string $quickSearchField = ['username', 'nickname'];
|
protected array|string $quickSearchField = ['username', 'nickname'];
|
||||||
|
|
||||||
@@ -224,6 +226,50 @@ class Admin extends Backend
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resetTotp(Request $request): Response
|
||||||
|
{
|
||||||
|
$response = $this->initializeBackend($request);
|
||||||
|
if ($response !== null) return $response;
|
||||||
|
|
||||||
|
if ($request->method() !== 'POST') {
|
||||||
|
return $this->error(__('Method not allowed'), [], 0, ['statusCode' => 405]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->auth->isSuperAdmin()) {
|
||||||
|
return $this->error(__('You have no permission'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = (int) ($request->post('id') ?? 0);
|
||||||
|
if ($id <= 0) {
|
||||||
|
return $this->error(__('Parameter error'));
|
||||||
|
}
|
||||||
|
if ($id === $this->auth->id) {
|
||||||
|
return $this->error(__('Cannot reset your own authenticator, please modify in database'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $this->model->find($id);
|
||||||
|
if (!$row) {
|
||||||
|
return $this->error(__('Record not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$dataLimitAdminIds = $this->getDataLimitAdminIds();
|
||||||
|
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
|
||||||
|
return $this->error(__('You have no permission'));
|
||||||
|
}
|
||||||
|
|
||||||
|
AdminLog::instance($request)->setTitle(__('Reset Google Authenticator'));
|
||||||
|
|
||||||
|
$row->save([
|
||||||
|
'totp_secret' => '',
|
||||||
|
'totp_bind_time' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Token::clear(\app\admin\library\Auth::TOKEN_TYPE, $id);
|
||||||
|
Token::clear(\app\admin\library\Auth::TOKEN_TYPE . '-refresh', $id);
|
||||||
|
|
||||||
|
return $this->success(__('Google Authenticator reset successfully'));
|
||||||
|
}
|
||||||
|
|
||||||
public function del(Request $request): Response
|
public function del(Request $request): Response
|
||||||
{
|
{
|
||||||
$response = $this->initializeBackend($request);
|
$response = $this->initializeBackend($request);
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ class AdminInfo extends Backend
|
|||||||
{
|
{
|
||||||
protected ?object $model = null;
|
protected ?object $model = null;
|
||||||
|
|
||||||
protected array|string $preExcludeFields = ['username', 'last_login_time', 'password', 'salt', 'status', 'channel_id'];
|
protected array|string $preExcludeFields = ['username', 'last_login_time', 'password', 'salt', 'status', 'channel_id', 'totp_secret', 'totp_bind_time'];
|
||||||
protected array $authAllowFields = ['id', 'username', 'nickname', 'avatar', 'email', 'mobile', 'motto', 'last_login_time'];
|
protected array $authAllowFields = ['id', 'username', 'nickname', 'avatar', 'email', 'mobile', 'motto', 'last_login_time', 'totp_bound'];
|
||||||
|
|
||||||
protected function initController(Request $request): ?Response
|
protected function initController(Request $request): ?Response
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -110,4 +110,16 @@ return [
|
|||||||
'Rejected successfully' => 'Rejected successfully',
|
'Rejected successfully' => 'Rejected successfully',
|
||||||
'Success' => 'success',
|
'Success' => 'success',
|
||||||
'Failed' => 'failed',
|
'Failed' => 'failed',
|
||||||
|
'Please enter Google Authenticator code' => 'Please enter Google Authenticator code',
|
||||||
|
'Please bind Google Authenticator' => 'Please bind Google Authenticator',
|
||||||
|
'TOTP session expired, please login again' => 'Verification session expired, please login again',
|
||||||
|
'Google Authenticator already bound' => 'Google Authenticator already bound',
|
||||||
|
'Google Authenticator code error' => 'Google Authenticator code error',
|
||||||
|
'Google Authenticator bind failed' => 'Google Authenticator bind failed',
|
||||||
|
'Google Authenticator bound successfully' => 'Google Authenticator bound successfully',
|
||||||
|
'Google Authenticator not bound' => 'Google Authenticator not bound',
|
||||||
|
'Reset Google Authenticator' => 'Reset Google Authenticator',
|
||||||
|
'Google Authenticator reset successfully' => 'Google Authenticator reset. The admin must re-bind on next login',
|
||||||
|
'Cannot reset your own authenticator, please modify in database' => 'Cannot reset your own authenticator. Super admin recovery requires database change',
|
||||||
|
'Login failed' => 'Login failed',
|
||||||
];
|
];
|
||||||
@@ -129,4 +129,16 @@ return [
|
|||||||
'Rejected successfully' => '驳回成功',
|
'Rejected successfully' => '驳回成功',
|
||||||
'Success' => '成功',
|
'Success' => '成功',
|
||||||
'Failed' => '失败',
|
'Failed' => '失败',
|
||||||
|
'Please enter Google Authenticator code' => '请输入谷歌验证器验证码',
|
||||||
|
'Please bind Google Authenticator' => '请绑定谷歌验证器',
|
||||||
|
'TOTP session expired, please login again' => '验证会话已过期,请重新登录',
|
||||||
|
'Google Authenticator already bound' => '谷歌验证器已绑定',
|
||||||
|
'Google Authenticator code error' => '谷歌验证器验证码错误',
|
||||||
|
'Google Authenticator bind failed' => '谷歌验证器绑定失败',
|
||||||
|
'Google Authenticator bound successfully' => '谷歌验证器绑定成功',
|
||||||
|
'Google Authenticator not bound' => '谷歌验证器未绑定',
|
||||||
|
'Reset Google Authenticator' => '重置谷歌验证器',
|
||||||
|
'Google Authenticator reset successfully' => '谷歌验证器已重置,该管理员下次登录需重新绑定',
|
||||||
|
'Cannot reset your own authenticator, please modify in database' => '不能重置自己的验证器,超管丢失验证器请在数据库中修改',
|
||||||
|
'Login failed' => '登录失败',
|
||||||
];
|
];
|
||||||
@@ -24,6 +24,8 @@ class Auth extends \ba\Auth
|
|||||||
public const LOGIN_RESPONSE_CODE = 303;
|
public const LOGIN_RESPONSE_CODE = 303;
|
||||||
public const NEED_LOGIN = 'need login';
|
public const NEED_LOGIN = 'need login';
|
||||||
public const LOGGED_IN = 'logged in';
|
public const LOGGED_IN = 'logged in';
|
||||||
|
public const NEED_TOTP = 'need_totp';
|
||||||
|
public const NEED_BIND_TOTP = 'need_bind_totp';
|
||||||
public const TOKEN_TYPE = 'admin';
|
public const TOKEN_TYPE = 'admin';
|
||||||
|
|
||||||
protected bool $loginEd = false;
|
protected bool $loginEd = false;
|
||||||
@@ -90,6 +92,21 @@ class Auth extends \ba\Auth
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function login(string $username, string $password, bool $keep = false): bool
|
public function login(string $username, string $password, bool $keep = false): bool
|
||||||
|
{
|
||||||
|
if (!$this->verifyCredentials($username, $password)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (config('buildadmin.admin_sso')) {
|
||||||
|
Token::clear(self::TOKEN_TYPE, $this->model->id);
|
||||||
|
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
|
||||||
|
}
|
||||||
|
if ($keep) {
|
||||||
|
$this->setRefreshToken($this->refreshTokenKeepTime);
|
||||||
|
}
|
||||||
|
return $this->loginSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function verifyCredentials(string $username, string $password): bool
|
||||||
{
|
{
|
||||||
$this->model = Admin::where('username', $username)->find();
|
$this->model = Admin::where('username', $username)->find();
|
||||||
if (!$this->model) {
|
if (!$this->model) {
|
||||||
@@ -124,18 +141,44 @@ class Auth extends \ba\Auth
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasTotpBound(): bool
|
||||||
|
{
|
||||||
|
if (!$this->model) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return \app\common\library\AdminTotp::isBound($this->model->getData('totp_secret'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loadAdminById(int $adminId): bool
|
||||||
|
{
|
||||||
|
$this->model = Admin::where('id', $adminId)->find();
|
||||||
|
if (!$this->model) {
|
||||||
|
$this->setError('Account not exist');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($this->model->status === 'disable') {
|
||||||
|
$this->setError('Account disabled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function finalizeLogin(bool $keep = false): bool
|
||||||
|
{
|
||||||
|
if (!$this->model) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (config('buildadmin.admin_sso')) {
|
if (config('buildadmin.admin_sso')) {
|
||||||
Token::clear(self::TOKEN_TYPE, $this->model->id);
|
Token::clear(self::TOKEN_TYPE, $this->model->id);
|
||||||
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
|
Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($keep) {
|
if ($keep) {
|
||||||
$this->setRefreshToken($this->refreshTokenKeepTime);
|
$this->setRefreshToken($this->refreshTokenKeepTime);
|
||||||
}
|
}
|
||||||
if (!$this->loginSuccessful()) {
|
return $this->loginSuccessful();
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setRefreshToken(int $keepTime = 0): void
|
public function setRefreshToken(int $keepTime = 0): void
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use support\think\Db;
|
|||||||
* @property string $password 密码密文
|
* @property string $password 密码密文
|
||||||
* @property string $salt 密码盐
|
* @property string $salt 密码盐
|
||||||
* @property string $status 状态:enable=启用,disable=禁用
|
* @property string $status 状态:enable=启用,disable=禁用
|
||||||
|
* @property string $totp_secret TOTP密钥(加密)
|
||||||
|
* @property int $totp_bind_time TOTP绑定时间
|
||||||
*/
|
*/
|
||||||
class Admin extends Model
|
class Admin extends Model
|
||||||
{
|
{
|
||||||
@@ -43,8 +45,18 @@ class Admin extends Model
|
|||||||
protected array $append = [
|
protected array $append = [
|
||||||
'group_arr',
|
'group_arr',
|
||||||
'group_name_arr',
|
'group_name_arr',
|
||||||
|
'totp_bound',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected array $hidden = [
|
||||||
|
'totp_secret',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function getTotpBoundAttr($value, $row): bool
|
||||||
|
{
|
||||||
|
return \app\common\library\AdminTotp::isBound($row['totp_secret'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
public function getGroupArrAttr($value, $row): array
|
public function getGroupArrAttr($value, $row): array
|
||||||
{
|
{
|
||||||
return Db::name('admin_group_access')
|
return Db::name('admin_group_access')
|
||||||
|
|||||||
122
app/common/library/AdminTotp.php
Normal file
122
app/common/library/AdminTotp.php
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\library;
|
||||||
|
|
||||||
|
use ba\Random;
|
||||||
|
use app\common\facade\Token;
|
||||||
|
use RobThree\Auth\TwoFactorAuth;
|
||||||
|
use RobThree\Auth\Providers\Qr\BaconQrCodeProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 管理员谷歌验证器(TOTP)
|
||||||
|
*/
|
||||||
|
class AdminTotp
|
||||||
|
{
|
||||||
|
public const TOKEN_TYPE_BIND = 'admin-totp-bind';
|
||||||
|
public const TOKEN_TYPE_VERIFY = 'admin-totp-verify';
|
||||||
|
public const TOKEN_EXPIRE = 300;
|
||||||
|
|
||||||
|
private static ?TwoFactorAuth $tfa = null;
|
||||||
|
|
||||||
|
private static function tfa(): TwoFactorAuth
|
||||||
|
{
|
||||||
|
if (self::$tfa === null) {
|
||||||
|
$issuer = (string) get_sys_config('site_name');
|
||||||
|
if ($issuer === '') {
|
||||||
|
$issuer = 'BuildAdmin';
|
||||||
|
}
|
||||||
|
self::$tfa = new TwoFactorAuth(new BaconQrCodeProvider(), $issuer);
|
||||||
|
}
|
||||||
|
return self::$tfa;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function isBound(?string $encryptedSecret): bool
|
||||||
|
{
|
||||||
|
return is_string($encryptedSecret) && $encryptedSecret !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function generateSecret(): string
|
||||||
|
{
|
||||||
|
return self::tfa()->createSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getQrDataUri(string $label, string $secret): string
|
||||||
|
{
|
||||||
|
return self::tfa()->getQRCodeImageAsDataUri($label, $secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verifyCode(string $plainSecret, string $code): bool
|
||||||
|
{
|
||||||
|
$code = trim($code);
|
||||||
|
if (!preg_match('/^\d{6}$/', $code)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return self::tfa()->verifyCode($plainSecret, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function encryptSecret(string $plainSecret): string
|
||||||
|
{
|
||||||
|
$key = substr(hash('sha256', (string) config('buildadmin.token.key')), 0, 32);
|
||||||
|
$iv = random_bytes(16);
|
||||||
|
$encrypted = openssl_encrypt($plainSecret, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
if ($encrypted === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return base64_encode($iv . $encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function decryptSecret(string $encryptedSecret): string
|
||||||
|
{
|
||||||
|
if ($encryptedSecret === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$raw = base64_decode($encryptedSecret, true);
|
||||||
|
if ($raw === false || strlen($raw) < 17) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
$iv = substr($raw, 0, 16);
|
||||||
|
$encrypted = substr($raw, 16);
|
||||||
|
$key = substr(hash('sha256', (string) config('buildadmin.token.key')), 0, 32);
|
||||||
|
$plain = openssl_decrypt($encrypted, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||||
|
return $plain === false ? '' : $plain;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verifyStoredCode(string $encryptedSecret, string $code): bool
|
||||||
|
{
|
||||||
|
$plain = self::decryptSecret($encryptedSecret);
|
||||||
|
if ($plain === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return self::verifyCode($plain, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function createPendingToken(int $adminId, string $type): string
|
||||||
|
{
|
||||||
|
$token = Random::uuid();
|
||||||
|
Token::set($token, $type, $adminId, self::TOKEN_EXPIRE);
|
||||||
|
return $token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function resolvePendingToken(string $token, string $type): int
|
||||||
|
{
|
||||||
|
$token = trim($token);
|
||||||
|
if ($token === '') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$data = Token::get($token);
|
||||||
|
if (!$data || ($data['type'] ?? '') !== $type) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
Token::tokenExpirationCheck($data);
|
||||||
|
return (int) ($data['user_id'] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function deletePendingToken(string $token): void
|
||||||
|
{
|
||||||
|
if ($token !== '') {
|
||||||
|
Token::delete($token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,9 @@
|
|||||||
"voku/anti-xss": "^4.1",
|
"voku/anti-xss": "^4.1",
|
||||||
"topthink/think-validate": "^3.0",
|
"topthink/think-validate": "^3.0",
|
||||||
"firebase/php-jwt": "^7.0",
|
"firebase/php-jwt": "^7.0",
|
||||||
"guzzlehttp/guzzle": "^7.10"
|
"guzzlehttp/guzzle": "^7.10",
|
||||||
|
"robthree/twofactorauth": "^3.0",
|
||||||
|
"bacon/bacon-qr-code": "^3.1"
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"ext-event": "For better performance. "
|
"ext-event": "For better performance. "
|
||||||
|
|||||||
@@ -79,12 +79,18 @@ Route::get('/admin/index/index', [\app\admin\controller\Index::class, 'index']);
|
|||||||
Route::get('/admin/index/login', [\app\admin\controller\Index::class, 'login']);
|
Route::get('/admin/index/login', [\app\admin\controller\Index::class, 'login']);
|
||||||
Route::post('/admin/index/login', [\app\admin\controller\Index::class, 'login']);
|
Route::post('/admin/index/login', [\app\admin\controller\Index::class, 'login']);
|
||||||
Route::post('/admin/index/logout', [\app\admin\controller\Index::class, 'logout']);
|
Route::post('/admin/index/logout', [\app\admin\controller\Index::class, 'logout']);
|
||||||
|
Route::post('/admin/index/totpVerify', [\app\admin\controller\Index::class, 'totpVerify']);
|
||||||
|
Route::post('/admin/index/totpBindInit', [\app\admin\controller\Index::class, 'totpBindInit']);
|
||||||
|
Route::post('/admin/index/totpBindConfirm', [\app\admin\controller\Index::class, 'totpBindConfirm']);
|
||||||
|
|
||||||
// 兼容前端请求 /admin/Index/*(首字母大写)
|
// 兼容前端请求 /admin/Index/*(首字母大写)
|
||||||
Route::get('/admin/Index/index', [\app\admin\controller\Index::class, 'index']);
|
Route::get('/admin/Index/index', [\app\admin\controller\Index::class, 'index']);
|
||||||
Route::get('/admin/Index/login', [\app\admin\controller\Index::class, 'login']);
|
Route::get('/admin/Index/login', [\app\admin\controller\Index::class, 'login']);
|
||||||
Route::post('/admin/Index/login', [\app\admin\controller\Index::class, 'login']);
|
Route::post('/admin/Index/login', [\app\admin\controller\Index::class, 'login']);
|
||||||
Route::post('/admin/Index/logout', [\app\admin\controller\Index::class, 'logout']);
|
Route::post('/admin/Index/logout', [\app\admin\controller\Index::class, 'logout']);
|
||||||
|
Route::post('/admin/Index/totpVerify', [\app\admin\controller\Index::class, 'totpVerify']);
|
||||||
|
Route::post('/admin/Index/totpBindInit', [\app\admin\controller\Index::class, 'totpBindInit']);
|
||||||
|
Route::post('/admin/Index/totpBindConfirm', [\app\admin\controller\Index::class, 'totpBindConfirm']);
|
||||||
|
|
||||||
// admin/dashboard
|
// admin/dashboard
|
||||||
Route::get('/admin/dashboard/index', [\app\admin\controller\Dashboard::class, 'index']);
|
Route::get('/admin/dashboard/index', [\app\admin\controller\Dashboard::class, 'index']);
|
||||||
@@ -106,6 +112,7 @@ Route::get('/admin/auth/admin/index', [\app\admin\controller\auth\Admin::class,
|
|||||||
Route::post('/admin/auth/admin/add', [\app\admin\controller\auth\Admin::class, 'add']);
|
Route::post('/admin/auth/admin/add', [\app\admin\controller\auth\Admin::class, 'add']);
|
||||||
Route::post('/admin/auth/admin/edit', [\app\admin\controller\auth\Admin::class, 'edit']);
|
Route::post('/admin/auth/admin/edit', [\app\admin\controller\auth\Admin::class, 'edit']);
|
||||||
Route::post('/admin/auth/admin/del', [\app\admin\controller\auth\Admin::class, 'del']);
|
Route::post('/admin/auth/admin/del', [\app\admin\controller\auth\Admin::class, 'del']);
|
||||||
|
Route::post('/admin/auth/admin/resetTotp', [\app\admin\controller\auth\Admin::class, 'resetTotp']);
|
||||||
|
|
||||||
// admin/auth/group
|
// admin/auth/group
|
||||||
Route::get('/admin/auth/group/index', [\app\admin\controller\auth\Group::class, 'index']);
|
Route::get('/admin/auth/group/index', [\app\admin\controller\auth\Group::class, 'index']);
|
||||||
|
|||||||
14
web/src/api/backend/auth/admin.ts
Normal file
14
web/src/api/backend/auth/admin.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import createAxios from '/@/utils/axios'
|
||||||
|
|
||||||
|
export function resetTotp(id: number) {
|
||||||
|
return createAxios(
|
||||||
|
{
|
||||||
|
url: '/admin/auth.Admin/resetTotp',
|
||||||
|
method: 'post',
|
||||||
|
data: { id },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
showSuccessMessage: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,6 +20,35 @@ export function login(method: 'get' | 'post', params: object = {}) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function totpVerify(params: object = {}) {
|
||||||
|
return createAxios({
|
||||||
|
url: url + 'totpVerify',
|
||||||
|
data: params,
|
||||||
|
method: 'post',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totpBindInit(params: object = {}) {
|
||||||
|
return createAxios({
|
||||||
|
url: url + 'totpBindInit',
|
||||||
|
data: params,
|
||||||
|
method: 'post',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function totpBindConfirm(params: object = {}) {
|
||||||
|
return createAxios(
|
||||||
|
{
|
||||||
|
url: url + 'totpBindConfirm',
|
||||||
|
data: params,
|
||||||
|
method: 'post',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
showSuccessMessage: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
const adminInfo = useAdminInfo()
|
const adminInfo = useAdminInfo()
|
||||||
return createAxios({
|
return createAxios({
|
||||||
|
|||||||
@@ -10,4 +10,9 @@ export default {
|
|||||||
'Please leave blank if not modified': 'Please leave blank if you do not modify.',
|
'Please leave blank if not modified': 'Please leave blank if you do not modify.',
|
||||||
'Personal signature': 'Personal Signature',
|
'Personal signature': 'Personal Signature',
|
||||||
'Administrator login': 'Administrator Login Name',
|
'Administrator login': 'Administrator Login Name',
|
||||||
|
'Google Authenticator': 'Google Authenticator',
|
||||||
|
Bound: 'Bound',
|
||||||
|
'Not bound': 'Not bound',
|
||||||
|
'Reset authenticator': 'Reset authenticator',
|
||||||
|
'Reset authenticator confirm': 'Reset this admin\'s Google Authenticator? They must re-bind on next login.',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,14 @@ export default {
|
|||||||
'Please input a password': 'Please enter your password',
|
'Please input a password': 'Please enter your password',
|
||||||
'Hold session': 'Keep the session',
|
'Hold session': 'Keep the session',
|
||||||
'Sign in': 'Sign in',
|
'Sign in': 'Sign in',
|
||||||
|
'Google Authenticator code': 'Google Authenticator code',
|
||||||
|
'Please enter the 6-digit code': 'Please enter the 6-digit code',
|
||||||
|
'Verify and sign in': 'Verify and sign in',
|
||||||
|
'Bind Google Authenticator': 'Bind Google Authenticator',
|
||||||
|
'Scan QR code with Google Authenticator': 'Scan the QR code with Google Authenticator',
|
||||||
|
'Or enter secret manually': 'Or enter the secret manually',
|
||||||
|
'Confirm bind': 'Confirm bind',
|
||||||
|
'Back to login': 'Back to login',
|
||||||
|
'Binding...': 'Binding...',
|
||||||
|
'Verifying...': 'Verifying...',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ export default {
|
|||||||
'Please leave blank if not modified': 'Please leave blank if you do not modify',
|
'Please leave blank if not modified': 'Please leave blank if you do not modify',
|
||||||
'Save changes': 'Save changes',
|
'Save changes': 'Save changes',
|
||||||
'Operation log': 'Operation log',
|
'Operation log': 'Operation log',
|
||||||
|
'Google Authenticator': 'Google Authenticator',
|
||||||
|
Bound: 'Bound',
|
||||||
|
'Not bound': 'Not bound',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,9 @@ export default {
|
|||||||
'Please leave blank if not modified': '不修改请留空',
|
'Please leave blank if not modified': '不修改请留空',
|
||||||
'Personal signature': '个性签名',
|
'Personal signature': '个性签名',
|
||||||
'Administrator login': '管理员登录名',
|
'Administrator login': '管理员登录名',
|
||||||
|
'Google Authenticator': '谷歌验证器',
|
||||||
|
Bound: '已绑定',
|
||||||
|
'Not bound': '未绑定',
|
||||||
|
'Reset authenticator': '重置验证器',
|
||||||
|
'Reset authenticator confirm': '确定要重置该管理员的谷歌验证器吗?重置后该管理员下次登录需重新绑定。',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,14 @@ export default {
|
|||||||
'Please input a password': '请输入密码',
|
'Please input a password': '请输入密码',
|
||||||
'Hold session': '保持会话',
|
'Hold session': '保持会话',
|
||||||
'Sign in': '登录',
|
'Sign in': '登录',
|
||||||
|
'Google Authenticator code': '谷歌验证器验证码',
|
||||||
|
'Please enter the 6-digit code': '请输入6位验证码',
|
||||||
|
'Verify and sign in': '验证并登录',
|
||||||
|
'Bind Google Authenticator': '绑定谷歌验证器',
|
||||||
|
'Scan QR code with Google Authenticator': '请使用 Google Authenticator 扫描下方二维码',
|
||||||
|
'Or enter secret manually': '或手动输入密钥',
|
||||||
|
'Confirm bind': '确认绑定',
|
||||||
|
'Back to login': '返回登录',
|
||||||
|
'Binding...': '绑定中...',
|
||||||
|
'Verifying...': '验证中...',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,7 @@ export default {
|
|||||||
'Please leave blank if not modified': '不修改请留空',
|
'Please leave blank if not modified': '不修改请留空',
|
||||||
'Save changes': '保存修改',
|
'Save changes': '保存修改',
|
||||||
'Operation log': '操作日志',
|
'Operation log': '操作日志',
|
||||||
|
'Google Authenticator': '谷歌验证器',
|
||||||
|
Bound: '已绑定',
|
||||||
|
'Not bound': '未绑定',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import Table from '/@/components/table/index.vue'
|
|||||||
import TableHeader from '/@/components/table/header/index.vue'
|
import TableHeader from '/@/components/table/header/index.vue'
|
||||||
import { defaultOptButtons } from '/@/components/table'
|
import { defaultOptButtons } from '/@/components/table'
|
||||||
import { baTableApi } from '/@/api/common'
|
import { baTableApi } from '/@/api/common'
|
||||||
|
import { resetTotp } from '/@/api/backend/auth/admin'
|
||||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -35,10 +36,41 @@ defineOptions({
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const adminInfo = useAdminInfo()
|
const adminInfo = useAdminInfo()
|
||||||
|
|
||||||
const optButtons = defaultOptButtons(['edit', 'delete'])
|
const optButtons: OptButton[] = [
|
||||||
optButtons[1].display = (row) => {
|
{
|
||||||
return row.id != adminInfo.id
|
render: 'confirmButton',
|
||||||
}
|
name: 'resetTotp',
|
||||||
|
title: 'auth.admin.Reset authenticator',
|
||||||
|
text: '',
|
||||||
|
type: 'warning',
|
||||||
|
icon: 'fa fa-refresh',
|
||||||
|
class: 'table-row-reset-totp',
|
||||||
|
popconfirm: {
|
||||||
|
confirmButtonText: t('Confirm'),
|
||||||
|
cancelButtonText: t('Cancel'),
|
||||||
|
confirmButtonType: 'warning',
|
||||||
|
title: t('auth.admin.Reset authenticator confirm'),
|
||||||
|
},
|
||||||
|
disabledTip: false,
|
||||||
|
display: (row: TableRow) => {
|
||||||
|
return adminInfo.super && row.id != adminInfo.id && !!row.totp_bound
|
||||||
|
},
|
||||||
|
click: (row: TableRow) => {
|
||||||
|
resetTotp(row.id).then(() => {
|
||||||
|
baTable.onTableHeaderAction('refresh', {})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...defaultOptButtons(['edit', 'delete']).map((btn) => {
|
||||||
|
if (btn.name === 'delete') {
|
||||||
|
return {
|
||||||
|
...btn,
|
||||||
|
display: (row: TableRow) => row.id != adminInfo.id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return btn
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
const baTable = new baTableClass(
|
const baTable = new baTableClass(
|
||||||
new baTableApi('/admin/auth.Admin/'),
|
new baTableApi('/admin/auth.Admin/'),
|
||||||
@@ -52,6 +84,16 @@ const baTable = new baTableClass(
|
|||||||
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
|
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
|
||||||
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||||
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||||
|
{
|
||||||
|
label: t('auth.admin.Google Authenticator'),
|
||||||
|
prop: 'totp_bound',
|
||||||
|
align: 'center',
|
||||||
|
render: 'tag',
|
||||||
|
custom: { true: 'success', false: 'info' },
|
||||||
|
replaceValue: { true: t('auth.admin.Bound'), false: t('auth.admin.Not bound') },
|
||||||
|
operator: false,
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: t('auth.admin.Last login'),
|
label: t('auth.admin.Last login'),
|
||||||
prop: 'last_login_time',
|
prop: 'last_login_time',
|
||||||
@@ -73,7 +115,7 @@ const baTable = new baTableClass(
|
|||||||
{
|
{
|
||||||
label: t('Operate'),
|
label: t('Operate'),
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: '100',
|
width: '140',
|
||||||
render: 'buttons',
|
render: 'buttons',
|
||||||
buttons: optButtons,
|
buttons: optButtons,
|
||||||
operator: false,
|
operator: false,
|
||||||
|
|||||||
@@ -23,7 +23,15 @@
|
|||||||
<div class="form">
|
<div class="form">
|
||||||
<img class="profile-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
|
<img class="profile-avatar" :src="fullUrl('/static/images/avatar.png')" alt="" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<el-form @keyup.enter="onSubmitPre()" ref="formRef" :rules="rules" size="large" :model="form">
|
<!-- 账号密码登录 -->
|
||||||
|
<el-form
|
||||||
|
v-if="state.step === 'login'"
|
||||||
|
@keyup.enter="onSubmitPre()"
|
||||||
|
ref="formRef"
|
||||||
|
:rules="rules"
|
||||||
|
size="large"
|
||||||
|
:model="form"
|
||||||
|
>
|
||||||
<el-form-item prop="username">
|
<el-form-item prop="username">
|
||||||
<el-input
|
<el-input
|
||||||
ref="usernameRef"
|
ref="usernameRef"
|
||||||
@@ -64,6 +72,84 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 谷歌验证器验证码 -->
|
||||||
|
<el-form
|
||||||
|
v-else-if="state.step === 'totp'"
|
||||||
|
@keyup.enter="onTotpVerify()"
|
||||||
|
ref="totpFormRef"
|
||||||
|
:rules="totpRules"
|
||||||
|
size="large"
|
||||||
|
:model="totpForm"
|
||||||
|
>
|
||||||
|
<div class="step-title">{{ t('login.Google Authenticator code') }}</div>
|
||||||
|
<div class="step-desc">{{ state.username }}</div>
|
||||||
|
<el-form-item prop="code">
|
||||||
|
<el-input
|
||||||
|
v-model="totpForm.code"
|
||||||
|
maxlength="6"
|
||||||
|
:placeholder="t('login.Please enter the 6-digit code')"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon name="fa fa-shield" class="form-item-icon" size="16" color="var(--el-input-icon-color)" />
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
:loading="state.submitLoading"
|
||||||
|
class="submit-button"
|
||||||
|
round
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
@click="onTotpVerify()"
|
||||||
|
>
|
||||||
|
{{ state.submitLoading ? t('login.Verifying...') : t('login.Verify and sign in') }}
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
<el-button class="back-button" link type="primary" @click="resetToLogin">{{ t('login.Back to login') }}</el-button>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 绑定谷歌验证器 -->
|
||||||
|
<div v-else-if="state.step === 'bind'" class="bind-panel">
|
||||||
|
<div class="step-title">{{ t('login.Bind Google Authenticator') }}</div>
|
||||||
|
<div class="step-desc">{{ t('login.Scan QR code with Google Authenticator') }}</div>
|
||||||
|
<div v-loading="state.bindLoading" class="qr-wrap">
|
||||||
|
<img v-if="state.qrCode" :src="state.qrCode" alt="QR Code" class="qr-image" />
|
||||||
|
</div>
|
||||||
|
<div v-if="state.secret" class="secret-box">
|
||||||
|
<span class="secret-label">{{ t('login.Or enter secret manually') }}:</span>
|
||||||
|
<span class="secret-value">{{ state.secret }}</span>
|
||||||
|
</div>
|
||||||
|
<el-form @keyup.enter="onBindConfirm()" ref="bindFormRef" :rules="totpRules" size="large" :model="totpForm">
|
||||||
|
<el-form-item prop="code">
|
||||||
|
<el-input
|
||||||
|
v-model="totpForm.code"
|
||||||
|
maxlength="6"
|
||||||
|
:placeholder="t('login.Please enter the 6-digit code')"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<template #prefix>
|
||||||
|
<Icon name="fa fa-shield" class="form-item-icon" size="16" color="var(--el-input-icon-color)" />
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button
|
||||||
|
:loading="state.submitLoading"
|
||||||
|
class="submit-button"
|
||||||
|
round
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
@click="onBindConfirm()"
|
||||||
|
>
|
||||||
|
{{ state.submitLoading ? t('login.Binding...') : t('login.Confirm bind') }}
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-button class="back-button" link type="primary" @click="resetToLogin">{{ t('login.Back to login') }}</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +164,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { editDefaultLang } from '/@/lang/index'
|
import { editDefaultLang } from '/@/lang/index'
|
||||||
import { useConfig } from '/@/stores/config'
|
import { useConfig } from '/@/stores/config'
|
||||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||||
import { login } from '/@/api/backend'
|
import { login, totpVerify, totpBindInit, totpBindConfirm } from '/@/api/backend'
|
||||||
import { uuid } from '/@/utils/random'
|
import { uuid } from '/@/utils/random'
|
||||||
import { buildValidatorData } from '/@/utils/validate'
|
import { buildValidatorData } from '/@/utils/validate'
|
||||||
import router from '/@/router'
|
import router from '/@/router'
|
||||||
@@ -86,6 +172,7 @@ import clickCaptcha from '/@/components/clickCaptcha'
|
|||||||
import toggleDark from '/@/utils/useDark'
|
import toggleDark from '/@/utils/useDark'
|
||||||
import { fullUrl } from '/@/utils/common'
|
import { fullUrl } from '/@/utils/common'
|
||||||
import { adminBaseRoutePath } from '/@/router/static/adminBase'
|
import { adminBaseRoutePath } from '/@/router/static/adminBase'
|
||||||
|
import type { FormItemRule } from 'element-plus'
|
||||||
let timer: number
|
let timer: number
|
||||||
|
|
||||||
const config = useConfig()
|
const config = useConfig()
|
||||||
@@ -93,13 +180,22 @@ const adminInfo = useAdminInfo()
|
|||||||
toggleDark(config.layout.isDark)
|
toggleDark(config.layout.isDark)
|
||||||
|
|
||||||
const formRef = useTemplateRef('formRef')
|
const formRef = useTemplateRef('formRef')
|
||||||
|
const totpFormRef = useTemplateRef('totpFormRef')
|
||||||
|
const bindFormRef = useTemplateRef('bindFormRef')
|
||||||
const usernameRef = useTemplateRef('usernameRef')
|
const usernameRef = useTemplateRef('usernameRef')
|
||||||
const passwordRef = useTemplateRef('passwordRef')
|
const passwordRef = useTemplateRef('passwordRef')
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
showCaptcha: false,
|
showCaptcha: false,
|
||||||
submitLoading: false,
|
submitLoading: false,
|
||||||
|
bindLoading: false,
|
||||||
|
step: 'login' as 'login' | 'totp' | 'bind',
|
||||||
|
tempToken: '',
|
||||||
|
username: '',
|
||||||
|
secret: '',
|
||||||
|
qrCode: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
@@ -108,14 +204,33 @@ const form = reactive({
|
|||||||
captchaInfo: '',
|
captchaInfo: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const totpForm = reactive({
|
||||||
|
code: '',
|
||||||
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 表单验证规则
|
|
||||||
const rules = reactive({
|
const rules = reactive({
|
||||||
username: [buildValidatorData({ name: 'required', message: t('login.Please enter an account') }), buildValidatorData({ name: 'account' })],
|
username: [buildValidatorData({ name: 'required', message: t('login.Please enter an account') }), buildValidatorData({ name: 'account' })],
|
||||||
password: [buildValidatorData({ name: 'required', message: t('login.Please input a password') }), buildValidatorData({ name: 'password' })],
|
password: [buildValidatorData({ name: 'required', message: t('login.Please input a password') }), buildValidatorData({ name: 'password' })],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const totpRules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||||
|
code: [
|
||||||
|
buildValidatorData({ name: 'required', message: t('login.Please enter the 6-digit code') }),
|
||||||
|
{
|
||||||
|
validator: (_rule, val, callback) => {
|
||||||
|
if (!/^\d{6}$/.test(val)) {
|
||||||
|
callback(new Error(t('login.Please enter the 6-digit code')))
|
||||||
|
} else {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
trigger: 'blur',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
const focusInput = () => {
|
const focusInput = () => {
|
||||||
if (form.username === '') {
|
if (form.username === '') {
|
||||||
usernameRef.value?.focus()
|
usernameRef.value?.focus()
|
||||||
@@ -124,6 +239,26 @@ const focusInput = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetToLogin = () => {
|
||||||
|
state.step = 'login'
|
||||||
|
state.tempToken = ''
|
||||||
|
state.secret = ''
|
||||||
|
state.qrCode = ''
|
||||||
|
state.username = ''
|
||||||
|
totpForm.code = ''
|
||||||
|
nextTick(() => focusInput())
|
||||||
|
}
|
||||||
|
|
||||||
|
const navigateAfterLogin = (userInfo: anyObj) => {
|
||||||
|
if (!userInfo?.token) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminInfo.dataFill(userInfo, false)
|
||||||
|
nextTick(() => {
|
||||||
|
router.push({ path: adminBaseRoutePath })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
timer = window.setTimeout(() => {
|
timer = window.setTimeout(() => {
|
||||||
pageBubble.init()
|
pageBubble.init()
|
||||||
@@ -161,22 +296,79 @@ const onSubmit = (captchaInfo = '') => {
|
|||||||
form.captchaInfo = captchaInfo
|
form.captchaInfo = captchaInfo
|
||||||
login('post', form)
|
login('post', form)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const userInfo = res?.data?.userInfo
|
const data = res?.data
|
||||||
if (!userInfo?.token) {
|
if (data?.userInfo?.token) {
|
||||||
|
navigateAfterLogin(data.userInfo)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
adminInfo.dataFill(userInfo, false)
|
if (data?.type === 'need_totp' && data?.tempToken) {
|
||||||
nextTick(() => {
|
state.step = 'totp'
|
||||||
router.push({ path: adminBaseRoutePath })
|
state.tempToken = data.tempToken
|
||||||
})
|
state.username = data.username || form.username
|
||||||
})
|
totpForm.code = ''
|
||||||
.catch(() => {
|
return
|
||||||
// 接口错误由 axios 拦截器处理
|
}
|
||||||
|
if (data?.type === 'need_bind_totp' && data?.tempToken) {
|
||||||
|
state.step = 'bind'
|
||||||
|
state.tempToken = data.tempToken
|
||||||
|
state.username = data.username || form.username
|
||||||
|
totpForm.code = ''
|
||||||
|
loadBindInfo()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
state.submitLoading = false
|
state.submitLoading = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadBindInfo = () => {
|
||||||
|
state.bindLoading = true
|
||||||
|
totpBindInit({ tempToken: state.tempToken })
|
||||||
|
.then((res) => {
|
||||||
|
state.secret = res.data.secret
|
||||||
|
state.qrCode = res.data.qrCode
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
state.bindLoading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onTotpVerify = () => {
|
||||||
|
totpFormRef.value?.validate((valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
state.submitLoading = true
|
||||||
|
totpVerify({
|
||||||
|
tempToken: state.tempToken,
|
||||||
|
code: totpForm.code,
|
||||||
|
keep: form.keep,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
navigateAfterLogin(res?.data?.userInfo)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
state.submitLoading = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onBindConfirm = () => {
|
||||||
|
bindFormRef.value?.validate((valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
state.submitLoading = true
|
||||||
|
totpBindConfirm({
|
||||||
|
tempToken: state.tempToken,
|
||||||
|
secret: state.secret,
|
||||||
|
code: totpForm.code,
|
||||||
|
keep: form.keep,
|
||||||
|
})
|
||||||
|
.then((res) => {
|
||||||
|
navigateAfterLogin(res?.data?.userInfo)
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
state.submitLoading = false
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -193,6 +385,57 @@ const onSubmit = (captchaInfo = '') => {
|
|||||||
.form-item-icon {
|
.form-item-icon {
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
.step-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
.step-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.bind-panel {
|
||||||
|
.qr-wrap {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
.qr-image {
|
||||||
|
width: 200px;
|
||||||
|
height: 200px;
|
||||||
|
border: 1px solid var(--el-border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.secret-box {
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 8px;
|
||||||
|
background: var(--el-fill-color-light);
|
||||||
|
border-radius: 6px;
|
||||||
|
word-break: break-all;
|
||||||
|
.secret-label {
|
||||||
|
display: block;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.secret-value {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.back-button {
|
||||||
|
display: block;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
.login {
|
.login {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
@@ -263,7 +506,6 @@ const onSubmit = (captchaInfo = '') => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 暗黑样式
|
|
||||||
@at-root .dark {
|
@at-root .dark {
|
||||||
.bubble {
|
.bubble {
|
||||||
background: url(/@/assets/bg-dark.jpg) repeat;
|
background: url(/@/assets/bg-dark.jpg) repeat;
|
||||||
|
|||||||
@@ -38,6 +38,11 @@
|
|||||||
<el-form-item :label="t('routine.adminInfo.user name')">
|
<el-form-item :label="t('routine.adminInfo.user name')">
|
||||||
<el-input disabled v-model="state.adminInfo.username"></el-input>
|
<el-input disabled v-model="state.adminInfo.username"></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item :label="t('routine.adminInfo.Google Authenticator')">
|
||||||
|
<el-tag :type="state.adminInfo.totp_bound ? 'success' : 'info'">
|
||||||
|
{{ state.adminInfo.totp_bound ? t('routine.adminInfo.Bound') : t('routine.adminInfo.Not bound') }}
|
||||||
|
</el-tag>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item :label="t('routine.adminInfo.User nickname')" prop="nickname">
|
<el-form-item :label="t('routine.adminInfo.User nickname')" prop="nickname">
|
||||||
<el-input :placeholder="t('routine.adminInfo.Please enter a nickname')" v-model="state.adminInfo.nickname"></el-input>
|
<el-input :placeholder="t('routine.adminInfo.Please enter a nickname')" v-model="state.adminInfo.nickname"></el-input>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|||||||
Reference in New Issue
Block a user