mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-26 16:07:13 +00:00
Merge pull request #2460 from HeyPuter/eric/262A0_PUT-453
Login/Signup with OIDC Wellp... this is not a rebase, this is merge. My hands were tied this time.
This commit is contained in:
@@ -65,6 +65,7 @@ export const rules = {
|
||||
}],
|
||||
'@stylistic/array-bracket-spacing': ['error', 'never'],
|
||||
'@stylistic/linebreak-style': ['error', 'unix'],
|
||||
'no-useless-computed-key': 'error',
|
||||
'no-sequences': [
|
||||
'error', {
|
||||
allowInParentheses: false,
|
||||
|
||||
@@ -76,6 +76,7 @@ extension.get('/whoami', { subdomain: 'api' }, async (req, res, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
const oidc_only = req.user.password === null;
|
||||
const details = {
|
||||
username: req.user.username,
|
||||
uuid: req.user.uuid,
|
||||
@@ -88,6 +89,23 @@ extension.get('/whoami', { subdomain: 'api' }, async (req, res, next) => {
|
||||
desktop_bg_color: req.user.desktop_bg_color,
|
||||
desktop_bg_fit: req.user.desktop_bg_fit,
|
||||
is_temp: (req.user.password === null && req.user.email === null),
|
||||
oidc_only,
|
||||
...(oidc_only ? await (async () => {
|
||||
try {
|
||||
const svc_oidc = req.services.get('oidc');
|
||||
const providers = await svc_oidc.getEnabledProviderIds();
|
||||
const origin = (svc_oidc.global_config?.origin || '').replace(/\/$/, '');
|
||||
const provider = providers && providers[0];
|
||||
if ( provider ) {
|
||||
return {
|
||||
oidc_revalidate_url: `${origin}/auth/oidc/${provider}/start?flow=revalidate&user_id=${req.user.id}`,
|
||||
};
|
||||
}
|
||||
return {};
|
||||
} catch ( _e ) {
|
||||
return {};
|
||||
}
|
||||
})() : {}),
|
||||
taskbar_items: await get_taskbar_items(req.user, {
|
||||
...(req.query.icon_size
|
||||
? { icon_size: req.query.icon_size }
|
||||
@@ -216,6 +234,7 @@ extension.post('/whoami', { subdomain: 'api' }, async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const oidc_only = req.user.password === null;
|
||||
// send user object
|
||||
res.send(Object.assign({
|
||||
username: req.user.username,
|
||||
@@ -228,6 +247,7 @@ extension.post('/whoami', { subdomain: 'api' }, async (req, res) => {
|
||||
desktop_bg_color: req.user.desktop_bg_color,
|
||||
desktop_bg_fit: req.user.desktop_bg_fit,
|
||||
is_temp: (req.user.password === null && req.user.email === null),
|
||||
oidc_only,
|
||||
taskbar_items: await get_taskbar_items(req.user),
|
||||
desktop_items: desktop_items,
|
||||
referral_code: req.user.referral_code,
|
||||
|
||||
@@ -269,6 +269,12 @@ const install = async ({ context, services, app, useapi, modapi }) => {
|
||||
const { OTPService } = require('./services/auth/OTPService');
|
||||
services.registerService('otp', OTPService);
|
||||
|
||||
const { OIDCService } = require('./services/auth/OIDCService');
|
||||
services.registerService('oidc', OIDCService);
|
||||
|
||||
const { SignupService } = require('./services/auth/SignupService');
|
||||
services.registerService('signup', SignupService);
|
||||
|
||||
const { UserProtectedEndpointsService } = require('./services/web/UserProtectedEndpointsService');
|
||||
services.registerService('__user-protected-endpoints', UserProtectedEndpointsService);
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ class ExtensionService extends BaseService {
|
||||
this.state.extension.emit('preinit');
|
||||
}
|
||||
|
||||
async ['__on_boot.consolidation'] (...a) {
|
||||
async '__on_boot.consolidation' (...a) {
|
||||
const svc_su = this.services.get('su');
|
||||
await svc_su.sudo(async () => {
|
||||
await this.state.extension.emit('init', {}, {
|
||||
@@ -161,7 +161,7 @@ class ExtensionService extends BaseService {
|
||||
});
|
||||
});
|
||||
}
|
||||
async ['__on_boot.activation'] (...a) {
|
||||
async '__on_boot.activation' (...a) {
|
||||
const svc_su = this.services.get('su');
|
||||
await svc_su.sudo(async () => {
|
||||
await this.state.extension.emit('activate', {}, {
|
||||
@@ -169,7 +169,7 @@ class ExtensionService extends BaseService {
|
||||
});
|
||||
});
|
||||
}
|
||||
async ['__on_boot.ready'] (...a) {
|
||||
async '__on_boot.ready' (...a) {
|
||||
const svc_su = this.services.get('su');
|
||||
await svc_su.sudo(async () => {
|
||||
await this.state.extension.emit('ready', {}, {
|
||||
@@ -178,7 +178,7 @@ class ExtensionService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
['__on_install.routes'] (_, { app }) {
|
||||
'__on_install.routes' (_, { app }) {
|
||||
if ( ! this.state ) debugger;
|
||||
for ( const thing of this.state.expressThings_ ) {
|
||||
if ( thing.type === 'endpoint' ) {
|
||||
|
||||
@@ -128,7 +128,7 @@ class Kernel extends AdvancedBase {
|
||||
extensionInfo: this.extensionInfo,
|
||||
registry: this.registry,
|
||||
args,
|
||||
['runtime-modules']: this.runtimeModuleRegistry,
|
||||
'runtime-modules': this.runtimeModuleRegistry,
|
||||
}, 'app');
|
||||
globalThis.root_context = root_context;
|
||||
|
||||
@@ -148,7 +148,7 @@ class Kernel extends AdvancedBase {
|
||||
services.registerModule(module_.constructor.name, module_);
|
||||
const mod_context = this._create_mod_context(Context.get(), {
|
||||
name: module_.constructor.name,
|
||||
['module']: module_,
|
||||
'module': module_,
|
||||
external: false,
|
||||
});
|
||||
await module_.install(mod_context);
|
||||
@@ -435,7 +435,7 @@ class Kernel extends AdvancedBase {
|
||||
|
||||
const mod_context = this._create_mod_context(mod_install_root_context, {
|
||||
name: mod_name,
|
||||
['module']: mod,
|
||||
'module': mod,
|
||||
external: true,
|
||||
mod_path,
|
||||
});
|
||||
|
||||
@@ -453,6 +453,10 @@ class APIError {
|
||||
status: 403,
|
||||
message: 'This endpoint must be requested with a user session',
|
||||
},
|
||||
'session_required': {
|
||||
status: 403,
|
||||
message: 'This endpoint requires a full session (e.g. change password cannot be done with a GUI token).',
|
||||
},
|
||||
'temporary_accounts_not_allowed': {
|
||||
status: 403,
|
||||
message: 'Temporary accounts cannot perform this action',
|
||||
@@ -465,6 +469,10 @@ class APIError {
|
||||
status: 403,
|
||||
message: 'Password does not match.',
|
||||
},
|
||||
'oidc_revalidation_required': {
|
||||
status: 403,
|
||||
message: 'Re-validate by signing in with your linked account (e.g. Google).',
|
||||
},
|
||||
|
||||
// Object Mapping
|
||||
'field_not_allowed_for_create': {
|
||||
|
||||
@@ -59,6 +59,12 @@ config.captcha = {
|
||||
difficulty: 'medium', // Default difficulty level
|
||||
};
|
||||
|
||||
// OIDC/OAuth2 providers (e.g. Google). Keys in config only, not env vars.
|
||||
// Example: config.oidc.providers.google = { client_id, client_secret }
|
||||
config.oidc = {
|
||||
providers: {},
|
||||
};
|
||||
|
||||
config.monitor = {
|
||||
metricsInterval: 60000,
|
||||
windowSize: 30,
|
||||
|
||||
@@ -24,7 +24,7 @@ const { NodeUIDSelector } = require('../node/selectors');
|
||||
|
||||
class HLStat extends HLFilesystemOperation {
|
||||
static MODULES = {
|
||||
['mime-types']: require('mime-types'),
|
||||
'mime-types': require('mime-types'),
|
||||
};
|
||||
|
||||
async _run () {
|
||||
|
||||
@@ -34,6 +34,7 @@ import { Context } from './util/context.js';
|
||||
import { ManagedError } from './util/errorutil.js';
|
||||
import { kv } from './util/kvSingleton.js';
|
||||
import { spanify } from './util/otelutil.js';
|
||||
import { generate_identifier } from './util/identifier.js';
|
||||
|
||||
export * from './validation.js';
|
||||
|
||||
@@ -1487,6 +1488,14 @@ export async function username_exists (username) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function generate_random_username () {
|
||||
let username;
|
||||
do {
|
||||
username = generate_identifier();
|
||||
} while ( await username_exists(username) );
|
||||
return username;
|
||||
}
|
||||
|
||||
export async function app_name_exists (name) {
|
||||
/** @type BaseDatabaseAccessService */
|
||||
const db = servicesContainer.services.get('database').get(DB_READ, 'filesystem');
|
||||
|
||||
@@ -20,6 +20,7 @@ const APIError = require('../api/APIError');
|
||||
const config = require('../config');
|
||||
const { LegacyTokenError } = require('../services/auth/AuthService');
|
||||
const { Context } = require('../util/context');
|
||||
const jwt = require('jsonwebtoken');
|
||||
|
||||
// The "/whoami" endpoint is a special case where we want to allow
|
||||
// a legacy token to be used for authentication. The "/whoami"
|
||||
@@ -47,7 +48,7 @@ const configurable_auth = options => async (req, res, next) => {
|
||||
const optional = options?.optional;
|
||||
|
||||
// Request might already have been authed (PreAuthService)
|
||||
if ( req.actor ) next();
|
||||
if ( req.actor ) return next();
|
||||
|
||||
// === Getting the Token ===
|
||||
// This step came from jwt_auth in src/helpers.js
|
||||
@@ -55,15 +56,18 @@ const configurable_auth = options => async (req, res, next) => {
|
||||
// auth middleware, it makes more sense to put it here.
|
||||
|
||||
let token;
|
||||
let tokenSource;
|
||||
// Auth token in body
|
||||
if ( req.body && req.body.auth_token )
|
||||
{
|
||||
token = req.body.auth_token;
|
||||
tokenSource = 'body';
|
||||
}
|
||||
// HTTML Auth header
|
||||
else if ( req.header && req.header('Authorization') && !req.header('Authorization').startsWith('Basic ') && req.header('Authorization') !== 'Bearer' ) { // Bearer with no space is something office does
|
||||
token = req.header('Authorization');
|
||||
token = token.replace('Bearer ', '').trim();
|
||||
tokenSource = 'header';
|
||||
if ( token === 'undefined' ) {
|
||||
APIError.create('unexpected_undefined', null, {
|
||||
msg: 'The Authorization token cannot be the string "undefined"',
|
||||
@@ -74,16 +78,19 @@ const configurable_auth = options => async (req, res, next) => {
|
||||
else if ( req.cookies && req.cookies[config.cookie_name] )
|
||||
{
|
||||
token = req.cookies[config.cookie_name];
|
||||
tokenSource = 'cookie';
|
||||
}
|
||||
// Auth token in URL
|
||||
else if ( req.query && req.query.auth_token )
|
||||
{
|
||||
token = req.query.auth_token;
|
||||
tokenSource = 'query';
|
||||
}
|
||||
// Socket
|
||||
else if ( req.handshake && req.handshake.query && req.handshake.query.auth_token )
|
||||
{
|
||||
token = req.handshake.query.auth_token;
|
||||
tokenSource = 'socket';
|
||||
}
|
||||
|
||||
if ( !token || token.startsWith('Basic ') ) {
|
||||
@@ -134,7 +141,8 @@ const configurable_auth = options => async (req, res, next) => {
|
||||
throw APIError.create('forbidden');
|
||||
}
|
||||
|
||||
res.cookie(config.cookie_name, new_info.token, {
|
||||
// Use session token in cookie so cookie-based requests have hasHttpOnlyCookie; client gets GUI token in response
|
||||
res.cookie(config.cookie_name, new_info.session_token ?? new_info.token, {
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import config from '../../config.js';
|
||||
import { createRequire } from 'node:module';
|
||||
import config from '../../config.js';
|
||||
import { APP_ICONS_SUBDOMAIN } from '../../consts/app-icons.js';
|
||||
import { HLWrite } from '../../filesystem/hl_operations/hl_write.js';
|
||||
import { LLMkdir } from '../../filesystem/ll_operations/ll_mkdir.js';
|
||||
import { LLRead } from '../../filesystem/ll_operations/ll_read.js';
|
||||
import { NodePathSelector } from '../../filesystem/node/selectors.js';
|
||||
import { APP_ICONS_SUBDOMAIN } from '../../consts/app-icons.js';
|
||||
import { get_app, get_user } from '../../helpers.js';
|
||||
import BaseService from '../../services/BaseService.js';
|
||||
import { DB_WRITE } from '../../services/database/consts.js';
|
||||
@@ -69,7 +69,7 @@ export class AppIconService extends BaseService {
|
||||
* endpoints /app-icon/:app_uid and /app-icon/:app_uid/:size
|
||||
* which serve the app icon at the requested size.
|
||||
*/
|
||||
async ['__on_install.routes'] (_, { app }) {
|
||||
async '__on_install.routes' (_, { app }) {
|
||||
const handler = async (req, res) => {
|
||||
// Validate parameters
|
||||
let { app_uid: appUid, size } = req.params;
|
||||
@@ -686,7 +686,7 @@ export class AppIconService extends BaseService {
|
||||
* `/system/app_icons` directory if it does not exist,
|
||||
* and then to register the event listener for `app.new-icon`.
|
||||
*/
|
||||
async ['__on_user.system-user-ready'] () {
|
||||
async '__on_user.system-user-ready' () {
|
||||
const svcSu = this.services.get('su');
|
||||
const svcUser = this.services.get('user');
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class OldAppNameService extends BaseService {
|
||||
this.db = this.services.get('database').get(DB_READ, 'old-app-name');
|
||||
}
|
||||
|
||||
async ['__on_boot.consolidation'] () {
|
||||
async '__on_boot.consolidation' () {
|
||||
const svc_event = this.services.get('event');
|
||||
svc_event.on('app.rename', async (_, { app_uid, old_name }) => {
|
||||
this.log.info('GOT EVENT', { app_uid, old_name });
|
||||
|
||||
@@ -70,7 +70,7 @@ export default class RecommendedAppsService extends BaseService {
|
||||
this.app_names = new Set(RecommendedAppsService.APP_NAMES);
|
||||
}
|
||||
|
||||
['__on_boot.consolidation'] () {
|
||||
'__on_boot.consolidation' () {
|
||||
const svc_appIcon = this.services.get('app-icon');
|
||||
const svc_event = this.services.get('event');
|
||||
svc_event.on('apps.invalidate', async (_, { app }) => {
|
||||
|
||||
@@ -98,7 +98,7 @@ class BroadcastService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async ['__on_install.routes'] (_, { app }) {
|
||||
async '__on_install.routes' (_, { app }) {
|
||||
const svc_web = this.services.get('web-server');
|
||||
svc_web.allow_undefined_origin('/broadcast/webhook');
|
||||
|
||||
@@ -253,7 +253,7 @@ class BroadcastService extends BaseService {
|
||||
}
|
||||
}
|
||||
|
||||
async ['__on_install.websockets'] () {
|
||||
async '__on_install.websockets' () {
|
||||
const svc_event = this.services.get('event');
|
||||
const svc_webServer = this.services.get('web-server');
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ class CaptchaService extends BaseService {
|
||||
this.endpointsRegistered = false;
|
||||
}
|
||||
|
||||
async ['__on_install.middlewares.context-aware'] (_, { app }) {
|
||||
async '__on_install.middlewares.context-aware' (_, { app }) {
|
||||
// Add express middleware
|
||||
app.use(checkCaptcha({ svc_captcha: this }));
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class AlarmService extends BaseService {
|
||||
* AlarmService registers its commands at the consolidation phase because
|
||||
* the '_init' method of CommandService may not have been called yet.
|
||||
*/
|
||||
['__on_boot.consolidation'] () {
|
||||
'__on_boot.consolidation' () {
|
||||
this._register_commands(this.services.get('commands'));
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ class ExpectationService extends BaseService {
|
||||
* ExpectationService registers its commands at the consolidation phase because
|
||||
* the '_init' method of CommandService may not have been called yet.
|
||||
*/
|
||||
['__on_boot.consolidation'] () {
|
||||
'__on_boot.consolidation' () {
|
||||
const commands = this.services.get('commands');
|
||||
commands.registerCommands('expectations', [
|
||||
{
|
||||
|
||||
@@ -398,7 +398,7 @@ class LogService extends BaseService {
|
||||
/**
|
||||
* Registers logging commands with the command service.
|
||||
*/
|
||||
['__on_boot.consolidation'] () {
|
||||
'__on_boot.consolidation' () {
|
||||
const commands = this.services.get('commands');
|
||||
commands.registerCommands('logs', [
|
||||
{
|
||||
|
||||
@@ -44,7 +44,7 @@ class PagerService extends BaseService {
|
||||
* PagerService registers its commands at the consolidation phase because
|
||||
* the '_init' method of CommandService may not have been called yet.
|
||||
*/
|
||||
['__on_boot.consolidation'] () {
|
||||
'__on_boot.consolidation' () {
|
||||
this._register_commands(this.services.get('commands'));
|
||||
}
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ class ParameterService extends BaseService {
|
||||
* for parameter management.
|
||||
* @private
|
||||
*/
|
||||
['__on_boot.consolidation'] () {
|
||||
'__on_boot.consolidation' () {
|
||||
this._registerCommands(this.services.get('commands'));
|
||||
}
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ export default class AppService extends BaseService {
|
||||
static WRITE_ALL_OWNER_PERMISSION = 'system:es:write-all-owners';
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['crud-q']: {
|
||||
'crud-q': {
|
||||
async create ({ object, options }) {
|
||||
return await this.#create({ object, options });
|
||||
},
|
||||
|
||||
@@ -35,7 +35,7 @@ class LocalTerminalService extends BaseService {
|
||||
}
|
||||
get_profiles () {
|
||||
return {
|
||||
['api-test']: {
|
||||
'api-test': {
|
||||
cwd: path_.join(__dirname,
|
||||
'../../../../../',
|
||||
'tools/api-tester'),
|
||||
@@ -48,7 +48,7 @@ class LocalTerminalService extends BaseService {
|
||||
},
|
||||
};
|
||||
};
|
||||
['__on_install.routes'] (_, { app }) {
|
||||
'__on_install.routes' (_, { app }) {
|
||||
const r_group = (() => {
|
||||
const require = this.require;
|
||||
const express = require('express');
|
||||
|
||||
@@ -3,7 +3,7 @@ const BaseService = require('../../services/BaseService');
|
||||
const { atimeout } = require('../../util/asyncutil');
|
||||
|
||||
class TXTVerifyService extends BaseService {
|
||||
['__on_boot.consolidation'] () {
|
||||
'__on_boot.consolidation' () {
|
||||
const svc_dns = this.services.get('dns');
|
||||
const dns = svc_dns.get_client();
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class EntityStoreInterfaceService extends BaseService {
|
||||
* Service class for managing Entity Store interface registrations.
|
||||
* Extends the base service to provide entity storage interface management.
|
||||
*/
|
||||
async ['__on_driver.register.interfaces'] () {
|
||||
async '__on_driver.register.interfaces' () {
|
||||
const svc_registry = this.services.get('registry');
|
||||
const col_interfaces = svc_registry.get('interfaces');
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ class KVStoreInterfaceService extends BaseService {
|
||||
* Service class for managing KVStore interface registrations.
|
||||
* Extends the base service to provide key-value store interface management.
|
||||
*/
|
||||
async ['__on_driver.register.interfaces'] () {
|
||||
async '__on_driver.register.interfaces' () {
|
||||
const svc_registry = this.services.get('registry');
|
||||
const col_interfaces = svc_registry.get('interfaces');
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class MountpointService extends BaseService {
|
||||
this.#mounters[name] = mounter;
|
||||
}
|
||||
|
||||
async ['__on_boot.consolidation'] () {
|
||||
async '__on_boot.consolidation' () {
|
||||
// Emit event for registering filesystem types
|
||||
const svc_event = this.services.get('event');
|
||||
const event = {};
|
||||
|
||||
@@ -41,7 +41,7 @@ class SizeService extends BaseService {
|
||||
|
||||
}
|
||||
|
||||
['__on_boot.consolidate'] () {
|
||||
'__on_boot.consolidate' () {
|
||||
const svc_commands = this.services.get('commands');
|
||||
svc_commands.registerCommands('size', [
|
||||
{
|
||||
|
||||
@@ -71,7 +71,7 @@ class DefaultUserService extends BaseService {
|
||||
async _init () {
|
||||
this._register_commands(this.services.get('commands'));
|
||||
}
|
||||
async ['__on_ready.webserver'] () {
|
||||
async '__on_ready.webserver' () {
|
||||
// check if a user named `admin` exists
|
||||
let user = await get_user({ username: USERNAME, cached: false });
|
||||
if ( ! user ) {
|
||||
|
||||
@@ -67,7 +67,7 @@ class DevWatcherService extends BaseService {
|
||||
// port is set to `auto` - you have no idea how confusing
|
||||
// this was to debug the first time, like Ahhhhhh!!
|
||||
// but hey at least we have this convenient event listener.
|
||||
async ['__on_ready.webserver'] () {
|
||||
async '__on_ready.webserver' () {
|
||||
const svc_process = this.services.get('process');
|
||||
|
||||
let { root, commands, webpack } = this.args;
|
||||
|
||||
@@ -23,7 +23,7 @@ class ServeSingleFileService extends BaseService {
|
||||
this.route = args.route;
|
||||
this.path = args.path;
|
||||
}
|
||||
async ['__on_install.routes'] () {
|
||||
async '__on_install.routes' () {
|
||||
const { app } = this.services.get('web-server');
|
||||
|
||||
app.get(this.route, (req, res) => {
|
||||
|
||||
@@ -23,7 +23,7 @@ class ServeStaticFilesService extends BaseService {
|
||||
this.directories = args.directories;
|
||||
}
|
||||
|
||||
async ['__on_install.routes'] () {
|
||||
async '__on_install.routes' () {
|
||||
const { app } = this.services.get('web-server');
|
||||
|
||||
for ( const { prefix, path } of this.directories ) {
|
||||
|
||||
@@ -45,7 +45,7 @@ class TemplateService extends BaseService {
|
||||
/**
|
||||
* TemplateService listens to this event to provide an example endpoint
|
||||
*/
|
||||
['__on_install.routes'] (_, { app }) {
|
||||
'__on_install.routes' (_, { app }) {
|
||||
this.log.info('TemplateService get the event for installing endpoint.');
|
||||
Endpoint({
|
||||
route: '/example-endpoint',
|
||||
@@ -61,7 +61,7 @@ class TemplateService extends BaseService {
|
||||
/**
|
||||
* TemplateService listens to this event to provide an example event
|
||||
*/
|
||||
['__on_boot.consolidation'] () {
|
||||
'__on_boot.consolidation' () {
|
||||
// At this stage, all services have been initialized and it is
|
||||
// safe to start emitting events.
|
||||
this.log.info('TemplateService sees consolidation boot phase.');
|
||||
@@ -81,14 +81,14 @@ class TemplateService extends BaseService {
|
||||
/**
|
||||
* TemplateService listens to this event to show you that it's here
|
||||
*/
|
||||
['__on_boot.activation'] () {
|
||||
'__on_boot.activation' () {
|
||||
this.log.info('TemplateService sees activation boot phase.');
|
||||
}
|
||||
|
||||
/**
|
||||
* TemplateService listens to this event to show you that it's here
|
||||
*/
|
||||
['__on_start.webserver'] () {
|
||||
'__on_start.webserver' () {
|
||||
this.log.info("TemplateService sees it's time to start web servers.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
const BaseService = require('../../services/BaseService');
|
||||
|
||||
class TestAssetHostService extends BaseService {
|
||||
async ['__on_install.routes'] () {
|
||||
async '__on_install.routes' () {
|
||||
const { app } = this.services.get('web-server');
|
||||
const path_ = require('node:path');
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ const PUBLIC_DOMAIN_IMAGES = [
|
||||
];
|
||||
|
||||
class TestImageService extends BaseService {
|
||||
async ['__on_driver.register.interfaces'] () {
|
||||
async '__on_driver.register.interfaces' () {
|
||||
const svc_registry = this.services.get('registry');
|
||||
const col_interfaces = svc_registry.get('interfaces');
|
||||
|
||||
@@ -68,12 +68,12 @@ class TestImageService extends BaseService {
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['version']: {
|
||||
'version': {
|
||||
get_version () {
|
||||
return 'v1.0.0';
|
||||
},
|
||||
},
|
||||
['test-image']: {
|
||||
'test-image': {
|
||||
async echo_image ({
|
||||
source,
|
||||
}) {
|
||||
|
||||
@@ -31,7 +31,7 @@ class SocketioService extends BaseService {
|
||||
*
|
||||
* @evtparam server The server to attach socket.io to.
|
||||
*/
|
||||
['__on_install.socketio'] (_, { server }) {
|
||||
'__on_install.socketio' (_, { server }) {
|
||||
/**
|
||||
* @type {import('socket.io').Server}
|
||||
*/
|
||||
|
||||
@@ -46,7 +46,7 @@ class WebServerService extends BaseService {
|
||||
helmet: require('helmet'),
|
||||
cookieParser: require('cookie-parser'),
|
||||
compression: require('compression'),
|
||||
['on-finished']: require('on-finished'),
|
||||
'on-finished': require('on-finished'),
|
||||
morgan: require('morgan'),
|
||||
};
|
||||
|
||||
@@ -64,7 +64,7 @@ class WebServerService extends BaseService {
|
||||
* @private
|
||||
*/
|
||||
// comment above line 44 in WebServerService.js
|
||||
async ['__on_boot.consolidation'] () {
|
||||
async '__on_boot.consolidation' () {
|
||||
const app = this.app;
|
||||
const services = this.services;
|
||||
await services.emit('install.middlewares.early', { app });
|
||||
@@ -115,7 +115,7 @@ class WebServerService extends BaseService {
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves once the server is started.
|
||||
*/
|
||||
async ['__on_boot.activation'] () {
|
||||
async '__on_boot.activation' () {
|
||||
const services = this.services;
|
||||
await services.emit('start.webserver');
|
||||
await services.emit('ready.webserver');
|
||||
@@ -130,7 +130,7 @@ class WebServerService extends BaseService {
|
||||
*
|
||||
* @return {Promise} A promise that resolves when the server is up and running.
|
||||
*/
|
||||
async ['__on_start.webserver'] () {
|
||||
async '__on_start.webserver' () {
|
||||
// error handling middleware goes last, as per the
|
||||
// expressjs documentation:
|
||||
// https://expressjs.com/en/guide/error-handling.html
|
||||
@@ -337,6 +337,35 @@ class WebServerService extends BaseService {
|
||||
next();
|
||||
});
|
||||
|
||||
// When the user visits the main origin (not api/dav subdomain) with ?auth_token=<GUI token>
|
||||
// (e.g. QR login), set the HTTP-only session cookie so user-protected endpoints work.
|
||||
app.use(async (req, res, next) => {
|
||||
const has_subdomain = req.hostname.slice(0, -1 * (config.domain.length + 1)) !== '';
|
||||
if ( has_subdomain ) return next();
|
||||
|
||||
const token = req.query?.auth_token;
|
||||
if ( !token || typeof token !== 'string' ) return next();
|
||||
|
||||
try {
|
||||
const svc_auth = req.services.get('auth');
|
||||
const cleanToken = token.replace('Bearer ', '').trim();
|
||||
const actor = await svc_auth.authenticate_from_token(cleanToken);
|
||||
const session_token = svc_auth.create_session_token_for_session(
|
||||
actor.type.user,
|
||||
actor.type.session,
|
||||
);
|
||||
res.cookie(config.cookie_name, session_token, {
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
});
|
||||
} catch ( e ) {
|
||||
console.log('query auth token (QR Code login probably) failed');
|
||||
console.error(e);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Measure data transfer amounts
|
||||
app.use(measure());
|
||||
|
||||
@@ -627,7 +656,7 @@ class WebServerService extends BaseService {
|
||||
req.co_isolation_enabled
|
||||
;
|
||||
|
||||
if ( req.path === '/signup' || req.path === '/login' || req.path.startsWith('/extensions/') ) {
|
||||
if ( req.path === '/signup' || req.path === '/login' || req.path.startsWith('/extensions/') || req.path.startsWith('/auth/oidc') ) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin ?? '*');
|
||||
}
|
||||
// Website(s) to allow to connect
|
||||
|
||||
@@ -282,7 +282,7 @@ module.exports = {
|
||||
return is_valid_uuid4(value);
|
||||
},
|
||||
},
|
||||
['puter-uuid']: {
|
||||
'puter-uuid': {
|
||||
from: 'string',
|
||||
validate (value, { descriptor }) {
|
||||
const prefix = `${descriptor.prefix }-`;
|
||||
@@ -297,7 +297,7 @@ module.exports = {
|
||||
return prefix + uuid;
|
||||
},
|
||||
},
|
||||
['image-base64']: {
|
||||
'image-base64': {
|
||||
from: 'string',
|
||||
is_set (value) {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
@@ -382,7 +382,7 @@ module.exports = {
|
||||
datetime: {
|
||||
from: 'base',
|
||||
},
|
||||
['puter-node']: {
|
||||
'puter-node': {
|
||||
// from: 'base',
|
||||
async sql_reference (value) {
|
||||
if ( value === null ) return null;
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import express from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import config from '../../config.js';
|
||||
import { get_user, subdomain } from '../../helpers.js';
|
||||
const router = express.Router();
|
||||
|
||||
const REVALIDATION_COOKIE_NAME = 'puter_revalidation';
|
||||
const REVALIDATION_EXPIRY_SEC = 300; // 5 minutes
|
||||
|
||||
const MISSING_CODE_OR_STATE = Symbol('MISSING_CODE_OR_STATE');
|
||||
const INVALID_OR_EXPIRED_STATE = Symbol('INVALID_OR_EXPIRED_STATE');
|
||||
const TOKEN_EXCHANGE_FAILED = Symbol('TOKEN_EXCHANGE_FAILED');
|
||||
const COULD_NOT_GET_USER_INFO = Symbol('COULD_NOT_GET_USER_INFO');
|
||||
|
||||
const OIDC_CALLBACK_ERROR_RESPONSES = {
|
||||
[MISSING_CODE_OR_STATE]: { status: 400, message: 'Missing code or state.' },
|
||||
[INVALID_OR_EXPIRED_STATE]: { status: 400, message: 'Invalid or expired state.' },
|
||||
[TOKEN_EXCHANGE_FAILED]: { status: 401, message: 'Token exchange failed.' },
|
||||
[COULD_NOT_GET_USER_INFO]: { status: 401, message: 'Could not get user info.' },
|
||||
};
|
||||
|
||||
/** Returns { session_token, target } for the caller to set cookie and redirect. */
|
||||
const finishOidcSuccess_ = async (req, res, user, stateDecoded) => {
|
||||
const svc_auth = req.services.get('auth');
|
||||
const { token: session_token } = await svc_auth.create_session_token(user, { req });
|
||||
let target = stateDecoded.redirect_uri || config.origin || '/';
|
||||
const origin = config.origin || '';
|
||||
if ( target && origin && !target.startsWith(origin) ) {
|
||||
target = origin;
|
||||
}
|
||||
return { session_token, target };
|
||||
};
|
||||
|
||||
/** Exchange code for tokens, get userinfo. Returns { provider, userinfo, stateDecoded } or { error } (symbol). */
|
||||
const processOIDCCallbackRequest_ = async (req, callbackRedirectUri) => {
|
||||
const svc_oidc = req.services.get('oidc');
|
||||
const code = req.query.code;
|
||||
const state = req.query.state;
|
||||
if ( !code || !state ) {
|
||||
return { error: MISSING_CODE_OR_STATE };
|
||||
}
|
||||
const stateDecoded = svc_oidc.verifyState(state);
|
||||
if ( !stateDecoded || !stateDecoded.provider ) {
|
||||
return { error: INVALID_OR_EXPIRED_STATE };
|
||||
}
|
||||
const provider = stateDecoded.provider;
|
||||
const tokens = await svc_oidc.exchangeCodeForTokens(provider, code, callbackRedirectUri);
|
||||
if ( !tokens || !tokens.access_token ) {
|
||||
return { error: TOKEN_EXCHANGE_FAILED };
|
||||
}
|
||||
const userinfo = await svc_oidc.getUserInfo(provider, tokens.access_token);
|
||||
if ( !userinfo || !userinfo.sub ) {
|
||||
return { error: COULD_NOT_GET_USER_INFO };
|
||||
}
|
||||
return { provider, userinfo, stateDecoded };
|
||||
};
|
||||
|
||||
// GET /auth/oidc/providers - list enabled provider ids for frontend
|
||||
router.get('/auth/oidc/providers', async (req, res) => {
|
||||
if ( subdomain(req) !== 'api' ) {
|
||||
return res.status(404).end();
|
||||
}
|
||||
const svc_oidc = req.services.get('oidc');
|
||||
const providers = await svc_oidc.getEnabledProviderIds();
|
||||
return res.json({ providers });
|
||||
});
|
||||
|
||||
// GET /auth/oidc/:provider/start - redirect to IdP authorization
|
||||
router.get('/auth/oidc/:provider/start', async (req, res) => {
|
||||
if ( subdomain(req) !== '' ) {
|
||||
return res.status(404).end();
|
||||
}
|
||||
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
|
||||
if ( ! svc_edgeRateLimit.check('oidc-general') ) {
|
||||
return res.status(429).send('Too many requests.');
|
||||
}
|
||||
const provider = req.params.provider;
|
||||
const svc_oidc = req.services.get('oidc');
|
||||
const cfg = await svc_oidc.getProviderConfig(provider);
|
||||
if ( ! cfg ) {
|
||||
return res.status(404).send('Provider not configured.');
|
||||
}
|
||||
const flow = req.query.flow ? String(req.query.flow) : undefined;
|
||||
const flowRedirects = {
|
||||
login: config.origin || '/',
|
||||
signup: config.origin || '/',
|
||||
revalidate: `${(config.origin || '').replace(/\/$/, '')}/auth/revalidate-done`,
|
||||
};
|
||||
const appRedirectUri = (flow && flowRedirects[flow]) ? flowRedirects[flow] : (config.origin || '/');
|
||||
const statePayload = { provider, redirect_uri: appRedirectUri };
|
||||
if ( flow === 'revalidate' ) {
|
||||
const user_id = req.query.user_id;
|
||||
if ( ! user_id ) {
|
||||
return res.status(400).send('user_id required for revalidate flow.');
|
||||
}
|
||||
statePayload.user_id = Number(user_id);
|
||||
statePayload.flow = 'revalidate';
|
||||
}
|
||||
const state = svc_oidc.signState(statePayload);
|
||||
const url = await svc_oidc.getAuthorizationUrl(provider, state, flow);
|
||||
if ( ! url ) {
|
||||
return res.status(502).send('Could not build authorization URL.');
|
||||
}
|
||||
return res.redirect(302, url);
|
||||
});
|
||||
|
||||
// GET /auth/oidc/callback/login - login only: existing account or abort. Never creates a user.
|
||||
router.get('/auth/oidc/callback/login', async (req, res) => {
|
||||
if ( subdomain(req) !== '' ) {
|
||||
return res.status(404).end();
|
||||
}
|
||||
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
|
||||
if ( ! svc_edgeRateLimit.check('oidc-general') ) {
|
||||
return res.status(429).send('Too many requests.');
|
||||
}
|
||||
const svc_oidc = req.services.get('oidc');
|
||||
const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('login');
|
||||
const result = await processOIDCCallbackRequest_(req, callbackRedirectUri);
|
||||
if ( result.error ) {
|
||||
const { status, message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error];
|
||||
return res.status(status).send(message);
|
||||
}
|
||||
const { provider, userinfo, stateDecoded } = result;
|
||||
const user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub);
|
||||
if ( ! user ) {
|
||||
return res.status(400).send('No account found. Sign up first.');
|
||||
}
|
||||
if ( user.suspended ) {
|
||||
return res.status(401).send('This account is suspended.');
|
||||
}
|
||||
const { session_token, target } = await finishOidcSuccess_(req, res, user, stateDecoded);
|
||||
res.cookie(config.cookie_name, session_token, {
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
});
|
||||
return res.redirect(302, target);
|
||||
});
|
||||
|
||||
// GET /auth/oidc/callback/signup - signup only: create new account or abort. Never logs in to existing account.
|
||||
router.get('/auth/oidc/callback/signup', async (req, res) => {
|
||||
if ( subdomain(req) !== '' ) {
|
||||
return res.status(404).end();
|
||||
}
|
||||
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
|
||||
if ( ! svc_edgeRateLimit.check('oidc-general') ) {
|
||||
return res.status(429).send('Too many requests.');
|
||||
}
|
||||
const svc_oidc = req.services.get('oidc');
|
||||
const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('signup');
|
||||
const result = await processOIDCCallbackRequest_(req, callbackRedirectUri);
|
||||
if ( result.error ) {
|
||||
const { status, message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error];
|
||||
return res.status(status).send(message);
|
||||
}
|
||||
const { provider, userinfo, stateDecoded } = result;
|
||||
const existingUser = await svc_oidc.findUserByProviderSub(provider, userinfo.sub);
|
||||
if ( existingUser ) {
|
||||
return res.status(400).send('Account already exists. Log in instead.');
|
||||
}
|
||||
const outcome = await svc_oidc.createUserFromOIDC(provider, userinfo);
|
||||
if ( outcome.failed ) {
|
||||
return res.status(400).send(outcome.userMessage);
|
||||
}
|
||||
const user = await get_user({ id: outcome.infoObject.user_id });
|
||||
const { session_token, target } = await finishOidcSuccess_(req, res, user, stateDecoded);
|
||||
res.cookie(config.cookie_name, session_token, {
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
});
|
||||
return res.redirect(302, target);
|
||||
});
|
||||
|
||||
// GET /auth/oidc/callback/revalidate - re-validate identity for protected actions (e.g. change username). Sets short-lived cookie and redirects.
|
||||
router.get('/auth/oidc/callback/revalidate', async (req, res) => {
|
||||
if ( subdomain(req) !== '' ) {
|
||||
return res.status(404).end();
|
||||
}
|
||||
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
|
||||
if ( ! svc_edgeRateLimit.check('oidc-general') ) {
|
||||
return res.status(429).send('Too many requests.');
|
||||
}
|
||||
const svc_oidc = req.services.get('oidc');
|
||||
const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('revalidate');
|
||||
const result = await processOIDCCallbackRequest_(req, callbackRedirectUri);
|
||||
if ( result.error ) {
|
||||
const { status, message } = OIDC_CALLBACK_ERROR_RESPONSES[result.error];
|
||||
return res.status(status).send(message);
|
||||
}
|
||||
const { provider, userinfo, stateDecoded } = result;
|
||||
if ( stateDecoded.flow !== 'revalidate' || stateDecoded.user_id == null ) {
|
||||
return res.status(400).send('Invalid revalidate state.');
|
||||
}
|
||||
const user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub);
|
||||
if ( ! user ) {
|
||||
return res.status(400).send('No account found.');
|
||||
}
|
||||
if ( user.id !== stateDecoded.user_id ) {
|
||||
return res.status(403).send('Wrong account. Sign in with the account linked to this session.');
|
||||
}
|
||||
const token = jwt.sign({ user_id: user.id, purpose: 'revalidate' },
|
||||
config.jwt_secret,
|
||||
{ expiresIn: REVALIDATION_EXPIRY_SEC });
|
||||
res.cookie(REVALIDATION_COOKIE_NAME, token, {
|
||||
sameSite: 'lax',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
maxAge: REVALIDATION_EXPIRY_SEC * 1000,
|
||||
path: '/',
|
||||
});
|
||||
const target = stateDecoded.redirect_uri || `${(config.origin || '').replace(/\/$/, '')}/auth/revalidate-done`;
|
||||
return res.redirect(302, target);
|
||||
});
|
||||
|
||||
// GET /auth/revalidate-done - landing page after OIDC revalidate; posts to opener and closes (for popup flow).
|
||||
router.get('/auth/revalidate-done', (req, res) => {
|
||||
if ( subdomain(req) !== '' ) {
|
||||
return res.status(404).end();
|
||||
}
|
||||
const origin = config.origin || '';
|
||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(`<!DOCTYPE html><html><head><title>Re-validated</title></head><body><script>
|
||||
(function(){
|
||||
var origin = ${JSON.stringify(origin)};
|
||||
if (window.opener) {
|
||||
try { window.opener.postMessage({ type: 'puter-revalidate-done' }, origin); } catch (e) {}
|
||||
window.close();
|
||||
} else {
|
||||
document.body.innerHTML = '<p>Re-validated. You can close this tab.</p>';
|
||||
}
|
||||
})();
|
||||
</script><p>Re-validated. Closing…</p></body></html>`);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -78,9 +78,9 @@ module.exports = eggspress('/change_username', {
|
||||
mysql: '`created_at` > DATE_SUB(NOW(), INTERVAL 1 MONTH)',
|
||||
sqlite: "`created_at` > datetime('now', '-1 month')",
|
||||
})}`,
|
||||
[ req.user.id, 'change_username' ]);
|
||||
[req.user.id, 'change_username']);
|
||||
|
||||
if ( rows[0].count >= 2 ) {
|
||||
if ( rows[0].count >= (config.max_username_changes ?? 2) ) {
|
||||
throw APIError.create('too_many_username_changes');
|
||||
}
|
||||
|
||||
|
||||
@@ -642,7 +642,7 @@ class PuterSiteMiddleware extends AdvancedBase {
|
||||
|
||||
res.redirect(`${config.origin}?${e.querystringize({
|
||||
...(req.query['puter.app_instance_id'] ? {
|
||||
['error_from_within_iframe']: true,
|
||||
'error_from_within_iframe': true,
|
||||
} : {}),
|
||||
})}`);
|
||||
}
|
||||
|
||||
@@ -26,21 +26,21 @@ const { requireCaptcha } = require('../modules/captcha/middleware/captcha-middle
|
||||
|
||||
const complete_ = async ({ req, res, user }) => {
|
||||
const svc_auth = req.services.get('auth');
|
||||
const { token } = await svc_auth.create_session_token(user, { req });
|
||||
const { session, token: session_token } = await svc_auth.create_session_token(user, { req });
|
||||
const gui_token = svc_auth.create_gui_token(user, session);
|
||||
|
||||
//set cookie
|
||||
// res.cookie(config.cookie_name, token);
|
||||
res.cookie(config.cookie_name, token, {
|
||||
// HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie)
|
||||
res.cookie(config.cookie_name, session_token, {
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
});
|
||||
|
||||
// send response
|
||||
// response body: GUI token only (client never gets session token)
|
||||
return res.send({
|
||||
proceed: true,
|
||||
next_step: 'complete',
|
||||
token: token,
|
||||
token: gui_token,
|
||||
user: {
|
||||
username: user.username,
|
||||
uuid: user.uuid,
|
||||
|
||||
@@ -207,9 +207,10 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => {
|
||||
}
|
||||
}
|
||||
|
||||
// create token for login
|
||||
// create token for login: session token for cookie, GUI token for client
|
||||
const svc_auth = req.services.get('auth');
|
||||
const { token } = await svc_auth.create_session_token(req.user, { req });
|
||||
const { session, token: session_token } = await svc_auth.create_session_token(req.user, { req });
|
||||
const gui_token = svc_auth.create_gui_token(req.user, session);
|
||||
|
||||
// user id
|
||||
// todo if pseudo user, assign directly no need to do another DB lookup
|
||||
@@ -219,8 +220,8 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => {
|
||||
|
||||
// todo send LINK-based verification email
|
||||
|
||||
//set cookie
|
||||
res.cookie(config.cookie_name, token);
|
||||
// HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie)
|
||||
res.cookie(config.cookie_name, session_token);
|
||||
|
||||
{
|
||||
const svc_event = req.services.get('event');
|
||||
@@ -229,7 +230,7 @@ router.post('/save_account', auth, express.json(), async (req, res, next) => {
|
||||
|
||||
// return results
|
||||
return res.send({
|
||||
token: token,
|
||||
token: gui_token,
|
||||
user: {
|
||||
username: user.username,
|
||||
uuid: user.uuid,
|
||||
|
||||
@@ -420,11 +420,12 @@ module.exports = eggspress(['/signup'], {
|
||||
const [user] = await db.pread('SELECT * FROM `user` WHERE `id` = ? LIMIT 1',
|
||||
[user_id]);
|
||||
|
||||
// create token for login
|
||||
const { token } = await svc_auth.create_session_token(user, {
|
||||
// create token for login: session token for cookie, GUI token for client
|
||||
const { session, token: session_token } = await svc_auth.create_session_token(user, {
|
||||
req,
|
||||
});
|
||||
// jwt.sign({uuid: user_uuid}, config.jwt_secret);
|
||||
const gui_token = svc_auth.create_gui_token(user, session);
|
||||
// jwt.sign({uuid: user_uuid}, config.jwt_secret);
|
||||
|
||||
//-------------------------------------------------------------
|
||||
// email confirmation
|
||||
@@ -456,8 +457,8 @@ module.exports = eggspress(['/signup'], {
|
||||
const svc_user = Context.get('services').get('user');
|
||||
await svc_user.generate_default_fsentries({ user });
|
||||
|
||||
//set cookie
|
||||
res.cookie(config.cookie_name, token, {
|
||||
// HTTP-only cookie gets session token (cookie-based requests have hasHttpOnlyCookie)
|
||||
res.cookie(config.cookie_name, session_token, {
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
@@ -471,7 +472,7 @@ module.exports = eggspress(['/signup'], {
|
||||
|
||||
// return results
|
||||
return res.send({
|
||||
token: token,
|
||||
token: gui_token,
|
||||
user: {
|
||||
username: user.username,
|
||||
uuid: user.uuid,
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import config from '../config.js';
|
||||
import { DB_WRITE } from '../services/database/consts.js';
|
||||
import { generate_identifier } from '../util/identifier.js';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Create a new user for signup. Common behavior shared by POST /signup and OIDC signup.
|
||||
* Form-signup path is still handled in signup.js; this handles OIDC and will support form signup after refactor.
|
||||
*
|
||||
* @param {object} services - Backend services (from req.services)
|
||||
* @param {object} options - Creation options. For OIDC: { providerId, userinfo }. For form signup: TBD (to be refactored from signup.js).
|
||||
* @returns {Promise<object|null>} The created user, or null on failure (e.g. email already registered).
|
||||
*/
|
||||
async function signup_create_new_user (services, options) {
|
||||
const { providerId, userinfo } = options;
|
||||
if ( !providerId || !userinfo ) {
|
||||
// Form signup: to be refactored from signup.js; not implemented here yet.
|
||||
return null;
|
||||
}
|
||||
|
||||
const db = await services.get('database').get(DB_WRITE, 'auth');
|
||||
const svc_group = services.get('group');
|
||||
const svc_user = services.get('user');
|
||||
const svc_oidc = services.get('oidc');
|
||||
if ( ! svc_oidc ) return null;
|
||||
|
||||
const claims = userinfo;
|
||||
let username = (claims.name || claims.email || '').toString().trim();
|
||||
if ( username ) {
|
||||
username = username.replace(/\s+/g, '_').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
if ( username.length > 45 ) username = username.slice(0, 45);
|
||||
}
|
||||
if ( !username || !/^\w+$/.test(username) ) {
|
||||
let candidate;
|
||||
do {
|
||||
candidate = generate_identifier();
|
||||
const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]);
|
||||
if ( ! r ) username = candidate;
|
||||
} while ( !username );
|
||||
} else {
|
||||
const [existing] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [username]);
|
||||
if ( existing ) {
|
||||
let suffix = 1;
|
||||
while ( true ) {
|
||||
const candidate = `${username}${suffix}`;
|
||||
const [r] = await db.pread('SELECT 1 FROM user WHERE username = ? LIMIT 1', [candidate]);
|
||||
if ( ! r ) {
|
||||
username = candidate; break;
|
||||
}
|
||||
suffix++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const email = (claims.email || '').toString().trim() || null;
|
||||
const clean_email = email ? email.toLowerCase().trim() : null;
|
||||
if ( clean_email ) {
|
||||
const [existingEmail] = await db.pread('SELECT 1 FROM user WHERE clean_email = ? LIMIT 1', [clean_email]);
|
||||
if ( existingEmail ) {
|
||||
return null; // email already registered; caller should return error
|
||||
}
|
||||
}
|
||||
|
||||
const user_uuid = uuidv4();
|
||||
const email_confirm_code = String(Math.floor(100000 + Math.random() * 900000));
|
||||
const email_confirm_token = uuidv4();
|
||||
|
||||
await db.write(`INSERT INTO user (
|
||||
username, email, clean_email, password, uuid, referrer,
|
||||
email_confirm_code, email_confirm_token, free_storage,
|
||||
referred_by, email_confirmed, requires_email_confirmation
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
username,
|
||||
email,
|
||||
clean_email,
|
||||
null,
|
||||
user_uuid,
|
||||
null,
|
||||
email_confirm_code,
|
||||
email_confirm_token,
|
||||
config.storage_capacity,
|
||||
null,
|
||||
1,
|
||||
0,
|
||||
]);
|
||||
const [inserted] = await db.pread('SELECT id FROM user WHERE uuid = ? LIMIT 1', [user_uuid]);
|
||||
const user_id = inserted.id;
|
||||
|
||||
await svc_oidc.linkProviderToUser(user_id, providerId, claims.sub, null);
|
||||
|
||||
await svc_group.add_users({
|
||||
uid: config.default_user_group,
|
||||
users: [username],
|
||||
});
|
||||
|
||||
const [user] = await db.pread('SELECT * FROM user WHERE id = ? LIMIT 1', [user_id]);
|
||||
if ( user && user.metadata && typeof user.metadata === 'string' ) {
|
||||
user.metadata = JSON.parse(user.metadata);
|
||||
} else if ( user && !user.metadata ) {
|
||||
user.metadata = {};
|
||||
}
|
||||
await svc_user.generate_default_fsentries({ user });
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
export default signup_create_new_user;
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
const config = require('../../config');
|
||||
const APIError = require('../../api/APIError.js');
|
||||
const { DB_WRITE } = require('../../services/database/consts');
|
||||
const { username_exists, change_username } = require('../../helpers');
|
||||
const { Context } = require('../../util/context');
|
||||
|
||||
module.exports = {
|
||||
route: '/change-username',
|
||||
methods: ['POST'],
|
||||
handler: async (req, res, _next) => {
|
||||
const user = req.user;
|
||||
const new_username = req.body.new_username;
|
||||
|
||||
if ( ! new_username ) {
|
||||
throw APIError.create('field_missing', null, { key: 'new_username' });
|
||||
}
|
||||
if ( typeof new_username !== 'string' ) {
|
||||
throw APIError.create('field_invalid', null, { key: 'new_username', expected: 'a string' });
|
||||
}
|
||||
if ( ! new_username.match(config.username_regex) ) {
|
||||
throw APIError.create('field_invalid', null, { key: 'new_username', expected: 'letters, numbers, underscore (_)' });
|
||||
}
|
||||
if ( new_username.length > config.username_max_length ) {
|
||||
throw APIError.create('field_too_long', null, { key: 'new_username', max_length: config.username_max_length });
|
||||
}
|
||||
if ( await username_exists(new_username) ) {
|
||||
throw APIError.create('username_already_in_use', null, { username: new_username });
|
||||
}
|
||||
|
||||
const svc_edgeRateLimit = req.services.get('edge-rate-limit');
|
||||
if ( ! svc_edgeRateLimit.check('change-username-start') ) {
|
||||
return res.status(429).send('Too many requests.');
|
||||
}
|
||||
|
||||
const db = Context.get('services').get('database').get(DB_WRITE, 'auth');
|
||||
const rows = await db.read('SELECT COUNT(*) AS `count` FROM `user_update_audit` ' +
|
||||
`WHERE \`user_id\`=? AND \`reason\`=? AND ${
|
||||
db.case({
|
||||
mysql: '`created_at` > DATE_SUB(NOW(), INTERVAL 1 MONTH)',
|
||||
sqlite: "`created_at` > datetime('now', '-1 month')",
|
||||
})}`,
|
||||
[user.id, 'change_username']);
|
||||
|
||||
if ( rows[0].count >= (config.max_username_changes ?? 2) ) {
|
||||
throw APIError.create('too_many_username_changes');
|
||||
}
|
||||
|
||||
await db.write('INSERT INTO `user_update_audit` ' +
|
||||
'(`user_id`, `user_id_keep`, `old_username`, `new_username`, `reason`) ' +
|
||||
'VALUES (?, ?, ?, ?, ?)',
|
||||
[user.id, user.id, user.username, new_username, 'change_username']);
|
||||
|
||||
await change_username(user.id, new_username);
|
||||
|
||||
res.json({});
|
||||
},
|
||||
};
|
||||
+16
-4
@@ -1,9 +1,15 @@
|
||||
import type { ServerHealthService } from '../modules/core/ServerHealthService/ServerHealthService';
|
||||
import { SqliteDatabaseAccessService } from './database/SqliteDatabaseAccessService';
|
||||
import { MeteringServiceWrapper } from './MeteringService/MeteringServiceWrapper.mjs';
|
||||
import { DDBClient } from '../clients/dynamodb/DDBClient';
|
||||
import { DynamoKVStore } from '../clients/dynamodb/DynamoKVStore/DynamoKVStore';
|
||||
import type { ServerHealthService } from '../modules/core/ServerHealthService/ServerHealthService';
|
||||
import { GroupService } from './auth/GroupService';
|
||||
import SignupService from './auth/SignupService';
|
||||
import { CleanEmailService } from './CleanEmailService';
|
||||
import { SqliteDatabaseAccessService } from './database/SqliteDatabaseAccessService';
|
||||
import { EventService } from './EventService';
|
||||
import { FeatureFlagService } from './FeatureFlagService';
|
||||
import { MeteringServiceWrapper } from './MeteringService/MeteringServiceWrapper.mjs';
|
||||
import type { SUService } from './SUService';
|
||||
import { UserService } from './UserService';
|
||||
|
||||
export interface ServiceResources {
|
||||
services: {
|
||||
@@ -13,7 +19,13 @@ export interface ServiceResources {
|
||||
get (name: 'server-health'): ServerHealthService;
|
||||
get (name: 'su'): SUService;
|
||||
get (name: 'dynamo'): DDBClient;
|
||||
get (name: string): any;
|
||||
get (name: 'user'): UserService;
|
||||
get (name: 'event'): EventService;
|
||||
get (name: 'signup'): SignupService;
|
||||
get (name: 'group'): GroupService;
|
||||
get (name: 'feature-flag'): FeatureFlagService;
|
||||
get (name: 'clean-email'): CleanEmailService;
|
||||
get (name: string): unknown;
|
||||
};
|
||||
config: Record<string, any> & { services?: Record<string, any>; server_id?: string };
|
||||
name?: string;
|
||||
|
||||
@@ -41,7 +41,7 @@ class BootScriptService extends BaseService {
|
||||
* @function
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async ['__on_boot.ready'] () {
|
||||
async '__on_boot.ready' () {
|
||||
const args = Context.get('args');
|
||||
if ( ! args['boot-script'] ) return;
|
||||
const script_name = args['boot-script'];
|
||||
@@ -66,7 +66,7 @@ class BootScriptService extends BaseService {
|
||||
async run_script (boot_json) {
|
||||
const scope = {
|
||||
runner: 'boot-script',
|
||||
['end-puter-process']: ({ args }) => {
|
||||
'end-puter-process': ({ args }) => {
|
||||
const svc_shutdown = this.services.get('shutdown');
|
||||
svc_shutdown.shutdown(args[0]);
|
||||
},
|
||||
|
||||
@@ -40,7 +40,7 @@ class ChatAPIService extends BaseService {
|
||||
* @param {Express} options.app Express application instance to install routes on
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async ['__on_install.routes'] (_, { app }) {
|
||||
async '__on_install.routes' (_, { app }) {
|
||||
// Create a router for chat API endpoints
|
||||
const router = (() => {
|
||||
const require = this.require;
|
||||
|
||||
@@ -99,7 +99,7 @@ class CommandService extends BaseService {
|
||||
}));
|
||||
}
|
||||
|
||||
async ['__on_boot.consolidation'] () {
|
||||
async '__on_boot.consolidation' () {
|
||||
const svc_event = this.services.get('event');
|
||||
const svc_command = this;
|
||||
const event = {
|
||||
|
||||
@@ -97,7 +97,7 @@ class ContextInitService extends BaseService {
|
||||
key, async_factory,
|
||||
});
|
||||
}
|
||||
async ['__on_install.middlewares.context-aware'] (_, { app }) {
|
||||
async '__on_install.middlewares.context-aware' (_, { app }) {
|
||||
this.mw.install(app);
|
||||
await this.services.emit('install.context-initializers');
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class EntityStoreService extends BaseService {
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['crud-q']: {
|
||||
'crud-q': {
|
||||
async create ({ object, options }) {
|
||||
if ( object.hasOwnProperty(this.om.primary_identifier) ) {
|
||||
throw APIError.create('field_not_allowed_for_create', null, {
|
||||
@@ -103,7 +103,7 @@ class EntityStoreService extends BaseService {
|
||||
es_params: options?.params ?? {},
|
||||
}).arun(async () => {
|
||||
const entities = await this.select(options);
|
||||
const promises = [];
|
||||
const promises = [];
|
||||
for ( const entity of entities ) {
|
||||
promises.push(entity.get_client_safe());
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ class EntriService extends BaseService {
|
||||
parseDomain = (await import('parse-domain')).parseDomain;
|
||||
}
|
||||
|
||||
['__on_install.routes'] (_, { app }) {
|
||||
'__on_install.routes' (_, { app }) {
|
||||
Endpoint({
|
||||
route: '/entri/webhook',
|
||||
methods: ['POST', 'GET'],
|
||||
@@ -94,7 +94,7 @@ class EntriService extends BaseService {
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['entri']: {
|
||||
'entri': {
|
||||
async getConfig ({ domain, userHostedSite }) {
|
||||
const es_subdomain = this.services.get('es:subdomain');
|
||||
const svc_su = this.services.get('su');
|
||||
@@ -231,7 +231,7 @@ class EntriService extends BaseService {
|
||||
},
|
||||
},
|
||||
};
|
||||
async ['__on_driver.register.interfaces'] () {
|
||||
async '__on_driver.register.interfaces' () {
|
||||
const svc_registry = this.services.get('registry');
|
||||
const col_interfaces = svc_registry.get('interfaces');
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ class EventService extends BaseService {
|
||||
this.global_listeners_ = [];
|
||||
}
|
||||
|
||||
async ['__on_boot.ready'] () {
|
||||
async '__on_boot.ready' () {
|
||||
this.emit('ready', {}, {});
|
||||
}
|
||||
|
||||
|
||||
@@ -70,10 +70,12 @@ class FeatureFlagService extends BaseService {
|
||||
|
||||
/**
|
||||
* checks is a feature flag is enabled for the current user
|
||||
* @return {boolean} - true if the feature flag is enabled, false otherwise
|
||||
* @return {boolean} true if the feature flag is enabled, false otherwise
|
||||
*
|
||||
* Usage:
|
||||
* check({ actor }, 'flag-name')
|
||||
* @example <caption>with a specified actor</caption>
|
||||
* check({ actor }, 'flag-name');
|
||||
* @example <caption>with actor in context</caption>
|
||||
* check('flag-name');
|
||||
*/
|
||||
async check (...a) {
|
||||
// allows binding call with multiple options objects;
|
||||
|
||||
@@ -38,7 +38,7 @@ class FilesystemAPIService extends BaseService {
|
||||
* @function __on_install.routes
|
||||
* @returns {Promise<void>} A promise that resolves when the routes are set up.
|
||||
*/
|
||||
async ['__on_install.routes'] () {
|
||||
async '__on_install.routes' () {
|
||||
const { app } = this.services.get('web-server');
|
||||
|
||||
// batch
|
||||
|
||||
@@ -28,7 +28,7 @@ const BaseService = require('./BaseService');
|
||||
*/
|
||||
class HelloWorldService extends BaseService {
|
||||
static IMPLEMENTS = {
|
||||
['version']: {
|
||||
'version': {
|
||||
/**
|
||||
* Returns the current version of the service.
|
||||
*
|
||||
@@ -38,7 +38,7 @@ class HelloWorldService extends BaseService {
|
||||
return 'v1.0.0';
|
||||
},
|
||||
},
|
||||
['hello-world']: {
|
||||
'hello-world': {
|
||||
/**
|
||||
* Greets the user with a customizable message.
|
||||
*
|
||||
|
||||
@@ -46,7 +46,7 @@ class KernelInfoService extends BaseService {
|
||||
* @param {Express} param1.app Express application instance
|
||||
* @private
|
||||
*/
|
||||
['__on_install.routes'] (_, { app }) {
|
||||
'__on_install.routes' (_, { app }) {
|
||||
const router = (() => {
|
||||
const require = this.require;
|
||||
const express = require('express');
|
||||
|
||||
@@ -43,7 +43,7 @@ class LocalDiskStorageService extends BaseService {
|
||||
*
|
||||
* @returns {Promise<void>} A promise that resolves when the context is initialized.
|
||||
*/
|
||||
async ['__on_install.context-initializers'] () {
|
||||
async '__on_install.context-initializers' () {
|
||||
const svc_contextInit = this.services.get('context-init');
|
||||
const storage = new LocalDiskStorageStrategy({ services: this.services });
|
||||
svc_contextInit.register_value('storage', storage);
|
||||
|
||||
@@ -110,7 +110,7 @@ class MakeProdDebuggingLessAwfulService extends BaseService {
|
||||
* @param {Express} options.app Express application instance
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async ['__on_install.middlewares.context-aware'] (_, { app }) {
|
||||
async '__on_install.middlewares.context-aware' (_, { app }) {
|
||||
// Add express middleware
|
||||
this.mw.install(app);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ class NotificationService extends BaseService {
|
||||
this.notifs_pending_write = {};
|
||||
}
|
||||
|
||||
['__on_install.routes'] (_, { app }) {
|
||||
'__on_install.routes' (_, { app }) {
|
||||
const require = this.require;
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
@@ -41,7 +41,7 @@ class PermissionAPIService extends BaseService {
|
||||
* @param {Express} options.app Express application instance to install routes on
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async ['__on_install.routes'] (_, { app }) {
|
||||
async '__on_install.routes' (_, { app }) {
|
||||
app.use(require('../routers/auth/get-user-app-token'));
|
||||
app.use(require('../routers/auth/grant-user-app'));
|
||||
app.use(require('../routers/auth/revoke-user-app'));
|
||||
|
||||
@@ -38,7 +38,7 @@ class PuterAPIService extends BaseService {
|
||||
* This method registers various API endpoints with the web server.
|
||||
* It does not return a value as it configures the server directly.
|
||||
*/
|
||||
async ['__on_install.routes'] () {
|
||||
async '__on_install.routes' () {
|
||||
const svc_web = this.services.get('web-server');
|
||||
const { app } = svc_web;
|
||||
svc_web.allow_undefined_origin('/healthcheck');
|
||||
@@ -70,6 +70,7 @@ class PuterAPIService extends BaseService {
|
||||
// app.use(require('../routers/get-launch-apps'))
|
||||
app.use(require('../routers/itemMetadata'));
|
||||
app.use(require('../routers/login'));
|
||||
app.use(require('../routers/auth/oidc').default);
|
||||
app.use(require('../routers/logout'));
|
||||
app.use(require('../routers/open_item'));
|
||||
app.use(require('../routers/passwd'));
|
||||
|
||||
@@ -59,7 +59,7 @@ export class PuterHomepageService extends BaseService {
|
||||
this.gui_params[key] = val;
|
||||
}
|
||||
|
||||
async ['__on_install.routes'] (_, { app }) {
|
||||
async '__on_install.routes' (_, { app }) {
|
||||
Endpoint({
|
||||
route: '/whoarewe',
|
||||
methods: ['GET'],
|
||||
@@ -327,8 +327,8 @@ export class PuterHomepageService extends BaseService {
|
||||
|
||||
<!-- Files from JSON (may be empty) -->
|
||||
${((!bundled && manifest?.css_paths)
|
||||
? manifest.css_paths.map(path => `<link rel="stylesheet" href="${path}">\n`)
|
||||
: []).join('')
|
||||
? manifest.css_paths.map(path => `<link rel="stylesheet" href="${path}">\n`)
|
||||
: []).join('')
|
||||
}
|
||||
<!-- END Files from JSON -->
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class PuterVersionService extends BaseService {
|
||||
* @async
|
||||
* @returns {Promise<void>} Resolves when the routes are successfully registered.
|
||||
*/
|
||||
async ['__on_install.routes'] () {
|
||||
async '__on_install.routes' () {
|
||||
const { app } = this.services.get('web-server');
|
||||
app.use(require('../routers/version'));
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ class RefreshAssociationsService extends BaseService {
|
||||
* @async
|
||||
* @returns {Promise<void>} - A promise that resolves when the cache refresh process is complete.
|
||||
*/
|
||||
async ['__on_boot.consolidation'] () {
|
||||
async '__on_boot.consolidation' () {
|
||||
const { refresh_associations_cache } = require('../helpers');
|
||||
|
||||
/**
|
||||
|
||||
@@ -101,7 +101,7 @@ class RegistryService extends BaseService {
|
||||
*
|
||||
* @private
|
||||
*/
|
||||
async ['__on_boot.consolidation'] () {
|
||||
async '__on_boot.consolidation' () {
|
||||
const services = this.services;
|
||||
await services.emit('registry.collections');
|
||||
await services.emit('registry.entries');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const BaseService = require('./BaseService');
|
||||
|
||||
class RequestMeasureService extends BaseService {
|
||||
async ['__on_install.middlewares.context-aware'] (_, { app }) {
|
||||
async '__on_install.middlewares.context-aware' (_, { app }) {
|
||||
const svc_event = this.services.get('event');
|
||||
app.use(async (req, res, next) => {
|
||||
next();
|
||||
|
||||
@@ -63,7 +63,7 @@ class SNSService extends BaseService {
|
||||
svc_web.allow_undefined_origin('/sns', '/sns/');
|
||||
}
|
||||
|
||||
async ['__on_install.routes'] (_, { app }) {
|
||||
async '__on_install.routes' (_, { app }) {
|
||||
Endpoint({
|
||||
route: '/sns',
|
||||
methods: ['POST'],
|
||||
|
||||
@@ -53,7 +53,7 @@ export class SUService extends BaseService {
|
||||
* @returns {Promise<void>} A promise that resolves when both the
|
||||
* system user and actor have been set.
|
||||
*/
|
||||
async ['__on_boot.consolidation'] () {
|
||||
async '__on_boot.consolidation' () {
|
||||
const sys_user = await this.services.get('get-user').get_user({ username: 'system' });
|
||||
this.sys_user_.resolve(sys_user);
|
||||
const sys_actor = new Actor({
|
||||
|
||||
@@ -35,7 +35,7 @@ class ServeGUIService extends BaseService {
|
||||
* @async
|
||||
* @returns {Promise<void>} Resolves when routing is successfully set up.
|
||||
*/
|
||||
async ['__on_install.routes-gui'] () {
|
||||
async '__on_install.routes-gui' () {
|
||||
const { app } = this.services.get('web-server');
|
||||
|
||||
// is this a puter.site domain?
|
||||
|
||||
@@ -86,7 +86,7 @@ class ShareService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
['__on_install.routes'] (_, { app }) {
|
||||
'__on_install.routes' (_, { app }) {
|
||||
this.install_sharelink_endpoints({ app });
|
||||
this.install_share_endpoint({ app });
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ const { invalidate_cached_user, invalidate_cached_user_by_id } = require('../hel
|
||||
const BaseService = require('./BaseService');
|
||||
const { DB_WRITE } = require('./database/consts');
|
||||
|
||||
/**
|
||||
* Lorem ipsum dolor sit amet
|
||||
*/
|
||||
class UserService extends BaseService {
|
||||
static MODULES = {
|
||||
uuidv4: require('uuid').v4,
|
||||
@@ -32,7 +35,7 @@ class UserService extends BaseService {
|
||||
this.dir_system = null;
|
||||
}
|
||||
|
||||
async ['__on_filesystem.ready'] () {
|
||||
async '__on_filesystem.ready' () {
|
||||
const svc_fs = this.services.get('filesystem');
|
||||
// Ensure system user has a home directory
|
||||
const dir_system = await svc_fs.node(new NodeChildSelector(new RootNodeSelector(),
|
||||
@@ -54,7 +57,9 @@ class UserService extends BaseService {
|
||||
return this.dir_system;
|
||||
}
|
||||
|
||||
// used to be called: generate_system_fsentries
|
||||
/**
|
||||
* This used to be called `generate_system_fsentries`
|
||||
*/
|
||||
async generate_default_fsentries ({ user }) {
|
||||
|
||||
// Note: The comment below is outdated as we now do parallel writes for
|
||||
|
||||
@@ -157,7 +157,7 @@ class WebDAVService extends BaseService {
|
||||
const base64Credentials = authHeader.split(' ')[1];
|
||||
const credentials = Buffer.from(base64Credentials,
|
||||
'base64').toString( 'ascii');
|
||||
let [ username, ...password ] = credentials.split(':');
|
||||
let [username, ...password] = credentials.split(':');
|
||||
password = password.join(':');
|
||||
|
||||
// Call user's authentication function
|
||||
@@ -209,7 +209,7 @@ class WebDAVService extends BaseService {
|
||||
|
||||
methodHandler(req, res, filePath, fileNode, headerLockToken);
|
||||
}
|
||||
['__on_install.routes'] ( _, { app } ) {
|
||||
'__on_install.routes' ( _, { app } ) {
|
||||
COOKIE_NAME = this.global_config.cookie_name;
|
||||
|
||||
const r_webdav = (() => {
|
||||
@@ -238,7 +238,7 @@ class WebDAVService extends BaseService {
|
||||
'UNLOCK',
|
||||
'OPTIONS',
|
||||
],
|
||||
mw: [ configurable_auth({ optional: true }) ],
|
||||
mw: [configurable_auth({ optional: true })],
|
||||
/**
|
||||
*
|
||||
* @param {import("express").Request} req
|
||||
|
||||
@@ -22,7 +22,7 @@ const { Endpoint } = require('../util/expressutil');
|
||||
const BaseService = require('./BaseService');
|
||||
|
||||
class WispService extends BaseService {
|
||||
['__on_install.routes'] (_, { app }) {
|
||||
'__on_install.routes' (_, { app }) {
|
||||
const r_wisp = (() => {
|
||||
const require = this.require;
|
||||
const express = require('express');
|
||||
|
||||
@@ -44,79 +44,87 @@ class EdgeRateLimitService extends BaseService {
|
||||
*/
|
||||
_construct () {
|
||||
this.scopes = {
|
||||
['login']: {
|
||||
'oidc-general': {
|
||||
limit: 100,
|
||||
window: 15 * MINUTE,
|
||||
},
|
||||
'login': {
|
||||
limit: 10,
|
||||
window: 15 * MINUTE,
|
||||
},
|
||||
['signup']: {
|
||||
'signup': {
|
||||
limit: 10,
|
||||
window: 15 * MINUTE,
|
||||
},
|
||||
['contact-us']: {
|
||||
'contact-us': {
|
||||
limit: 10,
|
||||
window: 15 * MINUTE,
|
||||
},
|
||||
['share']: {
|
||||
'share': {
|
||||
limit: 30,
|
||||
window: 1 * MINUTE,
|
||||
},
|
||||
['send-confirm-email']: {
|
||||
'send-confirm-email': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['confirm-email']: {
|
||||
'confirm-email': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['send-pass-recovery-email']: {
|
||||
'send-pass-recovery-email': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['verify-pass-recovery-token']: {
|
||||
'verify-pass-recovery-token': {
|
||||
limit: 10,
|
||||
window: 15 * MINUTE,
|
||||
},
|
||||
['set-pass-using-token']: {
|
||||
'set-pass-using-token': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['save-account']: {
|
||||
'save-account': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['change-email-start']: {
|
||||
'change-email-start': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['change-email-confirm']: {
|
||||
'change-email-confirm': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['passwd']: {
|
||||
'passwd': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['/user-protected/change-password']: {
|
||||
'/user-protected/change-password': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['/user-protected/change-email']: {
|
||||
'/user-protected/change-email': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['/user-protected/disable-2fa']: {
|
||||
'/user-protected/change-username': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['login-otp']: {
|
||||
'/user-protected/disable-2fa': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
'login-otp': {
|
||||
limit: 15,
|
||||
window: 30 * MINUTE,
|
||||
},
|
||||
['login-recovery']: {
|
||||
'login-recovery': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
['enable-2fa']: {
|
||||
'enable-2fa': {
|
||||
limit: 10,
|
||||
window: HOUR,
|
||||
},
|
||||
|
||||
@@ -193,7 +193,7 @@ class IdentificationService extends BaseService {
|
||||
/**
|
||||
* We need to listen to this event to install a context-aware middleware
|
||||
*/
|
||||
async ['__on_install.middlewares.context-aware'] (_, { app }) {
|
||||
async '__on_install.middlewares.context-aware' (_, { app }) {
|
||||
this.mw.install(app);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ class AIInterfaceService extends BaseService {
|
||||
* Extends the base service to provide AI-related interface management.
|
||||
* Handles registration of OCR, chat completion, image generation, and TTS interfaces.
|
||||
*/
|
||||
async ['__on_driver.register.interfaces'] () {
|
||||
async '__on_driver.register.interfaces' () {
|
||||
const svc_registry = this.services.get('registry');
|
||||
const col_interfaces = svc_registry.get('interfaces');
|
||||
|
||||
|
||||
@@ -86,13 +86,13 @@ export class AIChatService extends BaseService {
|
||||
|
||||
/** Driver interfaces */
|
||||
static IMPLEMENTS = {
|
||||
['driver-capabilities']: {
|
||||
'driver-capabilities': {
|
||||
supports_test_mode (iface: string, method_name: string) {
|
||||
return iface === 'puter-chat-completion' &&
|
||||
method_name === 'complete';
|
||||
},
|
||||
},
|
||||
['puter-chat-completion']: {
|
||||
'puter-chat-completion': {
|
||||
|
||||
async models () {
|
||||
return await (this as unknown as AIChatService).models();
|
||||
|
||||
@@ -45,12 +45,12 @@ class AWSTextractService extends BaseService {
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['driver-capabilities']: {
|
||||
'driver-capabilities': {
|
||||
supports_test_mode (iface, method_name) {
|
||||
return iface === 'puter-ocr' && method_name === 'recognize';
|
||||
},
|
||||
},
|
||||
['puter-ocr']: {
|
||||
'puter-ocr': {
|
||||
/**
|
||||
* Performs OCR recognition on a document using AWS Textract
|
||||
* @param {Object} params - Recognition parameters
|
||||
|
||||
@@ -46,12 +46,12 @@ class ElevenLabsVoiceChangerService extends BaseService {
|
||||
};
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['driver-capabilities']: {
|
||||
'driver-capabilities': {
|
||||
supports_test_mode (iface, method_name) {
|
||||
return iface === 'puter-speech2speech' && method_name === 'convert';
|
||||
},
|
||||
},
|
||||
['puter-speech2speech']: {
|
||||
'puter-speech2speech': {
|
||||
async convert (params) {
|
||||
return this.convert(params);
|
||||
},
|
||||
|
||||
@@ -99,13 +99,13 @@ class OpenAISpeechToTextService extends BaseService {
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['driver-capabilities']: {
|
||||
'driver-capabilities': {
|
||||
supports_test_mode (iface, method_name) {
|
||||
return iface === 'puter-speech2txt' &&
|
||||
(method_name === 'transcribe' || method_name === 'translate');
|
||||
},
|
||||
},
|
||||
['puter-speech2txt']: {
|
||||
'puter-speech2txt': {
|
||||
async list_models () {
|
||||
return this.listModels();
|
||||
},
|
||||
|
||||
@@ -61,12 +61,12 @@ class AWSPollyService extends BaseService {
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['driver-capabilities']: {
|
||||
'driver-capabilities': {
|
||||
supports_test_mode (iface, method_name) {
|
||||
return iface === 'puter-tts' && method_name === 'synthesize';
|
||||
},
|
||||
},
|
||||
['puter-tts']: {
|
||||
'puter-tts': {
|
||||
/**
|
||||
* Implements the driver interface methods for text-to-speech functionality
|
||||
* Contains methods for listing available voices and synthesizing speech
|
||||
|
||||
@@ -47,12 +47,12 @@ class ElevenLabsTTSService extends BaseService {
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['driver-capabilities']: {
|
||||
'driver-capabilities': {
|
||||
supports_test_mode (iface, method_name) {
|
||||
return iface === 'puter-tts' && method_name === 'synthesize';
|
||||
},
|
||||
},
|
||||
['puter-tts']: {
|
||||
'puter-tts': {
|
||||
async list_voices () {
|
||||
return this.listVoices();
|
||||
},
|
||||
|
||||
@@ -106,12 +106,12 @@ class OpenAITTSService extends BaseService {
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['driver-capabilities']: {
|
||||
'driver-capabilities': {
|
||||
supports_test_mode (iface, method_name) {
|
||||
return iface === 'puter-tts' && method_name === 'synthesize';
|
||||
},
|
||||
},
|
||||
['puter-tts']: {
|
||||
'puter-tts': {
|
||||
async list_voices ({ provider } = {}) {
|
||||
if ( provider && provider !== 'openai' ) {
|
||||
return [];
|
||||
|
||||
+2
-2
@@ -82,13 +82,13 @@ class OpenAIVideoGenerationService extends BaseService {
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['driver-capabilities']: {
|
||||
'driver-capabilities': {
|
||||
supports_test_mode (iface, method_name) {
|
||||
return iface === 'puter-video-generation' &&
|
||||
method_name === 'generate';
|
||||
},
|
||||
},
|
||||
['puter-video-generation']: {
|
||||
'puter-video-generation': {
|
||||
async generate (params) {
|
||||
return await this.generateVideo(params);
|
||||
},
|
||||
|
||||
+2
-2
@@ -53,13 +53,13 @@ class TogetherVideoGenerationService extends BaseService {
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['driver-capabilities']: {
|
||||
'driver-capabilities': {
|
||||
supports_test_mode (iface, method_name) {
|
||||
return iface === 'puter-video-generation' &&
|
||||
method_name === 'generate';
|
||||
},
|
||||
},
|
||||
['puter-video-generation']: {
|
||||
'puter-video-generation': {
|
||||
async generate (params) {
|
||||
return await this.generateVideo(params);
|
||||
},
|
||||
|
||||
@@ -101,7 +101,7 @@ class ACLService extends BaseService {
|
||||
* @returns {Promise<boolean>} True if actor has permission, false otherwise
|
||||
* @private
|
||||
*/
|
||||
async ['__on_install.routes'] (_, { app }) {
|
||||
async '__on_install.routes' (_, { app }) {
|
||||
/**
|
||||
* Handles route installation for ACL service endpoints.
|
||||
* Sets up routes for user-to-user permission management including:
|
||||
|
||||
+3
-1
@@ -12,8 +12,10 @@ export class SystemActorType {
|
||||
}
|
||||
|
||||
export class UserActorType {
|
||||
constructor (params: { user: IUser });
|
||||
constructor (params: { user: IUser; session?: { uuid: string }; hasHttpOnlyCookie?: boolean });
|
||||
user: IUser;
|
||||
/** When true, this actor can access user-protected HTTP endpoints (e.g. change password). GUI tokens set this false. */
|
||||
hasHttpOnlyCookie: boolean;
|
||||
get uid (): string;
|
||||
get_related_type (type_class: unknown): UserActorType;
|
||||
}
|
||||
|
||||
@@ -16,12 +16,12 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { AdvancedBase } from '../../../../putility/index.js';
|
||||
import { Context } from '../../util/context.js';
|
||||
import { get_user, get_app } from '../../helpers.js';
|
||||
import * as config from '../../config.js';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
import { AdvancedBase } from '../../../../putility/index.js';
|
||||
import * as config from '../../config.js';
|
||||
import { get_app, get_user } from '../../helpers.js';
|
||||
import { Context } from '../../util/context.js';
|
||||
// TODO: add these to configuration; production deployments should change these!
|
||||
|
||||
const PRIVATE_UID_NAMESPACE = config.private_uid_namespace
|
||||
@@ -222,6 +222,13 @@ export class Actor extends AdvancedBase {
|
||||
* user actors and define how they relate to other types of actors within the system.
|
||||
*/
|
||||
export class UserActorType extends ActorType {
|
||||
constructor (o) {
|
||||
super(o);
|
||||
if ( this.hasHttpOnlyCookie === undefined ) {
|
||||
this.hasHttpOnlyCookie = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the unique identifier for the user actor.
|
||||
*
|
||||
|
||||
@@ -43,7 +43,7 @@ class AntiCSRFService extends BaseService {
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
['__on_install.routes'] () {
|
||||
'__on_install.routes' () {
|
||||
const { app } = this.services.get('web-server');
|
||||
|
||||
app.use(eggspress('/get-anticsrf-token', {
|
||||
|
||||
@@ -107,6 +107,32 @@ class AuthService extends BaseService {
|
||||
const actor_type = new UserActorType({
|
||||
user,
|
||||
session: session.uuid,
|
||||
hasHttpOnlyCookie: true,
|
||||
});
|
||||
|
||||
return new Actor({
|
||||
user_uid: decoded.user_uid,
|
||||
type: actor_type,
|
||||
});
|
||||
}
|
||||
|
||||
if ( decoded.type === 'gui' ) {
|
||||
const session = await this.get_session_(decoded.uuid);
|
||||
|
||||
if ( ! session ) {
|
||||
throw APIError.create('token_auth_failed');
|
||||
}
|
||||
|
||||
const user = await get_user({ uuid: decoded.user_uid });
|
||||
|
||||
if ( ! user ) {
|
||||
throw APIError.create('user_not_found');
|
||||
}
|
||||
|
||||
const actor_type = new UserActorType({
|
||||
user,
|
||||
session: session.uuid,
|
||||
hasHttpOnlyCookie: false,
|
||||
});
|
||||
|
||||
return new Actor({
|
||||
@@ -310,6 +336,44 @@ class AuthService extends BaseService {
|
||||
return { session, token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a GUI token bound to the same session as the given session object.
|
||||
* GUI tokens create a UserActorType with hasHttpOnlyCookie false, so they cannot
|
||||
* access user-protected HTTP endpoints (e.g. change password). The GUI receives
|
||||
* only this token, not the full session token.
|
||||
*
|
||||
* @param {*} user - User object (must have .uuid).
|
||||
* @param {{ uuid: string }} session - Session object (must have .uuid).
|
||||
* @returns {string} JWT GUI token.
|
||||
*/
|
||||
create_gui_token (user, session) {
|
||||
return this.modules.jwt.sign({
|
||||
type: 'gui',
|
||||
version: '0.0.0',
|
||||
uuid: session.uuid,
|
||||
user_uid: user.uuid,
|
||||
}, this.global_config.jwt_secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a session token (hasHttpOnlyCookie) for an existing session.
|
||||
* Used when the client authenticated with a GUI token (e.g. QR login via
|
||||
* ?auth_token=) so we can set the HTTP-only cookie and allow user-protected
|
||||
* endpoints (change password, email, username, etc.) to work.
|
||||
*
|
||||
* @param {*} user - User object (must have .uuid).
|
||||
* @param {string} session_uuid - Existing session UUID.
|
||||
* @returns {string} JWT session token.
|
||||
*/
|
||||
create_session_token_for_session (user, session_uuid) {
|
||||
return this.modules.jwt.sign({
|
||||
type: 'session',
|
||||
version: '0.0.0',
|
||||
uuid: session_uuid,
|
||||
user_uid: user.uuid,
|
||||
}, this.global_config.jwt_secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method checks if the provided session token is valid and returns the associated user and token.
|
||||
* If the token is not a valid session token or it does not exist in the database, it returns an empty object.
|
||||
@@ -323,7 +387,7 @@ class AuthService extends BaseService {
|
||||
|
||||
console.log('\x1B[36;1mDECODED SESSION', decoded);
|
||||
|
||||
if ( decoded.type && decoded.type !== 'session' ) {
|
||||
if ( decoded.type && decoded.type !== 'session' && decoded.type !== 'gui' ) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -343,19 +407,24 @@ class AuthService extends BaseService {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Return the session
|
||||
return { user, token: cur_token };
|
||||
// Return GUI token to client (if they sent session token, exchange for GUI token)
|
||||
const gui_token = decoded.type === 'gui'
|
||||
? cur_token
|
||||
: this.create_gui_token(user, session);
|
||||
return { user, token: gui_token };
|
||||
}
|
||||
|
||||
this.log.info('UPGRADING SESSION');
|
||||
|
||||
// Upgrade legacy token
|
||||
// TODO: phase this out
|
||||
const { session, token } = await this.create_session_token(user, meta);
|
||||
const { session, token: session_token } = await this.create_session_token(user, meta);
|
||||
const gui_token = this.create_gui_token(user, session);
|
||||
|
||||
const actor_type = new UserActorType({
|
||||
user,
|
||||
session,
|
||||
hasHttpOnlyCookie: true,
|
||||
});
|
||||
|
||||
const actor = new Actor({
|
||||
@@ -363,7 +432,8 @@ class AuthService extends BaseService {
|
||||
type: actor_type,
|
||||
});
|
||||
|
||||
return { actor, user, token };
|
||||
// token = GUI token for client (response body); session_token = for HTTP-only cookie
|
||||
return { actor, user, token: gui_token, session_token };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -375,7 +445,7 @@ class AuthService extends BaseService {
|
||||
async remove_session_by_token (token) {
|
||||
const decoded = this.modules.jwt.verify(token, this.global_config.jwt_secret);
|
||||
|
||||
if ( decoded.type !== 'session' ) {
|
||||
if ( decoded.type !== 'session' && decoded.type !== 'gui' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -469,12 +539,12 @@ class AuthService extends BaseService {
|
||||
} else {
|
||||
token_uid = tokenOrUuid;
|
||||
}
|
||||
/* eslint-disable */
|
||||
|
||||
await this.db.write(
|
||||
'DELETE FROM `access_token_permissions` WHERE `token_uid` = ?',
|
||||
[token_uid],
|
||||
);
|
||||
/* eslint-enable */
|
||||
|
||||
const svc_permission = this.services.get('permission');
|
||||
svc_permission.invalidate_permission_scan_cache_for_access_token(token_uid);
|
||||
}
|
||||
@@ -610,6 +680,69 @@ class AuthService extends BaseService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers GET /get-gui-token. Must be called from the GUI origin (no api. subdomain)
|
||||
* so the HTTP-only session cookie is sent. Returns the GUI token for use in Authorization headers.
|
||||
*/
|
||||
'__on_install.routes' () {
|
||||
const { app } = this.services.get('web-server');
|
||||
const config = require('../../config');
|
||||
const { subdomain } = require('../../helpers');
|
||||
const configurable_auth = require('../../middleware/configurable_auth');
|
||||
const { Endpoint } = require('../../util/expressutil');
|
||||
const svc_auth = this;
|
||||
|
||||
Endpoint({
|
||||
route: '/get-gui-token',
|
||||
methods: ['GET'],
|
||||
mw: [configurable_auth()],
|
||||
handler: async (req, res) => {
|
||||
if ( ! req.user ) {
|
||||
return res.status(401).json({});
|
||||
}
|
||||
|
||||
const actor = Context.get('actor');
|
||||
if ( ! (actor.type instanceof UserActorType) ) {
|
||||
return res.status(403).json({});
|
||||
}
|
||||
if ( ! actor.type.session ) {
|
||||
return res.status(400).json({ error: 'No session bound to this actor' });
|
||||
}
|
||||
|
||||
const gui_token = svc_auth.create_gui_token(actor.type.user, { uuid: actor.type.session });
|
||||
return res.json({ token: gui_token });
|
||||
},
|
||||
}).attach(app);
|
||||
|
||||
// Sync HTTP-only session cookie to the user implied by the request's auth token.
|
||||
// Used when switching users in the UI: client sends Authorization with the new user's
|
||||
// GUI token; we set the session cookie so cookie-based (e.g. user-protected) requests match.
|
||||
Endpoint({
|
||||
route: '/session/sync-cookie',
|
||||
methods: ['GET'],
|
||||
mw: [configurable_auth()],
|
||||
handler: async (req, res) => {
|
||||
if ( ! req.user ) {
|
||||
return res.status(401).end();
|
||||
}
|
||||
const actor = Context.get('actor');
|
||||
if ( !(actor.type instanceof UserActorType) || !actor.type.session ) {
|
||||
return res.status(400).end();
|
||||
}
|
||||
const session_token = svc_auth.create_session_token_for_session(
|
||||
actor.type.user,
|
||||
actor.type.session,
|
||||
);
|
||||
res.cookie(config.cookie_name, session_token, {
|
||||
sameSite: 'none',
|
||||
secure: true,
|
||||
httpOnly: true,
|
||||
});
|
||||
return res.status(204).end();
|
||||
},
|
||||
}).attach(app);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
'use strict';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { username_exists } from '../../helpers.js';
|
||||
import { generate_identifier } from '../../util/identifier.js';
|
||||
import { OutcomeObject } from '../../util/outcomeutil.js';
|
||||
import BaseService from '../BaseService.js';
|
||||
import { DB_WRITE } from '../database/consts.js';
|
||||
import { CreatedUserOutcome } from './SignupService.js';
|
||||
|
||||
const GOOGLE_DISCOVERY_URL = 'https://accounts.google.com/.well-known/openid-configuration';
|
||||
const GOOGLE_SCOPES = 'openid email profile';
|
||||
const STATE_EXPIRY_SEC = 600; // 10 minutes
|
||||
|
||||
const VALID_OIDC_FLOWS = ['login', 'signup', 'revalidate'];
|
||||
|
||||
async function generate_random_username () {
|
||||
let username;
|
||||
do {
|
||||
username = generate_identifier();
|
||||
} while ( await username_exists(username) );
|
||||
return username;
|
||||
}
|
||||
|
||||
/**
|
||||
* OIDC/OAuth2 service for sign-in with Google (and extensible to other providers).
|
||||
* Uses config.oidc.providers only; no environment variables.
|
||||
*/
|
||||
export class OIDCService extends BaseService {
|
||||
#googleDiscovery;
|
||||
|
||||
async _init () {
|
||||
this.db = await this.services.get('database').get(DB_WRITE, 'auth');
|
||||
this.providers = this.config.providers ?? {};
|
||||
this.#googleDiscovery = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider config from config.oidc.providers. For Google, resolve endpoints from discovery.
|
||||
* @param {string} providerId - e.g. 'google'
|
||||
* @returns {Promise<object|null>} Config with client_id, client_secret, authorization_endpoint, token_endpoint, userinfo_endpoint, scopes
|
||||
*/
|
||||
async getProviderConfig (providerId) {
|
||||
const providers = this.providers;
|
||||
const raw = providers[providerId];
|
||||
if ( !raw || typeof raw !== 'object' || !raw.client_id || !raw.client_secret ) {
|
||||
return null;
|
||||
}
|
||||
if ( providerId === 'google' ) {
|
||||
const discovery = await this.#getGoogleDiscovery();
|
||||
if ( ! discovery ) return null;
|
||||
return {
|
||||
client_id: raw.client_id,
|
||||
client_secret: raw.client_secret,
|
||||
authorization_endpoint: discovery.authorization_endpoint,
|
||||
token_endpoint: discovery.token_endpoint,
|
||||
userinfo_endpoint: discovery.userinfo_endpoint,
|
||||
scopes: raw.scopes ?? GOOGLE_SCOPES,
|
||||
};
|
||||
}
|
||||
if ( raw.authorization_endpoint && raw.token_endpoint && raw.userinfo_endpoint ) {
|
||||
return {
|
||||
...raw,
|
||||
scopes: raw.scopes ?? 'openid email profile',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async #getGoogleDiscovery () {
|
||||
if ( this.#googleDiscovery ) return this.#googleDiscovery;
|
||||
try {
|
||||
const res = await fetch(GOOGLE_DISCOVERY_URL);
|
||||
if ( ! res.ok ) return null;
|
||||
this.#googleDiscovery = await res.json();
|
||||
return this.#googleDiscovery;
|
||||
} catch ( e ) {
|
||||
this.log?.warn?.('OIDC: Google discovery fetch failed', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the OAuth callback URL for a given flow. Structure: /auth/oidc/callback/<flow>
|
||||
* @param {string} flow - e.g. 'login' or 'signup'
|
||||
* @returns {string|null} Full callback URL, or null if flow is invalid
|
||||
*/
|
||||
getCallbackUrlForFlow (flow) {
|
||||
if ( !flow || !VALID_OIDC_FLOWS.includes(flow) ) return null;
|
||||
const base = this.global_config.origin || '';
|
||||
const callback_url = `${base.replace(/\/$/, '')}/auth/oidc/callback/${flow}`;
|
||||
this.log.noticeme('CALLBACK URL???', { callback_url });
|
||||
return callback_url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authorization URL for the provider. Callback URL is /auth/oidc/callback/<flow> when flow is provided.
|
||||
*/
|
||||
async getAuthorizationUrl (providerId, state, flow) {
|
||||
const config = await this.getProviderConfig(providerId);
|
||||
if ( ! config ) return null;
|
||||
const base = this.getCallbackUrlForFlow(flow) ?? `${this.global_config.api_base_url}/auth/oidc/callback`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.client_id,
|
||||
redirect_uri: base,
|
||||
response_type: 'code',
|
||||
scope: config.scopes,
|
||||
state,
|
||||
});
|
||||
return `${config.authorization_endpoint}?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign state payload for CSRF protection (short-lived JWT).
|
||||
*/
|
||||
signState (payload) {
|
||||
return jwt.sign(payload,
|
||||
this.global_config.jwt_secret,
|
||||
{ expiresIn: STATE_EXPIRY_SEC });
|
||||
}
|
||||
|
||||
verifyState (token) {
|
||||
try {
|
||||
return jwt.verify(token, this.global_config.jwt_secret);
|
||||
} catch ( e ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange authorization code for tokens. redirectUri must match the URL used in getAuthorizationUrl (e.g. /auth/oidc/callback/:flow).
|
||||
*/
|
||||
async exchangeCodeForTokens (providerId, code, redirectUri) {
|
||||
const config = await this.getProviderConfig(providerId);
|
||||
if ( ! config ) return null;
|
||||
const base = redirectUri ?? `${this.global_config.api_base_url}/auth/oidc/callback`;
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: base,
|
||||
client_id: config.client_id,
|
||||
client_secret: config.client_secret,
|
||||
});
|
||||
const res = await fetch(config.token_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: body.toString(),
|
||||
});
|
||||
if ( ! res.ok ) {
|
||||
const text = await res.text();
|
||||
this.log?.warn?.('OIDC token exchange failed', { status: res.status, body: text });
|
||||
return null;
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get userinfo from provider (e.g. Google userinfo endpoint).
|
||||
*/
|
||||
async getUserInfo (providerId, accessToken) {
|
||||
const config = await this.getProviderConfig(providerId);
|
||||
if ( !config || !config.userinfo_endpoint ) return null;
|
||||
const res = await fetch(config.userinfo_endpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if ( ! res.ok ) return null;
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Puter user by provider and IdP subject. Returns user object or null.
|
||||
*/
|
||||
async findUserByProviderSub (providerId, providerSub) {
|
||||
const rows = await this.db.pread('SELECT user_id FROM user_oidc_providers WHERE provider = ? AND provider_sub = ? LIMIT 1',
|
||||
[providerId, providerSub]);
|
||||
if ( !rows || rows.length === 0 ) return null;
|
||||
const svc_get_user = this.services.get('get-user');
|
||||
return await svc_get_user.get_user({ id: rows[0].user_id, cached: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Link an existing Puter user to an OIDC provider identity.
|
||||
*/
|
||||
async linkProviderToUser (userId, providerId, providerSub, refreshToken = null) {
|
||||
try {
|
||||
await this.db.write('INSERT INTO user_oidc_providers (user_id, provider, provider_sub, refresh_token) VALUES (?, ?, ?, ?)',
|
||||
[userId, providerId, providerSub, refreshToken]);
|
||||
} catch ( e ) {
|
||||
if ( e.message?.includes('UNIQUE') || e.code === 'SQLITE_CONSTRAINT' ) {
|
||||
// already linked
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Puter user from OIDC claims and link the provider. Delegates to signup_create_new_user.
|
||||
*/
|
||||
async createUserFromOIDC (providerId, claims) {
|
||||
if ( claims.email_verified === false ) {
|
||||
// This should never happen; Google always sends verified emails.
|
||||
const outcome = new OutcomeObject(new CreatedUserOutcome());
|
||||
return outcome.fail(
|
||||
'Provider did not verify this email address.',
|
||||
'oidc.email_not_verified',
|
||||
);
|
||||
}
|
||||
const svc_signup = this.services.get('signup');
|
||||
const outcome = await svc_signup.create_new_user({
|
||||
username: await generate_random_username(),
|
||||
email: claims?.email ?? null,
|
||||
password: null,
|
||||
oidc_only: true,
|
||||
assume_email_ownership: true,
|
||||
});
|
||||
const { user_id } = outcome.infoObject;
|
||||
if ( outcome.succeeded ) {
|
||||
await this.linkProviderToUser(user_id, providerId, claims.sub, null);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
/**
|
||||
* List provider ids that have valid config (for frontend to show "Sign in with Google" etc.).
|
||||
*/
|
||||
async getEnabledProviderIds () {
|
||||
const providers = this.providers ?? {};
|
||||
const ids = [];
|
||||
for ( const id of Object.keys(providers) ) {
|
||||
const cfg = await this.getProviderConfig(id);
|
||||
if ( cfg ) ids.push(id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ class OTPService extends BaseService {
|
||||
static MODULES = {
|
||||
otpauth: require('otpauth'),
|
||||
crypto: require('crypto'),
|
||||
['hi-base32']: require('hi-base32'),
|
||||
'hi-base32': require('hi-base32'),
|
||||
};
|
||||
|
||||
create_secret (label) {
|
||||
|
||||
@@ -74,7 +74,7 @@ class PermissionService extends BaseService {
|
||||
this.dbAvgTimes = { count: 0, avg: 0, max: 0 };
|
||||
}
|
||||
|
||||
async ['__on_boot.consolidation'] () {
|
||||
async '__on_boot.consolidation' () {
|
||||
const svc_event = this.services.get('event');
|
||||
// Event to allow extensions to add permissions
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ const configurable_auth = require('../../middleware/configurable_auth');
|
||||
const BaseService = require('../BaseService');
|
||||
|
||||
class PreAuthService extends BaseService {
|
||||
async ['__on_install.middlewares.early'] (_, { app }) {
|
||||
async '__on_install.middlewares.early' (_, { app }) {
|
||||
app.use(configurable_auth({ optional: true }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//@ts-check
|
||||
import bcrypt from 'bcrypt';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { generate_random_username, send_email_verification_code, send_email_verification_token, username_exists } from '../../helpers.js';
|
||||
import { OutcomeObject } from '../../util/outcomeutil.js';
|
||||
import { validate_nonEmpty_string } from '../../util/validutil.js';
|
||||
import BaseService from '../BaseService.js';
|
||||
import { DB_WRITE } from '../database/consts.js';
|
||||
|
||||
export class CreatedUserOutcome {
|
||||
/**
|
||||
* @type {number|null}
|
||||
*/
|
||||
user_id = null;
|
||||
}
|
||||
|
||||
export class SignupService extends BaseService {
|
||||
/**
|
||||
* Creates a new user.
|
||||
* @async
|
||||
* @param {object} params - The parameters for creating a new user.
|
||||
* @param {object} [params.req] - The request object (if applicable).
|
||||
* @param {boolean} [params.temporary] - Whether the user is a temporary user.
|
||||
* @param {boolean} [params.oidc_only] - Whether the user created with OIDC
|
||||
* @param {boolean} [params.send_confirmation_code] - Whether to send a confirmation code instead of a token by email
|
||||
* @param {boolean} [params.assume_email_ownership] - If true, set email_confirmed=1 without sending verification (e.g. OIDC provider already verified).
|
||||
* @param {string|null} params.username - The username of the user.
|
||||
* @param {string|null} params.email - The email of the user.
|
||||
* @param {string|null} params.password - The password of the user.
|
||||
* @returns {Promise<OutcomeObject<CreatedUserOutcome>>} The outcome of the user creation.
|
||||
*/
|
||||
async create_new_user ({
|
||||
req,
|
||||
temporary = false,
|
||||
oidc_only = false,
|
||||
send_confirmation_code = false,
|
||||
assume_email_ownership = false,
|
||||
username = null,
|
||||
email = null,
|
||||
password = null,
|
||||
}) {
|
||||
const outcome = new OutcomeObject(new CreatedUserOutcome());
|
||||
|
||||
let raw_email = email;
|
||||
|
||||
if ( ! username ) {
|
||||
throw new TypeError('username is a required parameter of create_new_user');
|
||||
}
|
||||
if ( !temporary && !validate_nonEmpty_string(email) ) {
|
||||
throw new TypeError('email is a required parameter of create_new_user');
|
||||
}
|
||||
|
||||
// Temp users get default values; they cannot have emails or passwords
|
||||
if ( temporary ) {
|
||||
username = username ?? await generate_random_username();
|
||||
email = email ?? `${username}@nonexis.com`;
|
||||
password = 'login-is-not-enabled'; // arbitrary, but accurate
|
||||
}
|
||||
|
||||
// Some installations of Puter are configured to disable
|
||||
// signup or temporary users. In these cases, we will specify
|
||||
// a failure message and abort creating a user.
|
||||
{
|
||||
const svc_featureFlag = this.services.get('feature-flag');
|
||||
const is_temp_users_disabled =
|
||||
await svc_featureFlag.check('temp-users-disabled');
|
||||
const is_user_signup_disabled =
|
||||
await svc_featureFlag.check('user-signup-disabled');
|
||||
|
||||
if ( is_user_signup_disabled && is_temp_users_disabled ) {
|
||||
return outcome.fail(
|
||||
'User signup and Temporary users are disabled.',
|
||||
'signup.signup_and_temp_users_disabled',
|
||||
);
|
||||
}
|
||||
|
||||
if ( temporary && is_temp_users_disabled ) {
|
||||
return outcome.fail(
|
||||
'Temporary users are disabled.',
|
||||
'signup.temp_users_disabled',
|
||||
);
|
||||
}
|
||||
|
||||
if ( !temporary && is_user_signup_disabled ) {
|
||||
return outcome.fail(
|
||||
'User signup is disabled.',
|
||||
'signup.user_signup_disabled',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit the `puter.signup` event
|
||||
// NOTICE: conditional early return
|
||||
{
|
||||
const svc_event = this.services.get('event');
|
||||
const event = { allow: true, outcome };
|
||||
|
||||
if ( req ) {
|
||||
event.ip = req.headers?.['x-forwarded-for'] ||
|
||||
req.connection?.remoteAddress;
|
||||
event.user_agent = req.headers?.['user-agent'];
|
||||
event.body = req.body;
|
||||
}
|
||||
|
||||
await svc_event.emit('puter.signup', event);
|
||||
|
||||
if ( ! event.allow ) {
|
||||
outcome.log('disallowed by a puter.signup listener');
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
|
||||
if ( await username_exists(username) ) {
|
||||
return outcome.fail(
|
||||
'Username already exists',
|
||||
'username_already_exists',
|
||||
);
|
||||
}
|
||||
|
||||
// These checks are required for non-temporary users
|
||||
if ( ! temporary ) {
|
||||
const db = this.services.get('database').get(DB_WRITE, 'create-user:not-temp-checks');
|
||||
const svc_cleanEmail = this.services.get('clean-email');
|
||||
raw_email = email;
|
||||
|
||||
if ( ! email ) {
|
||||
return outcome.fail(
|
||||
'An email address is required',
|
||||
'email_required',
|
||||
);
|
||||
}
|
||||
|
||||
email = svc_cleanEmail.clean(email);
|
||||
if ( ! await svc_cleanEmail.validate(email) ) {
|
||||
return outcome.fail(
|
||||
'This email does not seem to be valid',
|
||||
'email_invalid',
|
||||
);
|
||||
}
|
||||
|
||||
let rows2 = await db.read(`SELECT EXISTS(
|
||||
SELECT 1 FROM user WHERE (email=? OR clean_email=?) AND email_confirmed=1 AND password IS NOT NULL
|
||||
) AS email_exists`, [raw_email, email]);
|
||||
if ( rows2[0].email_exists )
|
||||
{
|
||||
return outcome.fail(
|
||||
'Email is already verified for another account',
|
||||
'email_already_exists',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: this is where referral goes. We might drop
|
||||
// referral, so I'm leaving it out here for now.
|
||||
|
||||
const user_uuid = uuidv4();
|
||||
const email_confirm_token = uuidv4();
|
||||
// TODO: `Math.random()` is not crypto-secure
|
||||
const email_confirm_code = `${Math.floor(100000 + Math.random() * 900000)}`;
|
||||
|
||||
const audit_metadata = {};
|
||||
if ( req ) {
|
||||
audit_metadata.ip = req.connection.remoteAddress;
|
||||
audit_metadata.ip_fwd = req.headers['x-forwarded-for'];
|
||||
audit_metadata.user_agent = req.headers['user-agent'];
|
||||
audit_metadata.origin = req.headers['origin'];
|
||||
audit_metadata.server = this.global_config.server_id;
|
||||
}
|
||||
|
||||
{
|
||||
const db = this.services.get('database').get(DB_WRITE, 'create-user:main-insert');
|
||||
|
||||
const insert_res = await db.write(`INSERT INTO user
|
||||
(
|
||||
username, email, clean_email, password, uuid, referrer,
|
||||
email_confirm_code, email_confirm_token, email_confirmed, free_storage,
|
||||
referred_by, audit_metadata, signup_ip, signup_ip_forwarded,
|
||||
signup_user_agent, signup_origin, signup_server
|
||||
)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
// username
|
||||
username,
|
||||
// email
|
||||
temporary ? null : raw_email,
|
||||
// normalized email
|
||||
temporary ? null : email,
|
||||
// password
|
||||
(temporary || oidc_only) ? null : await bcrypt.hash(password, 8),
|
||||
// uuid
|
||||
user_uuid,
|
||||
// referrer
|
||||
req?.body?.referrer ?? null,
|
||||
// email_confirm_code
|
||||
email_confirm_code,
|
||||
// email_confirm_token
|
||||
email_confirm_token,
|
||||
// email_confirmed (1 when assume_email_ownership, else 0)
|
||||
assume_email_ownership ? 1 : 0,
|
||||
// free_storage
|
||||
this.global_config.storage_capacity,
|
||||
// referred_by
|
||||
// TODO: we might remove referalls so I'mm leaving out
|
||||
// the value for the `referred_by` field for now
|
||||
null,
|
||||
// audit_metadata
|
||||
JSON.stringify(audit_metadata),
|
||||
// signup_ip
|
||||
req?.connection?.remoteAddress ?? null,
|
||||
// signup_ip_fwd
|
||||
req?.headers?.['x-forwarded-for'] ?? null,
|
||||
// signup_user_agent
|
||||
req?.headers?.['user-agent'] ?? null,
|
||||
// signup_origin
|
||||
req?.headers?.['origin'] ?? null,
|
||||
// signup_server
|
||||
this.global_config.server_id ?? null,
|
||||
]);
|
||||
|
||||
// record activity (asynchronously)
|
||||
db.write(
|
||||
'UPDATE `user` SET `last_activity_ts` = now() WHERE id=? LIMIT 1',
|
||||
[insert_res.insertId],
|
||||
);
|
||||
|
||||
// TODO: it would be VERY NICE if this was a calculated
|
||||
// group membership instead of something we store in the DB
|
||||
const svc_group = this.services.get('group');
|
||||
await svc_group.add_users({
|
||||
uid: temporary
|
||||
? this.global_config.default_temp_group
|
||||
: this.global_config.default_user_group,
|
||||
users: [username],
|
||||
});
|
||||
|
||||
const user_id = insert_res.insertId;
|
||||
outcome.infoObject.user_id = user_id;
|
||||
|
||||
const [user] = await db.pread(
|
||||
'SELECT * FROM `user` WHERE `id` = ? LIMIT 1',
|
||||
[user_id]);
|
||||
|
||||
// TODO(???): should user login happen here or by caller?
|
||||
{
|
||||
// const { token } = await svc_auth.create_session_token(user, {
|
||||
// req,
|
||||
// });
|
||||
}
|
||||
|
||||
if ( ! assume_email_ownership ) {
|
||||
if ( send_confirmation_code ) {
|
||||
send_email_verification_code(email_confirm_code, email);
|
||||
} else {
|
||||
send_email_verification_token(email_confirm_token, email, user_uuid);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This is where sending the referral code would
|
||||
// usually happen but we might remove referral so I'm
|
||||
// leaving it out for now.
|
||||
const svc_user = this.services.get('user');
|
||||
await svc_user.generate_default_fsentries({ user });
|
||||
|
||||
// NOTE: `res.cookie` happens here in @signup.js but this
|
||||
// should be handled by the caller over here.
|
||||
|
||||
{
|
||||
const svc_event = this.services.get('event');
|
||||
svc_event.emit('user.save_account', { user });
|
||||
}
|
||||
|
||||
return outcome.success();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class BaseDatabaseAccessService extends BaseService {
|
||||
*
|
||||
* @returns {BaseDatabaseAccessService} The current instance of the service.
|
||||
*/
|
||||
get () {
|
||||
get (_accessLevel, _scope) {
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -170,6 +170,9 @@ class SqliteDatabaseAccessService extends BaseDatabaseAccessService {
|
||||
[40, [
|
||||
'0044_dev-center-godmode.sql',
|
||||
]],
|
||||
[41, [
|
||||
'0045_user_oidc_providers.sql',
|
||||
]],
|
||||
];
|
||||
|
||||
// Database upgrade logic
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- OIDC/OAuth2: link user accounts to identity providers (e.g. Google)
|
||||
-- Used for "Sign in with Google" login and signup
|
||||
|
||||
CREATE TABLE `user_oidc_providers` (
|
||||
`id` INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
`user_id` INTEGER NOT NULL,
|
||||
`provider` VARCHAR(64) NOT NULL,
|
||||
`provider_sub` VARCHAR(255) NOT NULL,
|
||||
`refresh_token` TEXT DEFAULT NULL,
|
||||
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(`provider`, `provider_sub`),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX `idx_user_oidc_providers_provider_sub` ON `user_oidc_providers` (`provider`, `provider_sub`);
|
||||
CREATE INDEX `idx_user_oidc_providers_user_id` ON `user_oidc_providers` (`user_id`);
|
||||
@@ -130,7 +130,7 @@ class DriverService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
async ['__on_boot.consolidation'] () {
|
||||
async '__on_boot.consolidation' () {
|
||||
const svc_registry = this.services.get('registry');
|
||||
const svc_event = this.services.get('event');
|
||||
|
||||
@@ -159,7 +159,7 @@ class DriverService extends BaseService {
|
||||
* This method is responsible for registering collections in the service registry.
|
||||
* It registers 'interfaces', 'drivers', and 'types' collections.
|
||||
*/
|
||||
async ['__on_registry.collections'] () {
|
||||
async '__on_registry.collections' () {
|
||||
const svc_registry = this.services.get('registry');
|
||||
svc_registry.register_collection('interfaces');
|
||||
svc_registry.register_collection('drivers');
|
||||
@@ -170,7 +170,7 @@ class DriverService extends BaseService {
|
||||
* It registers 'interfaces', 'drivers', and 'types' collections.
|
||||
* It also populates the 'interfaces' collection with default interfaces and registers the collections with the driver service registry.
|
||||
*/
|
||||
async ['__on_registry.entries'] () {
|
||||
async '__on_registry.entries' () {
|
||||
const services = this.services;
|
||||
const svc_registry = services.get('registry');
|
||||
const col_interfaces = svc_registry.get('interfaces');
|
||||
@@ -193,7 +193,7 @@ class DriverService extends BaseService {
|
||||
// This allows DriverService to be a driver called "driver".
|
||||
// The driver drivers allows checking metered usage for drivers,
|
||||
// and in the future may provide other driver-related functions.
|
||||
async ['__on_driver.register.interfaces'] () {
|
||||
async '__on_driver.register.interfaces' () {
|
||||
const svc_registry = this.services.get('registry');
|
||||
const col_interfaces = svc_registry.get('interfaces');
|
||||
|
||||
@@ -283,13 +283,13 @@ class DriverService extends BaseService {
|
||||
// parameter. To support outdated clients we use this hard-coded
|
||||
// table to map interfaces to default drivers.
|
||||
const iface_to_driver = {
|
||||
['puter-ocr']: 'aws-textract',
|
||||
['puter-tts']: 'aws-polly',
|
||||
['puter-speech2speech']: 'elevenlabs-voice-changer',
|
||||
['puter-speech2txt']: 'openai-speech2txt',
|
||||
['puter-chat-completion']: 'openai-completion',
|
||||
['puter-image-generation']: 'openai-image-generation',
|
||||
['puter-video-generation']: 'openai-video-generation',
|
||||
'puter-ocr': 'aws-textract',
|
||||
'puter-tts': 'aws-polly',
|
||||
'puter-speech2speech': 'elevenlabs-voice-changer',
|
||||
'puter-speech2txt': 'openai-speech2txt',
|
||||
'puter-chat-completion': 'openai-completion',
|
||||
'puter-image-generation': 'openai-image-generation',
|
||||
'puter-video-generation': 'openai-video-generation',
|
||||
'puter-apps': 'es:app',
|
||||
'puter-subdomains': 'es:subdomain',
|
||||
'puter-notifications': 'es:notification',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user