Skip to content

Commit 04432df

Browse files
committed
feat: improve password recovery experience
1 parent c44028f commit 04432df

File tree

10 files changed

+190
-13
lines changed

10 files changed

+190
-13
lines changed

packages/backend/src/routers/send-pass-recovery-email.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ const { body_parser_error_handler, get_user, invalidate_cached_user } = require(
2323
const config = require('../config');
2424
const { DB_WRITE } = require('../services/database/consts');
2525

26+
const jwt = require('jsonwebtoken');
27+
2628
// -----------------------------------------------------------------------//
2729
// POST /send-pass-recovery-email
2830
// -----------------------------------------------------------------------//
@@ -86,8 +88,16 @@ router.post('/send-pass-recovery-email', express.json(), body_parser_error_handl
8688
);
8789
invalidate_cached_user(user);
8890

91+
// create jwt
92+
const jwt_token = jwt.sign({
93+
user_uid: user.uuid,
94+
token,
95+
// email change invalidates password recovery
96+
email: user.email,
97+
}, config.jwt_secret, { expiresIn: '1h' });
98+
8999
// create link
90-
const rec_link = config.origin + '/action/set-new-password?user=' + user.uuid + '&token=' + token;
100+
const rec_link = config.origin + '/action/set-new-password?token=' + jwt_token;
91101

92102
const svc_email = req.services.get('email');
93103
await svc_email.send_email({ email: user.email }, 'email_password_recovery', {

packages/backend/src/routers/set-pass-using-token.js

+15-6
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@
2020
const express = require('express')
2121
const router = new express.Router()
2222
const config = require('../config')
23-
const { invalidate_cached_user_by_id } = require('../helpers')
23+
const { invalidate_cached_user_by_id, get_user } = require('../helpers')
2424
const { DB_WRITE } = require('../services/database/consts')
2525

26+
const jwt = require('jsonwebtoken');
27+
28+
// Ensure we don't expose branches with differing messages.
29+
const SAFE_NEGATIVE_RESPONSE = 'This password recovery token is no longer valid.';
30+
2631
// -----------------------------------------------------------------------//
2732
// POST /set-pass-using-token
2833
// -----------------------------------------------------------------------//
@@ -39,9 +44,6 @@ router.post('/set-pass-using-token', express.json(), async (req, res, next)=>{
3944
// password is required
4045
if(!req.body.password)
4146
return res.status(401).send('password is required')
42-
// user_id is required
43-
else if(!req.body.user_id)
44-
return res.status(401).send('user_id is required')
4547
// token is required
4648
else if(!req.body.token)
4749
return res.status(401).send('token is required')
@@ -57,14 +59,21 @@ router.post('/set-pass-using-token', express.json(), async (req, res, next)=>{
5759
return res.status(429).send('Too many requests.');
5860
}
5961

62+
const { token, user_uid, email } = jwt.verify(req.body.token, config.jwt_secret);
63+
64+
const user = await get_user({ uuid: user_uid, force: true });
65+
if ( user.email !== email ) {
66+
return res.status(400).send(SAFE_NEGATIVE_RESPONSE);
67+
}
68+
6069
try{
6170
const info = await db.write(
6271
'UPDATE user SET password=?, pass_recovery_token=NULL WHERE `uuid` = ? AND pass_recovery_token = ?',
63-
[await bcrypt.hash(req.body.password, 8), req.body.user_id, req.body.token]
72+
[await bcrypt.hash(req.body.password, 8), user_uid, token],
6473
);
6574

6675
if ( ! info?.anyRowsAffected ) {
67-
return res.status(400).send('Invalid token or user_id.');
76+
return res.status(400).send(SAFE_NEGATIVE_RESPONSE);
6877
}
6978

7079
invalidate_cached_user_by_id(req.body.user_id);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* Copyright (C) 2024 Puter Technologies Inc.
3+
*
4+
* This file is part of Puter.
5+
*
6+
* Puter is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published
8+
* by the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
*/
19+
"use strict"
20+
const express = require('express')
21+
const router = new express.Router()
22+
const config = require('../config')
23+
const { invalidate_cached_user_by_id, get_user } = require('../helpers')
24+
const { DB_WRITE } = require('../services/database/consts')
25+
26+
const jwt = require('jsonwebtoken');
27+
28+
// Ensure we don't expose branches with differing messages.
29+
const SAFE_NEGATIVE_RESPONSE = 'This password recovery token is no longer valid.';
30+
31+
// -----------------------------------------------------------------------//
32+
// POST /verify-pass-recovery-token
33+
// -----------------------------------------------------------------------//
34+
router.post('/verify-pass-recovery-token', express.json(), async (req, res, next)=>{
35+
// check subdomain
36+
if(require('../helpers').subdomain(req) !== 'api' && require('../helpers').subdomain(req) !== '')
37+
next();
38+
39+
if ( ! req.body.token ) {
40+
return res.status(401).send('token is required')
41+
}
42+
43+
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
44+
if ( ! svc_edgeRateLimit.check('verify-pass-recovery-token') ) {
45+
return res.status(429).send('Too many requests.');
46+
}
47+
48+
const { exp, user_uid, email } = jwt.verify(req.body.token, config.jwt_secret);
49+
50+
const user = await get_user({ uuid: user_uid, force: true });
51+
if ( user.email !== email ) {
52+
return res.status(400).send(SAFE_NEGATIVE_RESPONSE);
53+
}
54+
55+
const current_time = Math.floor(Date.now() / 1000);
56+
const time_remaining = exp - current_time;
57+
58+
return res.status(200).send({ time_remaining });
59+
})
60+
61+
module.exports = router

packages/backend/src/services/PuterAPIService.js

+1
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ class PuterAPIService extends BaseService {
6363
app.use(require('../routers/send-confirm-email'))
6464
app.use(require('../routers/send-pass-recovery-email'))
6565
app.use(require('../routers/set-desktop-bg'))
66+
app.use(require('../routers/verify-pass-recovery-token'))
6667
app.use(require('../routers/set-pass-using-token'))
6768
app.use(require('../routers/set_layout'))
6869
app.use(require('../routers/set_sort_by'))

packages/backend/src/services/abuse-prevention/EdgeRateLimitService.js

+4
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ class EdgeRateLimitService extends BaseService {
3131
limit: 10,
3232
window: HOUR,
3333
},
34+
['verify-pass-recovery-token']: {
35+
limit: 10,
36+
window: 15 * MINUTE,
37+
},
3438
['set-pass-using-token']: {
3539
limit: 10,
3640
window: HOUR,

src/UI/UIWindowNewPassword.js

+56-1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,42 @@ async function UIWindowNewPassword(options){
4545
h += `<button class="change-password-btn button button-primary button-block button-normal">${i18n('set_new_password')}</button>`;
4646
h += `</div>`;
4747

48+
const response = await fetch(api_origin + "/verify-pass-recovery-token", {
49+
method: 'POST',
50+
headers: {
51+
'Content-Type': 'application/json',
52+
},
53+
body: JSON.stringify({
54+
token: options.token,
55+
})
56+
});
57+
58+
if( response.status !== 200 ) {
59+
if ( response.status === 429 ) {
60+
await UIAlert({
61+
message: i18n('password_recovery_rate_limit', [], false),
62+
});
63+
return;
64+
}
65+
66+
if ( response.status === 400 ) {
67+
await UIAlert({
68+
message: i18n('password_recovery_token_invalid', [], false),
69+
});
70+
return;
71+
}
72+
73+
await UIAlert({
74+
message: i18n('password_recovery_unknown_error', [], false),
75+
});
76+
return;
77+
}
78+
79+
const response_data = await response.json();
80+
console.log('response_data', response_data);
81+
let time_remaining = response_data.time_remaining;
82+
83+
4884
const el_window = await UIWindow({
4985
title: 'Set New Password',
5086
app: 'change-passowrd',
@@ -78,6 +114,26 @@ async function UIWindowNewPassword(options){
78114
}
79115
})
80116

117+
const expiration_clock = setInterval(() => {
118+
time_remaining -= 1;
119+
if( time_remaining <= 0 ) {
120+
clearInterval(expiration_clock);
121+
$(el_window).find('.change-password-btn').prop('disabled', true);
122+
$(el_window).find('.change-password-btn').html('Token Expired');
123+
return;
124+
}
125+
126+
const svc_locale = globalThis.services.get('locale');
127+
const countdown = svc_locale.format_duration(time_remaining);
128+
129+
$(el_window).find('.change-password-btn').html(`Set New Password (${countdown})`);
130+
}, 1000);
131+
el_window.on_close = () => {
132+
clearInterval(expiration_clock);
133+
};
134+
135+
136+
81137
$(el_window).find('.change-password-btn').on('click', function(e){
82138
const new_password = $(el_window).find('.new-password').val();
83139
const confirm_new_password = $(el_window).find('.confirm-new-password').val();
@@ -111,7 +167,6 @@ async function UIWindowNewPassword(options){
111167
data: JSON.stringify({
112168
password: new_password,
113169
token: options.token,
114-
user_id: options.user,
115170
}),
116171
success: async function (data){
117172
$(el_window).close();

src/definitions.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@
1717
* along with this program. If not, see <https://www.gnu.org/licenses/>.
1818
*/
1919
export class Service {
20-
//
20+
init (...a) {
21+
if ( ! this._init ) return;
22+
return this._init(...a)
23+
}
2124
};
2225

2326
export const PROCESS_INITIALIZING = { i18n_key: 'initializing' };

src/i18n/translations/en.js

+4
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ const en = {
151151
oss_code_and_content: "Open Source Software and Content",
152152
password: "Password",
153153
password_changed: "Password changed.",
154+
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.",
155+
password_recovery_token_invalid: "This password recovery token is no longer valid.",
156+
password_recovery_unknown_error: "An unknown error occurred. Please try again later.",
154157
password_required: 'Password is required.',
155158
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.",
156159
passwords_do_not_match: '`New Password` and `Confirm New Password` do not match.',
@@ -198,6 +201,7 @@ const en = {
198201
save_session: 'Save session',
199202
save_session_c2a: 'Create an account to save your current session and avoid losing your work.',
200203
scan_qr_c2a: 'Scan the code below to log into this session from other devices',
204+
seconds: 'seconds',
201205
select: "Select",
202206
selected: 'selected',
203207
select_color: 'Select color…',

src/initgui.js

+8-4
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { ThemeService } from './services/ThemeService.js';
3838
import { BroadcastService } from './services/BroadcastService.js';
3939
import { ProcessService } from './services/ProcessService.js';
4040
import { PROCESS_RUNNING } from './definitions.js';
41+
import { LocaleService } from './services/LocaleService.js';
4142

4243
const launch_services = async function () {
4344
const services_l_ = [];
@@ -53,10 +54,11 @@ const launch_services = async function () {
5354

5455
register('broadcast', new BroadcastService());
5556
register('theme', new ThemeService());
56-
register('process', new ProcessService())
57+
register('process', new ProcessService());
58+
register('locale', new LocaleService());
5759

5860
for (const [_, instance] of services_l_) {
59-
await instance._init();
61+
await instance.init();
6062
}
6163

6264
// Set init process status
@@ -141,6 +143,10 @@ window.initgui = async function(){
141143
window.is_fullpage_mode = true;
142144
}
143145

146+
147+
// Launch services before any UI is rendered
148+
await launch_services();
149+
144150
//--------------------------------------------------------------------------------------
145151
// Is GUI embedded in a popup?
146152
// i.e. https://puter.com/?embedded_in_popup=true
@@ -1983,8 +1989,6 @@ window.initgui = async function(){
19831989
// go to home page
19841990
window.location.replace("/");
19851991
});
1986-
1987-
await launch_services();
19881992
}
19891993

19901994
function requestOpenerOrigin() {

src/services/LocaleService.js

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Service } from "../definitions.js";
2+
import i18n from "../i18n/i18n.js";
3+
4+
export class LocaleService extends Service {
5+
format_duration (seconds) {
6+
console.log('seconds?', typeof seconds, seconds);
7+
const hours = Math.floor(seconds / 3600);
8+
const minutes = Math.floor((seconds % 3600) / 60);
9+
const remainingSeconds = seconds % 60;
10+
11+
// Padding each value to ensure it always has two digits
12+
const paddedHours = hours.toString().padStart(2, '0');
13+
const paddedMinutes = minutes.toString().padStart(2, '0');
14+
const paddedSeconds = remainingSeconds.toString().padStart(2, '0');
15+
16+
if (hours === 0 && minutes === 0) {
17+
return `${paddedSeconds} ${i18n('seconds')}`;
18+
}
19+
20+
if (hours === 0) {
21+
return `${paddedMinutes}:${paddedSeconds}`;
22+
}
23+
24+
return `${paddedHours}:${paddedMinutes}:${paddedSeconds}`;
25+
}
26+
}

0 commit comments

Comments
 (0)