diff --git a/controllers/UserController.php b/controllers/UserController.php index 62a143e..2e4972b 100644 --- a/controllers/UserController.php +++ b/controllers/UserController.php @@ -2,10 +2,30 @@ namespace app\controllers; +use app\models\PublicKeyCredentialSource; use app\models\User; use app\utils\FileSizeHelper; use OTPHP\TOTP; +use Random\RandomException; use ReCaptcha\ReCaptcha; +use Symfony\Component\Serializer\Serializer; +use Throwable; +use Webauthn\AttestationStatement\AttestationStatementSupportManager; +use Webauthn\AttestationStatement\NoneAttestationStatementSupport; +use Webauthn\AuthenticationExtensions\ExtensionOutputCheckerHandler; +use Webauthn\AuthenticatorAssertionResponse; +use Webauthn\AuthenticatorAssertionResponseValidator; +use Webauthn\AuthenticatorAttestationResponse; +use Webauthn\AuthenticatorAttestationResponseValidator; +use Webauthn\Exception\AuthenticatorResponseVerificationException; +use Webauthn\PublicKeyCredential; +use Webauthn\PublicKeyCredentialCreationOptions; +use Webauthn\PublicKeyCredentialDescriptor; +use Webauthn\PublicKeyCredentialRequestOptions; +use Webauthn\PublicKeyCredentialRpEntity; +use Webauthn\PublicKeyCredentialUserEntity; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Encoder\JsonEncoder; use Yii; use yii\base\Exception; use yii\base\InvalidConfigException; @@ -129,13 +149,13 @@ class UserController extends Controller $captchaResponse = null; $isCaptchaValid = false; if ($verifyProvider === 'reCAPTCHA') { - $captchaResponse = Yii::$app->request->post('g-recaptcha-response', null); + $captchaResponse = Yii::$app->request->post('g-recaptcha-response'); $isCaptchaValid = $this->validateRecaptcha($captchaResponse); } elseif ($verifyProvider === 'hCaptcha') { - $captchaResponse = Yii::$app->request->post('h-captcha-response', null); + $captchaResponse = Yii::$app->request->post('h-captcha-response'); $isCaptchaValid = $this->validateHcaptcha($captchaResponse); } elseif ($verifyProvider === 'Turnstile') { - $captchaResponse = Yii::$app->request->post('cf-turnstile-response', null); + $captchaResponse = Yii::$app->request->post('cf-turnstile-response'); $isCaptchaValid = $this->validateTurnstile($captchaResponse); } @@ -324,13 +344,13 @@ class UserController extends Controller $captchaResponse = null; $isCaptchaValid = false; if ($verifyProvider === 'reCAPTCHA') { - $captchaResponse = Yii::$app->request->post('g-recaptcha-response', null); + $captchaResponse = Yii::$app->request->post('g-recaptcha-response'); $isCaptchaValid = $this->validateRecaptcha($captchaResponse); } elseif ($verifyProvider === 'hCaptcha') { - $captchaResponse = Yii::$app->request->post('h-captcha-response', null); + $captchaResponse = Yii::$app->request->post('h-captcha-response'); $isCaptchaValid = $this->validateHcaptcha($captchaResponse); } elseif ($verifyProvider === 'Turnstile') { - $captchaResponse = Yii::$app->request->post('cf-turnstile-response', null); + $captchaResponse = Yii::$app->request->post('cf-turnstile-response'); $isCaptchaValid = $this->validateTurnstile($captchaResponse); } @@ -346,7 +366,7 @@ class UserController extends Controller if (!is_dir($userFolder)) { mkdir($userFolder); } - $secretFolder = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . $model->id.'.secret'; + $secretFolder = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . $model->id . '.secret'; if (!is_dir($secretFolder)) { mkdir($secretFolder); } @@ -548,4 +568,151 @@ class UserController extends Controller } return $this->redirect(['user/info']); } + + /** + * @return Response + * @throws RandomException + */ + public function actionCreateCredentialOptions(): Response + { + $id = Yii::$app->params['domain'] ?? null; + $user = Yii::$app->user->identity; + + $rpEntity = PublicKeyCredentialRpEntity::create( + 'NetDisk Application', + $id + ); + + $hash = md5(strtolower(trim($user->email))); + $gravatarUrl = "https://www.gravatar.com/avatar/$hash"; + + $userEntity = PublicKeyCredentialUserEntity::create( + $user->username, + $user->id, + $user->name, + $gravatarUrl + ); + + $challenge = random_bytes(16); + $publicKeyCredentialCreationOptions = + PublicKeyCredentialCreationOptions::create( + $rpEntity, + $userEntity, + $challenge + ); + + // 将选项存储在会话中,以便在后续的验证步骤中使用 + Yii::$app->session->set('publicKeyCredentialCreationOptions', $publicKeyCredentialCreationOptions); + + // 将选项发送给客户端 + return $this->asJson($publicKeyCredentialCreationOptions); + } + + /** + * @return void + */ + public function actionCreateCredential(): void + { + $data = Yii::$app->request->post('publicKeyCredential'); + $serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); + $publicKeyCredential = $serializer->deserialize($data, PublicKeyCredential::class, 'json'); + $authenticatorAttestationResponse = $publicKeyCredential->response; + if (!$authenticatorAttestationResponse instanceof AuthenticatorAttestationResponse) { + //e.g. process here with a redirection to the public key creation page. + } + $attestationStatementSupportManager = AttestationStatementSupportManager::create(); + $attestationStatementSupportManager->add(NoneAttestationStatementSupport::create()); + $extensionOutputCheckerHandler = ExtensionOutputCheckerHandler::create(); + $authenticatorAttestationResponseValidator = AuthenticatorAttestationResponseValidator::create( + $attestationStatementSupportManager, + null, //Deprecated Public Key Credential Source Repository. Please set null. + null, //Deprecated Token Binding Handler. Please set null. + $extensionOutputCheckerHandler + ); + $publicKeyCredentialCreationOptions = Yii::$app->session->get('publicKeyCredentialCreationOptions'); + try { + $publicKeyCredentialSource = $authenticatorAttestationResponseValidator->check( + $authenticatorAttestationResponse, + $publicKeyCredentialCreationOptions, + Yii::$app->params['domain'] ?? null + ); + // 创建一个PublicKeyCredentialSourceRepository对象 + $publicKeyCredentialSourceRepository = new PublicKeyCredentialSource(); + + // 保存PublicKeyCredentialSource对象到数据库 + $publicKeyCredentialSourceRepository->saveCredential($publicKeyCredentialSource); + + Yii::$app->session->setFlash('success', '公钥凭证已创建'); + } catch (Throwable $e) { + //e.g. process here with a redirection to the public key creation page. + //show error message + } + } + + /** + * @return Response + * @throws RandomException + */ + public function actionRequestAssertionOptions(): Response + { + $user = Yii::$app->user->identity; + + $publicKeyCredentialSourceRepository = new PublicKeyCredentialSource(); + $registeredAuthenticators = $publicKeyCredentialSourceRepository->findAllForUserEntity($user); + + $allowedCredentials = array_map( + static function (PublicKeyCredentialSource $credential): PublicKeyCredentialDescriptor { + return $credential->getPublicKeyCredentialDescriptor(); + }, + $registeredAuthenticators + ); + $publicKeyCredentialRequestOptions = + PublicKeyCredentialRequestOptions::create( + random_bytes(32), // Challenge + allowCredentials: $allowedCredentials + ); + Yii::$app->session->set('publicKeyCredentialRequestOptions', $publicKeyCredentialRequestOptions); + + return $this->asJson($publicKeyCredentialRequestOptions); + } + + /** + * + * @return void + */ + public function actionVerifyAssertion(): void + { + $data = Yii::$app->request->post('publicKeyCredential'); + $serializer = new Serializer([new ObjectNormalizer()], [new JsonEncoder()]); + $publicKeyCredential = $serializer->deserialize($data, PublicKeyCredential::class, 'json'); + $authenticatorAssertionResponse = $publicKeyCredential->response; + if (!$authenticatorAssertionResponse instanceof AuthenticatorAssertionResponse) { + //e.g. process here with a redirection to the public key login/MFA page. + } + $publicKeyCredentialSourceRepository = new PublicKeyCredentialSource(); + $publicKeyCredentialSource = $publicKeyCredentialSourceRepository->findOneByCredentialId( + $publicKeyCredential->rawId + ); + if ($publicKeyCredentialSource === null) { + // Throw an exception if the credential is not found. + // It can also be rejected depending on your security policy (e.g. disabled by the user because of loss) + } + $authenticatorAssertionResponseValidator = AuthenticatorAssertionResponseValidator::create(); + $publicKeyCredentialRequestOptions = Yii::$app->session->get('publicKeyCredentialRequestOptions'); + try { + $publicKeyCredentialSource = $authenticatorAssertionResponseValidator->check( + $publicKeyCredentialSource, + $authenticatorAssertionResponse, + $publicKeyCredentialRequestOptions, + Yii::$app->params['domain'] ?? null, + null //Deprecated Token Binding Handler. Please set null. + ); + } catch (AuthenticatorResponseVerificationException $e) { + } + +// Optional, but highly recommended, you can save the credential source as it may be modified +// during the verification process (counter may be higher). + $publicKeyCredentialSourceRepository->saveCredential($publicKeyCredentialSource); + } + } diff --git a/models/PublicKeyCredentialSource.php b/models/PublicKeyCredentialSource.php new file mode 100644 index 0000000..12620bb --- /dev/null +++ b/models/PublicKeyCredentialSource.php @@ -0,0 +1,115 @@ + true, 'targetClass' => User::class, 'targetAttribute' => ['user_id' => 'id']], + ]; + } + + /** + * {@inheritdoc} + */ + public function attributeLabels() + { + return [ + 'id' => 'ID', + 'user_id' => 'User ID', + 'public_key_credential_source' => 'Public Key Credential Source', + ]; + } + + /** + * Gets query for [[User]]. + * + * @return \yii\db\ActiveQuery + */ + public function getUser() + { + return $this->hasOne(User::class, ['id' => 'user_id']); + } + + /** + * 获取用户相关的所有公钥凭证 + * @param User $user + * @return \Webauthn\PublicKeyCredentialSource[] + */ + public function findAllForUserEntity(User $user): array + { + $records = self::findAll(['user_id' => $user->id]); + $publicKeyCredentialSources = []; + $webauthnSerializerFactory = new WebauthnSerializerFactory(new AttestationStatementSupportManager()); + foreach ($records as $record) { + $publicKeyCredentialSource = $webauthnSerializerFactory->create()->deserialize($record->public_key_credential_source, \Webauthn\PublicKeyCredentialSource::class, 'json'); + $publicKeyCredentialSources[] = $publicKeyCredentialSource; + } + return $publicKeyCredentialSources; + } + + /** + * 从数据库中获取指定$id的记录,转为PublicKeyCredentialSource对象 + * @param int $id + * @return \Webauthn\PublicKeyCredentialSource|null + */ + public function findOneByCredentialId(int $id): ?\Webauthn\PublicKeyCredentialSource + { + $record = self::findOne(['id' => $id]); + if ($record === null) { + return null; + } + $webauthnSerializerFactory = new WebauthnSerializerFactory(new AttestationStatementSupportManager()); + return $webauthnSerializerFactory->create()->deserialize($record->public_key_credential_source, \Webauthn\PublicKeyCredentialSource::class, 'json'); + } + + /** + * 保存PublicKeyCredentialSource对象到数据库 + * @param \Webauthn\PublicKeyCredentialSource $PKCS + * @return void + */ + public function saveCredential(\Webauthn\PublicKeyCredentialSource $PKCS): void + { + // Create an instance of WebauthnSerializerFactory + $webauthnSerializerFactory = new WebauthnSerializerFactory(new AttestationStatementSupportManager()); + + // Serialize the PublicKeyCredentialSource object into a JSON string + $publicKeyCredentialSourceJson = $webauthnSerializerFactory->create()->serialize($PKCS, 'json'); + + // Save the JSON string to the public_key_credential_source field in the database + $this->public_key_credential_source = $publicKeyCredentialSourceJson; + $this->user_id = Yii::$app->user->id; + // Save the record to the database + $this->save(); + } +} diff --git a/models/User.php b/models/User.php index 2f9ebc6..08edb4c 100644 --- a/models/User.php +++ b/models/User.php @@ -33,6 +33,7 @@ use yii\web\IdentityInterface; * @property string|null $vault_salt 保险箱加密密钥盐 * * @property CollectionTasks[] $collectionTasks + * @property PublicKeyCredentialSource[] $publicKeyCredentialSources * @property Share[] $shares */ class User extends ActiveRecord implements IdentityInterface @@ -64,7 +65,7 @@ class User extends ActiveRecord implements IdentityInterface return [ [['status', 'is_encryption_enabled', 'is_otp_enabled', 'dark_mode'], 'integer'], [['created_at', 'last_login'], 'safe'], - [['bio', 'totp_input', 'recoveryCode_input', 'name','vault_salt'], 'string'], + [['bio', 'totp_input', 'recoveryCode_input', 'name', 'vault_salt'], 'string'], ['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], @@ -228,6 +229,16 @@ class User extends ActiveRecord implements IdentityInterface return $this->hasMany(CollectionTasks::class, ['user_id' => 'id']); } + /** + * Gets query for [[PublicKeyCredentialSources]]. + * + * @return ActiveQuery + */ + public function getPublicKeyCredentialSources(): ActiveQuery + { + return $this->hasMany(PublicKeyCredentialSource::class, ['user_id' => 'id']); + } + /** * Gets query for [[Shares]]. *