mirror of
https://github.com/HeyPuter/puter
synced 2024-11-14 22:06:00 +00:00
feat: improve password recovery experience
This commit is contained in:
parent
c44028f413
commit
04432df554
@ -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', {
|
||||
|
@ -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);
|
||||
|
61
packages/backend/src/routers/verify-pass-recovery-token.js
Normal file
61
packages/backend/src/routers/verify-pass-recovery-token.js
Normal 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
|
@ -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'))
|
||||
|
@ -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,
|
||||
|
@ -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();
|
||||
|
@ -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' };
|
||||
|
@ -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…',
|
||||
|
@ -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() {
|
||||
|
26
src/services/LocaleService.js
Normal file
26
src/services/LocaleService.js
Normal 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}`;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user