From 3e88a5dd9119da6189141f25773cd1f3da026291 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Wed, 24 Jun 2026 16:25:31 +0800 Subject: [PATCH] =?UTF-8?q?1.=E5=A2=9E=E5=8A=A0=E8=B0=B7=E6=AD=8C=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/Index.php | 190 +++++++++++-- app/admin/controller/auth/Admin.php | 48 +++- app/admin/controller/routine/AdminInfo.php | 4 +- app/admin/lang/en.php | 12 + app/admin/lang/zh-cn.php | 12 + app/admin/library/Auth.php | 53 +++- app/admin/model/Admin.php | 12 + app/common/library/AdminTotp.php | 122 ++++++++ composer.json | 4 +- config/route.php | 7 + web/src/api/backend/auth/admin.ts | 14 + web/src/api/backend/index.ts | 29 ++ web/src/lang/backend/en/auth/admin.ts | 5 + web/src/lang/backend/en/login.ts | 10 + web/src/lang/backend/en/routine/adminInfo.ts | 3 + web/src/lang/backend/zh-cn/auth/admin.ts | 5 + web/src/lang/backend/zh-cn/login.ts | 10 + .../lang/backend/zh-cn/routine/adminInfo.ts | 3 + web/src/views/backend/auth/admin/index.vue | 52 +++- web/src/views/backend/login.vue | 268 +++++++++++++++++- web/src/views/backend/routine/adminInfo.vue | 5 + 21 files changed, 822 insertions(+), 46 deletions(-) create mode 100644 app/common/library/AdminTotp.php create mode 100644 web/src/api/backend/auth/admin.ts diff --git a/app/admin/controller/Index.php b/app/admin/controller/Index.php index 0a83d8b..703bd6e 100644 --- a/app/admin/controller/Index.php +++ b/app/admin/controller/Index.php @@ -6,8 +6,10 @@ namespace app\admin\controller; use ba\ClickCaptcha; use ba\Random; +use app\admin\model\Admin; use app\common\facade\Token; use app\admin\model\AdminLog; +use app\common\library\AdminTotp; use app\common\controller\Backend; use support\validation\Validator; use support\validation\ValidationException; @@ -16,7 +18,7 @@ use support\Response; class Index extends Backend { - protected array $noNeedLogin = ['logout', 'login']; + protected array $noNeedLogin = ['logout', 'login', 'totpVerify', 'totpBindInit', 'totpBindConfirm']; protected array $noNeedPermission = ['index']; public function index(Request $request): Response @@ -102,26 +104,26 @@ class Index extends Backend AdminLog::instance($request)->setTitle(__('Login')); - $res = $this->auth->login($username, $password, (bool) $keep); - if ($res === true) { - $userInfo = $this->auth->getInfo(); - $adminId = $this->auth->id; - $keepTime = (int) config('buildadmin.admin_token_keep_time', 86400 * 3); - // 兜底:若 getInfo 未返回 token,在控制器层生成并入库(login 成功时必有 adminId) - 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(__('Login succeeded!'), [ - 'userInfo' => $userInfo + if (!$this->auth->verifyCredentials($username, $password)) { + $msg = $this->auth->getError(); + return $this->error($msg ?: __('Incorrect user name or password!')); + } + + if ($this->auth->hasTotpBound()) { + $tempToken = AdminTotp::createPendingToken($this->auth->id, AdminTotp::TOKEN_TYPE_VERIFY); + return $this->success(__('Please enter Google Authenticator code'), [ + 'type' => $this->auth::NEED_TOTP, + 'tempToken' => $tempToken, + 'username' => $this->auth->username, ]); } - $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('', [ @@ -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 { $response = $this->initializeBackend($request); @@ -144,4 +278,22 @@ class Index extends Backend } 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, + ]); + } } diff --git a/app/admin/controller/auth/Admin.php b/app/admin/controller/auth/Admin.php index 429bd13..cd756d4 100644 --- a/app/admin/controller/auth/Admin.php +++ b/app/admin/controller/auth/Admin.php @@ -8,6 +8,8 @@ use Throwable; use support\think\Db; use support\validation\Validator; use support\validation\ValidationException; +use app\common\facade\Token; +use app\admin\model\AdminLog; use app\common\controller\Backend; use app\admin\model\Admin as AdminModel; use support\Response; @@ -17,7 +19,7 @@ class Admin extends Backend { 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']; @@ -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 { $response = $this->initializeBackend($request); diff --git a/app/admin/controller/routine/AdminInfo.php b/app/admin/controller/routine/AdminInfo.php index e30a640..8c253a2 100644 --- a/app/admin/controller/routine/AdminInfo.php +++ b/app/admin/controller/routine/AdminInfo.php @@ -13,8 +13,8 @@ class AdminInfo extends Backend { protected ?object $model = null; - protected array|string $preExcludeFields = ['username', 'last_login_time', 'password', 'salt', 'status', 'channel_id']; - protected array $authAllowFields = ['id', 'username', 'nickname', 'avatar', 'email', 'mobile', 'motto', 'last_login_time']; + 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', 'totp_bound']; protected function initController(Request $request): ?Response { diff --git a/app/admin/lang/en.php b/app/admin/lang/en.php index 6b55612..117468d 100644 --- a/app/admin/lang/en.php +++ b/app/admin/lang/en.php @@ -110,4 +110,16 @@ return [ 'Rejected successfully' => 'Rejected successfully', 'Success' => 'success', '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', ]; \ No newline at end of file diff --git a/app/admin/lang/zh-cn.php b/app/admin/lang/zh-cn.php index d6b9961..9b99ae9 100644 --- a/app/admin/lang/zh-cn.php +++ b/app/admin/lang/zh-cn.php @@ -129,4 +129,16 @@ return [ 'Rejected successfully' => '驳回成功', 'Success' => '成功', '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' => '登录失败', ]; \ No newline at end of file diff --git a/app/admin/library/Auth.php b/app/admin/library/Auth.php index 97c8e07..a9de56c 100644 --- a/app/admin/library/Auth.php +++ b/app/admin/library/Auth.php @@ -24,6 +24,8 @@ class Auth extends \ba\Auth public const LOGIN_RESPONSE_CODE = 303; public const NEED_LOGIN = 'need login'; 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'; protected bool $loginEd = false; @@ -90,6 +92,21 @@ class Auth extends \ba\Auth } 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(); if (!$this->model) { @@ -124,18 +141,44 @@ class Auth extends \ba\Auth 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')) { Token::clear(self::TOKEN_TYPE, $this->model->id); Token::clear(self::TOKEN_TYPE . '-refresh', $this->model->id); } - if ($keep) { $this->setRefreshToken($this->refreshTokenKeepTime); } - if (!$this->loginSuccessful()) { - return false; - } - return true; + return $this->loginSuccessful(); } public function setRefreshToken(int $keepTime = 0): void diff --git a/app/admin/model/Admin.php b/app/admin/model/Admin.php index 64ce127..ae74b97 100644 --- a/app/admin/model/Admin.php +++ b/app/admin/model/Admin.php @@ -21,6 +21,8 @@ use support\think\Db; * @property string $password 密码密文 * @property string $salt 密码盐 * @property string $status 状态:enable=启用,disable=禁用 + * @property string $totp_secret TOTP密钥(加密) + * @property int $totp_bind_time TOTP绑定时间 */ class Admin extends Model { @@ -43,8 +45,18 @@ class Admin extends Model protected array $append = [ 'group_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 { return Db::name('admin_group_access') diff --git a/app/common/library/AdminTotp.php b/app/common/library/AdminTotp.php new file mode 100644 index 0000000..dfcbc6c --- /dev/null +++ b/app/common/library/AdminTotp.php @@ -0,0 +1,122 @@ +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); + } + } +} diff --git a/composer.json b/composer.json index 6098c2b..a8a2d18 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,9 @@ "voku/anti-xss": "^4.1", "topthink/think-validate": "^3.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": { "ext-event": "For better performance. " diff --git a/config/route.php b/config/route.php index 0dcf3f7..5c99e94 100644 --- a/config/route.php +++ b/config/route.php @@ -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::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/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/*(首字母大写) Route::get('/admin/Index/index', [\app\admin\controller\Index::class, 'index']); 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/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 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/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/resetTotp', [\app\admin\controller\auth\Admin::class, 'resetTotp']); // admin/auth/group Route::get('/admin/auth/group/index', [\app\admin\controller\auth\Group::class, 'index']); diff --git a/web/src/api/backend/auth/admin.ts b/web/src/api/backend/auth/admin.ts new file mode 100644 index 0000000..f23629e --- /dev/null +++ b/web/src/api/backend/auth/admin.ts @@ -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, + } + ) +} diff --git a/web/src/api/backend/index.ts b/web/src/api/backend/index.ts index 9573c2c..4b78ca2 100644 --- a/web/src/api/backend/index.ts +++ b/web/src/api/backend/index.ts @@ -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() { const adminInfo = useAdminInfo() return createAxios({ diff --git a/web/src/lang/backend/en/auth/admin.ts b/web/src/lang/backend/en/auth/admin.ts index 285962a..494d036 100644 --- a/web/src/lang/backend/en/auth/admin.ts +++ b/web/src/lang/backend/en/auth/admin.ts @@ -10,4 +10,9 @@ export default { 'Please leave blank if not modified': 'Please leave blank if you do not modify.', 'Personal signature': 'Personal Signature', '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.', } diff --git a/web/src/lang/backend/en/login.ts b/web/src/lang/backend/en/login.ts index 9d29c63..5d5648a 100644 --- a/web/src/lang/backend/en/login.ts +++ b/web/src/lang/backend/en/login.ts @@ -3,4 +3,14 @@ export default { 'Please input a password': 'Please enter your password', 'Hold session': 'Keep the session', '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...', } diff --git a/web/src/lang/backend/en/routine/adminInfo.ts b/web/src/lang/backend/en/routine/adminInfo.ts index 0ae2dd1..d88ce98 100644 --- a/web/src/lang/backend/en/routine/adminInfo.ts +++ b/web/src/lang/backend/en/routine/adminInfo.ts @@ -11,4 +11,7 @@ export default { 'Please leave blank if not modified': 'Please leave blank if you do not modify', 'Save changes': 'Save changes', 'Operation log': 'Operation log', + 'Google Authenticator': 'Google Authenticator', + Bound: 'Bound', + 'Not bound': 'Not bound', } diff --git a/web/src/lang/backend/zh-cn/auth/admin.ts b/web/src/lang/backend/zh-cn/auth/admin.ts index e5c671f..4951761 100644 --- a/web/src/lang/backend/zh-cn/auth/admin.ts +++ b/web/src/lang/backend/zh-cn/auth/admin.ts @@ -10,4 +10,9 @@ export default { 'Please leave blank if not modified': '不修改请留空', 'Personal signature': '个性签名', 'Administrator login': '管理员登录名', + 'Google Authenticator': '谷歌验证器', + Bound: '已绑定', + 'Not bound': '未绑定', + 'Reset authenticator': '重置验证器', + 'Reset authenticator confirm': '确定要重置该管理员的谷歌验证器吗?重置后该管理员下次登录需重新绑定。', } diff --git a/web/src/lang/backend/zh-cn/login.ts b/web/src/lang/backend/zh-cn/login.ts index bba33b5..c66f249 100644 --- a/web/src/lang/backend/zh-cn/login.ts +++ b/web/src/lang/backend/zh-cn/login.ts @@ -3,4 +3,14 @@ export default { 'Please input a password': '请输入密码', 'Hold session': '保持会话', '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...': '验证中...', } diff --git a/web/src/lang/backend/zh-cn/routine/adminInfo.ts b/web/src/lang/backend/zh-cn/routine/adminInfo.ts index fec914f..505dd01 100644 --- a/web/src/lang/backend/zh-cn/routine/adminInfo.ts +++ b/web/src/lang/backend/zh-cn/routine/adminInfo.ts @@ -11,4 +11,7 @@ export default { 'Please leave blank if not modified': '不修改请留空', 'Save changes': '保存修改', 'Operation log': '操作日志', + 'Google Authenticator': '谷歌验证器', + Bound: '已绑定', + 'Not bound': '未绑定', } diff --git a/web/src/views/backend/auth/admin/index.vue b/web/src/views/backend/auth/admin/index.vue index a05c6dd..3782c70 100644 --- a/web/src/views/backend/auth/admin/index.vue +++ b/web/src/views/backend/auth/admin/index.vue @@ -25,6 +25,7 @@ import Table from '/@/components/table/index.vue' import TableHeader from '/@/components/table/header/index.vue' import { defaultOptButtons } from '/@/components/table' import { baTableApi } from '/@/api/common' +import { resetTotp } from '/@/api/backend/auth/admin' import { useAdminInfo } from '/@/stores/adminInfo' import { useI18n } from 'vue-i18n' @@ -35,10 +36,41 @@ defineOptions({ const { t } = useI18n() const adminInfo = useAdminInfo() -const optButtons = defaultOptButtons(['edit', 'delete']) -optButtons[1].display = (row) => { - return row.id != adminInfo.id -} +const optButtons: OptButton[] = [ + { + 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( 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.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.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'), prop: 'last_login_time', @@ -73,7 +115,7 @@ const baTable = new baTableClass( { label: t('Operate'), align: 'center', - width: '100', + width: '140', render: 'buttons', buttons: optButtons, operator: false, diff --git a/web/src/views/backend/login.vue b/web/src/views/backend/login.vue index 5dbd260..234c634 100644 --- a/web/src/views/backend/login.vue +++ b/web/src/views/backend/login.vue @@ -23,7 +23,15 @@