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');
|
||||
services.registerService('otp', OTPService);
|
||||
|
||||
const { UserProtectedEndpointsService } = require("./services/web/UserProtectedEndpointsService");
|
||||
services.registerService('__user-protected-endpoints', UserProtectedEndpointsService);
|
||||
}
|
||||
|
||||
const install_legacy = async ({ services }) => {
|
||||
|
@ -152,17 +152,7 @@ class Kernel extends AdvancedBase {
|
||||
const { services } = this;
|
||||
|
||||
await services.ready;
|
||||
{
|
||||
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 });
|
||||
}
|
||||
await services.emit('boot.consolidation');
|
||||
|
||||
// === END: Initialize Service Registry ===
|
||||
|
||||
@ -178,9 +168,8 @@ class Kernel extends AdvancedBase {
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
await services.emit('start.webserver');
|
||||
await services.emit('ready.webserver');
|
||||
await services.emit('boot.activation');
|
||||
await services.emit('boot.ready');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -340,6 +340,28 @@ module.exports = class APIError {
|
||||
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
|
||||
'field_not_allowed_for_create': {
|
||||
status: 400,
|
||||
|
@ -107,22 +107,6 @@ module.exports = eggspress('/auth/configure-2fa/:action', {
|
||||
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] ) {
|
||||
throw APIError.create('invalid_action', null, { action });
|
||||
}
|
||||
|
@ -28,72 +28,6 @@ const config = require('../config.js');
|
||||
const jwt = require('jsonwebtoken');
|
||||
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', {
|
||||
allowedMethods: ['GET'],
|
||||
}, async (req, res, next) => {
|
||||
@ -137,6 +71,5 @@ const CHANGE_EMAIL_CONFIRM = eggspress('/change_email/confirm', {
|
||||
});
|
||||
|
||||
module.exports = app => {
|
||||
app.use(CHANGE_EMAIL_START);
|
||||
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");
|
||||
|
||||
class RefreshAssociationsService extends BaseService {
|
||||
async ['__on_boot.services-initialized'] () {
|
||||
async ['__on_boot.consolidation'] () {
|
||||
const { refresh_associations_cache } = require('../helpers');
|
||||
|
||||
await Context.allow_fallback(async () => {
|
||||
|
@ -42,6 +42,20 @@ class WebServerService extends BaseService {
|
||||
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'] () {
|
||||
await es_import_promise;
|
||||
|
||||
|
@ -1,9 +1,16 @@
|
||||
const { Context } = require("../../util/context");
|
||||
const { asyncSafeSetInterval } = require("../../util/promise");
|
||||
const { quot } = require("../../util/strutil");
|
||||
|
||||
const { MINUTE, HOUR } = require('../../util/time.js');
|
||||
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 {
|
||||
_construct () {
|
||||
this.scopes = {
|
||||
@ -55,6 +62,18 @@ class EdgeRateLimitService extends BaseService {
|
||||
limit: 10,
|
||||
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']: {
|
||||
limit: 15,
|
||||
window: 30 * MINUTE,
|
||||
@ -77,6 +96,9 @@ class EdgeRateLimitService extends BaseService {
|
||||
}
|
||||
|
||||
check (scope) {
|
||||
if ( ! this.scopes.hasOwnProperty(scope) ) {
|
||||
throw new Error(`unrecognized rate-limit scope: ${quot(scope)}`)
|
||||
}
|
||||
const { window, limit } = this.scopes[scope];
|
||||
|
||||
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 UIComponentWindow from "../UIComponentWindow.js";
|
||||
import UIWindow2FASetup from "../UIWindow2FASetup.js";
|
||||
import UIWindowQR from "../UIWindowQR.js";
|
||||
|
||||
@ -64,35 +70,88 @@ export default {
|
||||
});
|
||||
|
||||
$el_window.find('.disable-2fa').on('click', async function (e) {
|
||||
const confirmation = i18n('disable_2fa_confirm');
|
||||
const alert_resp = await UIAlert({
|
||||
message: confirmation,
|
||||
window_options: {
|
||||
parent_uuid: $el_window.attr('data-element_uuid'),
|
||||
disable_parent_window: true,
|
||||
parent_center: true,
|
||||
},
|
||||
buttons:[
|
||||
{
|
||||
label: i18n('yes'),
|
||||
value: true,
|
||||
type: 'primary',
|
||||
},
|
||||
{
|
||||
label: i18n('cancel'),
|
||||
value: false,
|
||||
},
|
||||
]
|
||||
})
|
||||
if ( ! alert_resp ) return;
|
||||
const resp = await fetch(`${window.api_origin}/auth/configure-2fa/disable`, {
|
||||
let win, password_entry;
|
||||
const password_confirm_promise = new TeePromise();
|
||||
const try_password = async () => {
|
||||
const value = password_entry.get('value');
|
||||
const resp = await fetch(`${window.api_origin}/user-protected/disable-2fa`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${puter.authToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
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();
|
||||
}
|
||||
})
|
||||
]
|
||||
}),
|
||||
]
|
||||
});
|
||||
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('.disable-2fa').hide();
|
||||
|
@ -17,11 +17,18 @@
|
||||
* 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'
|
||||
|
||||
// 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){
|
||||
options = options ?? {};
|
||||
|
||||
const password_entry = new PasswordEntry({});
|
||||
const place_password_entry = Placeholder();
|
||||
|
||||
const internal_id = window.uuidv4();
|
||||
let h = '';
|
||||
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 += `<input id="confirm-new-email-${internal_id}" type="text" name="new-email" class="new-email" autocomplete="off" />`;
|
||||
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
|
||||
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
|
||||
})
|
||||
|
||||
password_entry.attach(place_password_entry);
|
||||
|
||||
$(el_window).find('.change-email-btn').on('click', function(e){
|
||||
// hide previous error/success msg
|
||||
$(el_window).find('.form-success-msg, .form-success-msg').hide();
|
||||
|
||||
const new_email = $(el_window).find('.new-email').val();
|
||||
const password = $(el_window).find('.password').val();
|
||||
|
||||
if(!new_email){
|
||||
$(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);
|
||||
|
||||
$.ajax({
|
||||
url: window.api_origin + "/change_email/start",
|
||||
url: window.api_origin + "/user-protected/change-email",
|
||||
type: 'POST',
|
||||
async: true,
|
||||
headers: {
|
||||
@ -102,6 +117,7 @@ async function UIWindowChangeEmail(options){
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({
|
||||
new_email: new_email,
|
||||
password: password_entry.get('value'),
|
||||
}),
|
||||
success: function (data){
|
||||
$(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();
|
||||
|
||||
$.ajax({
|
||||
url: window.api_origin + "/passwd",
|
||||
url: window.api_origin + "/user-protected/change-password",
|
||||
type: 'POST',
|
||||
async: true,
|
||||
headers: {
|
||||
@ -122,7 +122,7 @@ async function UIWindowChangePassword(options){
|
||||
},
|
||||
contentType: "application/json",
|
||||
data: JSON.stringify({
|
||||
old_pass: current_password,
|
||||
password: current_password,
|
||||
new_pass: new_password,
|
||||
}),
|
||||
success: function (data){
|
||||
|
@ -23,6 +23,7 @@ const en = {
|
||||
dictionary: {
|
||||
about: "About",
|
||||
account: "Account",
|
||||
account_password: "Verify Account Password",
|
||||
access_granted_to: "Access Granted To",
|
||||
add_existing_account: "Add Existing Account",
|
||||
all_fields_required: 'All fields are required.',
|
||||
@ -96,6 +97,7 @@ const en = {
|
||||
dir_published_as_website: `%strong% has been published to:`,
|
||||
disable_2fa: '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",
|
||||
download: 'Download',
|
||||
download_file: 'Download File',
|
||||
|
Loading…
Reference in New Issue
Block a user