unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
271 lines • 9.2 kB
JavaScript
import User from '../../types/user.js';
import NotFoundError from '../../error/notfound-error.js';
const TABLE = 'users';
const PASSWORD_HASH_TABLE = 'used_passwords';
const USER_COLUMNS_PUBLIC = [
'id',
'name',
'username',
'email',
'image_url',
'seen_at',
'is_service',
'scim_id',
];
const USER_COLUMNS = [...USER_COLUMNS_PUBLIC, 'login_attempts', 'created_at'];
const emptify = (value) => {
if (!value) {
return undefined;
}
return value;
};
const safeToLower = (s) => (s ? s.toLowerCase() : s);
const mapUserToColumns = (user) => ({
name: user.name,
username: user.username,
email: safeToLower(user.email),
image_url: user.imageUrl,
});
const rowToUser = (row) => {
if (!row) {
throw new NotFoundError('No user found');
}
return new User({
id: row.id,
name: emptify(row.name),
username: emptify(row.username),
email: emptify(row.email),
imageUrl: emptify(row.image_url),
loginAttempts: row.login_attempts,
seenAt: row.seen_at,
createdAt: row.created_at,
isService: row.is_service,
scimId: row.scim_id,
});
};
export class UserStore {
constructor(db, getLogger) {
this.db = db;
this.logger = getLogger('user-store.ts');
}
async getPasswordsPreviouslyUsed(userId) {
const previouslyUsedPasswords = await this.db(PASSWORD_HASH_TABLE)
.select('password_hash')
.where({ user_id: userId });
return previouslyUsedPasswords.map((row) => row.password_hash);
}
async deletePasswordsUsedMoreThanNTimesAgo(userId, keepLastN) {
await this.db.raw(`
WITH UserPasswords AS (
SELECT user_id, password_hash, used_at, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY used_at DESC) AS rn
FROM ${PASSWORD_HASH_TABLE}
WHERE user_id = ?)
DELETE FROM ${PASSWORD_HASH_TABLE} WHERE user_id = ? AND (user_id, password_hash, used_at) NOT IN (SELECT user_id, password_hash, used_at FROM UserPasswords WHERE rn <= ?
);
`, [userId, userId, keepLastN]);
}
async update(id, fields) {
await this.activeUsers()
.where('id', id)
.update(mapUserToColumns(fields));
return this.get(id);
}
async insert(user) {
const emailHash = user.email
? this.db.raw(`encode(sha256(?::bytea), 'hex')`, [user.email])
: null;
const rows = await this.db(TABLE)
.insert({
...mapUserToColumns(user),
email_hash: emailHash,
created_at: new Date(),
})
.returning(USER_COLUMNS);
return rowToUser(rows[0]);
}
async upsert(user) {
const id = await this.hasUser(user);
if (id) {
return this.update(id, user);
}
return this.insert(user);
}
buildSelectUser(q) {
const query = this.activeAll();
if (q.id) {
return query.where('id', q.id);
}
if (q.email) {
return query.where('email', safeToLower(q.email));
}
if (q.username) {
return query.where('username', q.username);
}
throw new Error('Can only find users with id, username or email.');
}
activeAll() {
return this.db(TABLE).where({
deleted_at: null,
});
}
activeUsers() {
return this.db(TABLE).where({
deleted_at: null,
is_service: false,
is_system: false,
});
}
async hasUser(idQuery) {
const query = this.buildSelectUser(idQuery);
const item = await query.first('id');
return item ? item.id : undefined;
}
async getAll(params) {
const usersQuery = this.activeUsers().select(USER_COLUMNS);
if (params) {
if (params.sortBy) {
usersQuery.orderBy(params.sortBy, params.sortOrder);
}
if (params.limit) {
usersQuery.limit(params.limit);
}
if (params.offset) {
usersQuery.offset(params.offset);
}
}
return (await usersQuery).map(rowToUser);
}
async search(query) {
const users = await this.activeUsers()
.select(USER_COLUMNS_PUBLIC)
.where('name', 'ILIKE', `%${query}%`)
.orWhere('username', 'ILIKE', `${query}%`)
.orWhere('email', 'ILIKE', `${query}%`);
return users.map(rowToUser);
}
async getAllWithId(userIdList) {
const users = await this.activeUsers()
.select(USER_COLUMNS_PUBLIC)
.whereIn('id', userIdList);
return users.map(rowToUser);
}
async getByQuery(idQuery) {
const row = await this.buildSelectUser(idQuery).first(USER_COLUMNS);
return rowToUser(row);
}
async delete(id) {
await this.activeUsers()
.where({ id })
.update({
deleted_at: new Date(),
// @ts-expect-error email is non-nullable in User type
email: null,
// @ts-expect-error username is non-nullable in User type
username: null,
scim_id: null,
scim_external_id: null,
name: this.db.raw('name || ?', '(Deleted)'),
});
}
async getPasswordHash(userId) {
const item = await this.activeUsers()
.where('id', userId)
.first('password_hash');
if (!item) {
throw new NotFoundError('User not found');
}
return item.password_hash;
}
async setPasswordHash(userId, passwordHash, disallowNPreviousPasswords) {
await this.activeUsers().where('id', userId).update({
// @ts-expect-error password_hash does not exist in User type
password_hash: passwordHash,
});
// We apparently set this to null, but you should be allowed to have null, so need to allow this
if (passwordHash) {
await this.db(PASSWORD_HASH_TABLE).insert({
user_id: userId,
password_hash: passwordHash,
});
await this.deletePasswordsUsedMoreThanNTimesAgo(userId, disallowNPreviousPasswords);
}
}
async incLoginAttempts(user) {
await this.buildSelectUser(user).increment('login_attempts', 1);
}
async successfullyLogin(user) {
const currentDate = new Date();
const updateQuery = this.buildSelectUser(user).update({
// @ts-expect-error login_attempts does not exist in User type
login_attempts: 0,
seen_at: currentDate,
});
let firstLoginOrder = 0;
const existingUser = await this.buildSelectUser(user).first('first_seen_at');
if (!existingUser.first_seen_at) {
const countEarlierUsers = await this.db(TABLE)
.whereNotNull('first_seen_at')
.andWhere('first_seen_at', '<', currentDate)
.count('*')
.then((res) => Number(res[0].count));
firstLoginOrder = countEarlierUsers;
await updateQuery.update({
// @ts-expect-error first_seen_at does not exist in User type
first_seen_at: currentDate,
});
}
await updateQuery;
return firstLoginOrder;
}
async deleteAll() {
await this.activeUsers().del();
}
async deleteScimUsers() {
const rows = await this.db(TABLE)
.whereNotNull('scim_id')
.del()
.returning(USER_COLUMNS);
return rows.map(rowToUser);
}
async count() {
return this.activeUsers()
.count('*')
.then((res) => Number(res[0].count));
}
async countServiceAccounts() {
return this.db(TABLE)
.where({
deleted_at: null,
is_service: true,
})
.count('*')
.then((res) => Number(res[0].count));
}
async countRecentlyDeleted() {
return this.db(TABLE)
.whereNotNull('deleted_at')
.andWhere('deleted_at', '>=', this.db.raw(`NOW() - INTERVAL '1 month'`))
.andWhere({ is_service: false, is_system: false })
.count('*')
.then((res) => Number(res[0].count));
}
destroy() { }
async exists(id) {
const result = await this.db.raw(`SELECT EXISTS (SELECT 1 FROM ${TABLE} WHERE id = ? and deleted_at = null) AS present`, [id]);
const { present } = result.rows[0];
return present;
}
async get(id) {
const row = await this.activeUsers().where({ id }).first();
return rowToUser(row);
}
async getFirstUserDate() {
const firstInstanceUser = await this.db('users')
.select('created_at')
.where('is_system', '=', false)
.orderBy('created_at', 'asc')
.first();
return firstInstanceUser ? firstInstanceUser.created_at : null;
}
}
//# sourceMappingURL=user-store.js.map