-
Tech
Как упростить аутентификацию для пользователя
Антон Немцев
-
Проблема
-
Почему нет?
- Нет доверия
- Недостаточно информации
- Сложно
-
Доверие
Пользователь должен думать, что пользоваться вашим сервисом — безопасно
-
-
Доверие
Покажите рейтинги и ревью, убедитесь, что вы активны и доступны в используемых медиа-каналах, добавьте микроразметку
-
Прозрачность
Расскажите зачем вам нужна та или иная информация и как она будет использоваться. А также — как не будет.
-
Простота
Не запрашивайте данные до момента, когда они понадобятся
-
HTML
-
Формы
-
Какие формы?
- Регистрация
- Логин
- Восстановление пароля
-
Семантика!
БИП! -
<form method="post" action="/sign-up/"> <fieldset> <legend>Регистрация</legend> <!-- Поля ввода --> </fieldset> <button type="submit"> Зарегистрироваться </button> </form>
-
<label> Email <small>Мы не будем спамить</small> <input value="" name="email" type="email" placeholder="email@gmail.com"> </label>
-
Атрибуты
-
autocorrect="off" spellcheck="false" autocapitalize="none"
-
Какие ещё атрибуты будут нам полезны?
- inputmode
- autocomplete
-
inputmode
- none
- text
- tel
- url
- numeric
- decimal
- search
-
autocomplete
- tel
- name
- given-name
- family-name
-
autocomplete
- username
- new-password
- current-password
- one-time-code
- …
-
inputmode="email" autocomplete="email"
-
autocomplete="new-password"
-
autocomplete="current-password"
-
inputmode="numeric" autocomplete="one-time-code"
-
`${code} is your authentication code. @${domain} #${code}`
-
iOS ищет слова в близости к цифрам
- code
- passcode
-
Chrome на Android не поддерживает декларативный подход к автозаполнению OTP
-
Automatic Strong Passwords and Security Code AutoFill
-
Origin-bound one-time codes delivered via SMS
-
HTML Living Standard
-
-
Источник данных
-
Твои пароли
зохвачены! БИП! -
Зада-а-a-a-ай na-a-a-a-ame!
-
<StyledInput ref={emailInputRef} type="email" onChange={setEmail} value={email} autoComplete="email" />
-
Chrome и Firefox
- username
- поле перед паролем
-
Safari
- username
- поле перед паролем
-
autocomplete="username email"
-
Восстановление пароля
-
server.use( "/.well-known/change-password", (req, res) => { res.redirect("/reset-password"); });
-
server { rewrite ^/.well-known/change-password$ http://domain.com/reset-password redirect; }
-
Ожидаемые ответы
- 302 Found
- 303 See Other
- 307 Temporary Redirect
-
server.use( "/.well-known/resource-that- should-not-exist-whose-status- code-should-not-be-200", (req, res) => { res.status(404).end(); });
-
Ожидаемый ответ
- 404 Not Found
-
A Well-Known URL for Changing Passwords
-
JavaScript
-
Credential Management
-
navigator.credentials
-
.create(CredentialCreationOptions) .store(Credential) .get(CredentialRequestOptions) .preventSilentAccess()
-
Типы Credential:
- PasswordCredential
- FederatedCredential
- OTPCredential (WebOTP)
- PublicKeyCredential (WebAuthn)
-
PasswordCredential
Регистрация с помощью логина и пароля.
-
CredentialData:
- id
-
PasswordCredential:
- name
- iconURL
- password
- type: "password"
-
const signUpHandler = async (event) => { // Регистрация const form = event.currentTarget; if (window?.PasswordCredential) { const credential = await navigator. credentials.create({ password: form }); navigator.credentials.store(credential); } }
-
const signUpHandler = (event) => { // Регистрация const form = event.currentTarget; if (window?.PasswordCredential) { const credential = new PasswordCredential(form); navigator.credentials.store(credential); } }
-
function signUpHandler (event) { // Регистрация const form = event.currentTarget; const data = new FormData(form); if (window?.PasswordCredential) { // … } }
-
const credential = new PasswordCredential({ id: data.get('email'), name: data.get('firstName'), password: data.get('password'), iconURL: data.get('avatar'), }); navigator.credentials.store(credential);
-
navigator.credentials.get()
-
CredentialRequestOptions:
- mediation
- silent
- optional
- required
- signal
- mediation
-
PasswordCredential:
- password: true
-
(async () => { if (window?.PasswordCredential) { const credential = await navigator.credentials.get({ password: true, mediation: 'silent', }); if (credential === null) return; // Авторизация // Обработка ошибок }})();
-
PasswordCredentialData:
- id
- name
- iconURL
- password
- type: "password"
-
if (window?.PasswordCredential) { const credential = await navigator.credentials.get({ password: true, mediation: 'optional', }); if (credential === null) return; // Авторизация // Обработка ошибок }
-
if (navigator?.credentials ?.preventSilentAccess) { await navigator?.credentials ?.preventSilentAccess(id); }
-
FederatedCredentialData:
- id
- name
- iconURL
- provider
- protocol
- type: "federated"
-
The Credential Management API (Web Fundamentals)
-
Credential Management
-
Web OTP API
-
OTPCredential
-
if ('OTPCredential' in window) { const { code, type } = await navigator.credentials.get({ otp: { transport:['sms'] } }); if (!code) return; // Авторизация }
-
Требования к SMS в Chrome под Android:
- Сообщение должно содержать 4-10 число-буквенных символов, минимум 1 из которых число
-
Требования к SMS в Chrome под Android:
- Сообщение должно быть отправлено с номера, который не принадлежит к контактам пользователя
-
Verify phone numbers on the web with the Web OTP API (Web Fundamentals)
-
Origin-bound one-time codes delivered via SMS
-
Web OTP API
-
isLoggedIn Web API
-
navigator.setLoggedIn navigator.setLoggedOut navigator.isLoggedIn
-
isLoggedIn
Web API -
WebAuthn
-
PublicKeyCredential
-
Кросс-платформенные
-
Платформенные
-
Платформенный ключ:
- Пользователь регистрируется
- Мы спрашиваем разрешения для использования платформенного ключа
- Если пользователь соглашается регистрируем ключ и храним id в браузере
- При попытке аутентификации — используем
-
Кросс-платформенный ключ:
- Пользователь регистрируется
- Даем ему возможность по его инициативе добавить ключи в профиле и храним их id на сервере
- При попытке аутентификации пользователь вводит логин и сервер присылает связанный список ключей
- Если ключи есть вместо или после ввода пароля авторизируется ключем с пином.
-
Регистрация ключа:
- Запрашиваем у сервера вызов (challenge), данные о платформе и пользователе
- Передаем их ключу и получаем PublicKeyCredential
-
Регистрация ключа:
- Отправляем PublicKeyCredential серверу для валидации и сохранения
- Получаем Сredential Id который следует хранить на клиенте
-
if ('PublicKeyCredential' in window) { /* … */ }
-
const hasPlatformAuthenticator = await PublicKeyCredential ?.isUserVerifyingPlatformAuthenticatorAvailable();
-
PublicKeyCredential .is User Verifying Platform Authenticator Available();
-
const data = new FormData(form); const response = await fetch( '/challenge/register', { method: 'POST', body: JSON.stringify({ name: data.get('name'), email: data.get('email'), 'new-password': data.get('new-password'), }), }); const options = await response.json();
-
npm i -S fido2-library
-
const { Fido2Lib } = require("fido2-library"); const f2l = new Fido2Lib({ rpId: HOST, attestation: "none", authenticatorAttachment: "platform", authenticatorUserVerification: "discouraged", });
-
const f2l = new Fido2Lib({ rpId: HOST, attestation: "none", authenticatorAttachment: "cross-platform", authenticatorUserVerification: "required", });
-
fastify.post('/challenge/register/', async (request, reply) => { const { email, name } = JSON.parse(request.body); // Регистрация пользователя // … });
-
const options = await f2l.attestationOptions();
-
{ 🔴 rp: { 🔴 name 🟢 id // origin 🟢 icon }, 🔴 user: { 🔴 id,❗️ 🔴 displayName, 🔴 name, 🟢 icon, }, 🔴 challenge,❗ 🔴 pubKeyCredParams,
🟢 timeout, 🟢 excludeCredentials, 🟢 authenticatorSelection: { authenticatorAttachment: [ "platform", "cross-platform" ], requireResidentKey, userVerification: [ "required", "preferred", "discouraged" ] }, 🟢 attestation, 🟢 extensions, }
-
{ rp: { name: 'Anonymous Service', id: 'nice-cheetah-30.loca.lt' }, user: { id: [ArrayBuffer], name: 'thesilentimp@gmail.com', displayName: 'Антон Немцев' }, challenge: [ArrayBuffer], pubKeyCredParams: [ { type: 'public-key', alg: -7 }, { type: 'public-key', alg: -257 } ], timeout: 60000, attestation: 'none', authenticatorSelection: { userVerification: 'discouraged' }}
-
const toBuffer = (base64) => { const ASCIIString = atob(base64); return Uint8Array.from( ASCIIString, c => c.charCodeAt(0) ).buffer; }
-
const toBase64 = (buffer) => { const binaryString = String.fromCharCode.apply( null, new Uint8Array(buffer)); return btoa(binaryString); }
-
const challengeString = toBuffer(options.challenge); request.session.set( 'challenge', challengeString);
-
options.challenge = challengeString; options.user = { id: nanoid(), // Хэш id из базы name: email, displayName: name, };
-
reply .type('application/json') .code(200) .send(options);
-
options.user.id = toBuffer(options.user.id); options.challenge = toBuffer(options.challenge); const credential = await navigator.credentials.create({ publicKey: options });
-
{ id: "nZOlyXf57erlQbudIIuOrQEdRUs", rawId: [ArrayBuffer], response: { attestationObject: [ArrayBuffer], clientDataJSON: [ArrayBuffer], }, type: "public-key", }
-
const { id, type, response: { clientDataJSON: clientDataBuffer, attestationObject: attestationBuffer, }} = credential;
-
const clientDataJSON = toBase64(clientDataBuffer); const attestationObject = toBase64(attestationBuffer);
-
const attestation = { id, type, response: { clientDataJSON, attestationObject, }, };
-
const body = JSON.stringify(attestation); const response = await fetch(`/register`, { method: "POST", body });
-
const { credentialId } = await response.json(); localStorage .setItem( 'credentialId', credentialId);
-
fastify.post('register/', async (request, reply) => { const { session } = request; const challenge = session.get('challenge'); // … });
-
const assertionExpectations = { challenge, origin: `https://${HOST}`, factor: "either" };
-
const clientAttestationResponse = JSON.parse(request.body); clientAttestationResponse.rawId = toBuffer(clientAttestationResponse.Id);
-
const result = await f2l.attestationResult( clientAttestationResponse, assertionExpectations); const { authnrData } = result;
-
const counter = authnrData.get('counter'); const credentialId = authnrData.get('credId'); const publicKey = authnrData.get('credentialPublicKeyPem'); const { session } = request; session.set('counter', counter); session.set('credentialId', credentialId); session.set('publicKey', publicKey);
-
const credentialIdBuffer = authnrData.get('credId') const credentialId = toBase64(credentialIdBuffer); reply .type('application/json') .code(200) .send({credentialId});
-
Логин с помощью ключа:
- Проверяем есть ли у нас Сredential Id
- Запрашиваем у сервера вызов (challenge) и данные о платформе
-
Логин с помощью ключа:
- Передаем их ключу и получаем PublicKeyCredential
- Отправляем PublicKeyCredential серверу для валидации
-
const credentialId = localStorage.getItem('credentialId'); if (credentialId === null) return;
-
const response = await fetch( '/challenge/login', { method: 'POST' }); const options = await response.json();
-
fastify.post( '/challenge/login', async (request, reply) => { // … });
-
const options = await f2l.assertionOptions();
-
{ challenge: [ArrayBuffer], timeout: 60000, rpId: HOST, userVerification: 'discouraged' }
-
const { session } = request; const challenge = toBuffer(option.challenge); session.set('challenge', challenge); option.challenge = challenge; reply .type('application/json') .code(200) .send(option);
-
const { challenge } = options; options.challenge = toBuffer(challenge); const credentialId = localStorage.getItem('credentialId'); options.allowCredentials = [{ type: "public-key", id: toBuffer(credentialId), }];
-
const credential = await navigator.credentials.get({ publicKey: options });
-
{ id: "FtjWT4fIT9…zs2LRxuG5fBRj9bA", rawId: [ArrayBuffer], response: { authenticatorData: [ArrayBuffer], clientDataJSON: [ArrayBuffer], signature: [ArrayBuffer], userHandle: null, }, type: "public-key", }
-
const { id, type, response: { authenticatorData: authenticatorDataBuffer, clientDataJSON: clientDataJSONBuffer, signature: signatureBuffer, userHandle, } } = credential;
-
const authenticatorData = toBase64(authenticatorDataBuffer); const clientDataJSON = toBase64(clientDataJSONBuffer); const signature = toBase64(signatureBuffer);
-
const authenticator = { id, type, response: { authenticatorData, clientDataJSON, signature, userHandle, }, };
-
const body = JSON.stringify(authenticator); await fetch('/login', { method: "POST", body });
-
const { session } = request; const challenge = session.get('challenge'); const publicKey = session.get('publicKey'); const prevCounter = session.get('counter');
-
const assertionExpectations = { challenge, origin: `https://${HOST}`, factor: "either", publicKey, prevCounter, userHandle, };
-
const clientAssertionResponse = JSON.parse(request.body); clientAssertionResponse.rawId = toBuffer(clientAssertionResponse.id); await f2l.assertionResult( clientAssertionResponse, assertionExpectations );
-
reply.code(204);
-
Приложение, с помощью которого я показал работу WebAuthn
-
A curated list of awesome WebAuthn/FIDO2 resources
-
WebAuthn guide
-
Introduction to WebAuthn API (Yuriy Ackermann)
-
Meet Face ID and Touch ID for the web (WWDC 2020 video)
-
Meet Face ID and Touch ID for the web (Webkit blog post)
-
Web Authentication and Windows Hello
-
Enabling Strong Authentication with WebAuthn
-
Emulate Authenticators and Debug WebAuthn in Chrome DevTools
-
Fido Alliance
-
Yubico (YubiKey)
-
Web Authentication: An API for accessing Public Key Credentials
-
Спасибо за
понимание -