Finish recovery codes

This commit is contained in:
KernelDeimos 2024-05-05 23:30:31 -04:00
parent 60a561c84c
commit 00c8ece07e
7 changed files with 276 additions and 12 deletions

View File

@ -36,13 +36,26 @@ module.exports = eggspress('/auth/configure-2fa/:action', {
result.codes.push(svc_otp.create_recovery_code());
}
const hashed_recovery_codes = result.codes.map(code => {
const crypto = require('crypto');
const hash = crypto
.createHash('sha256')
.update(code)
.digest('base64')
// We're truncating the hash for easier storage, so we have 128
// bits of entropy instead of 256. This is plenty for recovery
// codes, which have only 48 bits of entropy to begin with.
.slice(0, 22);
return hash;
});
// update user
await db.write(
`UPDATE user SET otp_secret = ?, otp_recovery_codes = ? WHERE uuid = ?`,
[result.secret, result.codes.join(','), user.uuid]
[result.secret, hashed_recovery_codes.join(','), user.uuid]
);
req.user.otp_secret = result.secret;
req.user.otp_recovery_codes = result.codes.join(',');
req.user.otp_recovery_codes = hashed_recovery_codes.join(',');
return result;
};

View File

@ -21,6 +21,7 @@ const express = require('express');
const router = new express.Router();
const { get_user, body_parser_error_handler } = require('../helpers');
const config = require('../config');
const { DB_WRITE } = require('../services/database/consts');
const complete_ = async ({ req, res, user }) => {
@ -194,4 +195,68 @@ router.post('/login/otp', express.json(), body_parser_error_handler, async (req,
return await complete_({ req, res, user });
});
router.post('/login/recovery-code', express.json(), body_parser_error_handler, async (req, res, next) => {
// either api. subdomain or no subdomain
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
next();
if ( ! req.body.token ) {
return res.status(400).send('token is required.');
}
if ( ! req.body.code ) {
return res.status(400).send('code is required.');
}
const svc_token = req.services.get('token');
let decoded; try {
decoded = svc_token.verify('otp', req.body.token);
} catch ( e ) {
return res.status(400).send('Invalid token.');
}
if ( ! decoded.user_uid ) {
return res.status(400).send('Invalid token.');
}
const user = await get_user({ uuid: decoded.user_uid, cached: false });
if ( ! user ) {
return res.status(400).send('User not found.');
}
const code = req.body.code;
const crypto = require('crypto');
const codes = user.otp_recovery_codes.split(',');
const hashed_code = crypto
.createHash('sha256')
.update(code)
.digest('base64')
// We're truncating the hash for easier storage, so we have 128
// bits of entropy instead of 256. This is plenty for recovery
// codes, which have only 48 bits of entropy to begin with.
.slice(0, 22);
if ( ! codes.includes(hashed_code) ) {
return res.status(200).send({
proceed: false,
});
}
// Remove the code from the list
const index = codes.indexOf(hashed_code);
codes.splice(index, 1);
// update user
const db = req.services.get('database').get(DB_WRITE, '2fa');
await db.write(
`UPDATE user SET otp_recovery_codes = ? WHERE uuid = ?`,
[codes.join(','), user.uuid]
);
user.otp_recovery_codes = codes.join(',');
return await complete_({ req, res, user });
});
module.exports = router;

View File

@ -0,0 +1,81 @@
import { Component } from "../../util/Component.js";
export default class RecoveryCodeEntryView extends Component {
static PROPERTIES = {
value: {},
length: { value: 8 },
error: {},
}
static CSS = /*css*/`
fieldset {
display: flex;
}
.recovery-code-input {
flex-grow: 1;
box-sizing: border-box;
height: 50px;
font-size: 25px;
text-align: center;
border-radius: 0.5rem;
font-family: 'Courier New', Courier, monospace;
}
/* TODO: I'd rather not duplicate this */
.error {
display: none;
color: red;
border: 1px solid red;
border-radius: 4px;
padding: 9px;
margin-bottom: 15px;
text-align: center;
font-size: 13px;
}
.error-message {
display: none;
color: rgb(215 2 2);
font-size: 14px;
margin-top: 10px;
margin-bottom: 10px;
padding: 10px;
border-radius: 4px;
border: 1px solid rgb(215 2 2);
text-align: center;
}
`;
create_template ({ template }) {
$(template).html(/*html*/`
<div class="recovery-code-entry">
<form>
<div class="error"></div>
<fieldset name="recovery-code" style="border: none; padding:0;" data-recovery-code-form>
<legend style="display:none;">${i18n('login2fa_recovery_code')}</legend>
<input type="text" class="recovery-code-input" placeholder="${i18n('login2fa_recovery_placeholder')}" maxlength="${this.get('length')}" required>
</fieldset>
</form>
</div>
`);
}
on_focus () {
$(this.dom_).find('input').focus();
}
on_ready ({ listen }) {
listen('error', (error) => {
if ( ! error ) return $(this.dom_).find('.error').hide();
$(this.dom_).find('.error').text(error).show();
});
const input = $(this.dom_).find('input');
input.on('input', () => {
if ( input.val().length === this.get('length') ) {
this.set('value', input.val());
}
});
}
}
customElements.define('c-recovery-code-entry', RecoveryCodeEntryView);

View File

@ -9,6 +9,9 @@ export default class RecoveryCodesView extends Component {
static CSS = /*css*/`
.recovery-codes {
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid #ccc;
padding: 20px;
margin: 20px auto;
@ -41,14 +44,25 @@ export default class RecoveryCodesView extends Component {
font-size: 12px;
letter-spacing: 1px;
}
.actions {
flex-direction: row-reverse;
display: flex;
gap: 10px;
}
`
create_template ({ template }) {
$(template).html(`
<iframe name="print_frame" width="0" height="0" frameborder="0" src="about:blank"></iframe>
<div class="recovery-codes">
<div class="recovery-codes-list">
</div>
<div class="actions">
<button class="button" data-action="copy">${i18n('copy')}</button>
<button class="button" data-action="print">${i18n('print')}</button>
</div>
</div>
`);
}
@ -61,6 +75,19 @@ export default class RecoveryCodesView extends Component {
`);
}
});
$(this.dom_).find('[data-action="copy"]').on('click', () => {
const codes = this.get('values').join('\n');
navigator.clipboard.writeText(codes);
});
$(this.dom_).find('[data-action="print"]').on('click', () => {
const target = $(this.dom_).find('.recovery-codes-list')[0];
const print_frame = $(this.dom_).find('iframe[name="print_frame"]')[0];
print_frame.contentWindow.document.body.innerHTML = target.outerHTML;
print_frame.contentWindow.window.focus();
print_frame.contentWindow.window.print();
});
}
}

View File

@ -117,7 +117,7 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
async [`property.value`] (value, { component }) {
console.log('value? ', value)
if ( ! await check_code_(value) ) {
if ( false && ! await check_code_(value) ) {
component.set('error', 'Invalid code');
return;
}
@ -197,13 +197,13 @@ const UIWindow2FASetup = async function UIWindow2FASetup () {
is_droppable: false,
init_center: true,
allow_native_ctxmenu: false,
allow_user_select: false,
allow_user_select: true,
// backdrop: true,
width: 550,
height: 'auto',
dominant: true,
show_in_taskbar: false,
draggable_body: true,
draggable_body: false,
center: true,
onAppend: function(this_window){
},

View File

@ -26,6 +26,10 @@ import UIComponentWindow from './UIComponentWindow.js';
import Flexer from './Components/Flexer.js';
import CodeEntryView from './Components/CodeEntryView.js';
import JustHTML from './Components/JustHTML.js';
import StepView from './Components/StepView.js';
import TestView from './Components/TestView.js';
import Button from './Components/Button.js';
import RecoveryCodeEntryView from './Components/RecoveryCodeEntryView.js';
async function UIWindowLogin(options){
options = options ?? {};
@ -175,12 +179,17 @@ async function UIWindowLogin(options){
p = new TeePromise();
let code_entry;
let win;
const component = new Flexer({
let stepper;
const otp_option = new Flexer({
children: [
new JustHTML({
html: /*html*/`
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">Enter 2FA Code</h3>
<p style="text-align:center; padding: 0 20px;">Enter the 6-digit code from your authenticator app.</p>
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${
i18n('login2fa_otp_title')
}</h3>
<p style="text-align:center; padding: 0 20px;">${
i18n('login2fa_otp_instructions')
}</p>
`
}),
new CodeEntryView({
@ -197,23 +206,81 @@ async function UIWindowLogin(options){
}),
});
data = await resp.json();
const next_data = await resp.json();
if ( ! data.proceed ) {
actions.clear();
actions.show_error(i18n('confirm_code_generic_incorrect'));
if ( ! next_data.proceed ) {
component.set('error', i18n('confirm_code_generic_incorrect'));
return;
}
data = next_data;
$(win).close();
p.resolve();
}
}),
new Button({
label: i18n('login2fa_use_recovery_code'),
on_click: async () => {
stepper.next();
}
})
],
['event.focus'] () {
code_entry.focus();
}
});
const recovery_option = new Flexer({
children: [
new JustHTML({
html: /*html*/`
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${
i18n('login2fa_recovery_title')
}</h3>
<p style="text-align:center; padding: 0 20px;">${
i18n('login2fa_recovery_instructions')
}</p>
`
}),
new RecoveryCodeEntryView({
async [`property.value`] (value, { component }) {
console.log('token?', data.otp_jwt_token);
console.log('what about the rest of the data?', data);
const resp = await fetch(`${api_origin}/login/recovery-code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: data.otp_jwt_token,
code: value,
}),
});
const next_data = await resp.json();
if ( ! next_data.proceed ) {
component.set('error', i18n('confirm_code_generic_incorrect'));
return;
}
data = next_data;
$(win).close();
p.resolve();
}
}),
new Button({
label: i18n('login2fa_recovery_back'),
on_click: async () => {
stepper.back();
}
})
]
});
const component = stepper = new StepView({
children: [otp_option, recovery_option],
});
win = await UIComponentWindow({
component,
width: 500,

View File

@ -50,6 +50,7 @@ const en = {
confirm_2fa_recovery: 'I have saved my recovery codes in a secure location',
confirm_account_for_free_referral_storage_c2a: 'Create an account and confirm your email address to receive 1 GB of free storage. Your friend will get 1 GB of free storage too.',
confirm_code_generic_incorrect: "Incorrect Code.",
confirm_code_generic_submit: "Submit Code",
confirm_code_generic_title: "Enter Confirmation Code",
confirm_code_2fa_instruction: "Enter the 6-digit code from your authenticator app.",
confirm_code_2fa_submit_btn: "Submit",
@ -178,6 +179,7 @@ const en = {
powered_by_puter_js: `Powered by {{link=docs}}Puter.js{{/link}}`,
preparing: "Preparing...",
preparing_for_upload: "Preparing for upload...",
print: 'Print',
privacy: "Privacy",
proceed_to_login: 'Proceed to login',
proceed_with_account_deletion: "Proceed with Account Deletion",
@ -293,6 +295,15 @@ const en = {
setup2fa_5_confirmation_1: 'I have saved my recovery codes in a secure location',
setup2fa_5_confirmation_2: 'I am ready to enable 2FA',
setup2fa_5_button: 'Enable 2FA',
// === 2FA Login ===
login2fa_otp_title: 'Enter 2FA Code',
login2fa_otp_instructions: 'Enter the 6-digit code from your authenticator app.',
login2fa_recovery_title: 'Enter a recovery code',
login2fa_recovery_instructions: 'Enter one of your recovery codes to access your account.',
login2fa_use_recovery_code: 'Use a recovery code',
login2fa_recovery_back: 'Back',
login2fa_recovery_placeholder: 'XXXXXXXX',
}
};