|
|
|
@@ -8,19 +8,33 @@ export interface IJwtData {
|
|
|
|
|
expiresAt: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type TAdminUser = {
|
|
|
|
|
id: string;
|
|
|
|
|
username: string;
|
|
|
|
|
email?: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
role: string;
|
|
|
|
|
status?: 'active' | 'disabled';
|
|
|
|
|
authSources?: Array<'local' | 'idp.global'>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export class AdminHandler {
|
|
|
|
|
public typedrouter = new plugins.typedrequest.TypedRouter();
|
|
|
|
|
|
|
|
|
|
// JWT instance
|
|
|
|
|
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
|
|
|
|
|
|
|
|
|
|
// Simple in-memory user storage (in production, use proper database)
|
|
|
|
|
// Ephemeral bootstrap users. Persisted accounts take over once an active admin exists.
|
|
|
|
|
private users = new Map<string, {
|
|
|
|
|
id: string;
|
|
|
|
|
username: string;
|
|
|
|
|
password: string;
|
|
|
|
|
role: string;
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
private accountStore?: plugins.idpSdkServer.SmartdataAccountStore;
|
|
|
|
|
private idpClient?: plugins.idpSdkServer.IdpGlobalServerClient;
|
|
|
|
|
private ownsIdpClient = false;
|
|
|
|
|
|
|
|
|
|
constructor(private opsServerRef: OpsServer) {
|
|
|
|
|
// Add this handler's router to the parent
|
|
|
|
@@ -32,6 +46,14 @@ export class AdminHandler {
|
|
|
|
|
this.initializeDefaultUsers();
|
|
|
|
|
this.registerHandlers();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async stop(): Promise<void> {
|
|
|
|
|
if (this.ownsIdpClient) {
|
|
|
|
|
await this.idpClient?.stop();
|
|
|
|
|
}
|
|
|
|
|
this.idpClient = undefined;
|
|
|
|
|
this.ownsIdpClient = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async initializeJwt(): Promise<void> {
|
|
|
|
|
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
|
|
|
|
@@ -61,54 +83,120 @@ export class AdminHandler {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Return a safe projection of the users Map — excludes password fields.
|
|
|
|
|
* Return a safe projection of the active user source — excludes password fields.
|
|
|
|
|
* Used by UsersHandler to serve the admin-only listUsers endpoint.
|
|
|
|
|
*/
|
|
|
|
|
public listUsers(): Array<{ id: string; username: string; role: string }> {
|
|
|
|
|
public async listUsers(): Promise<interfaces.requests.IAdminUserProjection[]> {
|
|
|
|
|
if (await this.hasPersistentAdminAccount()) {
|
|
|
|
|
const store = this.getAccountStore();
|
|
|
|
|
const accounts = await store!.listAccounts();
|
|
|
|
|
return accounts.map((accountArg) => this.accountToUser(accountArg));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Array.from(this.users.values()).map((user) => ({
|
|
|
|
|
id: user.id,
|
|
|
|
|
username: user.username,
|
|
|
|
|
role: user.role,
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async getBootstrapStatus(): Promise<interfaces.requests.IReq_GetAdminBootstrapStatus['response']> {
|
|
|
|
|
const dbEnabled = this.opsServerRef.dcRouterRef.options.dbConfig?.enabled !== false;
|
|
|
|
|
const store = this.getAccountStore();
|
|
|
|
|
const dbReady = !!store;
|
|
|
|
|
const hasPersistentAdmin = dbReady ? await store.hasActiveAdminAccount() : false;
|
|
|
|
|
return {
|
|
|
|
|
dbEnabled,
|
|
|
|
|
dbReady,
|
|
|
|
|
hasPersistentAdmin,
|
|
|
|
|
needsBootstrap: dbEnabled && dbReady && !hasPersistentAdmin,
|
|
|
|
|
ephemeralAdminAvailable: !hasPersistentAdmin,
|
|
|
|
|
idpGlobalConfigured: this.isIdpGlobalConfigured(),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async createInitialAdminUser(optionsArg: {
|
|
|
|
|
email: string;
|
|
|
|
|
name?: string;
|
|
|
|
|
password: string;
|
|
|
|
|
enableIdpGlobalAuth?: boolean;
|
|
|
|
|
}): Promise<interfaces.requests.IReq_CreateInitialAdminUser['response']> {
|
|
|
|
|
const store = this.getAccountStore();
|
|
|
|
|
if (!store) {
|
|
|
|
|
throw new plugins.typedrequest.TypedResponseError('database is not ready');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (await store.hasActiveAdminAccount()) {
|
|
|
|
|
throw new plugins.typedrequest.TypedResponseError('initial admin already exists');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const password = String(optionsArg.password || '');
|
|
|
|
|
if (!password) {
|
|
|
|
|
throw new plugins.typedrequest.TypedResponseError('password is required');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const email = String(optionsArg.email || '').trim();
|
|
|
|
|
const authSources: Array<'local' | 'idp.global'> = ['local'];
|
|
|
|
|
if (optionsArg.enableIdpGlobalAuth) {
|
|
|
|
|
authSources.push('idp.global');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const account = await store.createAccount({
|
|
|
|
|
email,
|
|
|
|
|
name: String(optionsArg.name || '').trim() || email,
|
|
|
|
|
role: 'admin',
|
|
|
|
|
authSources,
|
|
|
|
|
password,
|
|
|
|
|
});
|
|
|
|
|
const user = this.accountToUser(account);
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
identity: await this.createIdentityForUser(user),
|
|
|
|
|
user,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
throw new plugins.typedrequest.TypedResponseError((error as Error).message || 'failed to create initial admin');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private registerHandlers(): void {
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetAdminBootstrapStatus>(
|
|
|
|
|
'getAdminBootstrapStatus',
|
|
|
|
|
async (_dataArg) => this.getBootstrapStatus()
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
this.opsServerRef.adminRouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_CreateInitialAdminUser>(
|
|
|
|
|
'createInitialAdminUser',
|
|
|
|
|
async (dataArg) => this.createInitialAdminUser({
|
|
|
|
|
email: dataArg.email,
|
|
|
|
|
name: dataArg.name,
|
|
|
|
|
password: dataArg.password,
|
|
|
|
|
enableIdpGlobalAuth: dataArg.enableIdpGlobalAuth,
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Admin Login Handler
|
|
|
|
|
this.typedrouter.addTypedHandler(
|
|
|
|
|
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_AdminLoginWithUsernameAndPassword>(
|
|
|
|
|
'adminLoginWithUsernameAndPassword',
|
|
|
|
|
async (dataArg) => {
|
|
|
|
|
try {
|
|
|
|
|
// Find user by username and password
|
|
|
|
|
let user: { id: string; username: string; password: string; role: string } | null = null;
|
|
|
|
|
for (const [_, userData] of this.users) {
|
|
|
|
|
if (userData.username === dataArg.username && userData.password === dataArg.password) {
|
|
|
|
|
user = userData;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const user = await this.authenticateUser({
|
|
|
|
|
username: dataArg.username,
|
|
|
|
|
password: dataArg.password,
|
|
|
|
|
authSource: dataArg.authSource,
|
|
|
|
|
});
|
|
|
|
|
if (!user) {
|
|
|
|
|
throw new plugins.typedrequest.TypedResponseError('login failed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
|
|
|
|
|
|
|
|
|
const jwt = await this.smartjwtInstance.createJWT({
|
|
|
|
|
userId: user.id,
|
|
|
|
|
status: 'loggedIn',
|
|
|
|
|
expiresAt: expiresAtTimestamp,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
identity: {
|
|
|
|
|
jwt,
|
|
|
|
|
userId: user.id,
|
|
|
|
|
name: user.username,
|
|
|
|
|
expiresAt: expiresAtTimestamp,
|
|
|
|
|
role: user.role,
|
|
|
|
|
type: 'user',
|
|
|
|
|
},
|
|
|
|
|
identity: await this.createIdentityForUser(user),
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
if (error instanceof plugins.typedrequest.TypedResponseError) {
|
|
|
|
@@ -162,8 +250,7 @@ export class AdminHandler {
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Find user
|
|
|
|
|
const user = this.users.get(jwtData.userId);
|
|
|
|
|
const user = await this.resolveUser(jwtData.userId);
|
|
|
|
|
if (!user) {
|
|
|
|
|
return {
|
|
|
|
|
valid: false,
|
|
|
|
@@ -175,7 +262,7 @@ export class AdminHandler {
|
|
|
|
|
identity: {
|
|
|
|
|
jwt: dataArg.identity.jwt,
|
|
|
|
|
userId: user.id,
|
|
|
|
|
name: user.username,
|
|
|
|
|
name: user.name || user.username,
|
|
|
|
|
expiresAt: jwtData.expiresAt,
|
|
|
|
|
role: user.role,
|
|
|
|
|
type: 'user',
|
|
|
|
@@ -224,6 +311,15 @@ export class AdminHandler {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const user = await this.resolveUser(jwtData.userId);
|
|
|
|
|
if (!user) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (dataArg.identity.role && dataArg.identity.role !== user.role) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return false;
|
|
|
|
@@ -256,4 +352,120 @@ export class AdminHandler {
|
|
|
|
|
name: 'adminIdentityGuard',
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
private async authenticateUser(optionsArg: {
|
|
|
|
|
username: string;
|
|
|
|
|
password: string;
|
|
|
|
|
authSource?: interfaces.requests.TAdminLoginAuthSource;
|
|
|
|
|
}): Promise<TAdminUser | null> {
|
|
|
|
|
if (await this.hasPersistentAdminAccount()) {
|
|
|
|
|
const store = this.getAccountStore();
|
|
|
|
|
const authService = new plugins.idpSdkServer.AccountAuthService({
|
|
|
|
|
store: store!,
|
|
|
|
|
idpClient: this.getIdpClient() as plugins.idpSdkServer.IdpGlobalServerClient | undefined,
|
|
|
|
|
});
|
|
|
|
|
const result = await authService.authenticate({
|
|
|
|
|
email: optionsArg.username,
|
|
|
|
|
password: optionsArg.password,
|
|
|
|
|
authSource: optionsArg.authSource || 'auto',
|
|
|
|
|
});
|
|
|
|
|
return result ? this.accountToUser(result.account) : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const [_, userData] of this.users) {
|
|
|
|
|
if (userData.username === optionsArg.username && userData.password === optionsArg.password) {
|
|
|
|
|
return userData;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async resolveUser(userIdArg: string): Promise<TAdminUser | null> {
|
|
|
|
|
if (await this.hasPersistentAdminAccount()) {
|
|
|
|
|
const account = await this.getAccountStore()!.getAccountById(userIdArg);
|
|
|
|
|
if (!account || account.status !== 'active') {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return this.accountToUser(account);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return this.users.get(userIdArg) || null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async hasPersistentAdminAccount(): Promise<boolean> {
|
|
|
|
|
const store = this.getAccountStore();
|
|
|
|
|
return store ? store.hasActiveAdminAccount() : false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getAccountStore(): plugins.idpSdkServer.SmartdataAccountStore | null {
|
|
|
|
|
if (this.opsServerRef.dcRouterRef.options.dbConfig?.enabled === false) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
const dcRouterDb = this.opsServerRef.dcRouterRef.dcRouterDb;
|
|
|
|
|
if (!dcRouterDb?.isReady()) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
if (!this.accountStore) {
|
|
|
|
|
this.accountStore = new plugins.idpSdkServer.SmartdataAccountStore({
|
|
|
|
|
smartdataDb: dcRouterDb.getDb(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return this.accountStore;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private getIdpClient(): Pick<plugins.idpSdkServer.IdpGlobalServerClient, 'loginWithEmailAndPassword' | 'stop'> | undefined {
|
|
|
|
|
const configuredClient = this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient;
|
|
|
|
|
if (configuredClient) {
|
|
|
|
|
return configuredClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const baseUrl = this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl || process.env.DCROUTER_IDP_GLOBAL_URL;
|
|
|
|
|
if (!baseUrl) {
|
|
|
|
|
return undefined;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.idpClient) {
|
|
|
|
|
this.idpClient = new plugins.idpSdkServer.IdpGlobalServerClient({ baseUrl });
|
|
|
|
|
this.ownsIdpClient = true;
|
|
|
|
|
}
|
|
|
|
|
return this.idpClient;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private isIdpGlobalConfigured(): boolean {
|
|
|
|
|
return !!(
|
|
|
|
|
this.opsServerRef.dcRouterRef.options.adminAuth?.idpClient ||
|
|
|
|
|
this.opsServerRef.dcRouterRef.options.adminAuth?.idpGlobalUrl ||
|
|
|
|
|
process.env.DCROUTER_IDP_GLOBAL_URL
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private accountToUser(accountArg: plugins.idpSdkServer.IIdpSdkAccount): TAdminUser {
|
|
|
|
|
return {
|
|
|
|
|
id: accountArg.id,
|
|
|
|
|
username: accountArg.email,
|
|
|
|
|
email: accountArg.email,
|
|
|
|
|
name: accountArg.name,
|
|
|
|
|
role: accountArg.role,
|
|
|
|
|
status: accountArg.status,
|
|
|
|
|
authSources: accountArg.authSources,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async createIdentityForUser(userArg: TAdminUser): Promise<interfaces.data.IIdentity> {
|
|
|
|
|
const expiresAtTimestamp = Date.now() + 3600 * 1000 * 24; // 24 hours
|
|
|
|
|
const jwt = await this.smartjwtInstance.createJWT({
|
|
|
|
|
userId: userArg.id,
|
|
|
|
|
status: 'loggedIn',
|
|
|
|
|
expiresAt: expiresAtTimestamp,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
jwt,
|
|
|
|
|
userId: userArg.id,
|
|
|
|
|
name: userArg.name || userArg.username,
|
|
|
|
|
expiresAt: expiresAtTimestamp,
|
|
|
|
|
role: userArg.role,
|
|
|
|
|
type: 'user',
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|