Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add password requirement to: disable 2FA, change email #385

Merged
merged 7 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions packages/backend/doc/contributors/boot-sequence.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions packages/backend/src/CoreModule.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
17 changes: 3 additions & 14 deletions packages/backend/src/Kernel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ===

Expand All @@ -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');
}
}

Expand Down
22 changes: 22 additions & 0 deletions packages/backend/src/api/APIError.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 0 additions & 16 deletions packages/backend/src/routers/auth/configure-2fa.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down
67 changes: 0 additions & 67 deletions packages/backend/src/routers/change_email.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 changes: 68 additions & 0 deletions packages/backend/src/routers/user-protected/change-email.js
Original file line number Diff line number Diff line change
@@ -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 });
}
};
Loading