feat: improve password recovery experience

This commit is contained in:
KernelDeimos 2024-04-29 19:26:29 -04:00
parent c44028f413
commit 04432df554
10 changed files with 190 additions and 13 deletions

View File

@ -23,6 +23,8 @@ const { body_parser_error_handler, get_user, invalidate_cached_user } = require(
const config = require('../config');
const { DB_WRITE } = require('../services/database/consts');
const jwt = require('jsonwebtoken');
// -----------------------------------------------------------------------//
// POST /send-pass-recovery-email
// -----------------------------------------------------------------------//
@ -86,8 +88,16 @@ router.post('/send-pass-recovery-email', express.json(), body_parser_error_handl
);
invalidate_cached_user(user);
// create jwt
const jwt_token = jwt.sign({
user_uid: user.uuid,
token,
// email change invalidates password recovery
email: user.email,
}, config.jwt_secret, { expiresIn: '1h' });
// create link
const rec_link = config.origin + '/action/set-new-password?user=' + user.uuid + '&token=' + token;
const rec_link = config.origin + '/action/set-new-password?token=' + jwt_token;
const svc_email = req.services.get('email');
await svc_email.send_email({ email: user.email }, 'email_password_recovery', {

View File

@ -20,9 +20,14 @@
const express = require('express')
const router = new express.Router()
const config = require('../config')
const { invalidate_cached_user_by_id } = require('../helpers')
const { invalidate_cached_user_by_id, get_user } = require('../helpers')
const { DB_WRITE } = require('../services/database/consts')
const jwt = require('jsonwebtoken');
// Ensure we don't expose branches with differing messages.
const SAFE_NEGATIVE_RESPONSE = 'This password recovery token is no longer valid.';
// -----------------------------------------------------------------------//
// POST /set-pass-using-token
// -----------------------------------------------------------------------//
@ -39,9 +44,6 @@ router.post('/set-pass-using-token', express.json(), async (req, res, next)=>{
// password is required
if(!req.body.password)
return res.status(401).send('password is required')
// user_id is required
else if(!req.body.user_id)
return res.status(401).send('user_id is required')
// token is required
else if(!req.body.token)
return res.status(401).send('token is required')
@ -57,14 +59,21 @@ router.post('/set-pass-using-token', express.json(), async (req, res, next)=>{
return res.status(429).send('Too many requests.');
}
const { token, user_uid, email } = jwt.verify(req.body.token, config.jwt_secret);
const user = await get_user({ uuid: user_uid, force: true });
if ( user.email !== email ) {
return res.status(400).send(SAFE_NEGATIVE_RESPONSE);
}
try{
const info = await db.write(
'UPDATE user SET password=?, pass_recovery_token=NULL WHERE `uuid` = ? AND pass_recovery_token = ?',
[await bcrypt.hash(req.body.password, 8), req.body.user_id, req.body.token]
[await bcrypt.hash(req.body.password, 8), user_uid, token],
);
if ( ! info?.anyRowsAffected ) {
return res.status(400).send('Invalid token or user_id.');
return res.status(400).send(SAFE_NEGATIVE_RESPONSE);
}
invalidate_cached_user_by_id(req.body.user_id);

View File

@ -0,0 +1,61 @@
/*
* Copyright (C) 2024 Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
"use strict"
const express = require('express')
const router = new express.Router()
const config = require('../config')
const { invalidate_cached_user_by_id, get_user } = require('../helpers')
const { DB_WRITE } = require('../services/database/consts')
const jwt = require('jsonwebtoken');
// Ensure we don't expose branches with differing messages.
const SAFE_NEGATIVE_RESPONSE = 'This password recovery token is no longer valid.';
// -----------------------------------------------------------------------//
// POST /verify-pass-recovery-token
// -----------------------------------------------------------------------//
router.post('/verify-pass-recovery-token', express.json(), async (req, res, next)=>{
// check subdomain
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
next();
if ( ! req.body.token ) {
return res.status(401).send('token is required')
}
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
if ( ! svc_edgeRateLimit.check('verify-pass-recovery-token') ) {
return res.status(429).send('Too many requests.');
}
const { exp, user_uid, email } = jwt.verify(req.body.token, config.jwt_secret);
const user = await get_user({ uuid: user_uid, force: true });
if ( user.email !== email ) {
return res.status(400).send(SAFE_NEGATIVE_RESPONSE);
}
const current_time = Math.floor(Date.now() / 1000);
const time_remaining = exp - current_time;
return res.status(200).send({ time_remaining });
})
module.exports = router

View File

@ -63,6 +63,7 @@ class PuterAPIService extends BaseService {
app.use(require('../routers/send-confirm-email'))
app.use(require('../routers/send-pass-recovery-email'))
app.use(require('../routers/set-desktop-bg'))
app.use(require('../routers/verify-pass-recovery-token'))
app.use(require('../routers/set-pass-using-token'))
app.use(require('../routers/set_layout'))
app.use(require('../routers/set_sort_by'))

View File

@ -31,6 +31,10 @@ class EdgeRateLimitService extends BaseService {
limit: 10,
window: HOUR,
},
['verify-pass-recovery-token']: {
limit: 10,
window: 15 * MINUTE,
},
['set-pass-using-token']: {
limit: 10,
window: HOUR,

View File

@ -45,6 +45,42 @@ async function UIWindowNewPassword(options){
h += `<button class="change-password-btn button button-primary button-block button-normal">${i18n('set_new_password')}</button>`;
h += `</div>`;
const response = await fetch(api_origin + "/verify-pass-recovery-token", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
token: options.token,
})
});
if( response.status !== 200 ) {
if ( response.status === 429 ) {
await UIAlert({
message: i18n('password_recovery_rate_limit', [], false),
});
return;
}
if ( response.status === 400 ) {
await UIAlert({
message: i18n('password_recovery_token_invalid', [], false),
});
return;
}
await UIAlert({
message: i18n('password_recovery_unknown_error', [], false),
});
return;
}
const response_data = await response.json();
console.log('response_data', response_data);
let time_remaining = response_data.time_remaining;
const el_window = await UIWindow({
title: 'Set New Password',
app: 'change-passowrd',
@ -78,6 +114,26 @@ async function UIWindowNewPassword(options){
}
})
const expiration_clock = setInterval(() => {
time_remaining -= 1;
if( time_remaining <= 0 ) {
clearInterval(expiration_clock);
$(el_window).find('.change-password-btn').prop('disabled', true);
$(el_window).find('.change-password-btn').html('Token Expired');
return;
}
const svc_locale = globalThis.services.get('locale');
const countdown = svc_locale.format_duration(time_remaining);
$(el_window).find('.change-password-btn').html(`Set New Password (${countdown})`);
}, 1000);
el_window.on_close = () => {
clearInterval(expiration_clock);
};
$(el_window).find('.change-password-btn').on('click', function(e){
const new_password = $(el_window).find('.new-password').val();
const confirm_new_password = $(el_window).find('.confirm-new-password').val();
@ -111,7 +167,6 @@ async function UIWindowNewPassword(options){
data: JSON.stringify({
password: new_password,
token: options.token,
user_id: options.user,
}),
success: async function (data){
$(el_window).close();

View File

@ -17,7 +17,10 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
export class Service {
//
init (...a) {
if ( ! this._init ) return;
return this._init(...a)
}
};
export const PROCESS_INITIALIZING = { i18n_key: 'initializing' };

View File

@ -151,6 +151,9 @@ const en = {
oss_code_and_content: "Open Source Software and Content",
password: "Password",
password_changed: "Password changed.",
password_recovery_rate_limit: "You've reached our rate-limit; please wait a few minutes. To prevent this in the future, avoid reloading the page too many times.",
password_recovery_token_invalid: "This password recovery token is no longer valid.",
password_recovery_unknown_error: "An unknown error occurred. Please try again later.",
password_required: 'Password is required.',
password_strength_error: "Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one number, and one special character.",
passwords_do_not_match: '`New Password` and `Confirm New Password` do not match.',
@ -198,6 +201,7 @@ const en = {
save_session: 'Save session',
save_session_c2a: 'Create an account to save your current session and avoid losing your work.',
scan_qr_c2a: 'Scan the code below to log into this session from other devices',
seconds: 'seconds',
select: "Select",
selected: 'selected',
select_color: 'Select color…',

View File

@ -38,6 +38,7 @@ import { ThemeService } from './services/ThemeService.js';
import { BroadcastService } from './services/BroadcastService.js';
import { ProcessService } from './services/ProcessService.js';
import { PROCESS_RUNNING } from './definitions.js';
import { LocaleService } from './services/LocaleService.js';
const launch_services = async function () {
const services_l_ = [];
@ -53,10 +54,11 @@ const launch_services = async function () {
register('broadcast', new BroadcastService());
register('theme', new ThemeService());
register('process', new ProcessService())
register('process', new ProcessService());
register('locale', new LocaleService());
for (const [_, instance] of services_l_) {
await instance._init();
await instance.init();
}
// Set init process status
@ -141,6 +143,10 @@ window.initgui = async function(){
window.is_fullpage_mode = true;
}
// Launch services before any UI is rendered
await launch_services();
//--------------------------------------------------------------------------------------
// Is GUI embedded in a popup?
// i.e. https://puter.com/?embedded_in_popup=true
@ -1983,8 +1989,6 @@ window.initgui = async function(){
// go to home page
window.location.replace("/");
});
await launch_services();
}
function requestOpenerOrigin() {

View File

@ -0,0 +1,26 @@
import { Service } from "../definitions.js";
import i18n from "../i18n/i18n.js";
export class LocaleService extends Service {
format_duration (seconds) {
console.log('seconds?', typeof seconds, seconds);
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
// Padding each value to ensure it always has two digits
const paddedHours = hours.toString().padStart(2, '0');
const paddedMinutes = minutes.toString().padStart(2, '0');
const paddedSeconds = remainingSeconds.toString().padStart(2, '0');
if (hours === 0 && minutes === 0) {
return `${paddedSeconds} ${i18n('seconds')}`;
}
if (hours === 0) {
return `${paddedMinutes}:${paddedSeconds}`;
}
return `${paddedHours}:${paddedMinutes}:${paddedSeconds}`;
}
}