mirror of https://github.com/wg-easy/wg-easy
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
455 lines
11 KiB
455 lines
11 KiB
import { eq, sql, and } from 'drizzle-orm';
|
|
import { TOTP } from 'otpauth';
|
|
import { user } from './schema';
|
|
import type { UserType } from './types';
|
|
import type { DBType } from '#db/sqlite';
|
|
|
|
type LoginResult =
|
|
| {
|
|
success: true;
|
|
user: UserType;
|
|
}
|
|
| {
|
|
success: false;
|
|
error:
|
|
| 'INCORRECT_CREDENTIALS'
|
|
| 'USER_DISABLED'
|
|
| 'INVALID_TOTP_CODE'
|
|
| 'UNEXPECTED_ERROR';
|
|
}
|
|
| {
|
|
success: false;
|
|
error: 'TOTP_REQUIRED';
|
|
userId: ID;
|
|
};
|
|
|
|
type LoginWithOAuthResult =
|
|
| {
|
|
success: true;
|
|
user: UserType;
|
|
}
|
|
| {
|
|
success: false;
|
|
error:
|
|
| 'USER_DISABLED'
|
|
| 'USER_ALREADY_LINKED'
|
|
| 'UNEXPECTED_ERROR'
|
|
| 'AUTO_REGISTER_DISABLED';
|
|
}
|
|
| {
|
|
success: false;
|
|
error: 'TOTP_REQUIRED';
|
|
userId: ID;
|
|
};
|
|
|
|
function createPreparedStatement(db: DBType) {
|
|
return {
|
|
findAll: db.query.user.findMany().prepare(),
|
|
findById: db.query.user
|
|
.findFirst({ where: eq(user.id, sql.placeholder('id')) })
|
|
.prepare(),
|
|
findByUsername: db.query.user
|
|
.findFirst({
|
|
where: eq(user.username, sql.placeholder('username')),
|
|
})
|
|
.prepare(),
|
|
update: db
|
|
.update(user)
|
|
.set({
|
|
name: sql.placeholder('name') as never as string,
|
|
email: sql.placeholder('email') as never as string,
|
|
})
|
|
.where(eq(user.id, sql.placeholder('id')))
|
|
.prepare(),
|
|
updateKey: db
|
|
.update(user)
|
|
.set({
|
|
totpKey: sql.placeholder('key') as never as string,
|
|
totpVerified: false,
|
|
})
|
|
.where(eq(user.id, sql.placeholder('id')))
|
|
.prepare(),
|
|
};
|
|
}
|
|
|
|
export class UserService {
|
|
#db: DBType;
|
|
#statements: ReturnType<typeof createPreparedStatement>;
|
|
|
|
constructor(db: DBType) {
|
|
this.#db = db;
|
|
this.#statements = createPreparedStatement(db);
|
|
}
|
|
|
|
#createTotp(user: { username: string; totpKey: string }) {
|
|
return new TOTP({
|
|
issuer: 'wg-easy',
|
|
label: user.username,
|
|
algorithm: 'SHA1',
|
|
digits: 6,
|
|
period: 30,
|
|
secret: user.totpKey,
|
|
});
|
|
}
|
|
|
|
async getAll() {
|
|
return this.#statements.findAll.execute();
|
|
}
|
|
|
|
async get(id: ID) {
|
|
return this.#statements.findById.execute({ id });
|
|
}
|
|
|
|
async getByUsername(username: string) {
|
|
return this.#statements.findByUsername.execute({ username });
|
|
}
|
|
|
|
async create(username: string, password: string) {
|
|
const hash = await hashPassword(password);
|
|
|
|
return this.#db.transaction(async (tx) => {
|
|
const oldUser = await tx.query.user
|
|
.findFirst({
|
|
where: eq(user.username, username),
|
|
})
|
|
.execute();
|
|
|
|
if (oldUser) {
|
|
throw new Error('User already exists');
|
|
}
|
|
|
|
const userCount = await tx.$count(user);
|
|
|
|
await tx.insert(user).values({
|
|
password: hash,
|
|
username,
|
|
email: null,
|
|
name: 'Administrator',
|
|
role: userCount === 0 ? roles.ADMIN : roles.CLIENT,
|
|
totpVerified: false,
|
|
enabled: true,
|
|
});
|
|
});
|
|
}
|
|
|
|
async update(id: ID, name: string, email: string | null) {
|
|
return this.#statements.update.execute({ id, name, email });
|
|
}
|
|
|
|
async updatePassword(
|
|
id: ID,
|
|
currentPassword: string | null,
|
|
newPassword: string
|
|
) {
|
|
const hash = await hashPassword(newPassword);
|
|
|
|
return this.#db.transaction(async (tx) => {
|
|
// get user again to avoid password changing while request
|
|
const txUser = await tx.query.user
|
|
.findFirst({ where: eq(user.id, id) })
|
|
.execute();
|
|
|
|
if (!txUser) {
|
|
throw new Error('User not found');
|
|
}
|
|
|
|
// only check password if already set
|
|
if (txUser.password !== null) {
|
|
if (!currentPassword) {
|
|
throw new Error('Invalid password');
|
|
}
|
|
|
|
const passwordValid = await isPasswordValid(
|
|
currentPassword,
|
|
txUser.password
|
|
);
|
|
|
|
if (!passwordValid) {
|
|
throw new Error('Invalid password');
|
|
}
|
|
}
|
|
|
|
await tx
|
|
.update(user)
|
|
.set({ password: hash })
|
|
.where(eq(user.id, id))
|
|
.execute();
|
|
});
|
|
}
|
|
|
|
updateTotpKey(id: ID, key: string | null) {
|
|
return this.#statements.updateKey.execute({ id, key });
|
|
}
|
|
|
|
login(username: string, password: string) {
|
|
return this.#db.transaction(async (tx): Promise<LoginResult> => {
|
|
const txUser = await tx.query.user
|
|
.findFirst({ where: eq(user.username, username) })
|
|
.execute();
|
|
|
|
// always check to avoid timing attack
|
|
const userHashPassword = txUser?.password ?? null;
|
|
const passwordValid = await isPasswordValid(password, userHashPassword);
|
|
|
|
if (!txUser || !passwordValid) {
|
|
return { success: false, error: 'INCORRECT_CREDENTIALS' };
|
|
}
|
|
|
|
if (!txUser.enabled) {
|
|
return { success: false, error: 'USER_DISABLED' };
|
|
}
|
|
|
|
if (txUser.totpVerified) {
|
|
return {
|
|
success: false,
|
|
error: 'TOTP_REQUIRED',
|
|
userId: txUser.id,
|
|
};
|
|
}
|
|
|
|
return { success: true, user: txUser };
|
|
});
|
|
}
|
|
|
|
verifyTotp(id: ID, code: string) {
|
|
return this.#db.transaction(async (tx) => {
|
|
const txUser = await tx.query.user
|
|
.findFirst({ where: eq(user.id, id) })
|
|
.execute();
|
|
|
|
if (!txUser) {
|
|
throw new Error('User not found');
|
|
}
|
|
|
|
if (txUser.totpVerified) {
|
|
throw new Error('TOTP is already verified');
|
|
}
|
|
|
|
const totpKey = txUser.totpKey;
|
|
if (!totpKey) {
|
|
throw new Error('TOTP key is not set');
|
|
}
|
|
|
|
const totp = this.#createTotp({ username: txUser.username, totpKey });
|
|
if (totp.validate({ token: code, window: 1 }) === null) {
|
|
throw new Error('Invalid TOTP code');
|
|
}
|
|
|
|
await tx
|
|
.update(user)
|
|
.set({ totpVerified: true })
|
|
.where(eq(user.id, id))
|
|
.execute();
|
|
});
|
|
}
|
|
|
|
deleteTotpKey(id: ID, currentPassword: string) {
|
|
return this.#db.transaction(async (tx) => {
|
|
const txUser = await tx.query.user
|
|
.findFirst({ where: eq(user.id, id) })
|
|
.execute();
|
|
|
|
if (!txUser) {
|
|
throw new Error('User not found');
|
|
}
|
|
|
|
const passwordValid = await isPasswordValid(
|
|
currentPassword,
|
|
txUser.password
|
|
);
|
|
|
|
if (!passwordValid) {
|
|
throw new Error('Invalid password');
|
|
}
|
|
|
|
await tx
|
|
.update(user)
|
|
.set({ totpKey: null, totpVerified: false })
|
|
.where(eq(user.id, id))
|
|
.execute();
|
|
});
|
|
}
|
|
|
|
async validateTotpCode(id: ID, code: string) {
|
|
const txUser = await this.#db.query.user
|
|
.findFirst({ where: eq(user.id, id) })
|
|
.execute();
|
|
|
|
if (!txUser || !txUser.totpVerified || !txUser.totpKey) {
|
|
return 'INVALID_TOTP_CODE' as const;
|
|
}
|
|
|
|
const totp = this.#createTotp({
|
|
username: txUser.username,
|
|
totpKey: txUser.totpKey,
|
|
});
|
|
const isValid = totp.validate({ token: code, window: 1 }) !== null;
|
|
|
|
if (!isValid) {
|
|
return 'INVALID_TOTP_CODE' as const;
|
|
}
|
|
|
|
if (!txUser.enabled) {
|
|
return 'USER_DISABLED' as const;
|
|
}
|
|
|
|
return 'success' as const;
|
|
}
|
|
|
|
/**
|
|
* Login or register user with OAuth provider.
|
|
* If user with the same email already exists, link account with OAuth provider.
|
|
* Otherwise, create new user.
|
|
*/
|
|
async loginWithOAuth(
|
|
provider: OAUTH_PROVIDER,
|
|
oauthId: string,
|
|
username: string,
|
|
email: string,
|
|
name: string
|
|
): Promise<LoginWithOAuthResult> {
|
|
return this.#db.transaction(async (tx) => {
|
|
const userById = await tx.query.user
|
|
.findFirst({
|
|
where: and(
|
|
eq(user.oauthProvider, provider),
|
|
eq(user.oauthId, oauthId)
|
|
),
|
|
})
|
|
.execute();
|
|
|
|
if (userById) {
|
|
if (!userById.enabled) {
|
|
return { success: false, error: 'USER_DISABLED' };
|
|
}
|
|
if (userById.totpVerified) {
|
|
return {
|
|
success: false,
|
|
error: 'TOTP_REQUIRED',
|
|
userId: userById.id,
|
|
};
|
|
}
|
|
return { success: true, user: userById };
|
|
}
|
|
|
|
const userByEmail = await tx.query.user
|
|
.findFirst({
|
|
where: eq(user.email, email),
|
|
})
|
|
.execute();
|
|
|
|
if (userByEmail) {
|
|
if (!userByEmail.enabled) {
|
|
return { success: false, error: 'USER_DISABLED' };
|
|
}
|
|
if (userByEmail.oauthProvider && userByEmail.oauthId) {
|
|
return {
|
|
success: false,
|
|
error: 'USER_ALREADY_LINKED',
|
|
};
|
|
}
|
|
|
|
await tx
|
|
.update(user)
|
|
.set({ oauthProvider: provider, oauthId: oauthId })
|
|
.where(eq(user.id, userByEmail.id))
|
|
.execute();
|
|
|
|
if (userByEmail.totpVerified) {
|
|
return {
|
|
success: false,
|
|
error: 'TOTP_REQUIRED',
|
|
userId: userByEmail.id,
|
|
};
|
|
}
|
|
|
|
// TODO: return updated user
|
|
return { success: true, user: userByEmail };
|
|
}
|
|
|
|
if (!WG_ENV.OAUTH_AUTO_REGISTER) {
|
|
return { success: false, error: 'AUTO_REGISTER_DISABLED' };
|
|
}
|
|
|
|
// Create new user
|
|
const newUsers = await tx
|
|
.insert(user)
|
|
.values({
|
|
username,
|
|
password: null,
|
|
email,
|
|
name,
|
|
role: roles.ADMIN,
|
|
totpVerified: false,
|
|
enabled: true,
|
|
oauthProvider: provider,
|
|
oauthId,
|
|
})
|
|
.returning();
|
|
const newUser = newUsers[0];
|
|
|
|
if (!newUser) {
|
|
return { success: false as const, error: 'UNEXPECTED_ERROR' as const };
|
|
}
|
|
return { success: true as const, user: newUser };
|
|
});
|
|
}
|
|
|
|
unlinkOauth(id: ID) {
|
|
return this.#db.transaction(async (tx) => {
|
|
const txUser = await tx.query.user
|
|
.findFirst({ where: eq(user.id, id) })
|
|
.execute();
|
|
|
|
if (!txUser) {
|
|
throw new Error('User not found');
|
|
}
|
|
|
|
// can't unlink if no way to log back in
|
|
if (!txUser.password) {
|
|
throw new Error('Password login not enabled');
|
|
}
|
|
|
|
await tx
|
|
.update(user)
|
|
.set({ oauthProvider: null, oauthId: null })
|
|
.where(eq(user.id, id))
|
|
.execute();
|
|
});
|
|
}
|
|
|
|
async linkOauth(id: ID, provider: OAUTH_PROVIDER, oauthId: string) {
|
|
return this.#db.transaction(async (tx) => {
|
|
const txUser = await tx.query.user
|
|
.findFirst({ where: eq(user.id, id) })
|
|
.execute();
|
|
|
|
if (!txUser) {
|
|
throw new Error('User not found');
|
|
}
|
|
|
|
if (txUser.oauthProvider || txUser.oauthId) {
|
|
throw new Error('User already linked with an OAuth provider');
|
|
}
|
|
|
|
const existingUser = await tx.query.user
|
|
.findFirst({
|
|
where: and(
|
|
eq(user.oauthProvider, provider),
|
|
eq(user.oauthId, oauthId)
|
|
),
|
|
})
|
|
.execute();
|
|
|
|
if (existingUser) {
|
|
throw new Error('OAuth account already linked with another user');
|
|
}
|
|
|
|
await tx
|
|
.update(user)
|
|
.set({ oauthProvider: provider, oauthId: oauthId })
|
|
.where(eq(user.id, id))
|
|
.execute();
|
|
});
|
|
}
|
|
}
|
|
|