Web Authn(3/3)

修改登录逻辑,实现WebAuthn验证
This commit is contained in:
Chenx221 2024-03-19 17:11:41 +08:00
parent 043e70ab7d
commit 07caef2555
Signed by: chenx221
GPG Key ID: D7A9EC07024C3021
6 changed files with 251 additions and 67 deletions

View File

@ -16,7 +16,6 @@ use Webauthn\AuthenticatorAssertionResponse;
use Webauthn\AuthenticatorAssertionResponseValidator;
use Webauthn\AuthenticatorAttestationResponse;
use Webauthn\AuthenticatorAttestationResponseValidator;
use Webauthn\CeremonyStep\CeremonyStepManager;
use Webauthn\CeremonyStep\CeremonyStepManagerFactory;
use Webauthn\Denormalizer\WebauthnSerializerFactory;
use Webauthn\Exception\AuthenticatorResponseVerificationException;
@ -63,12 +62,12 @@ class UserController extends Controller
],
[
'allow' => true,
'actions' => ['login', 'register', 'verify-two-factor'],
'actions' => ['login', 'register', 'verify-two-factor', 'verify-assertion', 'request-assertion-options'],
'roles' => ['?', '@'], // everyone can access public share
],
[
'allow' => true,
'actions' => ['logout', 'setup-two-factor', 'change-password', 'download-recovery-codes', 'remove-two-factor', 'set-theme', 'change-name', 'create-credential-options', 'create-credential', 'request-assertion-options', 'verify-assertion', 'credential-list', 'credential-delete'],
'actions' => ['logout', 'setup-two-factor', 'change-password', 'download-recovery-codes', 'remove-two-factor', 'set-theme', 'change-name', 'create-credential-options', 'create-credential', 'credential-list', 'credential-delete'],
'roles' => ['@'], // only logged-in user can do these ( admin included )
]
],
@ -174,14 +173,16 @@ class UserController extends Controller
}
/**
* Displays the login page.
* visit via https://devs.chenx221.cyou:8081/index.php?r=user%2Flogin
* 显示登录页并接收登录请求
* GET时显示登录页如果带有username参数则直接到输入密码页
* POST时验证用户名密码如果用户启用了二步验证则重定向到二步验证页面
*
* @param string|null $username
* @return string|Response
* @throws InvalidConfigException
* @throws \yii\httpclient\Exception
*/
public function actionLogin(): Response|string
public function actionLogin(string $username = null): Response|string
{
if (!Yii::$app->user->isGuest) {
Yii::$app->session->setFlash('error', '账户已登录,请不要重复登录');
@ -190,36 +191,69 @@ class UserController extends Controller
$model = new User(['scenario' => 'login']);
if ($model->load(Yii::$app->request->post()) && $model->validate()) {
// 根据 verifyProvider 的值选择使用哪种验证码服务
list($verifyProvider, $captchaResponse, $isCaptchaValid) = $this->checkCaptcha();
if (($captchaResponse !== null && $isCaptchaValid) || ($verifyProvider === 'None')) {
// 验证用户名和密码
if (($model->load(Yii::$app->request->post()) && $model->validate()) | $username !== null) {
if ($model->password === null) {
if ($username !== null) {
$model->username = $username;
}
$user = User::findOne(['username' => $model->username]);
if ($user !== null && $user->validatePassword($model->password)) {
// 如果用户启用了二步验证,将用户重定向到二步验证页面
if ($user->is_otp_enabled) {
Yii::$app->session->set('login_verification', ['id' => $user->id]);
return $this->redirect(['user/verify-two-factor']);
} else {
//login without 2FA
$user->last_login = date('Y-m-d H:i:s');
$user->last_login_ip = Yii::$app->request->userIP;
if (!$user->save(false)) {
Yii::$app->session->setFlash('error', '登陆成功,但出现了内部错误');
}
Yii::$app->user->login($user, $model->rememberMe ? 3600 * 24 * 30 : 0);
return $this->goHome();
}
} else {
Yii::$app->session->setFlash('error', '用户名密码错误或账户已禁用');
if ($user === null) {
Yii::$app->session->setFlash('error', '用户不存在');
return $this->render('login', [
'model' => $model,
]);
} elseif ($user->status === 0) {
Yii::$app->session->setFlash('error', '用户已停用,请联系管理员获取支持');
return $this->render('login', [
'model' => $model,
]);
}
$publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository();
$fidoCredentials = $publicKeyCredentialSourceRepository->findAllForUserEntity($user);
if (empty($fidoCredentials) | $username !== null) { //未设置FIDO
return $this->render('_login1', [
'model' => $model,
]);
} else { //已设置FIDO
return $this->render('_login2', [
'model' => $model,
]);
}
} else {
Yii::$app->session->setFlash('error', '请等待验证码加载并完成验证');
// 根据 verifyProvider 的值选择使用哪种验证码服务
list($verifyProvider, $captchaResponse, $isCaptchaValid) = $this->checkCaptcha();
if (($captchaResponse !== null && $isCaptchaValid) || ($verifyProvider === 'None')) {
// 验证用户名和密码
$user = User::findOne(['username' => $model->username]);
if ($user !== null && $user->validatePassword($model->password)) {
// 如果用户启用了二步验证,将用户重定向到二步验证页面
if ($user->is_otp_enabled) {
Yii::$app->session->set('login_verification', ['id' => $user->id]);
return $this->redirect(['user/verify-two-factor']);
} else {
//login without 2FA
$user->last_login = date('Y-m-d H:i:s');
$user->last_login_ip = Yii::$app->request->userIP;
if (!$user->save(false)) {
Yii::$app->session->setFlash('error', '登陆成功,但出现了内部错误');
}
Yii::$app->user->login($user, $model->rememberMe ? 3600 * 24 * 30 : 0);
return $this->goHome();
}
} else {
Yii::$app->session->setFlash('error', '用户名密码错误或账户已禁用');
}
} else {
Yii::$app->session->setFlash('error', '请等待验证码加载并完成验证');
}
}
} else {
return $this->render('login', [
'model' => $model,
]);
}
return $this->render('login', [
return $this->render('_login1', [
'model' => $model,
]);
}
@ -703,13 +737,24 @@ class UserController extends Controller
/**
* 请求验证选项
* @param string|null $username
* @return Response
* @throws RandomException
*/
public function actionRequestAssertionOptions(): Response
public function actionRequestAssertionOptions(string $username = null): Response
{
$user = Yii::$app->user->identity;
$user = null;
if ($username !== null) {
$user = User::findOne(['username' => $username]);
if ($user === null) {
return $this->asJson(['message' => 'User not found']);
}
} else {
$user = Yii::$app->user->identity;
}
if ($user === null) {
return $this->asJson(['message' => 'Guest? Sure?']);
}
$publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository();
$registeredAuthenticators = $publicKeyCredentialSourceRepository->findAllForUserEntity($user);
@ -736,10 +781,12 @@ class UserController extends Controller
/**
* 验证断言
* 用于已登录情况下验证fifo设置是否成功
* @param int $is_login
* @param int $remember
* @return Response
* @throws JsonException
*/
public function actionVerifyAssertion(): Response
public function actionVerifyAssertion(int $is_login = 0,int $remember = 0): Response
{
$data = Yii::$app->request->getRawBody();
@ -787,9 +834,19 @@ class UserController extends Controller
return $this->asJson(['message' => $e->getMessage(), 'verified' => false]);
}
if ($is_login === 1) {
$user = User::findOne(['id' => $publicKeyCredentialSourceRepository1->user_id]);
$user->last_login = date('Y-m-d H:i:s');
$user->last_login_ip = Yii::$app->request->userIP;
if (!$user->save(false)) {
Yii::$app->session->setFlash('error', '登陆成功,但出现了内部错误');
}
Yii::$app->user->login($user, $remember===1 ? 3600 * 24 * 30 : 0);
}
// Optional, but highly recommended, you can save the credential source as it may be modified
// during the verification process (counter may be higher).
$publicKeyCredentialSourceRepository1->saveCredential($publicKeyCredentialSource, '',false);
$publicKeyCredentialSourceRepository1->saveCredential($publicKeyCredentialSource, '', false);
return $this->asJson(['verified' => true]);
}

View File

@ -69,10 +69,10 @@ class User extends ActiveRecord implements IdentityInterface
['input_vault_secret', 'string', 'min' => 6, 'max' => 24],
[['encryption_key', 'otp_secret', 'recovery_codes', 'vault_secret'], 'string', 'max' => 255],
[['last_login_ip'], 'string', 'max' => 45],
[['username', 'password'], 'required', 'on' => 'login'],
[['username'], 'required', 'on' => 'login'],
[['username', 'password', 'email', 'password2'], 'required', 'on' => 'register'],
['username', 'string', 'min' => 3, 'max' => 12],
['password', 'string', 'min' => 6, 'max' => 24, 'on' => 'register'],
['password', 'string', 'min' => 6, 'max' => 24],
['password2', 'compare', 'compareAttribute' => 'password', 'on' => 'register'],
['email', 'email', 'on' => 'register'],
['username', 'unique', 'on' => 'register'],

54
views/user/_login1.php Normal file
View File

@ -0,0 +1,54 @@
<?php
// username+password verify
use yii\bootstrap5\Html;
use yii\bootstrap5\ActiveForm;
use yii\helpers\Url;
use yii\web\JqueryAsset;
use yii\web\View;
use yii\widgets\Pjax;
/** @var yii\web\View $this */
/** @var app\models\User $model */
/** @var ActiveForm $form */
$this->title = '用户登录';
$this->params['breadcrumbs'][] = $this->title;
$verifyProvider = Yii::$app->params['verifyProvider'];
if ($verifyProvider === 'reCAPTCHA') {
$this->registerJsFile('https://www.recaptcha.net/recaptcha/api.js?hl=zh-CN', ['async' => true, 'defer' => true]);
} elseif ($verifyProvider === 'hCaptcha') {
$this->registerJsFile('https://js.hcaptcha.com/1/api.js?hl=zh-CN', ['async' => true, 'defer' => true]);
} elseif ($verifyProvider === 'Turnstile') {
$this->registerJsFile('https://challenges.cloudflare.com/turnstile/v0/api.js', ['async' => true, 'defer' => true]);
} ?>
<div class="user-login">
<h1><?= Html::encode($this->title) ?></h1>
<p>我们已经找到了你的账户,请在下方输入你的密码以继续:</p>
<div class="row">
<div class="col-lg-5">
<?php $form = ActiveForm::begin(['action' => [Url::to('user/login')]]); ?>
<?= $form->field($model, 'username')->label('用户名')->textInput(['autofocus' => true, 'readonly' => true]) ?>
<?= $form->field($model, 'password')->passwordInput()->label('密码') ?>
<?= $form->field($model, 'rememberMe')->checkbox()->label('记住本次登录') ?>
<div class="form-group">
<?php if ($verifyProvider === 'reCAPTCHA'): ?>
<div class="g-recaptcha" data-sitekey="<?= Yii::$app->params['reCAPTCHA']['siteKey'] ?>"></div>
<?php elseif ($verifyProvider === 'hCaptcha'): ?>
<div class="h-captcha" data-sitekey="<?= Yii::$app->params['hCaptcha']['siteKey'] ?>"></div>
<?php elseif ($verifyProvider === 'Turnstile'): ?>
<div class="cf-turnstile" data-sitekey="<?= Yii::$app->params['Turnstile']['siteKey'] ?>"
data-language="zh-cn"></div>
<?php endif; ?>
</div>
<div class="form-group">
<?= Html::submitButton('登录', ['class' => 'btn btn-primary']) ?>
</div>
<?php ActiveForm::end(); ?>
<div class="form-group">
<?= Html::a('回到上个页面', [Url::to('user/login')]) ?>
</div>
</div>
</div>
</div><!-- user-login -->

45
views/user/_login2.php Normal file
View File

@ -0,0 +1,45 @@
<?php
// username+fido verify
use app\assets\SimpleWebAuthnBrowser;
use yii\bootstrap5\Html;
use yii\bootstrap5\ActiveForm;
use yii\helpers\Url;
use yii\web\JqueryAsset;
use yii\web\View;
/** @var yii\web\View $this */
/** @var app\models\User $model */
/** @var ActiveForm $form */
JqueryAsset::register($this);
SimpleWebAuthnBrowser::register($this);
$this->title = '用户登录';
$this->params['breadcrumbs'][] = $this->title;
?>
<div class="user-login">
<h1><?= Html::encode($this->title) ?></h1>
<p>我们已经找到了你的账户请在下方验证Passkey以继续:</p>
<div class="alert alert-success" role="alert" hidden>
<span id="webauthn_success"></span>
</div>
<div class="alert alert-danger" role="alert" hidden>
<span id="webauthn_error"></span>
</div>
<div class="row">
<div class="col-lg-5">
<?php $form = ActiveForm::begin(['action' => '#']); ?>
<?= $form->field($model, 'username')->label('用户名')->textInput(['autofocus' => true, 'readonly' => true, 'id' => 'username']) ?>
<?= $form->field($model, 'rememberMe')->checkbox(['id' => 'rememberMe'])->label('记住本次登录') ?>
<div class="form-group">
<?= Html::button('登录', ['class' => 'btn btn-primary', 'id' => 'webauthn_verify']) ?>
</div>
<?php ActiveForm::end(); ?>
<div class="form-group">
<?= Html::a('不想使用Passkey? 回到传统登录方式', Url::to(['user/login', 'username' => $model->username])) ?>
</div>
</div>
</div>
</div><!-- user-login -->
<?php
$this->registerJsFile('@web/js/login-core.js', ['depends' => [JqueryAsset::class], 'position' => View::POS_END]);
?>

View File

@ -2,52 +2,33 @@
use yii\bootstrap5\Html;
use yii\bootstrap5\ActiveForm;
use yii\web\JqueryAsset;
use yii\web\View;
use yii\widgets\Pjax;
/** @var yii\web\View $this */
/** @var app\models\User $model */
/** @var ActiveForm $form */
$this->title = '用户登录';
$this->params['breadcrumbs'][] = $this->title;
$verifyProvider = Yii::$app->params['verifyProvider'];
if ($verifyProvider === 'reCAPTCHA') {
$this->registerJsFile('https://www.recaptcha.net/recaptcha/api.js?hl=zh-CN', ['async' => true, 'defer' => true]);
} elseif ($verifyProvider === 'hCaptcha') {
$this->registerJsFile('https://js.hcaptcha.com/1/api.js?hl=zh-CN', ['async' => true, 'defer' => true]);
} elseif ($verifyProvider === 'Turnstile') {
$this->registerJsFile('https://challenges.cloudflare.com/turnstile/v0/api.js', ['async' => true, 'defer' => true]);
} ?>
?>
<div class="user-login">
<h1><?= Html::encode($this->title) ?></h1>
<p>请在下方输入你的用户凭证:</p>
<p>请在下方输入你的用户名:</p>
<div class="row">
<div class="col-lg-5">
<?php $form = ActiveForm::begin(); ?>
<?= $form->field($model, 'username')->label('用户名')->textInput(['autofocus' => true]) ?>
<?= $form->field($model, 'password')->passwordInput()->label('密码') ?>
<?= $form->field($model, 'rememberMe')->checkbox()->label('记住本次登录') ?>
<div class="form-group">
<?php if ($verifyProvider === 'reCAPTCHA'): ?>
<div class="g-recaptcha" data-sitekey="<?= Yii::$app->params['reCAPTCHA']['siteKey'] ?>"></div>
<?php elseif ($verifyProvider === 'hCaptcha'): ?>
<div class="h-captcha" data-sitekey="<?= Yii::$app->params['hCaptcha']['siteKey'] ?>"></div>
<?php elseif ($verifyProvider === 'Turnstile'): ?>
<div class="cf-turnstile" data-sitekey="<?= Yii::$app->params['Turnstile']['siteKey'] ?>" data-language="zh-cn"></div>
<?php endif; ?>
</div>
<div class="form-group">
<?= Html::submitButton('登录', ['class' => 'btn btn-primary']) ?>
</div>
<div class="form-group">
<?= Html::a('还没有账户? 点击注册', ['user/register']) ?>
</div>
<div class="form-group">
<?= Html::a('忘记密码?', ['user/forget']) ?>
<?= Html::submitButton('下一步', ['class' => 'btn btn-primary']) ?>
</div>
<?php ActiveForm::end(); ?>
<div class="form-group">
<?= Html::a('还没有账户? 去注册', ['user/register']) ?>
</div>
</div>
</div>
</div><!-- user-login -->
</div><!-- user-login -->

47
web/js/login-core.js Normal file
View File

@ -0,0 +1,47 @@
const elemSuccess = document.getElementById('webauthn_success');
const elemError = document.getElementById('webauthn_error');
const { startAuthentication } = SimpleWebAuthnBrowser;
const elemBegin_v = document.getElementById('webauthn_verify');
const username_input = document.getElementById('username');
const remember = document.getElementById('rememberMe');
elemBegin_v.addEventListener('click', async () => {
elemSuccess.innerHTML = '';
elemError.innerHTML = '';
elemSuccess.parentElement.hidden = true;
elemError.parentElement.hidden = true;
const username = encodeURIComponent(username_input.value);
const resp = await fetch(`index.php?r=user%2Frequest-assertion-options&username=${username}`);
let asseResp;
try {
asseResp = await startAuthentication(await resp.json());
} catch (error) {
elemError.innerText = error;
elemError.parentElement.hidden = false;
throw error;
}
const isChecked = remember.checked? 1 : 0;
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
const verificationResp = await fetch(`index.php?r=user%2Fverify-assertion&is_login=1&remember=${isChecked}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify(asseResp),
});
const verificationJSON = await verificationResp.json();
if (verificationJSON && verificationJSON.verified) {
elemSuccess.innerHTML = '登录成功1s后跳转到首页';
elemSuccess.parentElement.hidden = false;
setTimeout(() => {
window.location.href = 'index.php';
}, 1000);
} else {
elemError.innerHTML = `Oh no, something went wrong! Response: <pre>${JSON.stringify(
verificationJSON,
)}</pre>`;
elemError.parentElement.hidden = false;
}
});