mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-04 00:20:45 +00:00
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
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
'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<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;
|
||||
}
|
||||
|
||||
module.exports = signup_create_new_user;
|
||||
+16
-4
@@ -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<string, any> & { services?: Record<string, any>; server_id?: string };
|
||||
name?: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.hasHttpPowers === undefined ) {
|
||||
this.hasHttpPowers = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the unique identifier for the user actor.
|
||||
*
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -17,21 +17,33 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
'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/<flow>
|
||||
* @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/<flow> 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 };
|
||||
|
||||
@@ -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<OutcomeObject<CreatedUserOutcome>>} 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ class BaseDatabaseAccessService extends BaseService {
|
||||
*
|
||||
* @returns {BaseDatabaseAccessService} The current instance of the service.
|
||||
*/
|
||||
get () {
|
||||
get (_accessLevel, _scope) {
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Represents the outcome of a task that might fail or succeed.
|
||||
*/
|
||||
export class OutcomeObject<T> {
|
||||
/**
|
||||
* 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<string, string>[] = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (_) {
|
||||
|
||||
@@ -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 (_) {
|
||||
|
||||
+18
-3
@@ -17,11 +17,14 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user