mirror of
https://github.com/HeyPuter/puter.git
synced 2025-01-23 22:40:20 +08:00
Merge pull request #385 from HeyPuter/eric/dry-middleware
Add password requirement to: disable 2FA, change email
This commit is contained in:
commit
7dc5929cfd
87
packages/backend/doc/contributors/boot-sequence.md
Normal file
87
packages/backend/doc/contributors/boot-sequence.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# Puter Backend Boot Sequence
|
||||||
|
|
||||||
|
This document describes the boot sequence of Puter's backend.
|
||||||
|
|
||||||
|
**Constriction**
|
||||||
|
- Data structures are created
|
||||||
|
|
||||||
|
**Initialization**
|
||||||
|
- Registries are populated
|
||||||
|
- Services prepare for next phase
|
||||||
|
|
||||||
|
**Consolidation**
|
||||||
|
- Service event bus receives first event (`boot.consolidation`)
|
||||||
|
- Services perform coordinated setup behaviors
|
||||||
|
- Services prepare for next phase
|
||||||
|
|
||||||
|
**Activation**
|
||||||
|
- Blocking listeners of `boot.consolidation` have resolved
|
||||||
|
- HTTP servers start listening
|
||||||
|
|
||||||
|
**Ready**
|
||||||
|
- Services are informed that Puter is providing service
|
||||||
|
|
||||||
|
## Boot Phases
|
||||||
|
|
||||||
|
### Construction
|
||||||
|
|
||||||
|
Services implement a method called `construct` which initializes members
|
||||||
|
of an instance. Services do not override the class constructor of
|
||||||
|
**BaseService**. This makes it possible to use the `new` operator without
|
||||||
|
invoking a service's constructor behavior during debugging.
|
||||||
|
|
||||||
|
The first phase of the boot sequence, "construction", is simply a loop to
|
||||||
|
call `construct` on all registered services.
|
||||||
|
|
||||||
|
The `_construct` override should not:
|
||||||
|
- call other services
|
||||||
|
- emit events
|
||||||
|
|
||||||
|
### Initialization
|
||||||
|
|
||||||
|
At initialization, the `init()` method is called on all services.
|
||||||
|
The `_init` override can be used to:
|
||||||
|
- register information with other services, when services don't
|
||||||
|
need to register this information in a specific sequence.
|
||||||
|
An example of this is registering commands with CommandService.
|
||||||
|
- perform setup that is required before the consolidation phase starts.
|
||||||
|
|
||||||
|
### Consolidation
|
||||||
|
|
||||||
|
Consolidation is a phase where services should emit events that
|
||||||
|
are related to bringing up the system. For example, WebServerService
|
||||||
|
('web-server') emits an event telling services to install middlewares,
|
||||||
|
and later emits an event telling services to install routes.
|
||||||
|
|
||||||
|
Consolidation starts when Kernel emits `boot.consolidation` to the
|
||||||
|
services event bus, which happens after `init()` resolves for all
|
||||||
|
services.
|
||||||
|
|
||||||
|
### Activation
|
||||||
|
|
||||||
|
Activation is a phase where services begin listening on external
|
||||||
|
interfaces. For example, this is when the web server starts listening.
|
||||||
|
|
||||||
|
Activation starts when Kernel emits `boot.activation`.
|
||||||
|
|
||||||
|
### Ready
|
||||||
|
|
||||||
|
Ready is a phase where services are informed that everything is up.
|
||||||
|
|
||||||
|
Ready starts when Kernel emits `boot.ready`.
|
||||||
|
|
||||||
|
## Events and Asynchronous Execution
|
||||||
|
|
||||||
|
The services event bus is implemented so you can `await` a call to `.emit()`.
|
||||||
|
Event listeners can choose to have blocking behavior by returning a promise.
|
||||||
|
|
||||||
|
During emission of a particular event, listeners of this event will not
|
||||||
|
block each other, but all listeners must resolve before the call to
|
||||||
|
`.emit()` is resolved. (i.e. `emit` uses `Promise.all`)
|
||||||
|
|
||||||
|
## Legacy Services
|
||||||
|
|
||||||
|
Some services were implemented before the `BaseService` class - which
|
||||||
|
implements the `init` method - was created. These services are called
|
||||||
|
"legacy services" and they are instantiated _after_ initialization but
|
||||||
|
_before_ consolidation.
|
@ -204,6 +204,9 @@ const install = async ({ services, app }) => {
|
|||||||
|
|
||||||
const { OTPService } = require('./services/auth/OTPService');
|
const { OTPService } = require('./services/auth/OTPService');
|
||||||
services.registerService('otp', OTPService);
|
services.registerService('otp', OTPService);
|
||||||
|
|
||||||
|
const { UserProtectedEndpointsService } = require("./services/web/UserProtectedEndpointsService");
|
||||||
|
services.registerService('__user-protected-endpoints', UserProtectedEndpointsService);
|
||||||
}
|
}
|
||||||
|
|
||||||
const install_legacy = async ({ services }) => {
|
const install_legacy = async ({ services }) => {
|
||||||
|
@ -152,17 +152,7 @@ class Kernel extends AdvancedBase {
|
|||||||
const { services } = this;
|
const { services } = this;
|
||||||
|
|
||||||
await services.ready;
|
await services.ready;
|
||||||
{
|
await services.emit('boot.consolidation');
|
||||||
const app = services.get('web-server').app;
|
|
||||||
app.use(async (req, res, next) => {
|
|
||||||
req.services = services;
|
|
||||||
next();
|
|
||||||
});
|
|
||||||
await services.emit('boot.services-initialized');
|
|
||||||
await services.emit('install.middlewares.context-aware', { app });
|
|
||||||
await services.emit('install.routes', { app });
|
|
||||||
await services.emit('install.routes-gui', { app });
|
|
||||||
}
|
|
||||||
|
|
||||||
// === END: Initialize Service Registry ===
|
// === END: Initialize Service Registry ===
|
||||||
|
|
||||||
@ -178,9 +168,8 @@ class Kernel extends AdvancedBase {
|
|||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
await services.emit('boot.activation');
|
||||||
await services.emit('start.webserver');
|
await services.emit('boot.ready');
|
||||||
await services.emit('ready.webserver');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -340,6 +340,28 @@ module.exports = class APIError {
|
|||||||
message: '2FA is already enabled.',
|
message: '2FA is already enabled.',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// protected endpoints
|
||||||
|
'too_many_requests': {
|
||||||
|
status: 429,
|
||||||
|
message: 'Too many requests.',
|
||||||
|
},
|
||||||
|
'user_tokens_only': {
|
||||||
|
status: 403,
|
||||||
|
message: 'This endpoint must be requested with a user session',
|
||||||
|
},
|
||||||
|
'temporary_accounts_not_allowed': {
|
||||||
|
status: 403,
|
||||||
|
message: 'Temporary accounts cannot perform this action',
|
||||||
|
},
|
||||||
|
'password_required': {
|
||||||
|
status: 400,
|
||||||
|
message: 'Password is required.',
|
||||||
|
},
|
||||||
|
'password_mismatch': {
|
||||||
|
status: 403,
|
||||||
|
message: 'Password does not match.',
|
||||||
|
},
|
||||||
|
|
||||||
// Object Mapping
|
// Object Mapping
|
||||||
'field_not_allowed_for_create': {
|
'field_not_allowed_for_create': {
|
||||||
status: 400,
|
status: 400,
|
||||||
|
@ -107,22 +107,6 @@ module.exports = eggspress('/auth/configure-2fa/:action', {
|
|||||||
return {};
|
return {};
|
||||||
};
|
};
|
||||||
|
|
||||||
actions.disable = async () => {
|
|
||||||
await db.write(
|
|
||||||
`UPDATE user SET otp_enabled = 0, otp_recovery_codes = '' WHERE uuid = ?`,
|
|
||||||
[user.uuid]
|
|
||||||
);
|
|
||||||
// update cached user
|
|
||||||
req.user.otp_enabled = 0;
|
|
||||||
|
|
||||||
const svc_email = req.services.get('email');
|
|
||||||
await svc_email.send_email({ email: user.email }, 'disabled_2fa', {
|
|
||||||
username: user.username,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
};
|
|
||||||
|
|
||||||
if ( ! actions[action] ) {
|
if ( ! actions[action] ) {
|
||||||
throw APIError.create('invalid_action', null, { action });
|
throw APIError.create('invalid_action', null, { action });
|
||||||
}
|
}
|
||||||
|
@ -28,72 +28,6 @@ const config = require('../config.js');
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { invalidate_cached_user_by_id } = require('../helpers.js');
|
const { invalidate_cached_user_by_id } = require('../helpers.js');
|
||||||
|
|
||||||
const CHANGE_EMAIL_START = eggspress('/change_email/start', {
|
|
||||||
subdomain: 'api',
|
|
||||||
auth: true,
|
|
||||||
verified: true,
|
|
||||||
allowedMethods: ['POST'],
|
|
||||||
}, async (req, res, next) => {
|
|
||||||
const user = req.user;
|
|
||||||
const new_email = req.body.new_email;
|
|
||||||
|
|
||||||
// TODO: DRY: signup.js
|
|
||||||
// validation
|
|
||||||
if( ! new_email ) {
|
|
||||||
throw APIError.create('field_missing', null, { key: 'new_email' });
|
|
||||||
}
|
|
||||||
if ( typeof new_email !== 'string' ) {
|
|
||||||
throw APIError.create('field_invalid', null, {
|
|
||||||
key: 'new_email', expected: 'a valid email address' });
|
|
||||||
}
|
|
||||||
if ( ! validator.isEmail(new_email) ) {
|
|
||||||
throw APIError.create('field_invalid', null, {
|
|
||||||
key: 'new_email', expected: 'a valid email address' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
|
|
||||||
if ( ! svc_edgeRateLimit.check('change-email-start') ) {
|
|
||||||
return res.status(429).send('Too many requests.');
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if email is already in use
|
|
||||||
const db = req.services.get('database').get(DB_WRITE, 'auth');
|
|
||||||
const rows = await db.read(
|
|
||||||
'SELECT COUNT(*) AS `count` FROM `user` WHERE `email` = ?',
|
|
||||||
[new_email]
|
|
||||||
);
|
|
||||||
if ( rows[0].count > 0 ) {
|
|
||||||
throw APIError.create('email_already_in_use', null, { email: new_email });
|
|
||||||
}
|
|
||||||
|
|
||||||
// generate confirmation token
|
|
||||||
const token = crypto.randomBytes(4).toString('hex');
|
|
||||||
const jwt_token = jwt.sign({
|
|
||||||
user_id: user.id,
|
|
||||||
token,
|
|
||||||
}, config.jwt_secret, { expiresIn: '24h' });
|
|
||||||
|
|
||||||
// send confirmation email
|
|
||||||
const svc_email = req.services.get('email');
|
|
||||||
await svc_email.send_email({ email: new_email }, 'email_change_request', {
|
|
||||||
confirm_url: `${config.origin}/change_email/confirm?token=${jwt_token}`,
|
|
||||||
username: user.username,
|
|
||||||
});
|
|
||||||
const old_email = user.email;
|
|
||||||
// TODO: NotificationService
|
|
||||||
await svc_email.send_email({ email: old_email }, 'email_change_notification', {
|
|
||||||
new_email: new_email,
|
|
||||||
});
|
|
||||||
|
|
||||||
// update user
|
|
||||||
await db.write(
|
|
||||||
'UPDATE `user` SET `unconfirmed_change_email` = ?, `change_email_confirm_token` = ? WHERE `id` = ?',
|
|
||||||
[new_email, token, user.id]
|
|
||||||
);
|
|
||||||
|
|
||||||
res.send({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', {
|
const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', {
|
||||||
allowedMethods: ['GET'],
|
allowedMethods: ['GET'],
|
||||||
}, async (req, res, next) => {
|
}, async (req, res, next) => {
|
||||||
@ -137,6 +71,5 @@ const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', {
|
|||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app => {
|
module.exports = app => {
|
||||||
app.use(CHANGE_EMAIL_START);
|
|
||||||
app.use(CHANGE_EMAIL_CONFIRM);
|
app.use(CHANGE_EMAIL_CONFIRM);
|
||||||
}
|
}
|
||||||
|
68
packages/backend/src/routers/user-protected/change-email.js
Normal file
68
packages/backend/src/routers/user-protected/change-email.js
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
const APIError = require("../../api/APIError");
|
||||||
|
const { DB_WRITE } = require("../../services/database/consts");
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const validator = require('validator');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const config = require("../../config");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
route: '/change-email',
|
||||||
|
methods: ['POST'],
|
||||||
|
handler: async (req, res, next) => {
|
||||||
|
const user = req.user;
|
||||||
|
const new_email = req.body.new_email;
|
||||||
|
|
||||||
|
console.log('DID REACH HERE');
|
||||||
|
|
||||||
|
// TODO: DRY: signup.js
|
||||||
|
// validation
|
||||||
|
if( ! new_email ) {
|
||||||
|
throw APIError.create('field_missing', null, { key: 'new_email' });
|
||||||
|
}
|
||||||
|
if ( typeof new_email !== 'string' ) {
|
||||||
|
throw APIError.create('field_invalid', null, {
|
||||||
|
key: 'new_email', expected: 'a valid email address' });
|
||||||
|
}
|
||||||
|
if ( ! validator.isEmail(new_email) ) {
|
||||||
|
throw APIError.create('field_invalid', null, {
|
||||||
|
key: 'new_email', expected: 'a valid email address' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// check if email is already in use
|
||||||
|
const db = req.services.get('database').get(DB_WRITE, 'auth');
|
||||||
|
const rows = await db.read(
|
||||||
|
'SELECT COUNT(*) AS `count` FROM `user` WHERE `email` = ?',
|
||||||
|
[new_email]
|
||||||
|
);
|
||||||
|
if ( rows[0].count > 0 ) {
|
||||||
|
throw APIError.create('email_already_in_use', null, { email: new_email });
|
||||||
|
}
|
||||||
|
|
||||||
|
// generate confirmation token
|
||||||
|
const token = crypto.randomBytes(4).toString('hex');
|
||||||
|
const jwt_token = jwt.sign({
|
||||||
|
user_id: user.id,
|
||||||
|
token,
|
||||||
|
}, config.jwt_secret, { expiresIn: '24h' });
|
||||||
|
|
||||||
|
// send confirmation email
|
||||||
|
const svc_email = req.services.get('email');
|
||||||
|
await svc_email.send_email({ email: new_email }, 'email_change_request', {
|
||||||
|
confirm_url: `${config.origin}/change_email/confirm?token=${jwt_token}`,
|
||||||
|
username: user.username,
|
||||||
|
});
|
||||||
|
const old_email = user.email;
|
||||||
|
// TODO: NotificationService
|
||||||
|
await svc_email.send_email({ email: old_email }, 'email_change_notification', {
|
||||||
|
new_email: new_email,
|
||||||
|
});
|
||||||
|
|
||||||
|
// update user
|
||||||
|
await db.write(
|
||||||
|
'UPDATE `user` SET `unconfirmed_change_email` = ?, `change_email_confirm_token` = ? WHERE `id` = ?',
|
||||||
|
[new_email, token, user.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.send({ success: true });
|
||||||
|
}
|
||||||
|
};
|
@ -0,0 +1,85 @@
|
|||||||
|
// TODO: DRY: This is the same function used by UIWindowChangePassword!
|
||||||
|
|
||||||
|
const { invalidate_cached_user } = require("../../helpers");
|
||||||
|
const { DB_WRITE } = require("../../services/database/consts");
|
||||||
|
|
||||||
|
// duplicate definition is in src/helpers.js (puter GUI)
|
||||||
|
const check_password_strength = (password) => {
|
||||||
|
// Define criteria for password strength
|
||||||
|
const criteria = {
|
||||||
|
minLength: 8,
|
||||||
|
hasUpperCase: /[A-Z]/.test(password),
|
||||||
|
hasLowerCase: /[a-z]/.test(password),
|
||||||
|
hasNumber: /\d/.test(password),
|
||||||
|
hasSpecialChar: /[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]/.test(password)
|
||||||
|
};
|
||||||
|
|
||||||
|
let overallPass = true;
|
||||||
|
|
||||||
|
// Initialize report object
|
||||||
|
let criteria_report = {
|
||||||
|
minLength: {
|
||||||
|
message: `Password must be at least ${criteria.minLength} characters long`,
|
||||||
|
pass: password.length >= criteria.minLength,
|
||||||
|
},
|
||||||
|
hasUpperCase: {
|
||||||
|
message: 'Password must contain at least one uppercase letter',
|
||||||
|
pass: criteria.hasUpperCase,
|
||||||
|
},
|
||||||
|
hasLowerCase: {
|
||||||
|
message: 'Password must contain at least one lowercase letter',
|
||||||
|
pass: criteria.hasLowerCase,
|
||||||
|
},
|
||||||
|
hasNumber: {
|
||||||
|
message: 'Password must contain at least one number',
|
||||||
|
pass: criteria.hasNumber,
|
||||||
|
},
|
||||||
|
hasSpecialChar: {
|
||||||
|
message: 'Password must contain at least one special character',
|
||||||
|
pass: criteria.hasSpecialChar,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check overall pass status and add messages
|
||||||
|
for (let criterion in criteria) {
|
||||||
|
if (!criteria_report[criterion].pass) {
|
||||||
|
overallPass = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
overallPass: overallPass,
|
||||||
|
report: criteria_report,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
route: '/change-password',
|
||||||
|
methods: ['POST'],
|
||||||
|
handler: async (req, res, next) => {
|
||||||
|
// Validate new password
|
||||||
|
const { new_pass } = req.body;
|
||||||
|
const { overallPass: strong } = check_password_strength(new_pass);
|
||||||
|
if ( ! strong ) {
|
||||||
|
req.status(400).send('Password does not meet requirements.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
// TODO: DI for endpoint definitions like this one
|
||||||
|
const bcrypt = require('bcrypt');
|
||||||
|
const db = req.services.get('database').get(DB_WRITE, 'auth');
|
||||||
|
await db.write(
|
||||||
|
'UPDATE user SET password=?, `pass_recovery_token` = NULL, `change_email_confirm_token` = NULL WHERE `id` = ?',
|
||||||
|
[await bcrypt.hash(req.body.new_pass, 8), req.user.id]
|
||||||
|
);
|
||||||
|
invalidate_cached_user(req.user);
|
||||||
|
|
||||||
|
// Notify user about password change
|
||||||
|
// TODO: audit log for user in security tab
|
||||||
|
const svc_email = req.services.get('email');
|
||||||
|
svc_email.send_email({ email: req.user.email }, 'password_change_notification');
|
||||||
|
|
||||||
|
return res.send('Password successfully updated.')
|
||||||
|
}
|
||||||
|
};
|
22
packages/backend/src/routers/user-protected/disable-2fa.js
Normal file
22
packages/backend/src/routers/user-protected/disable-2fa.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
const { DB_WRITE } = require("../../services/database/consts");
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
route: '/disable-2fa',
|
||||||
|
methods: ['POST'],
|
||||||
|
handler: async (req, res, next) => {
|
||||||
|
const db = req.services.get('database').get(DB_WRITE, '2fa.disable');
|
||||||
|
await db.write(
|
||||||
|
`UPDATE user SET otp_enabled = 0, otp_recovery_codes = NULL, otp_secret = NULL WHERE uuid = ?`,
|
||||||
|
[req.user.uuid]
|
||||||
|
);
|
||||||
|
// update cached user
|
||||||
|
req.user.otp_enabled = 0;
|
||||||
|
|
||||||
|
const svc_email = req.services.get('email');
|
||||||
|
await svc_email.send_email({ email: req.user.email }, 'disabled_2fa', {
|
||||||
|
username: req.user.username,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send({ success: true });
|
||||||
|
}
|
||||||
|
};
|
@ -20,7 +20,7 @@ const { Context } = require("../util/context");
|
|||||||
const BaseService = require("./BaseService");
|
const BaseService = require("./BaseService");
|
||||||
|
|
||||||
class RefreshAssociationsService extends BaseService {
|
class RefreshAssociationsService extends BaseService {
|
||||||
async ['__on_boot.services-initialized'] () {
|
async ['__on_boot.consolidation'] () {
|
||||||
const { refresh_associations_cache } = require('../helpers');
|
const { refresh_associations_cache } = require('../helpers');
|
||||||
|
|
||||||
await Context.allow_fallback(async () => {
|
await Context.allow_fallback(async () => {
|
||||||
|
@ -42,6 +42,20 @@ class WebServerService extends BaseService {
|
|||||||
morgan: require('morgan'),
|
morgan: require('morgan'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async ['__on_boot.consolidation'] () {
|
||||||
|
const app = this.app;
|
||||||
|
const services = this.services;
|
||||||
|
await services.emit('install.middlewares.context-aware', { app });
|
||||||
|
await services.emit('install.routes', { app });
|
||||||
|
await services.emit('install.routes-gui', { app });
|
||||||
|
}
|
||||||
|
|
||||||
|
async ['__on_boot.activation'] () {
|
||||||
|
const services = this.services;
|
||||||
|
await services.emit('start.webserver');
|
||||||
|
await services.emit('ready.webserver');
|
||||||
|
}
|
||||||
|
|
||||||
async ['__on_start.webserver'] () {
|
async ['__on_start.webserver'] () {
|
||||||
await es_import_promise;
|
await es_import_promise;
|
||||||
|
|
||||||
|
@ -1,9 +1,16 @@
|
|||||||
const { Context } = require("../../util/context");
|
const { Context } = require("../../util/context");
|
||||||
const { asyncSafeSetInterval } = require("../../util/promise");
|
const { asyncSafeSetInterval } = require("../../util/promise");
|
||||||
|
const { quot } = require("../../util/strutil");
|
||||||
|
|
||||||
const { MINUTE, HOUR } = require('../../util/time.js');
|
const { MINUTE, HOUR } = require('../../util/time.js');
|
||||||
const BaseService = require("../BaseService");
|
const BaseService = require("../BaseService");
|
||||||
|
|
||||||
|
/* INCREMENTAL CHANGES
|
||||||
|
The first scopes are of the form 'name-of-endpoint', but later it was
|
||||||
|
decided that they're of the form `/path/to/endpoint`. New scopes should
|
||||||
|
follow the latter form.
|
||||||
|
*/
|
||||||
|
|
||||||
class EdgeRateLimitService extends BaseService {
|
class EdgeRateLimitService extends BaseService {
|
||||||
_construct () {
|
_construct () {
|
||||||
this.scopes = {
|
this.scopes = {
|
||||||
@ -55,6 +62,18 @@ class EdgeRateLimitService extends BaseService {
|
|||||||
limit: 10,
|
limit: 10,
|
||||||
window: HOUR,
|
window: HOUR,
|
||||||
},
|
},
|
||||||
|
['/user-protected/change-password']: {
|
||||||
|
limit: 10,
|
||||||
|
window: HOUR,
|
||||||
|
},
|
||||||
|
['/user-protected/change-email']: {
|
||||||
|
limit: 10,
|
||||||
|
window: HOUR,
|
||||||
|
},
|
||||||
|
['/user-protected/disable-2fa']: {
|
||||||
|
limit: 10,
|
||||||
|
window: HOUR,
|
||||||
|
},
|
||||||
['login-otp']: {
|
['login-otp']: {
|
||||||
limit: 15,
|
limit: 15,
|
||||||
window: 30 * MINUTE,
|
window: 30 * MINUTE,
|
||||||
@ -77,6 +96,9 @@ class EdgeRateLimitService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
check (scope) {
|
check (scope) {
|
||||||
|
if ( ! this.scopes.hasOwnProperty(scope) ) {
|
||||||
|
throw new Error(`unrecognized rate-limit scope: ${quot(scope)}`)
|
||||||
|
}
|
||||||
const { window, limit } = this.scopes[scope];
|
const { window, limit } = this.scopes[scope];
|
||||||
|
|
||||||
const requester = Context.get('requester');
|
const requester = Context.get('requester');
|
||||||
|
@ -0,0 +1,100 @@
|
|||||||
|
const { get_user } = require("../../helpers");
|
||||||
|
const auth2 = require("../../middleware/auth2");
|
||||||
|
const { Context } = require("../../util/context");
|
||||||
|
const BaseService = require("../BaseService");
|
||||||
|
const { UserActorType } = require("../auth/Actor");
|
||||||
|
const { Endpoint } = require("../../util/expressutil");
|
||||||
|
const APIError = require("../../api/APIError.js");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This service registers endpoints that are protected by password authentication,
|
||||||
|
* excluding login. These endpoints are typically for actions that affect
|
||||||
|
* security settings on the user's account.
|
||||||
|
*/
|
||||||
|
class UserProtectedEndpointsService extends BaseService {
|
||||||
|
static MODULES = {
|
||||||
|
express: require('express'),
|
||||||
|
};
|
||||||
|
|
||||||
|
['__on_install.routes'] () {
|
||||||
|
const router = (() => {
|
||||||
|
const require = this.require;
|
||||||
|
const express = require('express');
|
||||||
|
return express.Router();
|
||||||
|
})()
|
||||||
|
|
||||||
|
const { app } = this.services.get('web-server');
|
||||||
|
app.use('/user-protected', router);
|
||||||
|
|
||||||
|
// Apply edge (unauthenticated) rate-limiting
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
|
||||||
|
if ( ! svc_edgeRateLimit.check(req.baseUrl + req.path) ) {
|
||||||
|
return APIError.create('too_many_requests').write(res);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
})
|
||||||
|
|
||||||
|
// Require authenticated session
|
||||||
|
router.use(auth2);
|
||||||
|
|
||||||
|
// Only allow user sessions, not API tokens for apps
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
const actor = Context.get('actor');
|
||||||
|
if ( ! (actor.type instanceof UserActorType) ) {
|
||||||
|
return APIError.create('user_tokens_only').write(res);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prioritize consistency for user object
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
const user = await get_user({ id: req.user.id, force: true });
|
||||||
|
req.user = user;
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Do not allow temporary users
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
if ( req.user.password === null ) {
|
||||||
|
return APIError.create('temporary_account').write(res);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Require password in request
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
if ( ! req.body.password ) {
|
||||||
|
return (APIError.create('password_required')).write(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bcrypt = (() => {
|
||||||
|
const require = this.require;
|
||||||
|
return require('bcrypt');
|
||||||
|
})();
|
||||||
|
|
||||||
|
const user = await get_user({ id: req.user.id, force: true });
|
||||||
|
const isMatch = await bcrypt.compare(req.body.password, user.password);
|
||||||
|
if ( ! isMatch ) {
|
||||||
|
return APIError.create('password_mismatch').write(res);
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
Endpoint(
|
||||||
|
require('../../routers/user-protected/change-password.js'),
|
||||||
|
).attach(router);
|
||||||
|
|
||||||
|
Endpoint(
|
||||||
|
require('../../routers/user-protected/change-email.js'),
|
||||||
|
).attach(router);
|
||||||
|
|
||||||
|
Endpoint(
|
||||||
|
require('../../routers/user-protected/disable-2fa.js'),
|
||||||
|
).attach(router);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
UserProtectedEndpointsService
|
||||||
|
};
|
21
packages/backend/src/util/expressutil.js
Normal file
21
packages/backend/src/util/expressutil.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
const eggspress = require("../api/eggspress");
|
||||||
|
|
||||||
|
const Endpoint = function Endpoint (spec) {
|
||||||
|
return {
|
||||||
|
attach (route) {
|
||||||
|
const eggspress_options = {
|
||||||
|
allowedMethods: spec.methods ?? ['GET'],
|
||||||
|
};
|
||||||
|
const eggspress_router = eggspress(
|
||||||
|
spec.route,
|
||||||
|
eggspress_options,
|
||||||
|
spec.handler,
|
||||||
|
);
|
||||||
|
route.use(eggspress_router);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
Endpoint,
|
||||||
|
};
|
136
src/UI/Components/PasswordEntry.js
Normal file
136
src/UI/Components/PasswordEntry.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { Component, defineComponent } from "../../util/Component.js";
|
||||||
|
|
||||||
|
export default class PasswordEntry extends Component {
|
||||||
|
static PROPERTIES = {
|
||||||
|
spec: {},
|
||||||
|
value: {},
|
||||||
|
error: {},
|
||||||
|
on_submit: {},
|
||||||
|
show_password: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
static CSS = /*css*/`
|
||||||
|
fieldset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TODO: I'd rather not duplicate this */
|
||||||
|
.error {
|
||||||
|
display: none;
|
||||||
|
color: red;
|
||||||
|
border: 1px solid red;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 9px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.error-message {
|
||||||
|
display: none;
|
||||||
|
color: rgb(215 2 2);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid rgb(215 2 2);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.password-and-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.password-and-toggle input {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* TODO: DRY: This is from style.css */
|
||||||
|
input[type=text], input[type=password], input[type=email], select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
outline: none;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
color: #393f46;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* to prevent auto-zoom on input focus in mobile */
|
||||||
|
.device-phone input[type=text], .device-phone input[type=password], .device-phone input[type=email], .device-phone select {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=text]:focus, input[type=password]:focus, input[type=email]:focus, select:focus {
|
||||||
|
border: 2px solid #01a0fd;
|
||||||
|
padding: 7px;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
create_template ({ template }) {
|
||||||
|
$(template).html(/*html*/`
|
||||||
|
<form>
|
||||||
|
<div class="error"></div>
|
||||||
|
<div class="password-and-toggle">
|
||||||
|
<input type="password" class="value-input" id="password" placeholder="${i18n('password')}" required>
|
||||||
|
<img
|
||||||
|
id="toggle-show-password"
|
||||||
|
src="${this.get('show_password')
|
||||||
|
? window.icons["eye-closed.svg"]
|
||||||
|
: window.icons["eye-open.svg"]}"
|
||||||
|
width="20"
|
||||||
|
height="20">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
on_focus () {
|
||||||
|
$(this.dom_).find('input').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
on_ready ({ listen }) {
|
||||||
|
listen('error', (error) => {
|
||||||
|
if ( ! error ) return $(this.dom_).find('.error').hide();
|
||||||
|
$(this.dom_).find('.error').text(error).show();
|
||||||
|
});
|
||||||
|
|
||||||
|
listen('value', (value) => {
|
||||||
|
// clear input
|
||||||
|
if ( value === undefined ) {
|
||||||
|
$(this.dom_).find('input').val('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = $(this.dom_).find('input');
|
||||||
|
input.on('input', () => {
|
||||||
|
this.set('value', input.val());
|
||||||
|
});
|
||||||
|
|
||||||
|
const on_submit = this.get('on_submit');
|
||||||
|
if ( on_submit ) {
|
||||||
|
$(this.dom_).find('input').on('keyup', (e) => {
|
||||||
|
if ( e.key === 'Enter' ) {
|
||||||
|
on_submit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(this.dom_).find("#toggle-show-password").on("click", () => {
|
||||||
|
this.set('show_password', !this.get('show_password'));
|
||||||
|
const show_password = this.get('show_password');
|
||||||
|
// hide/show password and update icon
|
||||||
|
$(this.dom_).find("input").attr("type", show_password ? "text" : "password");
|
||||||
|
$(this.dom_).find("#toggle-show-password").attr("src", show_password ? window.icons["eye-closed.svg"] : window.icons["eye-open.svg"])
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineComponent('c-password-entry', PasswordEntry);
|
@ -1,4 +1,10 @@
|
|||||||
|
import TeePromise from "../../util/TeePromise.js";
|
||||||
|
import Button from "../Components/Button.js";
|
||||||
|
import Flexer from "../Components/Flexer.js";
|
||||||
|
import JustHTML from "../Components/JustHTML.js";
|
||||||
|
import PasswordEntry from "../Components/PasswordEntry.js";
|
||||||
import UIAlert from "../UIAlert.js";
|
import UIAlert from "../UIAlert.js";
|
||||||
|
import UIComponentWindow from "../UIComponentWindow.js";
|
||||||
import UIWindow2FASetup from "../UIWindow2FASetup.js";
|
import UIWindow2FASetup from "../UIWindow2FASetup.js";
|
||||||
import UIWindowQR from "../UIWindowQR.js";
|
import UIWindowQR from "../UIWindowQR.js";
|
||||||
|
|
||||||
@ -64,35 +70,88 @@ export default {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$el_window.find('.disable-2fa').on('click', async function (e) {
|
$el_window.find('.disable-2fa').on('click', async function (e) {
|
||||||
const confirmation = i18n('disable_2fa_confirm');
|
let win, password_entry;
|
||||||
const alert_resp = await UIAlert({
|
const password_confirm_promise = new TeePromise();
|
||||||
message: confirmation,
|
const try_password = async () => {
|
||||||
window_options: {
|
const value = password_entry.get('value');
|
||||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, {
|
||||||
disable_parent_window: true,
|
method: 'POST',
|
||||||
parent_center: true,
|
headers: {
|
||||||
},
|
Authorization: `Bearer ${puter.authToken}`,
|
||||||
buttons:[
|
'Content-Type': 'application/json',
|
||||||
{
|
|
||||||
label: i18n('yes'),
|
|
||||||
value: true,
|
|
||||||
type: 'primary',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: i18n('cancel'),
|
|
||||||
value: false,
|
|
||||||
},
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
password: value,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if ( resp.status !== 200 ) {
|
||||||
|
/* eslint no-empty: ["error", { "allowEmptyCatch": true }] */
|
||||||
|
let message; try {
|
||||||
|
message = (await resp.json()).message;
|
||||||
|
} catch (e) {}
|
||||||
|
message = message || i18n('error_unknown_cause');
|
||||||
|
password_entry.set('error', message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
password_confirm_promise.resolve(true);
|
||||||
|
$(win).close();
|
||||||
|
}
|
||||||
|
const password_confirm = new Flexer({
|
||||||
|
children: [
|
||||||
|
new JustHTML({
|
||||||
|
html: /*html*/`
|
||||||
|
<h3 style="text-align:center; font-weight: 500; font-size: 20px;">${
|
||||||
|
i18n('disable_2fa_confirm')
|
||||||
|
}</h3>
|
||||||
|
<p style="text-align:center; padding: 0 20px;">${
|
||||||
|
i18n('disable_2fa_instructions')
|
||||||
|
}</p>
|
||||||
|
`
|
||||||
|
}),
|
||||||
|
new Flexer({
|
||||||
|
gap: '5pt',
|
||||||
|
children: [
|
||||||
|
new PasswordEntry({
|
||||||
|
_ref: me => password_entry = me,
|
||||||
|
on_submit: async () => {
|
||||||
|
try_password();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
new Button({
|
||||||
|
label: i18n('disable_2fa'),
|
||||||
|
on_click: async () => {
|
||||||
|
try_password();
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
new Button({
|
||||||
|
label: i18n('cancel'),
|
||||||
|
style: 'secondary',
|
||||||
|
on_click: async () => {
|
||||||
|
password_confirm_promise.resolve(false);
|
||||||
|
$(win).close();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
})
|
|
||||||
if ( ! alert_resp ) return;
|
|
||||||
const resp = await fetch(`${window.api_origin}/auth/configure-2fa/disable`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${puter.authToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({}),
|
|
||||||
});
|
});
|
||||||
|
win = await UIComponentWindow({
|
||||||
|
component: password_confirm,
|
||||||
|
width: 500,
|
||||||
|
backdrop: true,
|
||||||
|
is_resizable: false,
|
||||||
|
body_css: {
|
||||||
|
width: 'initial',
|
||||||
|
height: '100%',
|
||||||
|
'background-color': 'rgb(245 247 249)',
|
||||||
|
'backdrop-filter': 'blur(3px)',
|
||||||
|
padding: '20px',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
password_entry.focus();
|
||||||
|
|
||||||
|
const ok = await password_confirm_promise;
|
||||||
|
if ( ! ok ) return;
|
||||||
|
|
||||||
$el_window.find('.enable-2fa').show();
|
$el_window.find('.enable-2fa').show();
|
||||||
$el_window.find('.disable-2fa').hide();
|
$el_window.find('.disable-2fa').hide();
|
||||||
@ -101,4 +160,4 @@ export default {
|
|||||||
$el_window.find('.settings-card-security').addClass('settings-card-warning');
|
$el_window.find('.settings-card-security').addClass('settings-card-warning');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,11 +17,18 @@
|
|||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import Placeholder from '../../util/Placeholder.js';
|
||||||
|
import PasswordEntry from '../Components/PasswordEntry.js';
|
||||||
import UIWindow from '../UIWindow.js'
|
import UIWindow from '../UIWindow.js'
|
||||||
|
|
||||||
|
// TODO: DRY: We could specify a validator and endpoint instead of writing
|
||||||
|
// a DOM tree and event handlers for each of these. (low priority)
|
||||||
async function UIWindowChangeEmail(options){
|
async function UIWindowChangeEmail(options){
|
||||||
options = options ?? {};
|
options = options ?? {};
|
||||||
|
|
||||||
|
const password_entry = new PasswordEntry({});
|
||||||
|
const place_password_entry = Placeholder();
|
||||||
|
|
||||||
const internal_id = window.uuidv4();
|
const internal_id = window.uuidv4();
|
||||||
let h = '';
|
let h = '';
|
||||||
h += `<div class="change-email" style="padding: 20px; border-bottom: 1px solid #ced7e1;">`;
|
h += `<div class="change-email" style="padding: 20px; border-bottom: 1px solid #ced7e1;">`;
|
||||||
@ -34,6 +41,11 @@ async function UIWindowChangeEmail(options){
|
|||||||
h += `<label for="confirm-new-email-${internal_id}">${i18n('new_email')}</label>`;
|
h += `<label for="confirm-new-email-${internal_id}">${i18n('new_email')}</label>`;
|
||||||
h += `<input id="confirm-new-email-${internal_id}" type="text" name="new-email" class="new-email" autocomplete="off" />`;
|
h += `<input id="confirm-new-email-${internal_id}" type="text" name="new-email" class="new-email" autocomplete="off" />`;
|
||||||
h += `</div>`;
|
h += `</div>`;
|
||||||
|
// password confirmation
|
||||||
|
h += `<div style="overflow: hidden; margin-top: 10px; margin-bottom: 30px;">`;
|
||||||
|
h += `<label>${i18n('account_password')}</label>`;
|
||||||
|
h += `${place_password_entry.html}`;
|
||||||
|
h += `</div>`;
|
||||||
|
|
||||||
// Change Email
|
// Change Email
|
||||||
h += `<button class="change-email-btn button button-primary button-block button-normal">${i18n('change_email')}</button>`;
|
h += `<button class="change-email-btn button button-primary button-block button-normal">${i18n('change_email')}</button>`;
|
||||||
@ -73,11 +85,14 @@ async function UIWindowChangeEmail(options){
|
|||||||
...options.window_options
|
...options.window_options
|
||||||
})
|
})
|
||||||
|
|
||||||
|
password_entry.attach(place_password_entry);
|
||||||
|
|
||||||
$(el_window).find('.change-email-btn').on('click', function(e){
|
$(el_window).find('.change-email-btn').on('click', function(e){
|
||||||
// hide previous error/success msg
|
// hide previous error/success msg
|
||||||
$(el_window).find('.form-success-msg, .form-success-msg').hide();
|
$(el_window).find('.form-success-msg, .form-success-msg').hide();
|
||||||
|
|
||||||
const new_email = $(el_window).find('.new-email').val();
|
const new_email = $(el_window).find('.new-email').val();
|
||||||
|
const password = $(el_window).find('.password').val();
|
||||||
|
|
||||||
if(!new_email){
|
if(!new_email){
|
||||||
$(el_window).find('.form-error-msg').html(i18n('all_fields_required'));
|
$(el_window).find('.form-error-msg').html(i18n('all_fields_required'));
|
||||||
@ -93,7 +108,7 @@ async function UIWindowChangeEmail(options){
|
|||||||
$(el_window).find('.new-email').attr('disabled', true);
|
$(el_window).find('.new-email').attr('disabled', true);
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: window.api_origin + "/change_email/start",
|
url: window.api_origin + "/user-protected/change-email",
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
async: true,
|
async: true,
|
||||||
headers: {
|
headers: {
|
||||||
@ -102,6 +117,7 @@ async function UIWindowChangeEmail(options){
|
|||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
new_email: new_email,
|
new_email: new_email,
|
||||||
|
password: password_entry.get('value'),
|
||||||
}),
|
}),
|
||||||
success: function (data){
|
success: function (data){
|
||||||
$(el_window).find('.form-success-msg').html(i18n('email_change_confirmation_sent'));
|
$(el_window).find('.form-success-msg').html(i18n('email_change_confirmation_sent'));
|
||||||
|
@ -114,7 +114,7 @@ async function UIWindowChangePassword(options){
|
|||||||
$(el_window).find('.form-error-msg').hide();
|
$(el_window).find('.form-error-msg').hide();
|
||||||
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: window.api_origin + "/passwd",
|
url: window.api_origin + "/user-protected/change-password",
|
||||||
type: 'POST',
|
type: 'POST',
|
||||||
async: true,
|
async: true,
|
||||||
headers: {
|
headers: {
|
||||||
@ -122,7 +122,7 @@ async function UIWindowChangePassword(options){
|
|||||||
},
|
},
|
||||||
contentType: "application/json",
|
contentType: "application/json",
|
||||||
data: JSON.stringify({
|
data: JSON.stringify({
|
||||||
old_pass: current_password,
|
password: current_password,
|
||||||
new_pass: new_password,
|
new_pass: new_password,
|
||||||
}),
|
}),
|
||||||
success: function (data){
|
success: function (data){
|
||||||
|
@ -23,6 +23,7 @@ const en = {
|
|||||||
dictionary: {
|
dictionary: {
|
||||||
about: "About",
|
about: "About",
|
||||||
account: "Account",
|
account: "Account",
|
||||||
|
account_password: "Verify Account Password",
|
||||||
access_granted_to: "Access Granted To",
|
access_granted_to: "Access Granted To",
|
||||||
add_existing_account: "Add Existing Account",
|
add_existing_account: "Add Existing Account",
|
||||||
all_fields_required: 'All fields are required.',
|
all_fields_required: 'All fields are required.',
|
||||||
@ -96,6 +97,7 @@ const en = {
|
|||||||
dir_published_as_website: `%strong% has been published to:`,
|
dir_published_as_website: `%strong% has been published to:`,
|
||||||
disable_2fa: 'Disable 2FA',
|
disable_2fa: 'Disable 2FA',
|
||||||
disable_2fa_confirm: "Are you sure you want to disable 2FA?",
|
disable_2fa_confirm: "Are you sure you want to disable 2FA?",
|
||||||
|
disable_2fa_instructions: "Enter your password to disable 2FA.",
|
||||||
disassociate_dir: "Disassociate Directory",
|
disassociate_dir: "Disassociate Directory",
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
download_file: 'Download File',
|
download_file: 'Download File',
|
||||||
|
Loading…
Reference in New Issue
Block a user