2025-08-07 02:20:27 -05:00
import express from 'express' ;
import { db } from '../db/index.js' ;
2025-09-03 00:14:49 -05:00
import { users , settings , sshData , fileManagerRecent , fileManagerPinned , fileManagerShortcuts , dismissedAlerts } from '../db/schema.js' ;
2025-08-07 02:20:27 -05:00
import { eq , and } from 'drizzle-orm' ;
import chalk from 'chalk' ;
import bcrypt from 'bcryptjs' ;
import { nanoid } from 'nanoid' ;
import jwt from 'jsonwebtoken' ;
2025-08-29 20:29:33 -06:00
import speakeasy from 'speakeasy' ;
import QRCode from 'qrcode' ;
2025-08-07 02:20:27 -05:00
import type { Request , Response , NextFunction } from 'express' ;
async function verifyOIDCToken ( idToken : string , issuerUrl : string , clientId : string ) : Promise < any > {
try {
const normalizedIssuerUrl = issuerUrl . endsWith ( '/' ) ? issuerUrl . slice ( 0 , - 1 ) : issuerUrl ;
2025-09-03 00:14:49 -05:00
const possibleIssuers = [
issuerUrl ,
normalizedIssuerUrl ,
issuerUrl . replace ( /\/application\/o\/[^\/]+$/ , '' ),
normalizedIssuerUrl . replace ( /\/application\/o\/[^\/]+$/ , '' )
];
const jwksUrls = [
` ${ normalizedIssuerUrl } /.well-known/jwks.json` ,
` ${ normalizedIssuerUrl } /jwks/` ,
` ${ normalizedIssuerUrl . replace ( /\/application\/o\/[^\/]+$/ , '' ) } /.well-known/jwks.json`
];
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
try {
const discoveryUrl = ` ${ normalizedIssuerUrl } /.well-known/openid-configuration` ;
const discoveryResponse = await fetch ( discoveryUrl );
if ( discoveryResponse . ok ) {
const discovery = await discoveryResponse . json () as any ;
if ( discovery . jwks_uri ) {
2025-09-03 00:14:49 -05:00
jwksUrls . unshift ( discovery . jwks_uri );
2025-08-07 02:20:27 -05:00
}
}
} catch ( discoveryError ) {
2025-09-03 00:14:49 -05:00
logger . error ( `OIDC discovery failed: ${ discoveryError } ` );
2025-08-07 02:20:27 -05:00
}
2025-09-03 00:14:49 -05:00
let jwks : any = null ;
let jwksUrl : string | null = null ;
2025-08-07 02:20:27 -05:00
2025-09-03 00:14:49 -05:00
for ( const url of jwksUrls ) {
2025-08-07 02:20:27 -05:00
try {
2025-09-03 00:14:49 -05:00
const response = await fetch ( url );
if ( response . ok ) {
const jwksData = await response . json () as any ;
if ( jwksData && jwksData . keys && Array . isArray ( jwksData . keys )) {
jwks = jwksData ;
jwksUrl = url ;
break ;
} else {
logger . error ( `Invalid JWKS structure from ${ url } : ${ JSON . stringify ( jwksData ) } ` );
}
} else {
logger . error ( `JWKS fetch failed from ${ url } : ${ response . status } ${ response . statusText } ` );
2025-08-07 02:20:27 -05:00
}
} catch ( error ) {
2025-09-03 00:14:49 -05:00
logger . error ( `JWKS fetch error from ${ url } :` , error );
continue ;
2025-08-07 02:20:27 -05:00
}
}
2025-09-03 00:14:49 -05:00
if ( ! jwks ) {
throw new Error ( 'Failed to fetch JWKS from any URL' );
2025-08-07 02:20:27 -05:00
}
2025-08-12 13:15:08 -05:00
2025-09-03 00:14:49 -05:00
if ( ! jwks . keys || ! Array . isArray ( jwks . keys )) {
throw new Error ( `Invalid JWKS response structure. Expected 'keys' array, got: ${ JSON . stringify ( jwks ) } ` );
2025-08-07 02:20:27 -05:00
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const header = JSON . parse ( Buffer . from ( idToken . split ( '.' )[ 0 ], 'base64' ). toString ());
const keyId = header . kid ;
const publicKey = jwks . keys . find (( key : any ) => key . kid === keyId );
if ( ! publicKey ) {
2025-09-03 00:14:49 -05:00
throw new Error ( `No matching public key found for key ID: ${ keyId } . Available keys: ${ jwks . keys . map (( k : any ) => k . kid ). join ( ', ' ) } ` );
2025-08-07 02:20:27 -05:00
}
2025-08-12 13:15:08 -05:00
const { importJWK , jwtVerify } = await import ( 'jose' );
2025-08-07 02:20:27 -05:00
const key = await importJWK ( publicKey );
2025-08-12 13:15:08 -05:00
const { payload } = await jwtVerify ( idToken , key , {
2025-09-03 00:14:49 -05:00
issuer : possibleIssuers ,
2025-08-07 02:20:27 -05:00
audience : clientId ,
});
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
return payload ;
} catch ( error ) {
logger . error ( 'OIDC token verification failed:' , error );
throw error ;
}
}
const dbIconSymbol = '🗄️' ;
const getTimeStamp = () : string => chalk . gray ( `[ ${ new Date (). toLocaleTimeString () } ]` );
const formatMessage = ( level : string , colorFn : chalk.Chalk , message : string ) : string => {
return ` ${ getTimeStamp () } ${ colorFn ( `[ ${ level . toUpperCase () } ]` ) } ${ chalk . hex ( '#1e3a8a' )( `[ ${ dbIconSymbol } ]` ) } ${ message } ` ;
};
const logger = {
info : ( msg : string ) : void => {
console . log ( formatMessage ( 'info' , chalk . cyan , msg ));
},
warn : ( msg : string ) : void => {
console . warn ( formatMessage ( 'warn' , chalk . yellow , msg ));
},
error : ( msg : string , err? : unknown ) : void => {
console . error ( formatMessage ( 'error' , chalk . redBright , msg ));
if ( err ) console . error ( err );
},
success : ( msg : string ) : void => {
console . log ( formatMessage ( 'success' , chalk . greenBright , msg ));
},
debug : ( msg : string ) : void => {
if ( process . env . NODE_ENV !== 'production' ) {
console . debug ( formatMessage ( 'debug' , chalk . magenta , msg ));
}
}
};
const router = express . Router ();
function isNonEmptyString ( val : any ) : val is string {
return typeof val === 'string' && val . trim (). length > 0 ;
}
interface JWTPayload {
userId : string ;
iat? : number ;
exp? : number ;
}
// JWT authentication middleware
function authenticateJWT ( req : Request , res : Response , next : NextFunction ) {
const authHeader = req . headers [ 'authorization' ];
if ( ! authHeader || ! authHeader . startsWith ( 'Bearer ' )) {
logger . warn ( 'Missing or invalid Authorization header' );
return res . status ( 401 ). json ({ error : 'Missing or invalid Authorization header' });
}
const token = authHeader . split ( ' ' )[ 1 ];
const jwtSecret = process . env . JWT_SECRET || 'secret' ;
try {
const payload = jwt . verify ( token , jwtSecret ) as JWTPayload ;
( req as any ). userId = payload . userId ;
next ();
} catch ( err ) {
logger . warn ( 'Invalid or expired token' );
return res . status ( 401 ). json ({ error : 'Invalid or expired token' });
}
}
// Route: Create traditional user (username/password)
// POST /users/create
router . post ( '/create' , async ( req , res ) => {
try {
const row = db . $client . prepare ( "SELECT value FROM settings WHERE key = 'allow_registration'" ). get ();
if ( row && ( row as any ). value !== 'true' ) {
return res . status ( 403 ). json ({ error : 'Registration is currently disabled' });
}
} catch ( e ) {
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const { username , password } = req . body ;
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
if ( ! isNonEmptyString ( username ) || ! isNonEmptyString ( password )) {
logger . warn ( 'Invalid user creation attempt - missing username or password' );
2025-08-12 13:15:08 -05:00
return res . status ( 400 ). json ({ error : 'Username and password are required' });
2025-08-07 02:20:27 -05:00
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
try {
const existing = await db
. select ()
. from ( users )
. where ( eq ( users . username , username ));
if ( existing && existing . length > 0 ) {
logger . warn ( `Attempt to create duplicate username: ${ username } ` );
return res . status ( 409 ). json ({ error : 'Username already exists' });
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
let isFirstUser = false ;
try {
const countResult = db . $client . prepare ( 'SELECT COUNT(*) as count FROM users' ). get ();
isFirstUser = (( countResult as any ) ? . count || 0 ) === 0 ;
} catch ( e ) {
isFirstUser = true ;
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const saltRounds = parseInt ( process . env . SALT || '10' , 10 );
const password_hash = await bcrypt . hash ( password , saltRounds );
const id = nanoid ();
await db . insert ( users ). values ({
id ,
username ,
password_hash ,
is_admin : isFirstUser ,
is_oidc : false ,
client_id : '' ,
client_secret : '' ,
issuer_url : '' ,
authorization_url : '' ,
token_url : '' ,
identifier_path : '' ,
name_path : '' ,
scopes : 'openid email profile' ,
2025-08-29 20:29:33 -06:00
totp_secret : null ,
totp_enabled : false ,
totp_backup_codes : null ,
2025-08-07 02:20:27 -05:00
});
logger . success ( `Traditional user created: ${ username } (is_admin: ${ isFirstUser } )` );
res . json ({ message : 'User created' , is_admin : isFirstUser });
} catch ( err ) {
logger . error ( 'Failed to create user' , err );
res . status ( 500 ). json ({ error : 'Failed to create user' });
}
});
// Route: Create OIDC provider configuration (admin only)
// POST /users/oidc-config
router . post ( '/oidc-config' , authenticateJWT , async ( req , res ) => {
const userId = ( req as any ). userId ;
try {
const user = await db . select (). from ( users ). where ( eq ( users . id , userId ));
if ( ! user || user . length === 0 || ! user [ 0 ]. is_admin ) {
return res . status ( 403 ). json ({ error : 'Not authorized' });
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const {
client_id ,
client_secret ,
issuer_url ,
authorization_url ,
token_url ,
2025-09-03 00:14:49 -05:00
userinfo_url ,
2025-08-07 02:20:27 -05:00
identifier_path ,
name_path ,
scopes
} = req . body ;
2025-08-12 13:15:08 -05:00
if ( ! isNonEmptyString ( client_id ) || ! isNonEmptyString ( client_secret ) ||
2025-08-07 02:20:27 -05:00
! isNonEmptyString ( issuer_url ) || ! isNonEmptyString ( authorization_url ) ||
2025-08-12 13:15:08 -05:00
! isNonEmptyString ( token_url ) || ! isNonEmptyString ( identifier_path ) ||
2025-08-07 02:20:27 -05:00
! isNonEmptyString ( name_path )) {
return res . status ( 400 ). json ({ error : 'All OIDC configuration fields are required' });
}
const config = {
client_id ,
client_secret ,
issuer_url ,
authorization_url ,
token_url ,
2025-09-03 00:14:49 -05:00
userinfo_url : userinfo_url || '' ,
2025-08-07 02:20:27 -05:00
identifier_path ,
name_path ,
scopes : scopes || 'openid email profile'
};
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
db . $client . prepare ( "INSERT OR REPLACE INTO settings (key, value) VALUES ('oidc_config', ?)" ). run ( JSON . stringify ( config ));
res . json ({ message : 'OIDC configuration updated' });
} catch ( err ) {
logger . error ( 'Failed to update OIDC config' , err );
res . status ( 500 ). json ({ error : 'Failed to update OIDC config' });
}
});
// Route: Get OIDC configuration
// GET /users/oidc-config
router . get ( '/oidc-config' , async ( req , res ) => {
try {
const row = db . $client . prepare ( "SELECT value FROM settings WHERE key = 'oidc_config'" ). get ();
if ( ! row ) {
return res . status ( 404 ). json ({ error : 'OIDC not configured' });
}
res . json ( JSON . parse (( row as any ). value ));
} catch ( err ) {
logger . error ( 'Failed to get OIDC config' , err );
res . status ( 500 ). json ({ error : 'Failed to get OIDC config' });
}
});
// Route: Get OIDC authorization URL
// GET /users/oidc/authorize
router . get ( '/oidc/authorize' , async ( req , res ) => {
try {
const row = db . $client . prepare ( "SELECT value FROM settings WHERE key = 'oidc_config'" ). get ();
if ( ! row ) {
return res . status ( 404 ). json ({ error : 'OIDC not configured' });
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const config = JSON . parse (( row as any ). value );
const state = nanoid ();
const nonce = nanoid ();
let origin = req . get ( 'Origin' ) || req . get ( 'Referer' ) ? . replace ( /\/[^\/]*$/ , '' ) || 'http://localhost:5173' ;
if ( origin . includes ( 'localhost' )) {
origin = 'http://localhost:8081' ;
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const redirectUri = ` ${ origin } /users/oidc/callback` ;
db . $client . prepare ( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)" ). run ( `oidc_state_ ${ state } ` , nonce );
db . $client . prepare ( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)" ). run ( `oidc_redirect_ ${ state } ` , redirectUri );
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const authUrl = new URL ( config . authorization_url );
authUrl . searchParams . set ( 'client_id' , config . client_id );
authUrl . searchParams . set ( 'redirect_uri' , redirectUri );
authUrl . searchParams . set ( 'response_type' , 'code' );
authUrl . searchParams . set ( 'scope' , config . scopes );
authUrl . searchParams . set ( 'state' , state );
authUrl . searchParams . set ( 'nonce' , nonce );
res . json ({ auth_url : authUrl.toString (), state , nonce });
} catch ( err ) {
logger . error ( 'Failed to generate OIDC auth URL' , err );
res . status ( 500 ). json ({ error : 'Failed to generate authorization URL' });
}
});
// Route: OIDC callback - exchange code for token and create/login user
// GET /users/oidc/callback
router . get ( '/oidc/callback' , async ( req , res ) => {
const { code , state } = req . query ;
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
if ( ! isNonEmptyString ( code ) || ! isNonEmptyString ( state )) {
return res . status ( 400 ). json ({ error : 'Code and state are required' });
}
const storedRedirectRow = db . $client . prepare ( "SELECT value FROM settings WHERE key = ?" ). get ( `oidc_redirect_ ${ state } ` );
if ( ! storedRedirectRow ) {
return res . status ( 400 ). json ({ error : 'Invalid state parameter - redirect URI not found' });
}
const redirectUri = ( storedRedirectRow as any ). value ;
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
try {
const storedNonce = db . $client . prepare ( "SELECT value FROM settings WHERE key = ?" ). get ( `oidc_state_ ${ state } ` );
if ( ! storedNonce ) {
return res . status ( 400 ). json ({ error : 'Invalid state parameter' });
}
db . $client . prepare ( "DELETE FROM settings WHERE key = ?" ). run ( `oidc_state_ ${ state } ` );
db . $client . prepare ( "DELETE FROM settings WHERE key = ?" ). run ( `oidc_redirect_ ${ state } ` );
const configRow = db . $client . prepare ( "SELECT value FROM settings WHERE key = 'oidc_config'" ). get ();
if ( ! configRow ) {
return res . status ( 500 ). json ({ error : 'OIDC not configured' });
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const config = JSON . parse (( configRow as any ). value );
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const tokenResponse = await fetch ( config . token_url , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/x-www-form-urlencoded' ,
},
body : new URLSearchParams ({
grant_type : 'authorization_code' ,
client_id : config.client_id ,
client_secret : config.client_secret ,
code : code ,
redirect_uri : redirectUri ,
}),
});
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
if ( ! tokenResponse . ok ) {
logger . error ( 'OIDC token exchange failed' , await tokenResponse . text ());
return res . status ( 400 ). json ({ error : 'Failed to exchange authorization code' });
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const tokenData = await tokenResponse . json () as any ;
2025-09-03 00:14:49 -05:00
let userInfo : any = null ;
let userInfoUrls : string [] = [];
const normalizedIssuerUrl = config . issuer_url . endsWith ( '/' ) ? config . issuer_url . slice ( 0 , - 1 ) : config . issuer_url ;
const baseUrl = normalizedIssuerUrl . replace ( /\/application\/o\/[^\/]+$/ , '' );
try {
const discoveryUrl = ` ${ normalizedIssuerUrl } /.well-known/openid-configuration` ;
const discoveryResponse = await fetch ( discoveryUrl );
if ( discoveryResponse . ok ) {
const discovery = await discoveryResponse . json () as any ;
if ( discovery . userinfo_endpoint ) {
userInfoUrls . push ( discovery . userinfo_endpoint );
}
}
} catch ( discoveryError ) {
logger . error ( `OIDC discovery failed: ${ discoveryError } ` );
}
if ( config . userinfo_url ) {
userInfoUrls . unshift ( config . userinfo_url );
}
userInfoUrls . push (
` ${ baseUrl } /userinfo/` ,
` ${ baseUrl } /userinfo` ,
` ${ normalizedIssuerUrl } /userinfo/` ,
` ${ normalizedIssuerUrl } /userinfo` ,
` ${ baseUrl } /oauth2/userinfo/` ,
` ${ baseUrl } /oauth2/userinfo` ,
` ${ normalizedIssuerUrl } /oauth2/userinfo/` ,
` ${ normalizedIssuerUrl } /oauth2/userinfo`
);
2025-08-07 02:20:27 -05:00
if ( tokenData . id_token ) {
try {
userInfo = await verifyOIDCToken ( tokenData . id_token , config . issuer_url , config . client_id );
2025-09-03 00:14:49 -05:00
logger . info ( 'Successfully verified ID token and extracted user info' );
2025-08-07 02:20:27 -05:00
} catch ( error ) {
2025-09-03 00:14:49 -05:00
logger . error ( 'OIDC token verification failed, trying userinfo endpoints' , error );
try {
const parts = tokenData . id_token . split ( '.' );
if ( parts . length === 3 ) {
const payload = JSON . parse ( Buffer . from ( parts [ 1 ], 'base64' ). toString ());
userInfo = payload ;
logger . info ( 'Successfully decoded ID token payload without verification' );
}
} catch ( decodeError ) {
logger . error ( 'Failed to decode ID token payload:' , decodeError );
}
}
}
2025-08-12 13:15:08 -05:00
2025-09-03 00:14:49 -05:00
if ( ! userInfo && tokenData . access_token ) {
for ( const userInfoUrl of userInfoUrls ) {
try {
2025-08-07 02:20:27 -05:00
const userInfoResponse = await fetch ( userInfoUrl , {
headers : {
'Authorization' : `Bearer ${ tokenData . access_token } ` ,
2025-09-03 00:14:49 -05:00
}
2025-08-07 02:20:27 -05:00
});
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
if ( userInfoResponse . ok ) {
userInfo = await userInfoResponse . json ();
2025-09-03 00:14:49 -05:00
break ;
2025-08-07 02:20:27 -05:00
} else {
2025-09-03 00:14:49 -05:00
logger . error ( `Userinfo endpoint ${ userInfoUrl } failed with status: ${ userInfoResponse . status } ` );
2025-08-07 02:20:27 -05:00
}
2025-09-03 00:14:49 -05:00
} catch ( error ) {
logger . error ( `Userinfo endpoint ${ userInfoUrl } failed:` , error );
continue ;
2025-08-07 02:20:27 -05:00
}
}
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
if ( ! userInfo ) {
2025-09-03 00:14:49 -05:00
logger . error ( 'Failed to get user information from all sources' );
logger . error ( `Tried userinfo URLs: ${ userInfoUrls . join ( ', ' ) } ` );
logger . error ( `Token data keys: ${ Object . keys ( tokenData ). join ( ', ' ) } ` );
logger . error ( `Has id_token: ${ !! tokenData . id_token } ` );
logger . error ( `Has access_token: ${ !! tokenData . access_token } ` );
2025-08-07 02:20:27 -05:00
return res . status ( 400 ). json ({ error : 'Failed to get user information' });
}
2025-09-03 00:14:49 -05:00
const getNestedValue = ( obj : any , path : string ) : any => {
if ( ! path || ! obj ) return null ;
return path . split ( '.' ). reduce (( current , key ) => current ? .[ key ], obj );
};
const identifier = getNestedValue ( userInfo , config . identifier_path ) ||
userInfo [ config . identifier_path ] ||
userInfo . sub ||
userInfo . email ||
userInfo . preferred_username ;
const name = getNestedValue ( userInfo , config . name_path ) ||
userInfo [ config . name_path ] ||
userInfo . name ||
userInfo . given_name ||
identifier ;
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
if ( ! identifier ) {
logger . error ( `Identifier not found at path: ${ config . identifier_path } ` );
logger . error ( `Available fields: ${ Object . keys ( userInfo ). join ( ', ' ) } ` );
return res . status ( 400 ). json ({ error : `User identifier not found at path: ${ config . identifier_path } . Available fields: ${ Object . keys ( userInfo ). join ( ', ' ) } ` });
}
let user = await db
. select ()
. from ( users )
. where ( and ( eq ( users . is_oidc , true ), eq ( users . oidc_identifier , identifier )));
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
let isFirstUser = false ;
if ( ! user || user . length === 0 ) {
try {
const countResult = db . $client . prepare ( 'SELECT COUNT(*) as count FROM users' ). get ();
isFirstUser = (( countResult as any ) ? . count || 0 ) === 0 ;
} catch ( e ) {
isFirstUser = true ;
}
const id = nanoid ();
await db . insert ( users ). values ({
id ,
username : name ,
password_hash : '' ,
is_admin : isFirstUser ,
is_oidc : true ,
oidc_identifier : identifier ,
client_id : config.client_id ,
client_secret : config.client_secret ,
issuer_url : config.issuer_url ,
authorization_url : config.authorization_url ,
token_url : config.token_url ,
identifier_path : config.identifier_path ,
name_path : config.name_path ,
scopes : config.scopes ,
});
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
user = await db
. select ()
. from ( users )
. where ( eq ( users . id , id ));
} else {
await db . update ( users )
2025-08-12 13:15:08 -05:00
. set ({ username : name })
2025-08-07 02:20:27 -05:00
. where ( eq ( users . id , user [ 0 ]. id ));
user = await db
. select ()
. from ( users )
. where ( eq ( users . id , user [ 0 ]. id ));
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const userRecord = user [ 0 ];
const jwtSecret = process . env . JWT_SECRET || 'secret' ;
2025-08-12 13:15:08 -05:00
const token = jwt . sign ({ userId : userRecord.id }, jwtSecret , {
2025-08-07 02:20:27 -05:00
expiresIn : '50d' ,
});
let frontendUrl = redirectUri . replace ( '/users/oidc/callback' , '' );
if ( frontendUrl . includes ( 'localhost' )) {
frontendUrl = 'http://localhost:5173' ;
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const redirectUrl = new URL ( frontendUrl );
redirectUrl . searchParams . set ( 'success' , 'true' );
redirectUrl . searchParams . set ( 'token' , token );
res . redirect ( redirectUrl . toString ());
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
} catch ( err ) {
logger . error ( 'OIDC callback failed' , err );
let frontendUrl = redirectUri . replace ( '/users/oidc/callback' , '' );
if ( frontendUrl . includes ( 'localhost' )) {
frontendUrl = 'http://localhost:5173' ;
}
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
const redirectUrl = new URL ( frontendUrl );
redirectUrl . searchParams . set ( 'error' , 'OIDC authentication failed' );
2025-08-12 13:15:08 -05:00
2025-08-07 02:20:27 -05:00
res . redirect ( redirectUrl . toString ());
}
});
// Route: Get user JWT by username and password (traditional login)
// POST /users/login
router . post ( '/login' , async ( req , res ) => {
const { username , password } = req . body ;
if ( ! isNonEmptyString ( username ) || ! isNonEmptyString ( password )) {
logger . warn ( 'Invalid traditional login attempt' );
2025-08-12 13:15:08 -05:00
return res . status ( 400 ). json ({ error : 'Invalid username or password' });
2025-08-07 02:20:27 -05:00
}
try {
const user = await db
. select ()
. from ( users )
. where ( eq ( users . username , username ));
if ( ! user || user . length === 0 ) {
logger . warn ( `User not found: ${ username } ` );
2025-08-12 13:15:08 -05:00
return res . status ( 404 ). json ({ error : 'User not found' });
2025-08-07 02:20:27 -05:00
}
const userRecord = user [ 0 ];
if ( userRecord . is_oidc ) {
2025-08-12 13:15:08 -05:00
return res . status ( 403 ). json ({ error : 'This user uses external authentication' });
2025-08-07 02:20:27 -05:00
}
const isMatch = await bcrypt . compare ( password , userRecord . password_hash );
if ( ! isMatch ) {
logger . warn ( `Incorrect password for user: ${ username } ` );
2025-08-12 13:15:08 -05:00
return res . status ( 401 ). json ({ error : 'Incorrect password' });
2025-08-07 02:20:27 -05:00
}
const jwtSecret = process . env . JWT_SECRET || 'secret' ;
2025-08-12 13:15:08 -05:00
const token = jwt . sign ({ userId : userRecord.id }, jwtSecret , {
2025-08-07 02:20:27 -05:00
expiresIn : '50d' ,
});
2025-08-29 20:29:33 -06:00
if ( userRecord . totp_enabled ) {
return res . json ({
requires_totp : true ,
temp_token : jwt.sign (
{ userId : userRecord.id , pending_totp : true },
jwtSecret ,
{ expiresIn : '10m' }
)
});
}
2025-08-12 13:15:08 -05:00
return res . json ({
2025-08-07 02:20:27 -05:00
token ,
is_admin : !! userRecord . is_admin ,
username : userRecord.username
});
} catch ( err ) {
logger . error ( 'Failed to log in user' , err );
2025-08-12 13:15:08 -05:00
return res . status ( 500 ). json ({ error : 'Login failed' });
2025-08-07 02:20:27 -05:00
}
});
// Route: Get current user's info using JWT
// GET /users/me
router . get ( '/me' , authenticateJWT , async ( req : Request , res : Response ) => {
const userId = ( req as any ). userId ;
if ( ! isNonEmptyString ( userId )) {
logger . warn ( 'Invalid userId in JWT for /users/me' );
return res . status ( 401 ). json ({ error : 'Invalid userId' });
}
try {
const user = await db
. select ()
. from ( users )
. where ( eq ( users . id , userId ));
if ( ! user || user . length === 0 ) {
logger . warn ( `User not found for /users/me: ${ userId } ` );
return res . status ( 401 ). json ({ error : 'User not found' });
}
res . json ({
2025-08-13 00:05:13 -05:00
userId : user [ 0 ]. id ,
2025-08-12 13:15:08 -05:00
username : user [ 0 ]. username ,
2025-08-07 02:20:27 -05:00
is_admin : !! user [ 0 ]. is_admin ,
2025-08-29 20:29:33 -06:00
is_oidc : !! user [ 0 ]. is_oidc ,
totp_enabled : !! user [ 0 ]. totp_enabled
2025-08-07 02:20:27 -05:00
});
} catch ( err ) {
logger . error ( 'Failed to get username' , err );
res . status ( 500 ). json ({ error : 'Failed to get username' });
}
});
// Route: Count users
// GET /users/count
router . get ( '/count' , async ( req , res ) => {
try {
const countResult = db . $client . prepare ( 'SELECT COUNT(*) as count FROM users' ). get ();
const count = ( countResult as any ) ? . count || 0 ;
res . json ({ count });
} catch ( err ) {
logger . error ( 'Failed to count users' , err );
res . status ( 500 ). json ({ error : 'Failed to count users' });
}
});
// Route: DB health check (actually queries DB)
// GET /users/db-health
router . get ( '/db-health' , async ( req , res ) => {
try {
db . $client . prepare ( 'SELECT 1' ). get ();
res . json ({ status : 'ok' });
} catch ( err ) {
logger . error ( 'DB health check failed' , err );
res . status ( 500 ). json ({ error : 'Database not accessible' });
}
});
// Route: Get registration allowed status
// GET /users/registration-allowed
router . get ( '/registration-allowed' , async ( req , res ) => {
try {
const row = db . $client . prepare ( "SELECT value FROM settings WHERE key = 'allow_registration'" ). get ();
res . json ({ allowed : row ? ( row as any ). value === 'true' : true });
} catch ( err ) {
logger . error ( 'Failed to get registration allowed' , err );
res . status ( 500 ). json ({ error : 'Failed to get registration allowed' });
}
});
// Route: Set registration allowed status (admin only)
// PATCH /users/registration-allowed
router . patch ( '/registration-allowed' , authenticateJWT , async ( req , res ) => {
const userId = ( req as any ). userId ;
try {
const user = await db . select (). from ( users ). where ( eq ( users . id , userId ));
if ( ! user || user . length === 0 || ! user [ 0 ]. is_admin ) {
return res . status ( 403 ). json ({ error : 'Not authorized' });
}
const { allowed } = req . body ;
if ( typeof allowed !== 'boolean' ) {
return res . status ( 400 ). json ({ error : 'Invalid value for allowed' });
}
db . $client . prepare ( "UPDATE settings SET value = ? WHERE key = 'allow_registration'" ). run ( allowed ? 'true' : 'false' );
res . json ({ allowed });
} catch ( err ) {
logger . error ( 'Failed to set registration allowed' , err );
res . status ( 500 ). json ({ error : 'Failed to set registration allowed' });
}
});
2025-08-12 13:15:08 -05:00
// Route: Delete user account
// DELETE /users/delete-account
router . delete ( '/delete-account' , authenticateJWT , async ( req , res ) => {
const userId = ( req as any ). userId ;
const { password } = req . body ;
if ( ! isNonEmptyString ( password )) {
return res . status ( 400 ). json ({ error : 'Password is required to delete account' });
}
try {
const user = await db . select (). from ( users ). where ( eq ( users . id , userId ));
if ( ! user || user . length === 0 ) {
return res . status ( 404 ). json ({ error : 'User not found' });
}
const userRecord = user [ 0 ];
if ( userRecord . is_oidc ) {
return res . status ( 403 ). json ({ error : 'Cannot delete external authentication accounts through this endpoint' });
}
const isMatch = await bcrypt . compare ( password , userRecord . password_hash );
if ( ! isMatch ) {
logger . warn ( `Incorrect password provided for account deletion: ${ userRecord . username } ` );
return res . status ( 401 ). json ({ error : 'Incorrect password' });
}
if ( userRecord . is_admin ) {
const adminCount = db . $client . prepare ( 'SELECT COUNT(*) as count FROM users WHERE is_admin = 1' ). get ();
if (( adminCount as any ) ? . count <= 1 ) {
return res . status ( 403 ). json ({ error : 'Cannot delete the last admin user' });
}
}
await db . delete ( users ). where ( eq ( users . id , userId ));
logger . success ( `User account deleted: ${ userRecord . username } ` );
res . json ({ message : 'Account deleted successfully' });
} catch ( err ) {
logger . error ( 'Failed to delete user account' , err );
res . status ( 500 ). json ({ error : 'Failed to delete account' });
}
});
// Route: Initiate password reset
// POST /users/initiate-reset
router . post ( '/initiate-reset' , async ( req , res ) => {
const { username } = req . body ;
if ( ! isNonEmptyString ( username )) {
return res . status ( 400 ). json ({ error : 'Username is required' });
}
try {
const user = await db
. select ()
. from ( users )
. where ( eq ( users . username , username ));
if ( ! user || user . length === 0 ) {
logger . warn ( `Password reset attempted for non-existent user: ${ username } ` );
return res . status ( 404 ). json ({ error : 'User not found' });
}
if ( user [ 0 ]. is_oidc ) {
return res . status ( 403 ). json ({ error : 'Password reset not available for external authentication users' });
}
const resetCode = Math . floor ( 100000 + Math . random () * 900000 ). toString ();
const expiresAt = new Date ( Date . now () + 15 * 60 * 1000 );
db . $client . prepare ( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)" ). run (
`reset_code_ ${ username } ` ,
JSON . stringify ({ code : resetCode , expiresAt : expiresAt.toISOString ()})
);
logger . info ( `Password reset code for user ${ username } : ${ resetCode } (expires at ${ expiresAt . toLocaleString () } )` );
res . json ({ message : 'Password reset code has been generated and logged. Check docker logs for the code.' });
} catch ( err ) {
logger . error ( 'Failed to initiate password reset' , err );
res . status ( 500 ). json ({ error : 'Failed to initiate password reset' });
}
});
// Route: Verify reset code
// POST /users/verify-reset-code
router . post ( '/verify-reset-code' , async ( req , res ) => {
const { username , resetCode } = req . body ;
if ( ! isNonEmptyString ( username ) || ! isNonEmptyString ( resetCode )) {
return res . status ( 400 ). json ({ error : 'Username and reset code are required' });
}
try {
const resetDataRow = db . $client . prepare ( "SELECT value FROM settings WHERE key = ?" ). get ( `reset_code_ ${ username } ` );
if ( ! resetDataRow ) {
return res . status ( 400 ). json ({ error : 'No reset code found for this user' });
}
const resetData = JSON . parse (( resetDataRow as any ). value );
const now = new Date ();
const expiresAt = new Date ( resetData . expiresAt );
if ( now > expiresAt ) {
db . $client . prepare ( "DELETE FROM settings WHERE key = ?" ). run ( `reset_code_ ${ username } ` );
return res . status ( 400 ). json ({ error : 'Reset code has expired' });
}
if ( resetData . code !== resetCode ) {
return res . status ( 400 ). json ({ error : 'Invalid reset code' });
}
const tempToken = nanoid ();
const tempTokenExpiry = new Date ( Date . now () + 10 * 60 * 1000 );
db . $client . prepare ( "INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)" ). run (
`temp_reset_token_ ${ username } ` ,
JSON . stringify ({ token : tempToken , expiresAt : tempTokenExpiry.toISOString ()})
);
res . json ({ message : 'Reset code verified' , tempToken });
} catch ( err ) {
logger . error ( 'Failed to verify reset code' , err );
res . status ( 500 ). json ({ error : 'Failed to verify reset code' });
}
});
// Route: Complete password reset
// POST /users/complete-reset
router . post ( '/complete-reset' , async ( req , res ) => {
const { username , tempToken , newPassword } = req . body ;
if ( ! isNonEmptyString ( username ) || ! isNonEmptyString ( tempToken ) || ! isNonEmptyString ( newPassword )) {
return res . status ( 400 ). json ({ error : 'Username, temporary token, and new password are required' });
}
try {
const tempTokenRow = db . $client . prepare ( "SELECT value FROM settings WHERE key = ?" ). get ( `temp_reset_token_ ${ username } ` );
if ( ! tempTokenRow ) {
return res . status ( 400 ). json ({ error : 'No temporary token found' });
}
const tempTokenData = JSON . parse (( tempTokenRow as any ). value );
const now = new Date ();
const expiresAt = new Date ( tempTokenData . expiresAt );
if ( now > expiresAt ) {
// Clean up expired token
db . $client . prepare ( "DELETE FROM settings WHERE key = ?" ). run ( `temp_reset_token_ ${ username } ` );
return res . status ( 400 ). json ({ error : 'Temporary token has expired' });
}
if ( tempTokenData . token !== tempToken ) {
return res . status ( 400 ). json ({ error : 'Invalid temporary token' });
}
const saltRounds = parseInt ( process . env . SALT || '10' , 10 );
const password_hash = await bcrypt . hash ( newPassword , saltRounds );
await db . update ( users )
. set ({ password_hash })
. where ( eq ( users . username , username ));
db . $client . prepare ( "DELETE FROM settings WHERE key = ?" ). run ( `reset_code_ ${ username } ` );
db . $client . prepare ( "DELETE FROM settings WHERE key = ?" ). run ( `temp_reset_token_ ${ username } ` );
logger . success ( `Password successfully reset for user: ${ username } ` );
res . json ({ message : 'Password has been successfully reset' });
} catch ( err ) {
logger . error ( 'Failed to complete password reset' , err );
res . status ( 500 ). json ({ error : 'Failed to complete password reset' });
}
});
// Route: List all users (admin only)
// GET /users/list
router . get ( '/list' , authenticateJWT , async ( req , res ) => {
const userId = ( req as any ). userId ;
try {
const user = await db . select (). from ( users ). where ( eq ( users . id , userId ));
if ( ! user || user . length === 0 || ! user [ 0 ]. is_admin ) {
return res . status ( 403 ). json ({ error : 'Not authorized' });
}
const allUsers = await db . select ({
id : users.id ,
username : users.username ,
is_admin : users.is_admin ,
is_oidc : users.is_oidc
}). from ( users );
res . json ({ users : allUsers });
} catch ( err ) {
logger . error ( 'Failed to list users' , err );
res . status ( 500 ). json ({ error : 'Failed to list users' });
}
});
// Route: Make user admin (admin only)
// POST /users/make-admin
router . post ( '/make-admin' , authenticateJWT , async ( req , res ) => {
const userId = ( req as any ). userId ;
const { username } = req . body ;
if ( ! isNonEmptyString ( username )) {
return res . status ( 400 ). json ({ error : 'Username is required' });
}
try {
const adminUser = await db . select (). from ( users ). where ( eq ( users . id , userId ));
if ( ! adminUser || adminUser . length === 0 || ! adminUser [ 0 ]. is_admin ) {
return res . status ( 403 ). json ({ error : 'Not authorized' });
}
const targetUser = await db . select (). from ( users ). where ( eq ( users . username , username ));
if ( ! targetUser || targetUser . length === 0 ) {
return res . status ( 404 ). json ({ error : 'User not found' });
}
if ( targetUser [ 0 ]. is_admin ) {
return res . status ( 400 ). json ({ error : 'User is already an admin' });
}
await db . update ( users )
. set ({ is_admin : true })
. where ( eq ( users . username , username ));
logger . success ( `User ${ username } made admin by ${ adminUser [ 0 ]. username } ` );
res . json ({ message : `User ${ username } is now an admin` });
} catch ( err ) {
logger . error ( 'Failed to make user admin' , err );
res . status ( 500 ). json ({ error : 'Failed to make user admin' });
}
});
// Route: Remove admin status (admin only)
// POST /users/remove-admin
router . post ( '/remove-admin' , authenticateJWT , async ( req , res ) => {
const userId = ( req as any ). userId ;
const { username } = req . body ;
if ( ! isNonEmptyString ( username )) {
return res . status ( 400 ). json ({ error : 'Username is required' });
}
try {
const adminUser = await db . select (). from ( users ). where ( eq ( users . id , userId ));
if ( ! adminUser || adminUser . length === 0 || ! adminUser [ 0 ]. is_admin ) {
return res . status ( 403 ). json ({ error : 'Not authorized' });
}
if ( adminUser [ 0 ]. username === username ) {
return res . status ( 400 ). json ({ error : 'Cannot remove your own admin status' });
}
const targetUser = await db . select (). from ( users ). where ( eq ( users . username , username ));
if ( ! targetUser || targetUser . length === 0 ) {
return res . status ( 404 ). json ({ error : 'User not found' });
}
if ( ! targetUser [ 0 ]. is_admin ) {
return res . status ( 400 ). json ({ error : 'User is not an admin' });
}
await db . update ( users )
. set ({ is_admin : false })
. where ( eq ( users . username , username ));
logger . success ( `Admin status removed from ${ username } by ${ adminUser [ 0 ]. username } ` );
res . json ({ message : `Admin status removed from ${ username } ` });
} catch ( err ) {
logger . error ( 'Failed to remove admin status' , err );
res . status ( 500 ). json ({ error : 'Failed to remove admin status' });
}
});
2025-08-29 20:29:33 -06:00
// Route: Verify TOTP during login
// POST /users/totp/verify-login
router . post ( '/totp/verify-login' , async ( req , res ) => {
const { temp_token , totp_code } = req . body ;
if ( ! temp_token || ! totp_code ) {
return res . status ( 400 ). json ({ error : 'Token and TOTP code are required' });
}
const jwtSecret = process . env . JWT_SECRET || 'secret' ;
2025-09-03 00:14:49 -05:00
2025-08-29 20:29:33 -06:00
try {
const decoded = jwt . verify ( temp_token , jwtSecret ) as any ;
if ( ! decoded . pending_totp ) {
return res . status ( 401 ). json ({ error : 'Invalid temporary token' });
}
const user = await db . select (). from ( users ). where ( eq ( users . id , decoded . userId ));
if ( ! user || user . length === 0 ) {
return res . status ( 404 ). json ({ error : 'User not found' });
}
const userRecord = user [ 0 ];
2025-09-03 00:14:49 -05:00
2025-08-29 20:29:33 -06:00
if ( ! userRecord . totp_enabled || ! userRecord . totp_secret ) {
return res . status ( 400 ). json ({ error : 'TOTP not enabled for this user' });
}
const verified = speakeasy . totp . verify ({
secret : userRecord.totp_secret ,
encoding : 'base32' ,
token : totp_code ,
window : 2
});
if ( ! verified ) {
const backupCodes = userRecord . totp_backup_codes ? JSON . parse ( userRecord . totp_backup_codes ) : [];
const backupIndex = backupCodes . indexOf ( totp_code );
2025-09-03 00:14:49 -05:00
2025-08-29 20:29:33 -06:00
if ( backupIndex === - 1 ) {
return res . status ( 401 ). json ({ error : 'Invalid TOTP code' });
}
2025-09-03 00:14:49 -05:00
2025-08-29 20:29:33 -06:00
backupCodes . splice ( backupIndex , 1 );
await db . update ( users )
. set ({ totp_backup_codes : JSON.stringify ( backupCodes )})
. where ( eq ( users . id , userRecord . id ));
}
const token = jwt . sign ({ userId : userRecord.id }, jwtSecret , {
expiresIn : '50d' ,
});
return res . json ({
token ,
is_admin : !! userRecord . is_admin ,
username : userRecord.username
});
} catch ( err ) {
logger . error ( 'TOTP verification failed' , err );
return res . status ( 500 ). json ({ error : 'TOTP verification failed' });
}
});
// Route: Setup TOTP
// POST /users/totp/setup
router . post ( '/totp/setup' , authenticateJWT , async ( req , res ) => {
const userId = ( req as any ). userId ;
2025-09-03 00:14:49 -05:00
2025-08-29 20:29:33 -06:00
try {
const user = await db . select (). from ( users ). where ( eq ( users . id , userId ));
if ( ! user || user . length === 0 ) {
return res . status ( 404 ). json ({ error : 'User not found' });
}
const userRecord = user [ 0 ];
2025-09-03 00:14:49 -05:00
2025-08-29 20:29:33 -06:00
if ( userRecord . totp_enabled ) {
return res . status ( 400 ). json ({ error : 'TOTP is already enabled' });
}
const secret = speakeasy . generateSecret ({
name : `Termix ( ${ userRecord . username } )` ,
length : 32
});
await db . update ( users )
. set ({ totp_secret : secret.base32 })
. where ( eq ( users . id , userId ));
const qrCodeUrl = await QRCode . toDataURL ( secret . otpauth_url || '' );
res . json ({
secret : secret.base32 ,
qr_code : qrCodeUrl
});
} catch ( err ) {
logger . error ( 'Failed to setup TOTP' , err );
res . status ( 500 ). json ({ error : 'Failed to setup TOTP' });
}
});
// Route: Enable TOTP
// POST /users/totp/enable
router . post ( '/totp/enable' , authenticateJWT , async ( req , res ) => {
const userId = ( req as any ). userId ;
const { totp_code } = req . body ;
if ( ! totp_code ) {
return res . status ( 400 ). json ({ error : 'TOTP code is required' });
}
try {
const user = await db . select (). from ( users ). where ( eq ( users . id , userId ));
if ( ! user || user . length === 0 ) {
return res . status ( 404 ). json ({ error : 'User not found' });
}
const userRecord = user [ 0 ];
2025-09-03 00:14:49 -05:00
2025-08-29 20:29:33 -06:00
if ( userRecord . totp_enabled ) {
return res . status ( 400 ). json ({ error : 'TOTP is already enabled' });
}
if ( ! userRecord . totp_secret ) {
return res . status ( 400 ). json ({ error : 'TOTP setup not initiated' });
}
const verified = speakeasy . totp . verify ({
secret : userRecord.totp_secret ,
encoding : 'base32' ,
token : totp_code ,
window : 2
});
if ( ! verified ) {
return res . status ( 401 ). json ({ error : 'Invalid TOTP code' });
}
2025-09-03 00:14:49 -05:00
const backupCodes = Array . from ({ length : 8 }, () =>
2025-08-29 20:29:33 -06:00
Math . random (). toString ( 36 ). substring ( 2 , 10 ). toUpperCase ()
);
await db . update ( users )
. set ({
totp_enabled : true ,
totp_backup_codes : JSON.stringify ( backupCodes )
})
. where ( eq ( users . id , userId ));
res . json ({
message : 'TOTP enabled successfully' ,
backup_codes : backupCodes
});
} catch ( err ) {
logger . error ( 'Failed to enable TOTP' , err );
res . status ( 500 ). json ({ error : 'Failed to enable TOTP' });
}
});
// Route: Disable TOTP
// POST /users/totp/disable
router . post ( '/totp/disable' , authenticateJWT , async ( req , res ) => {
const userId = ( req as any ). userId ;
const { password , totp_code } = req . body ;
if ( ! password && ! totp_code ) {
return res . status ( 400 ). json ({ error : 'Password or TOTP code is required' });
}
try {
const user = await db . select (). from ( users ). where ( eq ( users . id , userId ));
if ( ! user || user . length === 0 ) {
return res . status ( 404 ). json ({ error : 'User not found' });
}
const userRecord = user [ 0 ];
2025-09-03 00:14:49 -05:00
2025-08-29 20:29:33 -06:00
if ( ! userRecord . totp_enabled ) {
return res . status ( 400 ). json ({ error : 'TOTP is not enabled' });
}
if ( password && ! userRecord . is_oidc ) {
const isMatch = await bcrypt . compare ( password , userRecord . password_hash );
if ( ! isMatch ) {
return res . status ( 401 ). json ({ error : 'Incorrect password' });
}
} else if ( totp_code ) {
const verified = speakeasy . totp . verify ({
secret : userRecord.totp_secret ! ,
encoding : 'base32' ,
token : totp_code ,
window : 2
});
if ( ! verified ) {
return res . status ( 401 ). json ({ error : 'Invalid TOTP code' });
}
} else {
return res . status ( 400 ). json ({ error : 'Authentication required' });
}
await db . update ( users )
. set ({
totp_enabled : false ,
totp_secret : null ,
totp_backup_codes : null
})
. where ( eq ( users . id , userId ));
res . json ({ message : 'TOTP disabled successfully' });
} catch ( err ) {
logger . error ( 'Failed to disable TOTP' , err );
res . status ( 500 ). json ({ error : 'Failed to disable TOTP' });
}
});
// Route: Generate new backup codes
// POST /users/totp/backup-codes
router . post ( '/totp/backup-codes' , authenticateJWT , async ( req , res ) => {
const userId = ( req as any ). userId ;
const { password , totp_code } = req . body ;
if ( ! password && ! totp_code ) {
return res . status ( 400 ). json ({ error : 'Password or TOTP code is required' });
}
try {
const user = await db . select (). from ( users ). where ( eq ( users . id , userId ));
if ( ! user || user . length === 0 ) {
return res . status ( 404 ). json ({ error : 'User not found' });
}
const userRecord = user [ 0 ];
2025-09-03 00:14:49 -05:00
2025-08-29 20:29:33 -06:00
if ( ! userRecord . totp_enabled ) {
return res . status ( 400 ). json ({ error : 'TOTP is not enabled' });
}
if ( password && ! userRecord . is_oidc ) {
const isMatch = await bcrypt . compare ( password , userRecord . password_hash );
if ( ! isMatch ) {
return res . status ( 401 ). json ({ error : 'Incorrect password' });
}
} else if ( totp_code ) {
const verified = speakeasy . totp . verify ({
secret : userRecord.totp_secret ! ,
encoding : 'base32' ,
token : totp_code ,
window : 2
});
if ( ! verified ) {
return res . status ( 401 ). json ({ error : 'Invalid TOTP code' });
}
} else {
return res . status ( 400 ). json ({ error : 'Authentication required' });
}
2025-09-03 00:14:49 -05:00
const backupCodes = Array . from ({ length : 8 }, () =>
2025-08-29 20:29:33 -06:00
Math . random (). toString ( 36 ). substring ( 2 , 10 ). toUpperCase ()
);
await db . update ( users )
. set ({ totp_backup_codes : JSON.stringify ( backupCodes )})
. where ( eq ( users . id , userId ));
res . json ({ backup_codes : backupCodes });
} catch ( err ) {
logger . error ( 'Failed to generate backup codes' , err );
res . status ( 500 ). json ({ error : 'Failed to generate backup codes' });
}
});
2025-08-12 13:15:08 -05:00
// Route: Delete user (admin only)
// DELETE /users/delete-user
router . delete ( '/delete-user' , authenticateJWT , async ( req , res ) => {
const userId = ( req as any ). userId ;
const { username } = req . body ;
if ( ! isNonEmptyString ( username )) {
return res . status ( 400 ). json ({ error : 'Username is required' });
}
try {
const adminUser = await db . select (). from ( users ). where ( eq ( users . id , userId ));
if ( ! adminUser || adminUser . length === 0 || ! adminUser [ 0 ]. is_admin ) {
return res . status ( 403 ). json ({ error : 'Not authorized' });
}
if ( adminUser [ 0 ]. username === username ) {
return res . status ( 400 ). json ({ error : 'Cannot delete your own account' });
}
const targetUser = await db . select (). from ( users ). where ( eq ( users . username , username ));
if ( ! targetUser || targetUser . length === 0 ) {
return res . status ( 404 ). json ({ error : 'User not found' });
}
if ( targetUser [ 0 ]. is_admin ) {
const adminCount = db . $client . prepare ( 'SELECT COUNT(*) as count FROM users WHERE is_admin = 1' ). get ();
if (( adminCount as any ) ? . count <= 1 ) {
return res . status ( 403 ). json ({ error : 'Cannot delete the last admin user' });
}
}
const targetUserId = targetUser [ 0 ]. id ;
try {
2025-09-03 00:14:49 -05:00
await db . delete ( fileManagerRecent ). where ( eq ( fileManagerRecent . userId , targetUserId ));
await db . delete ( fileManagerPinned ). where ( eq ( fileManagerPinned . userId , targetUserId ));
await db . delete ( fileManagerShortcuts ). where ( eq ( fileManagerShortcuts . userId , targetUserId ));
await db . delete ( dismissedAlerts ). where ( eq ( dismissedAlerts . userId , targetUserId ));
await db . delete ( sshData ). where ( eq ( sshData . userId , targetUserId ));
// Note: All user-related data has been deleted above
// The tables config_editor_* and shared_hosts don't exist in the current schema
2025-08-12 13:15:08 -05:00
} catch ( cleanupError ) {
logger . error ( `Cleanup failed for user ${ username } :` , cleanupError );
2025-09-03 00:14:49 -05:00
throw cleanupError ;
2025-08-12 13:15:08 -05:00
}
await db . delete ( users ). where ( eq ( users . id , targetUserId ));
logger . success ( `User ${ username } deleted by admin ${ adminUser [ 0 ]. username } ` );
res . json ({ message : `User ${ username } deleted successfully` });
} catch ( err ) {
logger . error ( 'Failed to delete user' , err );
if ( err && typeof err === 'object' && 'code' in err ) {
if ( err . code === 'SQLITE_CONSTRAINT_FOREIGNKEY' ) {
res . status ( 400 ). json ({ error : 'Cannot delete user: User has associated data that cannot be removed' });
} else {
res . status ( 500 ). json ({ error : `Database error: ${ err . code } ` });
}
} else {
res . status ( 500 ). json ({ error : 'Failed to delete account' });
}
}
});
2025-08-07 02:20:27 -05:00
export default router ;