More progress on backend user

This commit is contained in:
WardPearce
2026-02-13 23:21:30 +13:00
parent 700a93b83c
commit f2978a8d05
6 changed files with 157 additions and 7 deletions
+16
View File
@@ -35,6 +35,7 @@
"i18next": "^25.7.2",
"iso-3166": "^4.4.0",
"iso-639-1": "^3.1.5",
"libsodium-wrappers-sumo": "^0.8.2",
"material-dynamic-colors": "^1.1.1",
"media-captions": "^1.0.4",
"melt": "^0.44.0",
@@ -10217,6 +10218,21 @@
"node": ">= 0.8.0"
}
},
"node_modules/libsodium-sumo": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.2.tgz",
"integrity": "sha512-uMgnjphJ717jLN+jFG1HUgNrK/gOVVfaO1DGZ1Ig/fKLKLVhvaH/sM1I1v784JFvmkJDaczDpi7xSYC4Jvdo1Q==",
"license": "ISC"
},
"node_modules/libsodium-wrappers-sumo": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.2.tgz",
"integrity": "sha512-wd1xAY++Kr6VMikSaa4EPRAHJmFvNlGWiiwU3Jh3GR1zRYF3/I3vy/wYsr4k3LVsNzwb9sqfEQ4LdVQ6zEebyQ==",
"license": "ISC",
"dependencies": {
"libsodium-sumo": "^0.8.0"
}
},
"node_modules/lilconfig": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
+1
View File
@@ -78,6 +78,7 @@
"i18next": "^25.7.2",
"iso-3166": "^4.4.0",
"iso-639-1": "^3.1.5",
"libsodium-wrappers-sumo": "^0.8.2",
"material-dynamic-colors": "^1.1.1",
"media-captions": "^1.0.4",
"melt": "^0.44.0",
+3 -1
View File
@@ -2,6 +2,7 @@ export type IsOwnBackend = {
builtWithBackend: boolean;
internalAuth: boolean;
requireAuth: boolean;
registrationAllowed: boolean;
};
export function isOwnBackend(): IsOwnBackend | null {
@@ -10,6 +11,7 @@ export function isOwnBackend(): IsOwnBackend | null {
return {
builtWithBackend: true,
internalAuth: import.meta.env.VITE_INTERNAL_AUTH !== 'false',
requireAuth: import.meta.env.VITE_REQUIRE_AUTH !== 'false'
requireAuth: import.meta.env.VITE_REQUIRE_AUTH !== 'false',
registrationAllowed: import.meta.env.VITE_REGISTRATION_ALLOWED === 'true'
};
}
+37 -6
View File
@@ -1,4 +1,4 @@
import { Sequelize, DataTypes } from 'sequelize';
import { Sequelize, DataTypes, type Model } from 'sequelize';
import { DATABASE_CONNECTION_URI } from '$env/static/private';
export const sequelize = new Sequelize(
@@ -6,6 +6,14 @@ export const sequelize = new Sequelize(
DATABASE_CONNECTION_URI ? DATABASE_CONNECTION_URI : 'sqlite::memory:'
);
export interface UserTableModel extends Model {
id: string;
username: string;
passwordHash: string;
passwordSalt: string;
created: Date;
}
export const UserTable = sequelize.define('User', {
id: {
type: DataTypes.UUIDV4,
@@ -14,24 +22,46 @@ export const UserTable = sequelize.define('User', {
},
username: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
unique: true
},
passwordHash: {
type: DataTypes.STRING,
allowNull: false
},
create: {
passwordSalt: {
type: DataTypes.STRING,
allowNull: false
},
created: {
type: DataTypes.DATE,
allowNull: false
}
});
export interface ChannelSubscriptionModel {
channelIdCipher: string;
channelIdSalt: string;
channelNameCipher: string;
channelNameSalt: string;
lastRSSFetch: Date;
userId: string;
}
export const ChannelSubscriptionTable = sequelize.define('Subscriptions', {
channelId: {
channelIdCipher: {
type: DataTypes.STRING,
allowNull: false
},
channelName: {
channelIdSalt: {
type: DataTypes.STRING,
allowNull: false
},
channelNameCipher: {
type: DataTypes.STRING,
allowNull: false
},
channelNameSalt: {
type: DataTypes.STRING,
allowNull: false
},
@@ -44,6 +74,7 @@ export const ChannelSubscriptionTable = sequelize.define('Subscriptions', {
references: {
model: 'User',
key: 'id'
}
},
allowNull: false
}
});
+100
View File
@@ -0,0 +1,100 @@
import { UserTable, type ChannelSubscriptionModel, type UserTableModel } from './database';
import { Op } from 'sequelize';
import crypto from 'crypto';
export class User {
private id: string;
constructor(id: string) {
this.id = id;
}
private get userWhere() {
return {
where: {
[Op.or]: [{ id: this.id }, { username: this.id }]
}
};
}
async delete() {
await UserTable.destroy(this.userWhere);
}
async subscriptions(): Promise<ChannelSubscriptionModel[]> {
const subscriptions = await UserTable.findAll({
where: {
userId: this.id
}
});
if (!subscriptions) return [];
return subscriptions as unknown as ChannelSubscriptionModel[];
}
}
export type CreateUser = {
username: string;
password: {
hash: string;
salt: string;
};
};
export async function createUser(user: CreateUser): Promise<User> {
const id = crypto.randomUUID();
await UserTable.create({
id,
username: user.username,
passwordHash: user.password.hash,
passwordSalt: user.password.salt,
created: new Date()
});
return new User(id);
}
export async function getUser(identifier: string): Promise<User> {
const user = await UserTable.findOne({
where: {
[Op.or]: [{ id: identifier }, { username: identifier }]
}
});
if (!user) {
throw new Error('User does not exist');
}
return new User((user as UserTableModel).id);
}
export async function authenticateUser(username: string, passwordHash: string): Promise<User> {
const user = await UserTable.findOne({
where: {
username
}
});
if (!user) {
throw new Error('User does not exist');
}
const userModel = user as UserTableModel;
const textEncoder = new TextEncoder();
// Password is hashed in the browser, so if db is leaked it doesn't matter.
// Timing safe equal used to stop timing attacks when comparing strings.
if (
!crypto.timingSafeEqual(
textEncoder.encode(passwordHash),
textEncoder.encode(userModel.passwordHash)
)
) {
throw new Error('User does not exist');
}
return new User(userModel.id);
}