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}`;
+ }
+}