UNPKG

@intuitionrobotics/user-account

Version:
345 lines 14.9 kB
import { __stringify, auditBy, currentTimeMillies, Day, Dispatcher, generateHex, hashPasswordWithSalt, Minute, Module, validate, validateEmail, batchActionParallel } from "@intuitionrobotics/ts-common"; import { FirebaseModule, FirestoreCollection, FirestoreTransaction } from "@intuitionrobotics/firebase/backend"; import { FrontType, HeaderKey_SessionId, QueryParam_Email, QueryParam_JWT, QueryParam_RedirectUrl, QueryParam_SessionId } from "./_imports.js"; import { ApiException, ApiResponse, HeaderKey } from "@intuitionrobotics/thunderstorm/backend"; import { SecretsModule } from "./SecretsModule.js"; import { SamlModule } from "./SamlModule.js"; import { HeaderKey_JWT } from "@intuitionrobotics/thunderstorm"; export const Header_SessionId = new HeaderKey(HeaderKey_SessionId, 404); export const Collection_Sessions = "user-account--sessions"; export const Collection_Accounts = "user-account--accounts"; const dispatch_onUserLogin = new Dispatcher("__onUserLogin"); const dispatch_onNewUserRegistered = new Dispatcher("__onNewUserRegistered"); function getUIAccount(account) { const { email, _id, createdTimestamp } = account; return { email, _id, createdTimestamp }; } export class AccountsModule_Class extends Module { constructor() { super("AccountsModule"); this.setDefaultConfig({ sessionTTLms: { web: Day, app: Day, jwt: 30 * Minute }, jwtSecretKey: "TS_AUTH_SECRET" }); } async __queryRequestInfo(request) { let data; try { data = await this.validateSession(request, []); } catch (_e) { } return { key: this.getName(), data: data }; } // Collections resolve lazily on first use (admin session is created/cached then). _sessions; _accounts; get sessions() { return this._sessions ??= FirebaseModule.createAdminSession(this.config.projectId).getFirestore() .getCollection(Collection_Sessions, ["userId"]); } get accounts() { return this._accounts ??= FirebaseModule.createAdminSession(this.config.projectId).getFirestore() .getCollection(Collection_Accounts, ["email"]); } async getUser(_email) { const email = _email.toLowerCase(); return this.accounts.queryUnique({ where: { email }, select: ["email", "_id"] }); } async getUsers(_emails) { return batchActionParallel(_emails, 10, async (batchedEmails) => { return this.accounts.query({ where: { email: { $in: batchedEmails.map(e => e.toLowerCase()) } }, select: ["email", "_id"] }); }); } async listUsers() { return this.accounts.getAll(["_id", "email"]); } async listSessions() { return this.sessions.getAll(["userId", "timestamp"]); } async getSession(_email) { const email = _email.toLowerCase(); return this.accounts.queryUnique({ where: { email } }); } async querySessions(_email) { const account = await this.getSession(_email); if (!account) return; const sessions = await this.sessions.query({ select: ["userId", "timestamp", "version", "frontType"], where: { userId: account._id } }); return sessions.map((session) => { return { ...session, isExpired: this.TTLExpired(session) }; }); } async create(request, response) { const account = await this.createAccount(request); const session = await this.login(request, response); await dispatch_onNewUserRegistered.dispatchModuleAsync(getUIAccount(account)); return session; } async upsert(request) { let callback = () => Promise.resolve([]); const account = await this.accounts.runInTransaction(async (transaction) => { const existAccount = await transaction.queryUnique(this.accounts, { where: { email: request.email } }); if (existAccount) return this.changePassword(request.email, request.password, transaction); callback = async () => dispatch_onNewUserRegistered.dispatchModuleAsync(getUIAccount(account)); return this.createImpl(request, transaction); }); await this.loginValidate(request); await callback(); return getUIAccount(account); } async addNewAccount(email, password, password_check) { let account; if (password && password_check) { account = await this.createAccount({ password, password_check, email }); await dispatch_onNewUserRegistered.dispatchModuleAsync(getUIAccount(account)); } else account = await this.createSAML(email); return getUIAccount(account); } async changePassword(userEmail, newPassword, _transaction) { const email = userEmail.toLowerCase(); const processor = async (transaction) => { const account = await transaction.queryUnique(this.accounts, { where: { email } }); if (!account) throw new ApiException(422, "User with email does not exist"); if (!account.saltedPassword || !account.salt) throw new ApiException(401, "Account login using SAML"); account.saltedPassword = await hashPasswordWithSalt(account.salt, newPassword); account._audit = auditBy(email, 'Changed password'); return transaction.upsert(this.accounts, account); }; if (_transaction) return processor(_transaction); return this.accounts.runInTransaction(processor); } async createAccount(request) { request.email = request.email.toLowerCase(); validate(request.email, validateEmail); return this.accounts.runInTransaction(async (transaction) => { const account = await transaction.queryUnique(this.accounts, { where: { email: request.email } }); if (account) throw new ApiException(422, "User with email already exists"); return this.createImpl(request, transaction); }); } async createImpl(request, transaction) { const salt = generateHex(32); const account = { _id: generateHex(32), _audit: auditBy(request.email), createdTimestamp: currentTimeMillies(), email: request.email, salt, saltedPassword: await hashPasswordWithSalt(salt, request.password) }; return transaction.insert(this.accounts, account); } async logout(sessionId) { const query = { where: { sessionId } }; await this.sessions.deleteUnique(query); } async logoutAccount(accountId) { await this.sessions.delete({ where: { userId: accountId } }); } async login(request, response) { return this.loginValidate(request, response); } async loginValidate(request, response) { request.email = request.email.toLowerCase(); const query = { where: { email: request.email } }; const account = await this.accounts.queryUnique(query); if (!account) throw new ApiException(401, "account does not exists"); if (!account.saltedPassword || !account.salt) throw new ApiException(401, "Account login using SAML"); if (account.saltedPassword !== await hashPasswordWithSalt(account.salt, request.password)) throw new ApiException(401, "wrong username or password"); if (!account._id) { account._id = generateHex(32); await this.accounts.upsert(account); } let sessionWithAccountId; if (response) { sessionWithAccountId = await this.upsertSession(account, request.frontType); this.setJWTinResp(response, sessionWithAccountId.jwt); } await dispatch_onUserLogin.dispatchModuleAsync(getUIAccount(account)); return sessionWithAccountId; } async loginSAML(__email) { const _email = __email.toLowerCase(); const account = await this.createSAML(_email); const sessionWithAccountId = await this.upsertSession(account); await dispatch_onUserLogin.dispatchModuleAsync(getUIAccount(account)); return sessionWithAccountId; } async createSAML(__email) { const _email = __email.toLowerCase(); const query = { where: { email: _email } }; let dispatchEvent = false; const toRet = await this.accounts.runInTransaction(async (transaction) => { const account = await transaction.queryUnique(this.accounts, query); if (account?._id) return account; const _account = { _id: generateHex(32), _audit: auditBy(_email), createdTimestamp: currentTimeMillies(), email: _email, ...account }; dispatchEvent = true; return transaction.upsert(this.accounts, _account); }); if (dispatchEvent) await dispatch_onNewUserRegistered.dispatchModuleAsync(getUIAccount(toRet)); return toRet; } isAuthRequest = (request) => request.header(SecretsModule.AUTHENTICATION_KEY) !== undefined; verifyAccount(account) { if (!account) throw new ApiException(401, 'Missing account in token payload'); const email = account['email']; if (!email || typeof email !== 'string') throw new ApiException(401, 'Missing email in token payload'); const _id = account['_id']; if (!_id || typeof _id !== 'string') throw new ApiException(401, 'Missing _id in token payload'); return { _id, email }; } async validateAuthenticationHeader(request, scopes, response) { const token = SecretsModule.validateRequest(request, scopes); const payload = token.payload; const isExpired = SecretsModule.isExpired(token); const sessionId = payload.sessionId; if (!sessionId) throw new ApiException(401, `Missing session id in token ${JSON.stringify(payload)}`); if (!isExpired) { const account = payload.account; this.verifyAccount(account); return account; } const dbAccount = await this.validateSessionId(sessionId); if (response) { const jwt = this.generateJWT(dbAccount, sessionId); this.setJWTinResp(response, jwt); } return dbAccount; } setJWTinResp(response, jwt) { // Set in header response response.setHeaders({ [HeaderKey_JWT]: jwt }); } generateJWT(account, sessionId) { return SecretsModule.generateJwt({ account, sessionId, exp: currentTimeMillies() + this.config.sessionTTLms.jwt }, this.config.jwtSecretKey); } validateSession = async (request, scopes, response) => { if (this.isAuthRequest(request)) return this.validateAuthenticationHeader(request, scopes, response); return await this.validateSessionId(Header_SessionId.get(request)); }; async validateSessionId(sessionId) { const query = { where: { sessionId } }; const session = await this.sessions.queryUnique(query); if (!session) throw new ApiException(401, `Invalid session id: ${sessionId}`); if (this.TTLExpired(session)) throw new ApiException(401, "Session timed out"); return await this.getUserEmailFromSession(session); } async getUserEmailFromSession(session) { const account = await this.accounts.queryUnique({ where: { _id: session.userId } }); if (!account) { await this.sessions.deleteItem(session); throw new ApiException(403, `No user found for session: ${__stringify(session)}`); } return getUIAccount(account); } async getUserEmailFromUserId(userId) { const account = await this.accounts.queryUnique({ where: { _id: userId } }); if (!account) throw new ApiException(403, `No user found for session: ${userId}`); return getUIAccount(account); } TTLExpired = (session) => { const delta = currentTimeMillies() - session.timestamp; const sessionTTLms = this.config.sessionTTLms.web; if (session.frontType === FrontType.App) return false; return delta > sessionTTLms || delta < 0; }; async getAccountFromParams(p) { if (typeof p === "string") return this.getUserEmailFromUserId(p); return getUIAccount(p); } async upsertSession(p, frontType) { const account = await this.getAccountFromParams(p); const session = await this.getSessionFromAccount(account, frontType); const sessionId = session.sessionId; return { sessionId, jwt: this.generateJWT(account, sessionId), email: account.email, _id: account._id, createdTimestamp: account.createdTimestamp }; } ; async getSessionFromAccount(account, frontType) { const session = await this.sessions.queryUnique({ where: { userId: account._id } }); if (session && !this.TTLExpired(session)) return session; const _session = { sessionId: generateHex(64), timestamp: currentTimeMillies(), userId: account._id, }; if (frontType) _session.frontType = frontType; return this.sessions.upsert(_session); } async assertApi(body, response) { const options = { request_body: body }; try { const data = await SamlModule.assert(options); this.logDebug(`Got data from assertion ${__stringify(data)}`); const email = data.userId; const loginData = await AccountModule.loginSAML(email); let redirectUrl = data.loginContext[QueryParam_RedirectUrl]; redirectUrl = redirectUrl.replace(new RegExp(QueryParam_SessionId.toUpperCase(), "g"), loginData.sessionId); redirectUrl = redirectUrl.replace(new RegExp(QueryParam_Email.toUpperCase(), "g"), email); redirectUrl = redirectUrl.replace(new RegExp(QueryParam_JWT.toUpperCase(), "g"), loginData.jwt); return await response.redirect(302, redirectUrl); } catch (error) { throw new ApiException(401, 'Error authenticating user', error); } } } export const AccountModule = new AccountsModule_Class(); //# sourceMappingURL=AccountModule.js.map