1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00
unleash.unleash/src/lib/db/account-store.ts
Nuno Góis c0bcc50b28
fix: add confirmation to disable password login (#3829)
https://linear.app/unleash/issue/2-1071/prevent-users-from-disabling-password-authentication-when-there-are-no

Improves the behavior of disabling password based login by adding some
relevant information and a confirmation dialog with a warning. This felt
better than trying to disable the toggle, by still allowing the end
users to make the decision, except now it should be a properly informed
decision with confirmation.


![image](https://github.com/Unleash/unleash/assets/14320932/2ca754d8-cfa2-4fda-984d-0c34b89750f3)

- **Password based administrators**: Admin accounts that have a password
set;
- **Other administrators**: Other admin users that do not have a
password. May be SSO, but may also be users that did not set a password
yet;
- **Admin service accounts**: Service accounts that have the admin root
role. Depending on how you're using the SA this may not necessarily mean
locking yourself out of an admin account, especially if you secured its
token beforehand;
- **Admin API tokens**: Similar to the above. If you secured an admin
API token beforehand, you still have access to all features through the
API;

Each one of them link to the respective page inside Unleash (e.g. users
page, service accounts page, tokens page...);

If you try to disable and press "save", and only in that scenario, you
are presented with the following confirmation dialog:


![image](https://github.com/Unleash/unleash/assets/14320932/5ad6d105-ad47-4d31-a1df-04737aed4e00)
2023-05-23 15:56:34 +01:00

201 lines
5.9 KiB
TypeScript

import { Logger, LogProvider } from '../logger';
import User from '../types/user';
import NotFoundError from '../error/notfound-error';
import { IUserLookup } from '../types/stores/user-store';
import { IAdminCount } from '../types/stores/account-store';
import { IAccountStore } from '../types';
import { Db } from './db';
const TABLE = 'users';
const USER_COLUMNS_PUBLIC = [
'id',
'name',
'username',
'email',
'image_url',
'seen_at',
'is_service',
];
const USER_COLUMNS = [...USER_COLUMNS_PUBLIC, 'login_attempts', 'created_at'];
const emptify = (value) => {
if (!value) {
return undefined;
}
return value;
};
const safeToLower = (s?: string) => (s ? s.toLowerCase() : s);
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,
});
};
export class AccountStore implements IAccountStore {
private db: Db;
private logger: Logger;
constructor(db: Db, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('account-store.ts');
}
buildSelectAccount(q: IUserLookup): any {
const query = this.activeAccounts();
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.');
}
activeAccounts(): any {
return this.db(TABLE).where({
deleted_at: null,
});
}
async hasAccount(idQuery: IUserLookup): Promise<number | undefined> {
const query = this.buildSelectAccount(idQuery);
const item = await query.first('id');
return item ? item.id : undefined;
}
async getAll(): Promise<User[]> {
const users = await this.activeAccounts().select(USER_COLUMNS);
return users.map(rowToUser);
}
async search(query: string): Promise<User[]> {
const users = await this.activeAccounts()
.select(USER_COLUMNS_PUBLIC)
.where('name', 'ILIKE', `%${query}%`)
.orWhere('username', 'ILIKE', `${query}%`)
.orWhere('email', 'ILIKE', `${query}%`);
return users.map(rowToUser);
}
async getAllWithId(userIdList: number[]): Promise<User[]> {
const users = await this.activeAccounts()
.select(USER_COLUMNS_PUBLIC)
.whereIn('id', userIdList);
return users.map(rowToUser);
}
async getByQuery(idQuery: IUserLookup): Promise<User> {
const row = await this.buildSelectAccount(idQuery).first(USER_COLUMNS);
return rowToUser(row);
}
async delete(id: number): Promise<void> {
return this.activeAccounts()
.where({ id })
.update({
deleted_at: new Date(),
email: null,
username: null,
name: this.db.raw('name || ?', '(Deleted)'),
});
}
async deleteAll(): Promise<void> {
await this.activeAccounts().del();
}
async count(): Promise<number> {
return this.activeAccounts()
.count('*')
.then((res) => Number(res[0].count));
}
destroy(): void {}
async exists(id: number): Promise<boolean> {
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: number): Promise<User> {
const row = await this.activeAccounts().where({ id }).first();
return rowToUser(row);
}
async getAccountByPersonalAccessToken(secret: string): Promise<User> {
const row = await this.activeAccounts()
.select(USER_COLUMNS.map((column) => `${TABLE}.${column}`))
.leftJoin(
'personal_access_tokens',
'personal_access_tokens.user_id',
`${TABLE}.id`,
)
.where('secret', secret)
.andWhere('expires_at', '>', 'now()')
.first();
return rowToUser(row);
}
async markSeenAt(secrets: string[]): Promise<void> {
const now = new Date();
try {
await this.db('personal_access_tokens')
.whereIn('secret', secrets)
.update({ seen_at: now });
} catch (err) {
this.logger.error('Could not update lastSeen, error: ', err);
}
}
async getAdminCount(): Promise<IAdminCount> {
const adminCount = await this.activeAccounts()
.join('role_user as ru', 'users.id', 'ru.user_id')
.where(
'ru.role_id',
'=',
this.db.raw('(SELECT id FROM roles WHERE name = ?)', ['Admin']),
)
.select(
this.db.raw(
'COUNT(CASE WHEN users.password_hash IS NOT NULL AND users.is_service = false THEN 1 END)::integer AS password',
),
this.db.raw(
'COUNT(CASE WHEN users.password_hash IS NULL AND users.is_service = false THEN 1 END)::integer AS no_password',
),
this.db.raw(
'COUNT(CASE WHEN users.is_service = true THEN 1 END)::integer AS service',
),
);
return {
password: adminCount[0].password,
noPassword: adminCount[0].no_password,
service: adminCount[0].service,
};
}
}