1.增加谷歌验证器

This commit is contained in:
2026-06-24 16:25:31 +08:00
parent 5187330c38
commit 3e88a5dd91
21 changed files with 822 additions and 46 deletions

View File

@@ -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,
]);
}
}

View File

@@ -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);

View File

@@ -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
{

View File

@@ -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',
];

View File

@@ -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' => '登录失败',
];

View File

@@ -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

View File

@@ -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')

View 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);
}
}
}