Browse Source

fix: review done

- remove: REQUIRES_PASSWORD & RELEASE environment variables
pull/1330/head
tetuaoro 2 years ago
parent
commit
d883e8a63b
  1. 55
      src/adapters/database/inmemory.ts
  2. 5
      src/i18n.config.ts
  3. 2
      src/package.json
  4. 6
      src/pages/login.vue
  5. 7
      src/pages/setup.vue
  6. 17
      src/pnpm-lock.yaml
  7. 27
      src/ports/database.ts
  8. 10
      src/ports/system/model.ts
  9. 33
      src/ports/types.ts
  10. 18
      src/ports/user/interface.ts
  11. 27
      src/ports/user/model.ts
  12. 8
      src/server/api/account/new.post.ts
  13. 2
      src/server/api/lang.get.ts
  14. 10
      src/server/api/release.get.ts
  15. 6
      src/server/api/session.get.ts
  16. 19
      src/server/api/session.post.ts
  17. 4
      src/server/middleware/auth.ts
  18. 1
      src/server/middleware/session.ts
  19. 4
      src/server/utils/config.ts
  20. 8
      src/server/utils/password.ts
  21. 10
      src/server/utils/types.ts

55
src/adapters/database/inmemory.ts

@ -3,18 +3,14 @@ import packageJson from '@/package.json';
import DatabaseProvider, { DatabaseError } from '~/ports/database';
import { ChartType, Lang } from '~/ports/types';
import { ROLE } from '~/ports/user/model';
import type { SessionConfig } from 'h3';
import type { ID } from '~/ports/types';
import type { System } from '~/ports/system/model';
import type { User } from '~/ports/user/model';
import type { Identity, String } from '~/ports/types';
import {
hashPasswordWithBcrypt,
isPasswordStrong,
} from '~/server/utils/password';
import { hashPassword, isPasswordStrong } from '~/server/utils/password';
const INMDP_DEBUG = debug('InMemoryDP');
const DEBUG = debug('InMemoryDB');
// Represent in-memory data structure
type InMemoryData = {
@ -27,7 +23,7 @@ export default class InMemory extends DatabaseProvider {
protected data: InMemoryData = { users: [] };
async connect() {
INMDP_DEBUG('Connection...');
DEBUG('Connection...');
const system: System = {
release: packageJson.release.version,
interface: {
@ -74,7 +70,7 @@ export default class InMemory extends DatabaseProvider {
};
this.data.system = system;
INMDP_DEBUG('Connection done');
DEBUG('Connection done');
}
async disconnect() {
@ -82,15 +78,10 @@ export default class InMemory extends DatabaseProvider {
}
async getSystem() {
INMDP_DEBUG('Get System');
DEBUG('Get System');
return this.data.system;
}
async saveSystem(system: System) {
INMDP_DEBUG('Save System');
this.data.system = system;
}
async getLang() {
return this.data.system?.lang || Lang.EN;
}
@ -99,18 +90,15 @@ export default class InMemory extends DatabaseProvider {
return this.data.users;
}
async getUser(id: Identity<User>) {
INMDP_DEBUG('Get User');
if (typeof id === 'string' || typeof id === 'number') {
return this.data.users.find((user) => user.id === id);
}
return this.data.users.find((user) => user.id === id.id);
async getUser(id: ID) {
DEBUG('Get User');
return this.data.users.find((user) => user.id === id);
}
async newUserWithPassword(username: String, password: String) {
INMDP_DEBUG('New User');
async newUserWithPassword(username: string, password: string) {
DEBUG('New User');
if (username.length < 8) {
throw new DatabaseError(DatabaseError.ERROR_USERNAME_LEN);
throw new DatabaseError(DatabaseError.ERROR_USERNAME_REQ);
}
if (!isPasswordStrong(password)) {
@ -125,13 +113,13 @@ export default class InMemory extends DatabaseProvider {
}
const now = new Date();
const isUserEmpty = this.data.users.length == 0;
const isUserEmpty = this.data.users.length === 0;
const newUser: User = {
id: `${this.data.users.length + 1}`,
password: hashPasswordWithBcrypt(password),
password: hashPassword(password),
username,
role: isUserEmpty ? ROLE.ADMIN : ROLE.CLIENT,
role: isUserEmpty ? 'ADMIN' : 'CLIENT',
enabled: true,
createdAt: now,
updatedAt: now,
@ -140,18 +128,17 @@ export default class InMemory extends DatabaseProvider {
this.data.users.push(newUser);
}
async saveUser(user: User) {
let _user = await this.getUser(user);
async updateUser(user: User) {
let _user = await this.getUser(user.id);
if (_user) {
INMDP_DEBUG('Update User');
DEBUG('Update User');
_user = user;
}
}
async deleteUser(id: Identity<User>) {
INMDP_DEBUG('Delete User');
const _id = typeof id === 'string' || typeof id === 'number' ? id : id.id;
const idx = this.data.users.findIndex((user) => user.id == _id);
async deleteUser(id: ID) {
DEBUG('Delete User');
const idx = this.data.users.findIndex((user) => user.id === id);
if (idx !== -1) {
this.data.users.splice(idx, 1);
}

5
src/i18n.config.ts

@ -46,6 +46,11 @@ export default defineI18nConfig(() => ({
ExpireDate: 'Expire Date',
Permanent: 'Permanent',
OneTimeLink: 'Generate short one time link',
errorDatabaseConn: 'Failed to connect to the database.',
errorPasswordReq:
'Password does not meet the strength requirements. It must be at least 12 characters long, with at least one uppercase letter, one lowercase letter, one number, and one special character.',
errorUsernameReq: 'Username must be longer than 8 characters.',
errorUserExist: 'User already exists.',
},
ua: {
name: 'Ім`я',

2
src/package.json

@ -35,6 +35,7 @@
"qrcode": "^1.5.4",
"tailwindcss": "^3.4.10",
"timeago.js": "^4.0.2",
"uuid": "^10.0.0",
"vue": "latest",
"vue3-apexcharts": "^1.5.3",
"zod": "^3.23.8"
@ -44,6 +45,7 @@
"@types/bcryptjs": "^2.4.6",
"@types/debug": "^4.1.12",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"eslint": "^9.8.0",
"eslint-config-prettier": "^9.1.0",
"prettier": "^3.3.3",

6
src/pages/login.vue

@ -86,7 +86,7 @@
<script setup lang="ts">
const authenticating = ref(false);
const remember = ref(false);
const username = ref<string>();
const username = ref<null | string>(null);
const password = ref<null | string>(null);
const authStore = useAuthStore();
const globalStore = useGlobalStore();
@ -94,9 +94,7 @@ const globalStore = useGlobalStore();
async function login(e: Event) {
e.preventDefault();
if (!username.value) return;
if (!password.value) return;
if (authenticating.value) return;
if (!username.value || !password.value || authenticating.value) return;
authenticating.value = true;
try {

7
src/pages/setup.vue

@ -35,15 +35,14 @@
</template>
<script setup lang="ts">
const username = ref<string>();
const password = ref<string>();
const username = ref<null | string>(null);
const password = ref<null | string>(null);
const authStore = useAuthStore();
async function newAccount(e: Event) {
e.preventDefault();
if (!username.value) return;
if (!password.value) return;
if (!username.value || !password.value) return;
try {
const res = await authStore.signup(username.value, password.value);

17
src/pnpm-lock.yaml

@ -56,6 +56,9 @@ importers:
timeago.js:
specifier: ^4.0.2
version: 4.0.2
uuid:
specifier: ^10.0.0
version: 10.0.0
vue:
specifier: latest
version: 3.4.37([email protected])
@ -78,6 +81,9 @@ importers:
'@types/qrcode':
specifier: ^1.5.5
version: 1.5.5
'@types/uuid':
specifier: ^10.0.0
version: 10.0.0
eslint:
specifier: ^9.8.0
version: 9.8.0
@ -1254,6 +1260,9 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
'@types/[email protected]':
resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
'@typescript-eslint/[email protected]':
resolution: {integrity: sha512-STIZdwEQRXAHvNUS6ILDf5z3u95Gc8jzywunxSNqX00OooIemaaNIA0vEgynJlycL5AjabYLLrIyHd4iazyvtg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@ -4203,6 +4212,10 @@ packages:
[email protected]:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
[email protected]:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
[email protected]:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
@ -5727,6 +5740,8 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]': {}
'@typescript-eslint/[email protected](@typescript-eslint/[email protected]([email protected])([email protected]))([email protected])([email protected])':
dependencies:
'@eslint-community/regexpp': 4.11.0
@ -9063,6 +9078,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
spdx-correct: 3.2.0

27
src/ports/database.ts

@ -1,6 +1,6 @@
import type SystemRepository from './system/interface';
import type UserRepository from './user/interface';
import type { Identity, Undefined, Lang, String } from './types';
import type { Undefined, Lang, ID } from './types';
import type { User } from './user/model';
import type { System } from './system/model';
@ -28,25 +28,24 @@ export default abstract class DatabaseProvider
abstract getLang(): Promise<Lang>;
abstract getUsers(): Promise<Array<User>>;
abstract getUser(_id: Identity<User>): Promise<User | Undefined>;
abstract getUser(id: ID): Promise<User | Undefined>;
abstract newUserWithPassword(
_username: String,
_password: String
username: string,
password: string
): Promise<void>;
abstract saveUser(_user: User): Promise<void>;
abstract deleteUser(_id: Identity<User>): Promise<void>;
abstract updateUser(_user: User): Promise<void>;
abstract deleteUser(id: ID): Promise<void>;
}
export class DatabaseError extends Error {
static readonly ERROR_PASSWORD_REQ =
'Password does not meet the strength requirements. It must be at least 12 characters long, with at least one uppercase letter, one lowercase letter, one number, and one special character.';
static readonly ERROR_USER_EXIST = 'User already exists.';
static readonly ERROR_DATABASE_CONNECTION =
'Failed to connect to the database.';
static readonly ERROR_USERNAME_LEN =
'Username must be longer than 8 characters.';
static readonly ERROR_PASSWORD_REQ = 'errorPasswordReq';
static readonly ERROR_USER_EXIST = 'errorUserExist';
static readonly ERROR_DATABASE_CONNECTION = 'errorDatabaseConn';
static readonly ERROR_USERNAME_REQ = 'errorUsernameReq';
constructor(message: string) {
constructor(messageKey: string) {
const { t } = useI18n();
const message = t(messageKey);
super(message);
this.name = 'DatabaseError';
}

10
src/ports/system/model.ts

@ -6,8 +6,6 @@ import type {
Port,
Prometheus,
SessionTimeOut,
String,
Boolean,
TrafficStats,
Version,
WGConfig,
@ -22,15 +20,15 @@ export type System = {
release: Version;
port: number;
webuiHost: String;
webuiHost: string;
// maxAge
sessionTimeout: SessionTimeOut;
lang: Lang;
userConfig: WGConfig;
wgPath: String;
wgDevice: String;
wgPath: string;
wgDevice: string;
wgHost: Address;
wgPort: Port;
wgConfigPort: Port;
@ -38,7 +36,7 @@ export type System = {
iptables: IpTables;
trafficStats: TrafficStats;
wgEnableExpiresTime: Boolean;
wgEnableExpiresTime: boolean;
prometheus: Prometheus;
sessionConfig: SessionConfig;
};

33
src/ports/types.ts

@ -5,17 +5,14 @@ export enum Lang {
/* french */
FR = 'fr',
}
export type String = string;
export type Number = number;
export type ID = String | Number;
export type Boolean = boolean;
export type Version = String;
export type SessionTimeOut = Number;
export type Port = Number;
export type Address = String;
export type HashPassword = String;
export type Command = String;
export type Key = String;
export type ID = string;
export type Version = string;
export type SessionTimeOut = number;
export type Port = number;
export type Address = string;
export type HashPassword = string;
export type Command = string;
export type Key = string;
export type IpTables = {
wgPreUp: Command;
wgPostUp: Command;
@ -28,8 +25,8 @@ export type WGInterface = {
address: Address;
};
export type WGConfig = {
mtu: Number;
persistentKeepalive: Number;
mtu: number;
persistentKeepalive: number;
rangeAddress: Address;
defaultDns: Array<Address>;
allowedIps: Array<Address>;
@ -41,16 +38,10 @@ export enum ChartType {
Bar = 3,
}
export type TrafficStats = {
enabled: Boolean;
enabled: boolean;
type: ChartType;
};
export type Prometheus = {
enabled: Boolean;
enabled: boolean;
password?: HashPassword | Undefined;
};
/**
* `id` of T or T.
*
* @template T - The specific type that can be used in place of id String.
*/
export type Identity<T> = ID | T;

18
src/ports/user/interface.ts

@ -1,4 +1,4 @@
import type { Identity, String, Undefined } from '../types';
import type { ID, Undefined } from '../types';
import type { User } from './model';
/**
@ -14,28 +14,26 @@ export default interface UserRepository {
/**
* Retrieves a user by their ID or User object from the database.
* @param {Identity<User>} id - The ID of the user or a User object.
* @param {ID} id - The ID of the user or a User object.
* @returns {Promise<User | Undefined>} A promise that resolves to the user data
* if found, or `undefined` if the user is not available.
*/
getUser(id: Identity<User>): Promise<User | Undefined>;
getUser(id: ID): Promise<User | Undefined>;
newUserWithPassword(username: String, password: String): Promise<void>;
newUserWithPassword(username: string, password: string): Promise<void>;
/**
* Creates or updates a user in the database.
* Updates a user in the database.
* @param {User} user - The user to be saved.
*
* **Note:** If the user already exists, this method will update their details.
* If the user does not exist, it will create a new user entry.
* @returns {Promise<void>} A promise that resolves when the operation is complete.
*/
saveUser(user: User): Promise<void>;
updateUser(user: User): Promise<void>;
/**
* Deletes a user from the database.
* @param {Identity<User>} id - The ID of the user or a User object to be deleted.
* @param {ID} id - The ID of the user or a User object to be deleted.
* @returns {Promise<void>} A promise that resolves when the user has been deleted.
*/
deleteUser(id: Identity<User>): Promise<void>;
deleteUser(id: ID): Promise<void>;
}

27
src/ports/user/model.ts

@ -1,13 +1,14 @@
import type { Address, ID, Key, HashPassword, String, Boolean } from '../types';
import type { Address, ID, Key, HashPassword } from '../types';
export enum ROLE {
/* Full permissions to any resources (app, database...) */
ADMIN = 'ADMIN',
/* Grants write and read permissions on their own resources and `CLIENT` resources without `ADMIN` permissions */
EDITOR = 'EDITOR',
/* Grants write and read permissions on their own resources */
CLIENT = 'CLIENT',
}
/**
* Represents user roles within the application, each with specific permissions :
*
* - `ADMIN`: Full permissions to all resources, including the app, database, etc
* - `EDITOR`: Granted write and read permissions on their own resources as well as
* `CLIENT` resources, but without `ADMIN` privileges
* - `CLIENT`: Granted write and read permissions only on their own resources.
*/
export type ROLE = 'ADMIN' | 'EDITOR' | 'CLIENT';
/**
* Representing a user data structure.
@ -15,14 +16,14 @@ export enum ROLE {
export type User = {
id: ID;
role: ROLE;
username: String;
username: string;
password: HashPassword;
name?: String;
name?: string;
address?: Address;
privateKey?: Key;
publicKey?: Key;
preSharedKey?: String;
preSharedKey?: string;
createdAt: Date;
updatedAt: Date;
enabled: Boolean;
enabled: boolean;
};

8
src/server/api/account/new.post.ts

@ -1,12 +1,12 @@
import { DatabaseError } from '~/ports/database';
type Request = { username: string; password: string };
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
try {
// TODO use zod
const { username, password } = await readBody<Request>(event);
const { username, password } = await readValidatedBody(
event,
validateZod(passwordType)
);
await Database.newUserWithPassword(username, password);
return { success: true };
} catch (error) {

2
src/server/api/lang.get.ts

@ -1,4 +1,4 @@
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json');
return await Database.getLang();
return Database.getLang();
});

10
src/server/api/release.get.ts

@ -1,8 +1,14 @@
export default defineEventHandler(async () => {
const release = Number.parseInt(RELEASE, 10);
const system = await Database.getSystem();
if (!system)
throw createError({
statusCode: 400,
statusMessage: 'Invalid',
});
const latestRelease = await fetchLatestRelease();
return {
currentRelease: release,
currentRelease: system.release,
latestRelease: latestRelease,
};
});

6
src/server/api/session.get.ts

@ -1,11 +1,9 @@
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
const authenticated = REQUIRES_PASSWORD
? session.data.authenticated === true
: true;
const authenticated = session.data.authenticated === true;
return {
requiresPassword: REQUIRES_PASSWORD,
requiresPassword: true,
authenticated,
};
});

19
src/server/api/session.post.ts

@ -3,34 +3,27 @@ import type { SessionConfig } from 'h3';
export default defineEventHandler(async (event) => {
const { username, password, remember } = await readValidatedBody(
event,
validateZod(passwordType)
validateZod(credentialsType)
);
if (!REQUIRES_PASSWORD) {
// if no password is required, the API should never be called.
// Do not automatically authenticate the user.
throw createError({
statusCode: 401,
statusMessage: 'Invalid state',
});
}
const users = await Database.getUsers();
const user = users.find((user) => user.username == username);
if (!user)
throw createError({
statusCode: 400,
statusMessage: 'User with username does not exist',
statusMessage: 'Incorrect credentials',
});
const userHashPassword = user.password;
if (!isPasswordValid(password, userHashPassword)) {
throw createError({
statusCode: 401,
statusMessage: 'Incorrect Password',
statusMessage: 'Incorrect credentials',
});
}
// TODO: timing againts timing attack
const conf: SessionConfig = SESSION_CONFIG;
if (MAX_AGE && remember) {
conf.cookie = {
@ -50,5 +43,5 @@ export default defineEventHandler(async (event) => {
SERVER_DEBUG(`New Session: ${data.id}`);
return { success: true, requiresPassword: REQUIRES_PASSWORD };
return { success: true, requiresPassword: true };
});

4
src/server/middleware/auth.ts

@ -2,12 +2,12 @@ export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
const session = await useWGSession(event);
if (url.pathname === '/login') {
if (!REQUIRES_PASSWORD || session.data.authenticated) {
if (session.data.authenticated) {
return sendRedirect(event, '/', 302);
}
}
if (url.pathname === '/') {
if (!session.data.authenticated && REQUIRES_PASSWORD) {
if (!session.data.authenticated) {
return sendRedirect(event, '/login', 302);
}
}

1
src/server/middleware/session.ts

@ -1,7 +1,6 @@
export default defineEventHandler(async (event) => {
const url = getRequestURL(event);
if (
!REQUIRES_PASSWORD ||
!url.pathname.startsWith('/api/') ||
url.pathname === '/api/account/new' ||
url.pathname === '/api/session' ||

4
src/server/utils/config.ts

@ -1,10 +1,7 @@
import type { SessionConfig } from 'h3';
import packageJSON from '../../package.json';
import debug from 'debug';
const version = packageJSON.release.version;
export const RELEASE = version;
export const PORT = process.env.PORT || '51821';
export const WEBUI_HOST = process.env.WEBUI_HOST || '0.0.0.0';
export const MAX_AGE = process.env.MAX_AGE
@ -63,7 +60,6 @@ export const ENABLE_PROMETHEUS_METRICS =
export const PROMETHEUS_METRICS_PASSWORD =
process.env.PROMETHEUS_METRICS_PASSWORD;
export const REQUIRES_PASSWORD = true;
export const REQUIRES_PROMETHEUS_PASSWORD = !!PROMETHEUS_METRICS_PASSWORD;
export const SESSION_CONFIG = {

8
src/server/utils/password.ts

@ -3,7 +3,7 @@ import bcrypt from 'bcryptjs';
/**
* Checks if `password` matches the user password.
*
* @param {string} password String to test
* @param {string} password string to test
* @returns {boolean} `true` if matching user password, otherwise `false`
*/
export function isPasswordValid(password: string, hash: string): boolean {
@ -37,11 +37,11 @@ export function isPasswordStrong(password: string): boolean {
}
/**
* Hashes a password using the bcrypt algorithm.
* Hashes a password.
*
* @param {string} password - The plaintext password to hash
* @returns {string} The bcrypt hash of the password
* @returns {string} The hash of the password
*/
export function hashPasswordWithBcrypt(password: string): string {
export function hashPassword(password: string): string {
return bcrypt.hashSync(password, 12);
}

10
src/server/utils/types.ts

@ -85,7 +85,7 @@ export const fileType = z.object(
{ message: 'Body must be a valid object' }
);
export const passwordType = z.object(
export const credentialsType = z.object(
{
username: username,
password: password,
@ -94,6 +94,14 @@ export const passwordType = z.object(
{ message: 'Body must be a valid object' }
);
export const passwordType = z.object(
{
username: username,
password: password,
},
{ message: 'Body must be a valid object' }
);
export function validateZod<T>(schema: ZodSchema<T>) {
return async (data: unknown) => {
try {

Loading…
Cancel
Save