// auth's user, profile, password and MFA event handlers, the request servers // its scripts compute, and the logins it runs over the same tables, as the // product does (auth v8.15.5, scripts/platform-preset/seeds/auth@8.15.5/ // scripts: auth-user-eventhandler.kts as UEH, auth-profile-eventhandler.kts // as PEH, auth-password-eventhandler.kts as PWD, auth-mfa-eventhandler.kts as // MFA, auth-reqrep.kts as RR). Each DTO's fields come from the product's // messages jar (global.genesis.auth.messages.event.*) and, for a user's own // columns, the generated UserInsertView and UserAmendView. The handlers write // through the event's audited transaction, so USER_AUDIT, // USER_ATTRIBUTES_AUDIT, PROFILE_AUDIT, PROFILE_USER_AUDIT and // PROFILE_RIGHT_AUDIT fill as GSF's do, and keep RIGHT_SUMMARY inside the // event as auth's UserProfileConsolidationService does. Not modelled, as // elsewhere in the preset: permission codes and the USER_VISIBILITY auth map; // nor password strength rules (the auth-preferences Create and the showcase // ship set none), nor MFA at login. import { createHash, randomUUID } from 'node:crypto'; import type { ServerResponse } from 'node:http'; import { primaryKeyIndexName } from '../db/schema.ts'; import type { Store } from '../db/store.ts'; import { checkUnique } from '../handlers/crudEvents.ts'; import { NackError, type NackErrorInput } from '../protocol/errors.ts'; import type { AuthDirectory, AuthUser, EventHandler, EventHandlerCtx, EventSchemaOverride, FieldDef, HttpRouteDef, LoginCtx, LoginFailure, RequestReplyDef, Row, ViewDef, } from '../types.ts'; import { BodyTooLarge, readBody } from './multipart.ts'; import { dto, ENUMS, nullable, stringSet } from './notify.ts'; const EVENT_CLASS = 'global.genesis.auth.messages.event'; const ENABLED = 'ENABLED'; const DISABLED = 'DISABLED'; const PASSWORD_EXPIRED = 'PASSWORD_EXPIRED'; // The statuses that ask for a new password (UserStatus): a login answers // PASSWORD_EXPIRED, and a password change re-enables the user // (PasswordService.performPasswordChange). const NEEDS_NEW_PASSWORD = new Set([PASSWORD_EXPIRED, 'PASSWORD_RESET']); // A password as auth keeps it (PasswordService.hashPassword, with the empty // salt of an auth-preferences without passwordValidation): never as given. const passwordHash = (user: string, password: string) => createHash('sha512').update(`${user}${password}`).digest('hex'); // The users' passwords, hashed, beside each server's store: USER.PASSWORD on // the real server, which the preset leaves out of USER, as a row's every // column can reach a client (fidelity: 'legacy' sends rows whole) and GSF // masks this one. An event changes them once it commits, so one that NACKs // keeps the old. interface Credentials { has(store: Store, user: string): boolean; matches(store: Store, user: string, password: string): boolean; set(ctx: EventHandlerCtx, user: string, password: string): void; remove(ctx: EventHandlerCtx, user: string): void; } function credentials(passwords: Map): Credentials { const byStore = new WeakMap>(); const of = (store: Store) => { let hashes = byStore.get(store); if (!hashes) { hashes = new Map( [...passwords].map(([user, password]) => [user, passwordHash(user, password)]), ); byStore.set(store, hashes); } return hashes; }; return { has: (store, user) => of(store).has(user), matches: (store, user, password) => of(store).get(user) === passwordHash(user, password), set: (ctx, user, password) => { const hash = passwordHash(user, password); ctx.afterCommit(() => of(ctx.store).set(user, hash)); }, remove: (ctx, user) => ctx.afterCommit(() => of(ctx.store).delete(user)), }; } // A script's StandardError("CODE", text): 500 whatever the code // (GenesisError.kt:22-26), which NackError.standard would map by the code. const scriptError = (CODE: string, TEXT: string): NackErrorInput => ({ '@type': 'StandardError', CODE, TEXT, STATUS_CODE: '500 Internal Server Error', }); const notAuthorised = (user: string) => new NackError(scriptError('NOT_AUTHORISED', `User ${user} lacks sufficient permissions`)); // --- DTOs ---------------------------------------------------------------------------- const userOperation: EventSchemaOverride = { className: `${EVENT_CLASS}.UserOperation`, fields: [dto('USER_NAME', 'STRING')], }; const profiles = (name: string, more: Partial = nullable) => dto(name, 'ARRAY', { ...stringSet, ...more }); // UserInsertView / UserAmendView (auth-view-dictionary.kts.json // USER_INSERT_VIEW, USER_AMEND_VIEW), in the generated view's order: USER's // columns, then USER_ATTRIBUTES' (ADDRESS_LINEn as ADDRESS_LINE_n, the wire // name a view property's camelCase gives). Every one but USER_NAME nullable // and defaulted to null. InsertUser and AmendUser unwrap the view // (@JsonUnwrapped), so its fields sit in DETAILS beside USER_PROFILES. const viewField = (name: string, type: FieldDef['type'] = 'STRING', more: Partial = {}) => dto(name, type, { ...nullable, ...more }); const ADDRESS_LINES = [ ['ADDRESS_LINE_1', 'ADDRESS_LINE1'], ['ADDRESS_LINE_2', 'ADDRESS_LINE2'], ['ADDRESS_LINE_3', 'ADDRESS_LINE3'], ['ADDRESS_LINE_4', 'ADDRESS_LINE4'], ]; const USER_VIEW_COLUMNS = [ 'LAST_NAME', 'FIRST_NAME', 'COMPANY_NAME', 'EMAIL_ADDRESS', 'COMPANY_ID', 'DOMAIN', ]; const ATTRIBUTE_VIEW_COLUMNS = [ 'CITY', 'REGION', 'POSTAL_CODE', 'COUNTRY', 'TITLE', 'WEBSITE', 'MOBILE_NUMBER', 'TELEPHONE_NUMBER_DIRECT', 'TELEPHONE_NUMBER_OFFICE', ]; const userViewFields = (insert: boolean): FieldDef[] => [ dto('USER_NAME', 'STRING', { maxSize: 255 }), ...(insert ? [viewField('ONE_TIME_PASSWORD', 'STRING', { maxSize: 255 })] : []), viewField('LAST_LOGIN', 'DATE'), ...USER_VIEW_COLUMNS.map((name) => viewField(name)), viewField('USER_TYPE', 'ENUM', { values: ['USER', 'ADVISOR', 'CLIENT', 'SYSTEM', 'API'], enumClass: `${ENUMS}.UserType`, }), viewField('ACCESS_TYPE', 'ENUM', { values: ['ALL', 'ENTITY', 'MULTI_ENTITY'], enumClass: `${ENUMS}.AccessType`, }), ...ADDRESS_LINES.map(([name]) => viewField(name, 'STRING', { maxSize: 400 })), ...ATTRIBUTE_VIEW_COLUMNS.map((name) => viewField(name)), ]; const profileFields: FieldDef[] = [ dto('NAME', 'STRING'), dto('DESCRIPTION', 'STRING', { nullable: true }), dto('STATUS', 'STRING', { nullable: true }), profiles('RIGHT_CODES'), profiles('USER_NAMES'), ]; const EVENT_SCHEMAS: Record = { EVENT_INSERT_USER: { className: `${EVENT_CLASS}.InsertUser`, fields: [profiles('USER_PROFILES'), ...userViewFields(true)], }, EVENT_AMEND_USER: { className: `${EVENT_CLASS}.AmendUser`, fields: [profiles('USER_PROFILES'), ...userViewFields(false)], }, EVENT_DELETE_USER: userOperation, EVENT_ENABLE_USER: userOperation, EVENT_DISABLE_USER: userOperation, EVENT_USER_PROFILE_AMEND: { className: `${EVENT_CLASS}.UserProfileAmend`, fields: [dto('USER_NAME', 'STRING'), profiles('USER_PROFILES', { optional: true })], }, EVENT_INSERT_PROFILE: { className: `${EVENT_CLASS}.InsertAmendProfile`, fields: profileFields }, EVENT_AMEND_PROFILE: { className: `${EVENT_CLASS}.InsertAmendProfile`, fields: profileFields }, EVENT_DELETE_PROFILE: { className: `${EVENT_CLASS}.DeleteProfile`, fields: [dto('NAME', 'STRING')], }, EVENT_CHANGE_USER_PASSWORD: { className: `${EVENT_CLASS}.ChangePassword`, fields: [ dto('OLD_PASSWORD', 'STRING'), dto('NEW_PASSWORD', 'STRING'), dto('USER_NAME', 'STRING', nullable), ], }, EVENT_EXPIRE_USER_PASSWORD: { className: `${EVENT_CLASS}.ExpirePassword`, fields: [dto('USER_NAME', 'STRING'), dto('PASSWORD', 'STRING', { nullable: true })], }, EVENT_MFA_CREATE_SECRET: { fields: [], className: 'kotlin.Unit' }, EVENT_MFA_CONFIRM_SECRET: { className: `${EVENT_CLASS}.ConfirmMfaSecret`, fields: [dto('MFA_CODE', 'STRING')], }, EVENT_ENABLE_MFA_FOR_USER: userOperation, EVENT_DISABLE_MFA_FOR_USER: userOperation, }; // --- writes -------------------------------------------------------------------------- // entityDb's writes: unique indexes checked (DUPLICATE_KEY), a missing row // RECORD_NOT_FOUND, each change pushed live. function insert(ctx: EventHandlerCtx, table: string, row: Row): Row { checkUnique(table, ctx.config.tables![table], row, ctx.store); const written = ctx.store.insertRow(table, row); ctx.broadcastTableChange(table, 'INSERT', ctx.store.rowKey(table, written)); return written; } function modify(ctx: EventHandlerCtx, table: string, key: string, patch: Row): Row { const def = ctx.config.tables![table]; const existing = ctx.store.getRow(table, key); if (!existing) throw NackError.recordNotFound(table, primaryKeyIndexName(table, def)); const written = ctx.store.updateRow(table, key, patch)!; ctx.broadcastTableChange(table, 'MODIFY', key); return written; } function remove(ctx: EventHandlerCtx, table: string, key: string): void { if (!ctx.store.deleteRow(table, key)) throw NackError.recordNotFound(table); ctx.broadcastTableChange(table, 'DELETE', key); } const removeRow = (ctx: EventHandlerCtx, table: string, row: Row) => remove(ctx, table, ctx.store.rowKey(table, row)); const rowsWhere = (store: Store, table: string, column: string, value: unknown) => store.getAllRows(table).filter((row) => row[column] === value); const sorted = (values: Iterable) => [...values].sort(); // `a - b`, as Kotlin's Set minus: a's order. const minus = (a: Iterable, b: Iterable) => { const without = new Set(b); return [...new Set(a)].filter((value) => !without.has(value)); }; // A user's profiles (PROFILE_USER.byUserNameProfileName) and rights // (RIGHT_SUMMARY), in index order. const profilesOf = (store: Store, user: string) => sorted(rowsWhere(store, 'PROFILE_USER', 'USER_NAME', user).map((row) => row.PROFILE_NAME)); const rightsOf = (store: Store, user: string) => sorted(rowsWhere(store, 'RIGHT_SUMMARY', 'USER_NAME', user).map((row) => row.RIGHT_CODE)); const usersOf = (store: Store, profile: string) => sorted(rowsWhere(store, 'PROFILE_USER', 'PROFILE_NAME', profile).map((row) => row.USER_NAME)); const profileRightsOf = (store: Store, profile: string) => sorted(rowsWhere(store, 'PROFILE_RIGHT', 'PROFILE_NAME', profile).map((row) => row.RIGHT_CODE)); export const hasRight = (store: Store, user: string, code: string) => rightsOf(store, user).includes(code); // The rights the preset gives a user whatever their profiles // (PlatformUser.permission), by user name. type FixedRights = ReadonlyMap; // UserProfileConsolidationService.consolidateRightsForUser: the user's // RIGHT_SUMMARY becomes their profiles' PROFILE_RIGHT codes (none without a // profile). Neither PROFILE.STATUS nor RIGHT is consulted. A user with fixed // rights keeps them for as long as they exist. function consolidate( ctx: EventHandlerCtx, user: string, profileNames: Iterable, fixed: FixedRights, ): void { const current = rightsOf(ctx.store, user); const own = ctx.store.getRow('USER', user) ? fixed.get(user) : undefined; const wanted = new Set( own ?? [...profileNames].flatMap((name) => profileRightsOf(ctx.store, name)), ); for (const code of minus(wanted, current)) { insert(ctx, 'RIGHT_SUMMARY', { USER_NAME: user, RIGHT_CODE: code }); } for (const code of minus(current, wanted)) { removeRow(ctx, 'RIGHT_SUMMARY', { USER_NAME: user, RIGHT_CODE: code }); } } // consolidateRightsForUsers: each user's own PROFILE_USER rows. function consolidateUsers(ctx: EventHandlerCtx, users: Iterable, fixed: FixedRights): void { for (const user of new Set(users)) consolidate(ctx, user, profilesOf(ctx.store, user), fixed); } const profileUser = (PROFILE_NAME: string, USER_NAME: string) => ({ PROFILE_NAME, USER_NAME }); const profileRight = (PROFILE_NAME: string, RIGHT_CODE: string) => ({ RIGHT_CODE, PROFILE_NAME }); // UEH :22-38 validateProfiles: one INVALID_PARAMETER per profile that isn't // there, all in one NACK. function checkProfiles(store: Store, names: string[]): void { const missing = names.filter((name) => !store.getRow('PROFILE', name)); if (missing.length > 0) { throw new NackError( missing.map((name) => scriptError('INVALID_PARAMETER', `Profile ${name} not found`)), ); } } // User.fromGenesisSet and UserAttributes.fromGenesisSet of the view's fields // (UEH :79-82, :190-193): every column the view has, null when DETAILS leaves // it out, and the entity's own defaults for the rest. entityDb.modify(entity) // writes the whole entity (the field-list modify ENABLE_USER uses is the one // that writes some), so an amend sets every one of them. const userEntity = (details: Row): Row => ({ USER_NAME: details.USER_NAME, LAST_LOGIN: details.LAST_LOGIN ?? null, ...Object.fromEntries(USER_VIEW_COLUMNS.map((name) => [name, details[name] ?? null])), PASSWORD_EXPIRY_DATETIME: null, }); const attributesEntity = (details: Row): Row => ({ USER_NAME: details.USER_NAME, USER_TYPE: details.USER_TYPE ?? 'USER', ACCESS_TYPE: details.ACCESS_TYPE ?? 'ALL', ...Object.fromEntries(ADDRESS_LINES.map(([wire, column]) => [column, details[wire] ?? null])), ...Object.fromEntries(ATTRIBUTE_VIEW_COLUMNS.map((name) => [name, details[name] ?? null])), }); // --- the events ---------------------------------------------------------------------- export interface AuthBackingOptions { // Who an event runs as: its connection's login, else the session user. userOf(ctx: EventHandlerCtx): string; // The users' passwords at the start, by user name. passwords: Map; // The rights some users keep whatever their profiles, by user name. fixedRights?: FixedRights; } // A base64 PNG for CREATE_MFA_SECRET_ACK's QR_IMAGE: one transparent pixel, // as the mock makes no TOTP secret to draw. const QR_IMAGE = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='; function userEvents(passwords: Credentials, fixed: FixedRights): Record { // UEH :254-308, ENABLE_USER and DISABLE_USER: STATUS alone. const setStatus = (status: string): EventHandler => (details, ctx) => { const user = ctx.store.getRow('USER', details.USER_NAME); if (!user || !ctx.store.getRow('USER_ATTRIBUTES', details.USER_NAME)) { throw NackError.nack('User does not exist'); } if (user.STATUS === status) { throw NackError.nack(`User ${user.USER_NAME} is already ${status}`); } if (ctx.validate) return {}; modify(ctx, 'USER', details.USER_NAME, { STATUS: status }); return {}; }; return { // UEH :73. A new user's STATUS is auth-preferences' userStatusOnCreation, // PASSWORD_EXPIRED by default: their first login asks for a new password. EVENT_INSERT_USER: (details, ctx) => { const name = details.USER_NAME as string; const userProfiles = details.USER_PROFILES as string[] | null | undefined; if (userProfiles != null) checkProfiles(ctx.store, userProfiles); if (details.ONE_TIME_PASSWORD == null) { throw new NackError( scriptError('INVALID_PASSWORD', 'A valid password is required to insert a new user'), ); } if (ctx.store.getRow('USER', name)) { throw new NackError(scriptError('DUPLICATE_KEY', `User ${name} already exists`)); } if (ctx.validate) return {}; insert(ctx, 'USER', { ...userEntity(details), ONLINE: false, STATUS: PASSWORD_EXPIRED }); insert(ctx, 'USER_ATTRIBUTES', attributesEntity(details)); // PasswordService.processHistory keeps no USER_PASSWORD_HISTORY rows // under the default reuse count (none): it inserts the password, then // deletes the user's every row. passwords.set(ctx, name, details.ONE_TIME_PASSWORD); if (userProfiles != null) { for (const profile of new Set(userProfiles)) { insert(ctx, 'PROFILE_USER', profileUser(profile, name)); } consolidate(ctx, name, userProfiles, fixed); } return {}; }, // UEH :161. STATUS and PASSWORD stay the stored ones (:196-197); a missing // USER_ATTRIBUTES row fails the modify. Profiles change only when // USER_PROFILES is sent. EVENT_AMEND_USER: (details, ctx) => { const name = details.USER_NAME as string; const userProfiles = details.USER_PROFILES as string[] | null | undefined; if (userProfiles != null) checkProfiles(ctx.store, userProfiles); const stored = ctx.store.getRow('USER', name); if (!stored) throw NackError.nack('User does not exist'); if (ctx.validate) return {}; // ONLINE as stored: the entity's false, which GSF's session listener // (UserSessionTableListener) puts back within ten seconds. modify(ctx, 'USER', name, { ...userEntity(details), ONLINE: stored.ONLINE ?? false, STATUS: stored.STATUS ?? null, }); modify(ctx, 'USER_ATTRIBUTES', name, attributesEntity(details)); if (userProfiles != null) { const current = profilesOf(ctx.store, name); for (const profile of minus(userProfiles, current)) { insert(ctx, 'PROFILE_USER', profileUser(profile, name)); } for (const profile of minus(current, userProfiles)) { removeRow(ctx, 'PROFILE_USER', profileUser(profile, name)); } consolidate(ctx, name, userProfiles, fixed); } return {}; }, // UEH :229: no onValidate, so VALIDATE acks whoever it names. EVENT_DELETE_USER: (details, ctx) => { const name = details.USER_NAME as string; if (ctx.validate) return {}; remove(ctx, 'USER', name); passwords.remove(ctx, name); remove(ctx, 'USER_ATTRIBUTES', name); for (const row of rowsWhere(ctx.store, 'PROFILE_USER', 'USER_NAME', name)) { removeRow(ctx, 'PROFILE_USER', row); } consolidate(ctx, name, [], fixed); return {}; }, EVENT_ENABLE_USER: setStatus(ENABLED), EVENT_DISABLE_USER: setStatus(DISABLED), // UEH :310. It doesn't check the user is there, and consolidates over the // profiles the user had plus the new ones (:337-341): a removed profile's // rights stay in RIGHT_SUMMARY until something consolidates the user // again. EVENT_USER_PROFILE_AMEND: (details, ctx) => { const name = details.USER_NAME as string; const wanted = (details.USER_PROFILES ?? []) as string[]; checkProfiles(ctx.store, wanted); if (ctx.validate) return {}; const current = profilesOf(ctx.store, name); const added = minus(wanted, current); for (const profile of added) insert(ctx, 'PROFILE_USER', profileUser(profile, name)); for (const profile of minus(current, wanted)) { removeRow(ctx, 'PROFILE_USER', profileUser(profile, name)); } consolidate(ctx, name, [...current, ...added], fixed); return {}; }, }; } function profileEvents(fixed: FixedRights): Record { // PROFILE as InsertAmendProfile builds it: STATUS "ACTIVE" unless sent // (the platform's seeded profiles say ENABLED), IS_PRIMARY its default. const profileEntity = (details: Row): Row => ({ NAME: details.NAME, DESCRIPTION: details.DESCRIPTION ?? null, STATUS: details.STATUS ?? 'ACTIVE', IS_PRIMARY: true, }); return { // PEH :10: no onValidate. Rights and user names aren't checked against // RIGHT and USER. EVENT_INSERT_PROFILE: (details, ctx) => { const name = details.NAME as string; if (ctx.validate) return {}; insert(ctx, 'PROFILE', profileEntity(details)); const rights = minus(details.RIGHT_CODES ?? [], profileRightsOf(ctx.store, name)); for (const code of rights) insert(ctx, 'PROFILE_RIGHT', profileRight(name, code)); const users = minus(details.USER_NAMES ?? [], usersOf(ctx.store, name)); for (const user of users) insert(ctx, 'PROFILE_USER', profileUser(name, user)); consolidateUsers(ctx, users, fixed); return {}; }, // PEH :74. A list left out empties the profile (:94, :101): an amend // sends both in full. EVENT_AMEND_PROFILE: (details, ctx) => { const name = details.NAME as string; if (!ctx.store.getRow('PROFILE', name)) { throw NackError.nack(`Profile ${name} does not exist`); } if (ctx.validate) return {}; const currentUsers = usersOf(ctx.store, name); const wantedUsers = (details.USER_NAMES ?? []) as string[]; const currentRights = profileRightsOf(ctx.store, name); const wantedRights = (details.RIGHT_CODES ?? []) as string[]; const [usersToAdd, usersToDelete] = [ minus(wantedUsers, currentUsers), minus(currentUsers, wantedUsers), ]; const [rightsToAdd, rightsToDelete] = [ minus(wantedRights, currentRights), minus(currentRights, wantedRights), ]; modify(ctx, 'PROFILE', name, profileEntity(details)); for (const user of usersToAdd) insert(ctx, 'PROFILE_USER', profileUser(name, user)); for (const code of rightsToAdd) insert(ctx, 'PROFILE_RIGHT', profileRight(name, code)); for (const user of usersToDelete) removeRow(ctx, 'PROFILE_USER', profileUser(name, user)); for (const code of rightsToDelete) { removeRow(ctx, 'PROFILE_RIGHT', profileRight(name, code)); } // :118-122: every user of the profile when its rights changed. const rightsChanged = rightsToAdd.length > 0 || rightsToDelete.length > 0; consolidateUsers( ctx, [...(rightsChanged ? currentUsers : []), ...usersToDelete, ...usersToAdd], fixed, ); return {}; }, // PEH :56. The users it consolidates are read off `profileUsers`, a cold // Flow (getRange) collected again after deleteAll removed its rows // (:63-68): none, so the profile's users keep its rights in RIGHT_SUMMARY // until something consolidates them again. EVENT_DELETE_PROFILE: (details, ctx) => { const name = details.NAME as string; if (ctx.validate) return {}; remove(ctx, 'PROFILE', name); for (const row of rowsWhere(ctx.store, 'PROFILE_USER', 'PROFILE_NAME', name)) { removeRow(ctx, 'PROFILE_USER', row); } for (const row of rowsWhere(ctx.store, 'PROFILE_RIGHT', 'PROFILE_NAME', name)) { removeRow(ctx, 'PROFILE_RIGHT', row); } return {}; }, }; } function passwordEvents( userOf: AuthBackingOptions['userOf'], passwords: Credentials, ): Record { // ScriptUtils.checkSelfAccess: one's own account, or the right to act on // anyone's. const checkSelfAccess = (store: Store, requester: string, target: string, right: string) => { if (requester !== target && !hasRight(store, requester, right)) { throw notAuthorised(requester); } }; return { // PWD :21: no permission codes, and the router lets it through logged // out too (MsgType.kt:51-53): the expired-password screen sends it before // its user can log in (see the change-password route). EVENT_CHANGE_USER_PASSWORD: (details, ctx) => { const requester = userOf(ctx); const target = (details.USER_NAME as string | null | undefined) ?? requester; checkSelfAccess(ctx.store, requester, target, 'CHANGE_PWD'); if (ctx.validate) return {}; // PasswordService.changeUserPassword: the target's old password, even // for an admin (a user without one, and a blank old one, pass); no // strength rules; no history to check under the default reuse count. const user = ctx.store.getRow('USER', target); const old = details.OLD_PASSWORD as string; const oldIsValid = passwords.has(ctx.store, target) ? passwords.matches(ctx.store, target, old) : old.trim() === ''; if (!user || !oldIsValid) throw new NackError(INCORRECT_CREDENTIALS); modify(ctx, 'USER', target, NEEDS_NEW_PASSWORD.has(user.STATUS) ? { STATUS: ENABLED } : {}); passwords.set(ctx, target, details.NEW_PASSWORD); // :48-52, as part of the self-service flow. const attempt = { USER_NAME: target, LOGIN_ATTEMPTS: 0, LOGIN_LAST_ATTEMPT_TIME: Date.now() }; if (ctx.store.getRow('USER_LOGIN_ATTEMPT', target)) { modify(ctx, 'USER_LOGIN_ATTEMPT', target, attempt); } else { insert(ctx, 'USER_LOGIN_ATTEMPT', attempt); } return {}; }, // PWD :63, PasswordService.expireUserPassword: a new password when one's // given, and PASSWORD_EXPIRED either way. EVENT_EXPIRE_USER_PASSWORD: (details, ctx) => { const target = details.USER_NAME as string; checkSelfAccess(ctx.store, userOf(ctx), target, 'EXPIRE_PWD'); if (ctx.validate) return {}; if (!ctx.store.getRow('USER', target)) throw new Error(`Failed to find user ${target}`); const password = details.PASSWORD as string | null | undefined; if (password) passwords.set(ctx, target, password); modify(ctx, 'USER', target, { STATUS: PASSWORD_EXPIRED }); return {}; }, }; } // PasswordService.genericPasswordValidationFailure, which the script NACKs // with as it is: a missing user and a wrong old password alike. const INCORRECT_CREDENTIALS: NackErrorInput = { '@type': 'PasswordValidationError', CODE: 'INCORRECT_CREDENTIALS', TEXT: 'Incorrect credentials', STATUS_CODE: '401 Unauthorized', }; // MFA: a secret waits in memory for its confirmation (MfaSecretService), and // MFA_SECRET isn't kept, as no login here asks for a code. function mfaEvents(userOf: AuthBackingOptions['userOf']): Record { // The users whose secret waits for a confirmation, per server. const waiting = new WeakMap>(); const pendingOf = (store: Store) => { let users = waiting.get(store); if (!users) { users = new Set(); waiting.set(store, users); } return users; }; return { // MFA :16, eventHandler: replies of its own. EVENT_MFA_CREATE_SECRET: (_details, ctx) => { const user = userOf(ctx); if (!ctx.store.getRow('USER', user) || !ctx.store.getRow('USER_ATTRIBUTES', user)) { throw new NackError(scriptError('UNKNOWN_ACCOUNT', 'User does not exist'), { messageType: 'CREATE_MFA_SECRET_NACK', }); } if (ctx.validate) return { reply: { MESSAGE_TYPE: 'CREATE_MFA_SECRET_VALIDATE_ACK' } }; ctx.afterCommit(() => pendingOf(ctx.store).add(user)); return { reply: { MESSAGE_TYPE: 'CREATE_MFA_SECRET_ACK', QR_IMAGE } }; }, // MFA :35: any code confirms the user's waiting secret. EVENT_MFA_CONFIRM_SECRET: (_details, ctx) => { if (ctx.validate) return {}; const user = userOf(ctx); if (!pendingOf(ctx.store).has(user)) { throw NackError.nack('Secret is Unavailable, probably User took too long to Confirm'); } ctx.afterCommit(() => pendingOf(ctx.store).delete(user)); return {}; }, // MFA :48, :64: on the real server they need MFA_ENABLE and MFA_DISABLE, // rights no platform profile holds. EVENT_ENABLE_MFA_FOR_USER: () => ({}), EVENT_DISABLE_MFA_FOR_USER: () => ({}), }; } // --- request servers ----------------------------------------------------------------- // RR :173 requestReply("USER_PROFILES", USER_PROFILE_DETAILS_VIEW) { request // { USER_NAME } reply { PROFILE_NAME withAlias "NAME", DESCRIPTION } }: in // PROFILE_USER's key order, so by profile. const USER_PROFILE_NAMES: ViewDef = { base: 'PROFILE_USER', joins: [ { table: 'USER', on: ['USER_NAME', 'USER_NAME'], type: 'INNER', backwardsJoin: true }, { table: 'PROFILE', on: ['PROFILE_NAME', 'NAME'], type: 'INNER', backwardsJoin: true, select: { DESCRIPTION: 'DESCRIPTION' }, }, ], select: { NAME: 'PROFILE_NAME' }, fields: ['USER_NAME', 'NAME', 'DESCRIPTION'], }; // RR :137 requestReply("USER_ATTRIBUTES_AUDIT", USER_AUDIT_VIEW): its reply // block names USER_ATTRIBUTES_AUDIT's columns, which the view doesn't have // under those names, and a real server sends the view's whole row instead // (captured META: REPLY_FIELD is USER_AUDIT_VIEW's fields, then RECORD_ID and // TIMESTAMP). The view's USER_ATTRIBUTES_AUDIT join (USER_AUDIT_ID = // USER_ATTRIBUTES_AUDIT_ID, ids of two sequences) never matches, so the // attributes' columns are null. function requestReplies(): Record { return { USER_PROFILES: { source: 'USER_PROFILE_NAMES', requestFields: ['USER_NAME'], fields: ['NAME', 'DESCRIPTION'], }, USER_ATTRIBUTES_AUDIT: { source: 'USER_AUDIT_VIEW', requestFields: ['USER_AUDIT_ID'] }, }; } // --- logins -------------------------------------------------------------------------- // The login ack's USER_DETAILS source (AuthUser.user). const LOGIN_USER_FIELDS = ['FIRST_NAME', 'LAST_NAME', 'EMAIL_ADDRESS', 'COMPANY_NAME']; // auth's retry defaults (RetryOptionsDefinition): three failed attempts lock // the name they named out for five minutes, a right password included. const MAX_ATTEMPTS = 3; const LOCKOUT_MS = 300_000; // USER.LAST_LOGIN, a DATE: today. const today = () => { const now = new Date(); return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); }; // The writes of one login or logout, which runs in no event: a transaction // of its own, audited as the message (USER's ONLINE and LAST_LOGIN add // USER_AUDIT rows), kept whatever the login answers, as auth returns its // failures rather than throwing them, and pushed live once committed. interface LoginWrites { put(table: string, row: Row): void; // A USER_LOGIN_AUDIT row (LoginAuditController.processAuthAuditUpdate). audit(user: string, action: string, reason: string): void; } function inLoginTransaction( ctx: LoginCtx, eventType: string, auditUser: string, write: (writes: LoginWrites) => T, ): T { const { store } = ctx; const changes: Array<[string, 'INSERT' | 'MODIFY', string]> = []; const put = (table: string, row: Row) => { const key = store.rowKey(table, row); if (store.getRow(table, key)) { store.updateRow(table, key, row); changes.push([table, 'MODIFY', key]); } else { changes.push([table, 'INSERT', store.rowKey(table, store.insertRow(table, row))]); } }; const writes: LoginWrites = { put, audit: (user, action, reason) => put('USER_LOGIN_AUDIT', { USER_NAME: user, AUTH_ACTION: action, IP_ADDRESS: ctx.hostname, REASON: reason, }), }; store.begin({ eventType, user: auditUser, text: null }); let result: T; try { result = write(writes); } catch (error) { store.rollback(); throw error; } const audits = store.takeAuditWrites(); store.commit(); // Committed whatever a push does: one that fails is logged, and the login // still answers. const pushes: Array<[string, 'INSERT' | 'MODIFY', string]> = [ ...changes, ...audits.map(({ tableName, key }): [string, 'INSERT', string] => [tableName, 'INSERT', key]), ]; for (const [table, operation, key] of pushes) { try { ctx.broadcastTableChange(table, operation, key); } catch (error) { console.error('[mock-server] Error pushing a login change:', error); } } return result; } // Logins over the USER, PROFILE_USER and RIGHT_SUMMARY tables, as auth's // authenticators and LoginProcessor run them (auth v8.15.5's bytecode): the // checks in GenesisDbAuthenticator's order, the lockout // LoginAttemptCounterService keeps in USER_LOGIN_ATTEMPT, a USER_LOGIN_AUDIT // row for each login, failed login and logout, and USER's ONLINE and // LAST_LOGIN. The ack's PROFILE and PERMISSION are the user's rows now. Not // modelled: USER_SESSION and USER_LOGIN (no resource reads them; the // sessions are the engine's), PASSWORD_EXPIRY_DATETIME (nothing here sets // one), the expiry sweep that clears ONLINE. function authDirectory(passwords: Credentials): AuthDirectory { function user(username: string, store: Store): AuthUser | undefined { const row = store.getRow('USER', username); if (!row) return undefined; return { username, status: String(row.STATUS ?? ''), profile: profilesOf(store, username), permission: rightsOf(store, username), user: Object.fromEntries(LOGIN_USER_FIELDS.map((name) => [name, row[name] ?? null])), }; } // GenesisDbAuthenticator: an unknown or DISABLED user fails as a wrong // password does, and a status that asks for a new password answers so, // both before the password is looked at; a right password with any other // status but ENABLED (null too) is LOCKED_ACCOUNT. function authenticate(store: Store, username: string, password: string) { const row = store.getRow('USER', username); if (!row || row.STATUS === DISABLED) return 'INCORRECT_CREDENTIALS'; if (NEEDS_NEW_PASSWORD.has(row.STATUS)) return 'PASSWORD_EXPIRED'; if (!passwords.matches(store, username, password)) return 'INCORRECT_CREDENTIALS'; return row.STATUS === ENABLED ? undefined : 'LOCKED_ACCOUNT'; } // AuthenticationService.login after the authenticator: the attempt // counter for the name the login named (known or not), and what // LoginProcessor writes. A locked-out name fails MAX_TRIES_REACHED whatever // the answer, recorded under its name and under UNKNOWN (the AuthException // that carries it names no one), without counting. function settle( writes: LoginWrites, store: Store, username: string, failure: LoginFailure | undefined, ): AuthUser | LoginFailure { const now = Date.now(); const counter = store.getRow('USER_LOGIN_ATTEMPT', username); let attempts = (counter?.LOGIN_ATTEMPTS as number | undefined) ?? 0; const lastAttempt = (counter?.LOGIN_LAST_ATTEMPT_TIME as number | undefined) ?? now; if (attempts >= MAX_ATTEMPTS) { if (now - lastAttempt <= LOCKOUT_MS) { writes.audit(username, 'FAILED_LOGIN', 'MAX_TRIES_REACHED'); writes.audit('UNKNOWN', 'FAILED_LOGIN', 'MAX_TRIES_REACHED'); return 'MAX_TRIES_REACHED'; } attempts = 0; } if (failure) { attempts += 1; writes.put('USER_LOGIN_ATTEMPT', { USER_NAME: username, LOGIN_ATTEMPTS: attempts, LOGIN_LAST_ATTEMPT_TIME: attempts >= MAX_ATTEMPTS ? now : lastAttempt, }); writes.audit(username, 'FAILED_LOGIN', failure); return failure; } writes.put('USER_LOGIN_ATTEMPT', { USER_NAME: username, LOGIN_ATTEMPTS: 0, LOGIN_LAST_ATTEMPT_TIME: now, }); // SessionManager.updateUserStatus and LoginProcessor: always a write, // as LAST_LOGIN is now. writes.put('USER', { USER_NAME: username, ONLINE: true, LAST_LOGIN: today() }); writes.audit(username, 'LOGIN', ''); return user(username, store)!; } // Who what a login writes is audited as: the message's own USER_NAME. const loginUser = (ctx: LoginCtx) => ctx.messageUser ?? 'UNKNOWN'; return { user, login: (username, password, ctx) => inLoginTransaction(ctx, 'EVENT_LOGIN_AUTH', loginUser(ctx), (writes) => { // No PASSWORD: no authenticator takes it, and no name is counted. if (password === undefined) { writes.audit('UNKNOWN', 'FAILED_LOGIN', 'NO_COMPATIBLE_PROVIDERS'); return 'NO_COMPATIBLE_PROVIDERS'; } const failure = authenticate(ctx.store, username, password); return settle(writes, ctx.store, username, failure); }), // RefreshTokenAuthenticator: the session's user re-read, and refused by // their status; a refresh logs the old session out ("Session refreshed") // before it logs in as a login does. refresh: (username, ctx) => inLoginTransaction(ctx, 'EVENT_LOGIN_AUTH', loginUser(ctx), (writes) => { if (username === undefined) { writes.audit('UNKNOWN', 'FAILED_LOGIN', 'UNKNOWN_ACCOUNT'); return 'UNKNOWN_ACCOUNT'; } const row = ctx.store.getRow('USER', username); let failure: LoginFailure | undefined; if (!row) failure = 'UNKNOWN_ACCOUNT'; else if (NEEDS_NEW_PASSWORD.has(row.STATUS)) failure = 'PASSWORD_EXPIRED'; else if (row.STATUS !== ENABLED) failure = 'LOCKED_ACCOUNT'; else writes.audit(username, 'LOGOUT', 'Session refreshed'); return settle(writes, ctx.store, username, failure); }), // The logout handler (auth-login-eventhandler.kts:50-80): ONLINE as the // user's other sessions say, written only when it changes. logout: (username, online, ctx) => inLoginTransaction(ctx, 'EVENT_LOGOUT', username, (writes) => { const row = ctx.store.getRow('USER', username); if (row && row.ONLINE !== online) { writes.put('USER', { USER_NAME: username, ONLINE: online }); } writes.audit(username, 'LOGOUT', ''); }), }; } const HTTP_OK = 200; const HTTP_BAD_REQUEST = 400; const HTTP_TOO_LARGE = 413; // HttpObjectAggregatorDefinition.kt:4 @v8.15.29: the most a router endpoint // reads of a body. const MAX_CONTENT_LENGTH = 262_144; // The router's refusal of a body it can't make a message of // (HttpHandler.kt:184-188 @v8.15.29). function undecodable(res: ServerResponse, sourceRef: string): void { const text = 'Unable to deserialize message payload'; const STATUS_CODE = '400 Bad Request'; res.writeHead(HTTP_BAD_REQUEST, { 'Content-Type': 'application/json' }); res.end( JSON.stringify({ MESSAGE_TYPE: 'MSG_NACK', SOURCE_REF: sourceRef, ERROR: [{ CODE: HTTP_BAD_REQUEST, TEXT: text, STATUS_CODE }], DETAILS: { ERROR: text, STATUS_CODE }, }), ); } // POST gwf/event-change-user-password: foundation-auth's change of password // (passwordManagementMapper.ts), which the expired-password screen sends // before its user can log in. GSF's router takes it logged in or out // (ChangeUserPassword is MUST_BE_LOGGED_IN_OR_OUT, MsgType.kt:51-53): the // session's user runs it, else the message's USER_NAME, else no one (whose // rights reach no one else's password), with the SOURCE_REF the router mints // for a request without one. The answer is the event's reply, with HTTP 200 // for a NACK too, as a router without strictHttpStatusCode sends (Utils.kt // getHttpResponseStatus). A body over the router's aggregator limit is its // empty 413, and one that isn't a JSON object its 400. export const changePasswordRoute: HttpRouteDef = { method: 'POST', path: /\/event-change-user-password\/?$/, handler: async (req, res, ctx) => { const sourceRef = `AUTO_${randomUUID()}`; let message: unknown; try { const raw = (await readBody(req, MAX_CONTENT_LENGTH)).toString('utf8'); message = raw ? JSON.parse(raw) : {}; } catch (error) { if (error instanceof BodyTooLarge) { res.writeHead(HTTP_TOO_LARGE, { 'Content-Length': 0, Connection: 'close' }); res.end(); return; } message = undefined; } if (typeof message !== 'object' || message === null || Array.isArray(message)) { undecodable(res, sourceRef); return; } const request = message as Row; const named = typeof request.USER_NAME === 'string' ? request.USER_NAME : ''; const reply = ctx.commitEvent( { ...request, SOURCE_REF: request.SOURCE_REF ?? sourceRef, MESSAGE_TYPE: 'EVENT_CHANGE_USER_PASSWORD', }, ctx.userName ?? named, ); res.writeHead(HTTP_OK, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(reply)); }, }; /** * auth's events, request servers and views, for the platform preset to merge. */ export function authBacking({ userOf, passwords, fixedRights = new Map() }: AuthBackingOptions) { const kept = credentials(passwords); return { eventHandlers: { ...userEvents(kept, fixedRights), ...profileEvents(fixedRights), ...passwordEvents(userOf, kept), ...mfaEvents(userOf), }, eventSchemas: EVENT_SCHEMAS, requestReplies: requestReplies(), views: { USER_PROFILE_NAMES }, directory: authDirectory(kept), }; }