From 7c8f0d5572eb02035cd0e18dd16bd4f8f2e75396 Mon Sep 17 00:00:00 2001 From: KernelDeimos <7225168+KernelDeimos@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:12:01 -0500 Subject: [PATCH] dev(backend): OIDC continued [1] This commit is rather monolithic. An attempt to split it up into smaller changes proved too difficult (as well as frustrating) and I realized it would absolutely increase the chance of having a broken commit (making bisects more difficult) unless a lot of testing effort between commits was performed, which would have very little benefit. The changes in this commit include: - Outcome utility used by SignupService for error handling - SignupService, whichs implements re-usable create_user function - Signup method in OIDCService - flow-specific callbacks in OIDC (separates login from signup) - **SEPARATE SESSION COOKIE AND GUI COOKIE** - this change "rocks the boat" the most and has the highest likelihood of causing problems --- src/backend/src/CoreModule.js | 3 + src/backend/src/api/APIError.js | 4 + src/backend/src/helpers.js | 10 + .../src/middleware/configurable_auth.js | 3 +- src/backend/src/routers/auth/oidc.js | 160 ++++++----- src/backend/src/routers/login.js | 12 +- src/backend/src/routers/logout.js | 2 + src/backend/src/routers/save_account.js | 11 +- src/backend/src/routers/signup.js | 13 +- .../src/routers/signup_create_new_user.js | 127 ++++++++ src/backend/src/services/BaseService.d.ts | 20 +- .../src/services/FeatureFlagService.js | 8 +- src/backend/src/services/UserService.js | 7 +- src/backend/src/services/auth/Actor.d.ts | 4 +- src/backend/src/services/auth/Actor.js | 17 +- src/backend/src/services/auth/AuthService.js | 102 ++++++- src/backend/src/services/auth/OIDCService.js | 139 +++------ .../src/services/auth/SignupService.js | 270 ++++++++++++++++++ .../database/BaseDatabaseAccessService.js | 2 +- .../web/UserProtectedEndpointsService.js | 7 +- src/backend/src/util/outcomeutil.js | 32 +++ src/backend/src/util/outcomeutil.ts | 77 +++++ src/backend/src/util/validutil.js | 17 +- .../src/UI/Settings/UIWindowChangeEmail.js | 8 +- src/gui/src/UI/UIWindowLogin.js | 6 +- src/gui/src/UI/UIWindowSignup.js | 6 +- src/gui/src/initgui.js | 21 +- 27 files changed, 868 insertions(+), 220 deletions(-) create mode 100644 src/backend/src/routers/signup_create_new_user.js create mode 100644 src/backend/src/services/auth/SignupService.js create mode 100644 src/backend/src/util/outcomeutil.js create mode 100644 src/backend/src/util/outcomeutil.ts diff --git a/src/backend/src/CoreModule.js b/src/backend/src/CoreModule.js index 36ebe6434..cea0be5df 100644 --- a/src/backend/src/CoreModule.js +++ b/src/backend/src/CoreModule.js @@ -272,6 +272,9 @@ const install = async ({ context, services, app, useapi, modapi }) => { 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); diff --git a/src/backend/src/api/APIError.js b/src/backend/src/api/APIError.js index 9b0879bdb..496348f64 100644 --- a/src/backend/src/api/APIError.js +++ b/src/backend/src/api/APIError.js @@ -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', diff --git a/src/backend/src/helpers.js b/src/backend/src/helpers.js index da2862220..b14f4ad55 100644 --- a/src/backend/src/helpers.js +++ b/src/backend/src/helpers.js @@ -30,6 +30,7 @@ const { NodeUIDSelector } = require('./filesystem/node/selectors'); const { redisClient } = require('./clients/redis/redisSingleton'); const { kv } = require('./util/kvSingleton'); const { APP_ICONS_SUBDOMAIN } = require('./consts/app-icons.js'); +const { generate_identifier } = require('./util/identifier'); const identifying_uuid = require('uuid').v4(); @@ -1479,6 +1480,14 @@ async function username_exists (username) { } } +async function generate_random_username () { + let username; + do { + username = generate_identifier(); + } while ( await username_exists(username) ); + return username; +} + async function app_name_exists (name) { /** @type BaseDatabaseAccessService */ const db = _servicesHolder.services.get('database').get(DB_READ, 'filesystem'); @@ -2070,6 +2079,7 @@ module.exports = { suggestedAppForFsEntry, df, username_exists, + generate_random_username, uuid2fsentry, validate_fsentry_name, validate_signature_auth, diff --git a/src/backend/src/middleware/configurable_auth.js b/src/backend/src/middleware/configurable_auth.js index e65fdb279..364acaf41 100644 --- a/src/backend/src/middleware/configurable_auth.js +++ b/src/backend/src/middleware/configurable_auth.js @@ -134,7 +134,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 hasHttpPowers; client gets GUI token in response + res.cookie(config.cookie_name, new_info.session_token ?? new_info.token, { sameSite: 'none', secure: true, httpOnly: true, diff --git a/src/backend/src/routers/auth/oidc.js b/src/backend/src/routers/auth/oidc.js index ac54c2e90..52e5bee30 100644 --- a/src/backend/src/routers/auth/oidc.js +++ b/src/backend/src/routers/auth/oidc.js @@ -20,32 +20,61 @@ const express = require('express'); const router = new express.Router(); const config = require('../../config'); +const { get_user } = require('../../helpers'); -const complete_ = async ({ req, res, user }) => { +/** If Accept includes text/html, set session cookie and redirect to app; otherwise send JSON. */ +const finishOidcSuccess_ = async (req, res, user, stateDecoded) => { + console.log('okay finishOidSuccess_ is happening'); const svc_auth = req.services.get('auth'); - const { token } = await svc_auth.create_session_token(user, { req }); - res.cookie(config.cookie_name, token, { + const { session, token: session_token } = await svc_auth.create_session_token(user, { req }); + res.cookie(config.cookie_name, session_token, { sameSite: 'none', secure: true, httpOnly: true, }); - return res.send({ - proceed: true, - next_step: 'complete', - token, - user: { - username: user.username, - uuid: user.uuid, - email: user.email, - email_confirmed: user.email_confirmed, - is_temp: (user.password === null && user.email === null), - }, + console.log('what are these values?', { + stateDecoded, }); + let target = stateDecoded.redirect_uri || config.origin || '/'; + const origin = config.origin || ''; + console.log('okay what\'s target though?', { target, origin }); + if ( target && origin && !target.startsWith(origin) ) { + target = origin; + } + return res.redirect(302, target); +}; + +/** Exchange code for tokens, get userinfo; returns { provider, userinfo, stateDecoded } or sends error and returns null. */ +const oidcCallbackPreamble_ = async (req, res, callbackRedirectUri) => { + const svc_oidc = req.services.get('oidc'); + const code = req.query.code; + const state = req.query.state; + if ( !code || !state ) { + res.status(400).send('Missing code or state.'); + return null; + } + const stateDecoded = svc_oidc.verifyState(state); + if ( !stateDecoded || !stateDecoded.provider ) { + res.status(400).send('Invalid or expired state.'); + return null; + } + const provider = stateDecoded.provider; + const tokens = await svc_oidc.exchangeCodeForTokens(provider, code, callbackRedirectUri); + if ( !tokens || !tokens.access_token ) { + res.status(401).send('Token exchange failed.'); + return null; + } + const userinfo = await svc_oidc.getUserInfo(provider, tokens.access_token); + if ( !userinfo || !userinfo.sub ) { + res.status(401).send('Could not get user info.'); + return null; + } + return { provider, userinfo, stateDecoded }; }; // GET /auth/oidc/providers - list enabled provider ids for frontend router.get('/auth/oidc/providers', async (req, res) => { - if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) { + if ( require('../../helpers').subdomain(req) !== 'api' ) { return res.status(404).end(); } const svc_oidc = req.services.get('oidc'); @@ -55,7 +84,7 @@ router.get('/auth/oidc/providers', async (req, res) => { // GET /auth/oidc/:provider/start - redirect to IdP authorization router.get('/auth/oidc/:provider/start', async (req, res) => { - if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) { + if ( require('../../helpers').subdomain(req) !== '' ) { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); @@ -68,74 +97,71 @@ router.get('/auth/oidc/:provider/start', async (req, res) => { if ( ! cfg ) { return res.status(404).send('Provider not configured.'); } - const redirectUri = req.query.redirect_uri ? String(req.query.redirect_uri) : undefined; - const statePayload = { provider, redirect_uri: redirectUri }; + const flow = req.query.flow ? String(req.query.flow) : undefined; + const flowRedirects = { + login: config.origin || '/', + signup: config.origin || '/', + }; + const appRedirectUri = (flow && flowRedirects[flow]) ? flowRedirects[flow] : (config.origin || '/'); + const statePayload = { provider, redirect_uri: appRedirectUri }; const state = svc_oidc.signState(statePayload); - const url = await svc_oidc.getAuthorizationUrl(provider, state, redirectUri ? undefined : undefined); + 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 - handle IdP redirect (code + state) -router.get('/auth/oidc/callback', async (req, res) => { - if ( require('../../helpers').subdomain(req) !== 'api' && require('../../helpers').subdomain(req) !== '' ) { +// 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 ( require('../../helpers').subdomain(req) !== '' ) { return res.status(404).end(); } const svc_edgeRateLimit = req.services.get('edge-rate-limit'); if ( ! svc_edgeRateLimit.check('login') ) { return res.status(429).send('Too many requests.'); } - const code = req.query.code; - const state = req.query.state; - if ( !code || !state ) { - return res.status(400).send('Missing code or state.'); + const svc_oidc = req.services.get('oidc'); + const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('login'); + const preamble = await oidcCallbackPreamble_(req, res, callbackRedirectUri); + if ( ! preamble ) return; + const { provider, userinfo, stateDecoded } = preamble; + 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.'); + } + return await finishOidcSuccess_(req, res, user, stateDecoded); +}); + +// 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 ( require('../../helpers').subdomain(req) !== '' ) { + return res.status(404).end(); + } + const svc_edgeRateLimit = req.services.get('edge-rate-limit'); + if ( ! svc_edgeRateLimit.check('login') ) { + return res.status(429).send('Too many requests.'); } const svc_oidc = req.services.get('oidc'); - const stateDecoded = svc_oidc.verifyState(state); - if ( !stateDecoded || !stateDecoded.provider ) { - return res.status(400).send('Invalid or expired state.'); + const callbackRedirectUri = svc_oidc.getCallbackUrlForFlow('signup'); + const preamble = await oidcCallbackPreamble_(req, res, callbackRedirectUri); + if ( ! preamble ) return; + const { provider, userinfo, stateDecoded } = preamble; + const existingUser = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); + if ( existingUser ) { + return res.status(400).send('Account already exists. Log in instead.'); } - const provider = stateDecoded.provider; - const redirectUri = `${config.api_base_url}/auth/oidc/callback`; - const tokens = await svc_oidc.exchangeCodeForTokens(provider, code, redirectUri); - if ( !tokens || !tokens.access_token ) { - return res.status(401).send('Token exchange failed.'); + const outcome = await svc_oidc.createUserFromOIDC(provider, userinfo); + if ( outcome.failed ) { + console.log('it looks like the outcome failed...'); + return res.status(400).send(outcome.userMessage); } - const userinfo = await svc_oidc.getUserInfo(provider, tokens.access_token); - if ( !userinfo || !userinfo.sub ) { - return res.status(401).send('Could not get user info.'); - } - let user = await svc_oidc.findUserByProviderSub(provider, userinfo.sub); - if ( user ) { - if ( user.suspended ) { - return res.status(401).send('This account is suspended.'); - } - return await complete_({ req, res, user }); - } - user = await svc_oidc.createUserFromOIDC(provider, userinfo); - if ( ! user ) { - return res.status(400).send('Email already registered. Please log in with your password and link your Google account, or use a different email.'); - } - const accept = req.headers.accept || ''; - const wantsRedirect = accept.includes('text/html'); - if ( wantsRedirect ) { - const svc_auth = req.services.get('auth'); - const { token } = await svc_auth.create_session_token(user, { req }); - res.cookie(config.cookie_name, token, { - sameSite: 'none', - secure: true, - httpOnly: true, - }); - let target = stateDecoded.redirect_uri || config.origin || '/'; - const origin = config.origin || ''; - if ( target && origin && !target.startsWith(origin) ) { - target = origin; - } - return res.redirect(302, target); - } - return await complete_({ req, res, user }); + const user = await get_user({ id: outcome.infoObject.user_id }); + console.log('got user????', user); + return await finishOidcSuccess_(req, res, user, stateDecoded); }); module.exports = router; diff --git a/src/backend/src/routers/login.js b/src/backend/src/routers/login.js index 6aac43f51..21948e518 100644 --- a/src/backend/src/routers/login.js +++ b/src/backend/src/routers/login.js @@ -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 hasHttpPowers) + 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, diff --git a/src/backend/src/routers/logout.js b/src/backend/src/routers/logout.js index 84cbe275b..da3ca586a 100644 --- a/src/backend/src/routers/logout.js +++ b/src/backend/src/routers/logout.js @@ -51,7 +51,9 @@ router.post('/logout', auth, express.json(), async (req, res, next) => { //--------------------------------------------------------- // DANGER ZONE: delete temp user and all its data //--------------------------------------------------------- + console.log('wait... what are these?', req.user.password, req.user.email); if ( req.user.password === null && req.user.email === null ) { + console.log('ACTUALLY DELETING A USER'); const { deleteUser } = require('../helpers'); deleteUser(req.user.id); } diff --git a/src/backend/src/routers/save_account.js b/src/backend/src/routers/save_account.js index 5d4cb342c..32471dc55 100644 --- a/src/backend/src/routers/save_account.js +++ b/src/backend/src/routers/save_account.js @@ -208,9 +208,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 @@ -220,8 +221,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 hasHttpPowers) + res.cookie(config.cookie_name, session_token); { const svc_event = req.services.get('event'); @@ -230,7 +231,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, diff --git a/src/backend/src/routers/signup.js b/src/backend/src/routers/signup.js index 0e96ee4e2..1bf9e4499 100644 --- a/src/backend/src/routers/signup.js +++ b/src/backend/src/routers/signup.js @@ -421,11 +421,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 @@ -457,8 +458,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 hasHttpPowers) + res.cookie(config.cookie_name, session_token, { sameSite: 'none', secure: true, httpOnly: true, @@ -472,7 +473,7 @@ module.exports = eggspress(['/signup'], { // return results return res.send({ - token: token, + token: gui_token, user: { username: user.username, uuid: user.uuid, diff --git a/src/backend/src/routers/signup_create_new_user.js b/src/backend/src/routers/signup_create_new_user.js new file mode 100644 index 000000000..fa577953a --- /dev/null +++ b/src/backend/src/routers/signup_create_new_user.js @@ -0,0 +1,127 @@ +/* + * 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 . + */ +'use strict'; +const config = require('../config'); +const { DB_WRITE } = require('../services/database/consts'); +const { generate_identifier } = require('../util/identifier'); +const { v4: uuidv4 } = require('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} 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; +} + +module.exports = signup_create_new_user; diff --git a/src/backend/src/services/BaseService.d.ts b/src/backend/src/services/BaseService.d.ts index 65554c240..416bc3a7b 100644 --- a/src/backend/src/services/BaseService.d.ts +++ b/src/backend/src/services/BaseService.d.ts @@ -1,9 +1,15 @@ -import type { ServerHealthService } from '../modules/core/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'; +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 & { services?: Record; server_id?: string }; name?: string; diff --git a/src/backend/src/services/FeatureFlagService.js b/src/backend/src/services/FeatureFlagService.js index 6424cf5cb..03a90c4be 100644 --- a/src/backend/src/services/FeatureFlagService.js +++ b/src/backend/src/services/FeatureFlagService.js @@ -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 with a specified actor + * check({ actor }, 'flag-name'); + * @example with actor in context + * check('flag-name'); */ async check (...a) { // allows binding call with multiple options objects; diff --git a/src/backend/src/services/UserService.js b/src/backend/src/services/UserService.js index 217505c32..c9b4de12d 100644 --- a/src/backend/src/services/UserService.js +++ b/src/backend/src/services/UserService.js @@ -22,6 +22,9 @@ const { invalidate_cached_user } = require('../helpers'); 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, @@ -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 diff --git a/src/backend/src/services/auth/Actor.d.ts b/src/backend/src/services/auth/Actor.d.ts index 55f6790e9..1b2c913a0 100644 --- a/src/backend/src/services/auth/Actor.d.ts +++ b/src/backend/src/services/auth/Actor.d.ts @@ -12,8 +12,10 @@ export class SystemActorType { } export class UserActorType { - constructor (params: { user: IUser }); + constructor (params: { user: IUser; session?: { uuid: string }; hasHttpPowers?: boolean }); user: IUser; + /** When true, this actor can access user-protected HTTP endpoints (e.g. change password). GUI tokens set this false. */ + hasHttpPowers: boolean; get uid (): string; get_related_type (type_class: unknown): UserActorType; } diff --git a/src/backend/src/services/auth/Actor.js b/src/backend/src/services/auth/Actor.js index e8d624594..0feeb7b66 100644 --- a/src/backend/src/services/auth/Actor.js +++ b/src/backend/src/services/auth/Actor.js @@ -16,12 +16,12 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -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.hasHttpPowers === undefined ) { + this.hasHttpPowers = false; + } + } + /** * Gets the unique identifier for the user actor. * diff --git a/src/backend/src/services/auth/AuthService.js b/src/backend/src/services/auth/AuthService.js index bfc5c8d2d..91b6ad0a4 100644 --- a/src/backend/src/services/auth/AuthService.js +++ b/src/backend/src/services/auth/AuthService.js @@ -107,6 +107,32 @@ class AuthService extends BaseService { const actor_type = new UserActorType({ user, session: session.uuid, + hasHttpPowers: 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, + hasHttpPowers: false, }); return new Actor({ @@ -310,6 +336,25 @@ 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 hasHttpPowers 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); + } + /** * 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 +368,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 +388,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, + hasHttpPowers: true, }); const actor = new Actor({ @@ -363,7 +413,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 +426,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 +520,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 +661,41 @@ 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); + } } module.exports = { diff --git a/src/backend/src/services/auth/OIDCService.js b/src/backend/src/services/auth/OIDCService.js index e1029a429..c5fba6318 100644 --- a/src/backend/src/services/auth/OIDCService.js +++ b/src/backend/src/services/auth/OIDCService.js @@ -17,21 +17,33 @@ * along with this program. If not, see . */ 'use strict'; -const BaseService = require('../BaseService'); -const { DB_WRITE } = require('../database/consts'); -const { generate_identifier } = require('../../util/identifier'); +import jwt from 'jsonwebtoken'; +import { username_exists } from '../../helpers.js'; +import { generate_identifier } from '../../util/identifier.js'; +import BaseService from '../BaseService.js'; +import { DB_WRITE } from '../database/consts.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']; + +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. */ -class OIDCService extends BaseService { +export class OIDCService extends BaseService { static MODULES = { - jwt: require('jsonwebtoken'), + jwt, }; async _init () { @@ -86,12 +98,25 @@ class OIDCService extends BaseService { } /** - * Build authorization URL for the provider. redirect_uri is our callback URL. + * Return the OAuth callback URL for a given flow. Structure: /auth/oidc/callback/ + * @param {string} flow - e.g. 'login' or 'signup' + * @returns {string|null} Full callback URL, or null if flow is invalid */ - async getAuthorizationUrl (providerId, state, redirectUri) { + 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/ when flow is provided. + */ + async getAuthorizationUrl (providerId, state, flow) { const config = await this.getProviderConfig(providerId); if ( ! config ) return null; - const base = redirectUri ?? `${this.global_config.api_base_url}/auth/oidc/callback`; + 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, @@ -120,7 +145,7 @@ class OIDCService extends BaseService { } /** - * Exchange authorization code for tokens. + * 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); @@ -187,91 +212,23 @@ class OIDCService extends BaseService { } /** - * Create a new Puter user from OIDC claims and link the provider. Reuses signup patterns (groups, default fs). + * Create a new Puter user from OIDC claims and link the provider. Delegates to signup_create_new_user. */ async createUserFromOIDC (providerId, claims) { - const db = this.db; - const svc_group = this.services.get('group'); - const svc_user = this.services.get('user'); - const { v4: uuidv4 } = require('uuid'); - - 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, - this.global_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 this.linkProviderToUser(user_id, providerId, claims.sub, null); - - await svc_group.add_users({ - uid: this.global_config.default_user_group, - users: [username], + 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, }); - - 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 = {}; + const { user_id } = outcome.infoObject; + console.log('user_id?', user_id); + if ( outcome.success ) + { + await this.linkProviderToUser(user_id, providerId, claims.sub, null); } - await svc_user.generate_default_fsentries({ user }); - - return user; + return outcome; } /** @@ -287,5 +244,3 @@ class OIDCService extends BaseService { return ids; } } - -module.exports = { OIDCService }; diff --git a/src/backend/src/services/auth/SignupService.js b/src/backend/src/services/auth/SignupService.js new file mode 100644 index 000000000..53e5766c4 --- /dev/null +++ b/src/backend/src/services/auth/SignupService.js @@ -0,0 +1,270 @@ +//@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 {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>} The outcome of the user creation. + */ + async create_new_user ({ + req, + temporary = false, + oidc_only = false, + send_confirmation_code = 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, 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, + // 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 ( 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(); + } + } +} diff --git a/src/backend/src/services/database/BaseDatabaseAccessService.js b/src/backend/src/services/database/BaseDatabaseAccessService.js index 3d427aa37..4e46eec94 100644 --- a/src/backend/src/services/database/BaseDatabaseAccessService.js +++ b/src/backend/src/services/database/BaseDatabaseAccessService.js @@ -58,7 +58,7 @@ class BaseDatabaseAccessService extends BaseService { * * @returns {BaseDatabaseAccessService} The current instance of the service. */ - get () { + get (_accessLevel, _scope) { return this; } diff --git a/src/backend/src/services/web/UserProtectedEndpointsService.js b/src/backend/src/services/web/UserProtectedEndpointsService.js index 245fbe24a..5a269eb1a 100644 --- a/src/backend/src/services/web/UserProtectedEndpointsService.js +++ b/src/backend/src/services/web/UserProtectedEndpointsService.js @@ -74,7 +74,7 @@ class UserProtectedEndpointsService extends BaseService { // Require authenticated session router.use(configurable_auth({ no_options_auth: true })); - // Only allow user sessions, not API tokens for apps + // Only allow user sessions with HTTP powers (session token), not GUI tokens or API tokens router.use((req, res, next) => { if ( req.method === 'OPTIONS' ) return next(); @@ -82,6 +82,9 @@ class UserProtectedEndpointsService extends BaseService { if ( ! (actor.type instanceof UserActorType) ) { return APIError.create('user_tokens_only').write(res); } + if ( ! actor.type.hasHttpPowers ) { + return APIError.create('session_required').write(res); + } next(); }); @@ -97,7 +100,7 @@ class UserProtectedEndpointsService extends BaseService { router.use(async (req, res, next) => { if ( req.method === 'OPTIONS' ) return next(); - if ( req.user.password === null ) { + if ( req.user.password === null && req.user.email === null ) { return APIError.create('temporary_account').write(res); } next(); diff --git a/src/backend/src/util/outcomeutil.js b/src/backend/src/util/outcomeutil.js new file mode 100644 index 000000000..df64a6ccc --- /dev/null +++ b/src/backend/src/util/outcomeutil.js @@ -0,0 +1,32 @@ +export class OutcomeObject { + userMessage = null; + userMessageKey = null; + userMessageFields = {}; + failed = false; + messages = []; + fields = {}; + ended = false; + infoObject; + constructor (infoObject) { + this.failed = true; + this.userMessageFields = {}; + this.infoObject = infoObject; + } + log (text, fields) { + this.messages.push({ text, fields }); + } + fail (message, i18nKey, fields = {}) { + this.userMessage = message; + this.userMessageKey = i18nKey; + this.userMessageFields = fields; + this.ended = true; + this.failed = true; + return this; + } + success () { + this.ended = true; + this.failed = false; + return this; + } +} +//# sourceMappingURL=outcomeutil.js.map \ No newline at end of file diff --git a/src/backend/src/util/outcomeutil.ts b/src/backend/src/util/outcomeutil.ts new file mode 100644 index 000000000..cdc838a82 --- /dev/null +++ b/src/backend/src/util/outcomeutil.ts @@ -0,0 +1,77 @@ +/** + * Represents the outcome of a task that might fail or succeed. + */ +export class OutcomeObject { + /** + * If the task was not successful, this will be the message a user + * sees. + */ + userMessage = null; + + /** + * If the task was not successful, this will be the i18n key for + * the message a user sees. + */ + userMessageKey = null; + + /** + * If the task was not successful, this will be values used for + * a message template that is identified using `userMessageKey`. + */ + userMessageFields = {}; + + /** + * If the task being performed failed + */ + failed = false; + + messages: Record[] = []; + fields = {}; + + /** + * Whether the task being performed has ended, + * either successfully or unsuccessfully. + */ + ended = false; + + infoObject: T; + + constructor (infoObject: T) { + this.failed = true; + this.userMessageFields = {}; + this.infoObject = infoObject; + } + log (text, fields) { + this.messages.push({ text, fields }); + } + + /** + * Records a failure message. + * Returns the outcome object for chaining with a return statement. + * + * @example + * return outcome.fail( + * 'User already exists', + * 'signup.user_already_exists', + * { username: 'john_doe' } + * ); + * + * @param {*} message - message the user sees without i18n + * @param {*} i18nKey - i18n key for the message + * @param {*} fields - fields for i18n-key-identified template + */ + fail (message, i18nKey, fields = {}) { + this.userMessage = message; + this.userMessageKey = i18nKey; + this.userMessageFields = fields; + this.ended = true; + this.failed = true; + return this; + } + + success () { + this.ended = true; + this.failed = false; + return this; + } +} diff --git a/src/backend/src/util/validutil.js b/src/backend/src/util/validutil.js index f992a953b..e90bc7779 100644 --- a/src/backend/src/util/validutil.js +++ b/src/backend/src/util/validutil.js @@ -1,4 +1,4 @@ -const APIError = require("../api/APIError"); +const APIError = require('../api/APIError'); /* * Copyright (C) 2024-present Puter Technologies Inc. @@ -31,7 +31,7 @@ const valid_file_size = v => { const validate_fields = (fields, values) => { // First, check for missing fields (undefined) - const missing_fields = Object.keys(fields).filter(field => ! fields[field].optional && values[field] === undefined); + const missing_fields = Object.keys(fields).filter(field => !fields[field].optional && values[field] === undefined); if ( missing_fields.length > 0 ) { throw APIError.create('fields_missing', null, { keys: missing_fields }); } @@ -54,9 +54,20 @@ const validate_fields = (fields, values) => { })), }); } -} +}; + +const validate_nonEmpty_string = value => { + if ( typeof value !== 'string' ) { + return false; + } + if ( value.length === 0 ) { + return false; + } + return true; +}; module.exports = { valid_file_size, + validate_nonEmpty_string, validate_fields, }; diff --git a/src/gui/src/UI/Settings/UIWindowChangeEmail.js b/src/gui/src/UI/Settings/UIWindowChangeEmail.js index 0fd394b2d..66f58b231 100644 --- a/src/gui/src/UI/Settings/UIWindowChangeEmail.js +++ b/src/gui/src/UI/Settings/UIWindowChangeEmail.js @@ -108,12 +108,12 @@ async function UIWindowChangeEmail (options) { $(el_window).find('.new-email').attr('disabled', true); $.ajax({ - url: `${window.api_origin }/user-protected/change-email`, + url: `${window.gui_origin}/user-protected/change-email`, type: 'POST', async: true, - headers: { - 'Authorization': `Bearer ${window.auth_token}`, - }, + // headers: { + // 'Authorization': `Bearer ${window.auth_token}`, + // }, contentType: 'application/json', data: JSON.stringify({ new_email: new_email, diff --git a/src/gui/src/UI/UIWindowLogin.js b/src/gui/src/UI/UIWindowLogin.js index 8f660f9ec..c944c4b8c 100644 --- a/src/gui/src/UI/UIWindowLogin.js +++ b/src/gui/src/UI/UIWindowLogin.js @@ -154,15 +154,13 @@ async function UIWindowLogin (options) { (async () => { try { - const origin = window.gui_origin || window.location.origin; - const res = await fetch(`${origin}/auth/oidc/providers`); + const res = await fetch(`${window.api_origin}/auth/oidc/providers`); if ( ! res.ok ) return; const data = await res.json(); if ( data.providers && data.providers.includes('google') ) { $(el_window).find('.oidc-providers-wrapper').show(); $(el_window).find('.oidc-google-btn').on('click', function () { - const redirectUri = encodeURIComponent(window.location.origin + (window.location.pathname || '/')); - window.location.href = `${origin}/auth/oidc/google/start?redirect_uri=${redirectUri}`; + window.location.href = `${window.gui_origin}/auth/oidc/google/start?flow=login`; }); } } catch (_) { diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 905fd5a31..61be05e5e 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -162,15 +162,13 @@ function UIWindowSignup (options) { (async () => { try { - const origin = window.gui_origin || window.location.origin; - const res = await fetch(`${origin}/auth/oidc/providers`); + const res = await fetch(`${window.api_origin}/auth/oidc/providers`); if ( ! res.ok ) return; const data = await res.json(); if ( data.providers && data.providers.includes('google') ) { $(el_window).find('.oidc-providers-wrapper').show(); $(el_window).find('.oidc-google-btn').on('click', function () { - const redirectUri = encodeURIComponent(window.location.origin + (window.location.pathname || '/')); - window.location.href = `${origin}/auth/oidc/google/start?redirect_uri=${redirectUri}`; + window.location.href = `${window.gui_origin}/auth/oidc/google/start?flow=signup`; }); } } catch (_) { diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index aba48a669..b634e37a4 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -17,11 +17,14 @@ * along with this program. If not, see . */ +import UIDashboard from './UI/Dashboard/UIDashboard.js'; import UIAlert from './UI/UIAlert.js'; import UIComponentWindow from './UI/UIComponentWindow.js'; import UIDesktop from './UI/UIDesktop.js'; import UIWindow from './UI/UIWindow.js'; +import UIWindowAuthMe from './UI/UIWindowAuthMe.js'; import UIWindowChangeUsername from './UI/UIWindowChangeUsername.js'; +import UIWindowCopyToken from './UI/UIWindowCopyToken.js'; import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js'; import UIWindowLogin from './UI/UIWindowLogin.js'; import UIWindowLoginInProgress from './UI/UIWindowLoginInProgress.js'; @@ -30,8 +33,6 @@ import UIWindowRequestPermission from './UI/UIWindowRequestPermission.js'; import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js'; import UIWindowSessionList from './UI/UIWindowSessionList.js'; import UIWindowSignup from './UI/UIWindowSignup.js'; -import UIWindowCopyToken from './UI/UIWindowCopyToken.js'; -import UIWindowAuthMe from './UI/UIWindowAuthMe.js'; import { PROCESS_RUNNING } from './definitions.js'; import item_icon from './helpers/item_icon.js'; import update_last_touch_coordinates from './helpers/update_last_touch_coordinates.js'; @@ -49,7 +50,6 @@ import { ProcessService } from './services/ProcessService.js'; import { SettingsService } from './services/SettingsService.js'; import { ThemeService } from './services/ThemeService.js'; import { privacy_aware_path } from './util/desktop.js'; -import UIDashboard from './UI/Dashboard/UIDashboard.js'; const launch_services = async function (options) { // === Services Data Structures === @@ -393,6 +393,21 @@ window.initgui = async function (options) { // Launch services before any UI is rendered await launch_services(options); + // If no token in storage but we have a session cookie (e.g. after OIDC redirect), fetch GUI token + if ( !localStorage.getItem('auth_token') && window.auth_token == null ) { + try { + const r = await fetch(`${window.gui_origin}/get-gui-token`, { credentials: 'include' }); + if ( r.ok ) { + const { token } = await r.json(); + window.auth_token = token; + localStorage.setItem('auth_token', token); + if ( typeof puter !== 'undefined' ) puter.setAuthToken(token, window.api_origin); + } + } catch (e) { + // ignore + } + } + //-------------------------------------------------------------------------------------- // Is attempt_temp_user_creation? // i.e. https://puter.com/?attempt_temp_user_creation=true