diff --git a/packages/backend/src/routers/send-pass-recovery-email.js b/packages/backend/src/routers/send-pass-recovery-email.js index b9c9cac0..32b7a034 100644 --- a/packages/backend/src/routers/send-pass-recovery-email.js +++ b/packages/backend/src/routers/send-pass-recovery-email.js @@ -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', { diff --git a/packages/backend/src/routers/set-pass-using-token.js b/packages/backend/src/routers/set-pass-using-token.js index f3df6d04..6f58592a 100644 --- a/packages/backend/src/routers/set-pass-using-token.js +++ b/packages/backend/src/routers/set-pass-using-token.js @@ -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); diff --git a/packages/backend/src/routers/verify-pass-recovery-token.js b/packages/backend/src/routers/verify-pass-recovery-token.js new file mode 100644 index 00000000..be5caa63 --- /dev/null +++ b/packages/backend/src/routers/verify-pass-recovery-token.js @@ -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 . + */ +"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 diff --git a/packages/backend/src/services/PuterAPIService.js b/packages/backend/src/services/PuterAPIService.js index 1427dab0..750ed6c0 100644 --- a/packages/backend/src/services/PuterAPIService.js +++ b/packages/backend/src/services/PuterAPIService.js @@ -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')) diff --git a/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js b/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js index a4fbd691..347892aa 100644 --- a/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js +++ b/packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js @@ -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, diff --git a/src/UI/UIWindowNewPassword.js b/src/UI/UIWindowNewPassword.js index ce5f258b..1b5d579a 100644 --- a/src/UI/UIWindowNewPassword.js +++ b/src/UI/UIWindowNewPassword.js @@ -45,6 +45,42 @@ async function UIWindowNewPassword(options){ h += ``; h += ``; + 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(); diff --git a/src/definitions.js b/src/definitions.js index b2ec9a72..9a6a84c4 100644 --- a/src/definitions.js +++ b/src/definitions.js @@ -17,7 +17,10 @@ * along with this program. If not, see . */ export class Service { - // + init (...a) { + if ( ! this._init ) return; + return this._init(...a) + } }; export const PROCESS_INITIALIZING = { i18n_key: 'initializing' }; diff --git a/src/i18n/translations/en.js b/src/i18n/translations/en.js index c8c1ec33..7ee77764 100644 --- a/src/i18n/translations/en.js +++ b/src/i18n/translations/en.js @@ -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…', diff --git a/src/initgui.js b/src/initgui.js index 165135e8..459c0654 100644 --- a/src/initgui.js +++ b/src/initgui.js @@ -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() { diff --git a/src/services/LocaleService.js b/src/services/LocaleService.js new file mode 100644 index 00000000..dbeeedb9 --- /dev/null +++ b/src/services/LocaleService.js @@ -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}`; + } +}