@intuitionrobotics/user-account
Version:
345 lines • 14.9 kB
JavaScript
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