diff --git a/materialious/package-lock.json b/materialious/package-lock.json index cff0290b..c5819482 100644 --- a/materialious/package-lock.json +++ b/materialious/package-lock.json @@ -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", diff --git a/materialious/package.json b/materialious/package.json index bed15b1d..64404360 100644 --- a/materialious/package.json +++ b/materialious/package.json @@ -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", diff --git a/materialious/src/lib/backend.ts b/materialious/src/lib/backend.ts index b4ccbed0..99eb30d8 100644 --- a/materialious/src/lib/backend.ts +++ b/materialious/src/lib/backend.ts @@ -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' }; } diff --git a/materialious/src/lib/backendOnly/auth.ts b/materialious/src/lib/backendOnly/auth.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/materialious/src/lib/backendOnly/database.ts b/materialious/src/lib/backendOnly/database.ts index 7c8e28d1..998e4467 100644 --- a/materialious/src/lib/backendOnly/database.ts +++ b/materialious/src/lib/backendOnly/database.ts @@ -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 } }); diff --git a/materialious/src/lib/backendOnly/user.ts b/materialious/src/lib/backendOnly/user.ts new file mode 100644 index 00000000..414f4ee5 --- /dev/null +++ b/materialious/src/lib/backendOnly/user.ts @@ -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 { + 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 { + 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 { + 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 { + 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); +}