Browse Source

various improvements (#2671)

* improve oauth config logic

* expire pending login

* move sort to backend
master
Bernd Storath 11 hours ago
committed by GitHub
parent
commit
e7ea65a898
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 4
      src/app/components/Clients/Sort.vue
  2. 8
      src/app/pages/login/2fa.vue
  3. 24
      src/app/stores/clients.ts
  4. 2
      src/app/stores/global.ts
  5. 3
      src/i18n/locales/en.json
  6. 2
      src/server/api/auth/[provider]/callback.get.ts
  7. 4
      src/server/api/auth/cancel.post.ts
  8. 2
      src/server/api/auth/password.post.ts
  9. 10
      src/server/api/auth/pending.get.ts
  10. 7
      src/server/api/auth/verify-2fa.post.ts
  11. 6
      src/server/api/client/index.get.ts
  12. 151
      src/server/database/repositories/client/service.ts
  13. 10
      src/server/database/repositories/client/types.ts
  14. 19
      src/server/utils/WireGuard.ts
  15. 2
      src/server/utils/config.ts
  16. 34
      src/server/utils/oauth.ts
  17. 3
      src/server/utils/session.ts

4
src/app/components/Clients/Sort.vue

@ -1,6 +1,6 @@
<template>
<BasePrimaryButton @click="toggleSort">
<IconsArrowDown v-if="globalStore.sortClient === true" class="mr-2 w-4" />
<IconsArrowDown v-if="globalStore.sortClient === 'asc'" class="mr-2 w-4" />
<IconsArrowUp v-else class="mr-2 w-4" />
<span class="text-sm">{{ $t('client.sort') }}</span>
</BasePrimaryButton>
@ -11,7 +11,7 @@ const globalStore = useGlobalStore();
const clientsStore = useClientsStore();
function toggleSort() {
globalStore.sortClient = !globalStore.sortClient;
globalStore.sortClient = globalStore.sortClient === 'asc' ? 'desc' : 'asc';
clientsStore.refresh().catch(console.error);
}
</script>

8
src/app/pages/login/2fa.vue

@ -65,6 +65,14 @@ const _submit = useSubmit(
type: 'error',
});
return;
} else if (data?.status === 'PENDING_LOGIN_EXPIRED') {
toast.showToast({
title: t('general.2fa'),
message: t('login.loginExpired'),
type: 'error',
});
await navigateTo('/login');
return;
}
}
authenticating.value = false;

24
src/app/stores/clients.ts

@ -31,9 +31,12 @@ export const useClientsStore = defineStore('Clients', () => {
const clients = ref<null | LocalClient[]>(null);
const clientsPersist = ref<Record<string, ClientPersist>>({});
const searchParams = ref({
filter: undefined as string | undefined,
});
const filter = ref<string | undefined>(undefined);
const searchParams = computed(() => ({
filter: filter.value,
sort: globalStore.sortClient,
}));
const { data: _clients, refresh: _refresh } = useFetch('/api/client', {
method: 'get',
@ -43,7 +46,7 @@ export const useClientsStore = defineStore('Clients', () => {
// TODO: rewrite
async function refresh({ updateCharts = false } = {}) {
await _refresh();
let transformedClients = _clients.value?.map((client) => {
const transformedClients = _clients.value?.map((client) => {
let avatar = undefined;
if (client.name.includes('@') && client.name.includes('.')) {
avatar = `https://gravatar.com/avatar/${sha256(client.name.toLowerCase().trim())}.jpg`;
@ -126,21 +129,12 @@ export const useClientsStore = defineStore('Clients', () => {
};
});
// TODO: move sort to backend
if (transformedClients !== undefined) {
transformedClients = sortByProperty(
transformedClients,
'name',
globalStore.sortClient
);
}
clients.value = transformedClients ?? null;
}
function setSearchQuery(filter: string) {
function setSearchQuery(query: string) {
clients.value = null;
searchParams.value.filter = filter || undefined;
filter.value = query || undefined;
}
return { clients, clientsPersist, refresh, _clients, setSearchQuery };

2
src/app/stores/global.ts

@ -6,7 +6,7 @@ export const useGlobalStore = defineStore('Global', () => {
}
);
const sortClient = ref(true); // Sort clients by name, true = asc, false = desc
const sortClient = ref<'asc' | 'desc'>('asc');
const uiShowCharts = useCookie<boolean>('uiShowCharts', {
default: () => false,

3
src/i18n/locales/en.json

@ -84,7 +84,8 @@
"rememberMeDesc": "Stay logged after closing the browser",
"insecure": "You can't log in with an insecure connection. Use HTTPS.",
"2faRequired": "Two Factor Authentication is required",
"2faWrong": "Two Factor Authentication is wrong"
"2faWrong": "Two Factor Authentication is wrong",
"loginExpired": "Login expired. Try again"
},
"client": {
"empty": "There are no clients yet.",

2
src/server/api/auth/[provider]/callback.get.ts

@ -40,6 +40,8 @@ export default defineEventHandler(async (event) => {
type: 'oauth',
userId: result.userId,
remember: false,
// 5min
expires_at: Date.now() + 5 * 60 * 1000,
},
oauth_nonce: undefined,
oauth_state: undefined,

4
src/server/api/auth/cancel.post.ts

@ -1,10 +1,12 @@
export default defineEventHandler(async (event) => {
const session = await useWGSession(event, false);
const session = await useWGSession(event);
await session.update({
pendingLogin: undefined,
oauth_nonce: undefined,
oauth_state: undefined,
oauth_verifier: undefined,
});
return { success: true as const };
});

2
src/server/api/auth/password.post.ts

@ -32,6 +32,8 @@ export default defineEventHandler(async (event) => {
type: 'password',
userId: result.userId,
remember,
// 5min
expires_at: Date.now() + 5 * 60 * 1000,
},
});
return { status: 'TOTP_REQUIRED' as const };

10
src/server/api/auth/pending.get.ts

@ -7,6 +7,16 @@ export default defineEventHandler(async (event) => {
statusMessage: 'No pending authentication',
});
}
if (new Date() > new Date(session.data.pendingLogin.expires_at)) {
await session.update({
pendingLogin: undefined,
});
throw createError({
statusCode: 401,
statusMessage: 'No pending authentication',
});
}
return {
type: session.data.pendingLogin.type,

7
src/server/api/auth/verify-2fa.post.ts

@ -14,6 +14,13 @@ export default defineEventHandler(async (event) => {
statusMessage: 'No pending authentication',
});
}
if (new Date() > new Date(pendingLogin.expires_at)) {
await session.update({
pendingLogin: undefined,
});
return { status: 'PENDING_LOGIN_EXPIRED' as const };
}
const totpStatus = await Database.users.validateTotpCode(
pendingLogin.userId,

6
src/server/api/client/index.get.ts

@ -4,14 +4,14 @@ export default definePermissionEventHandler(
'clients',
'custom',
async ({ event, user }) => {
const { filter } = await getValidatedQuery(
const { filter, sort } = await getValidatedQuery(
event,
validateZod(ClientQuerySchema, event)
);
if (user.role === roles.ADMIN) {
return WireGuard.getAllClients(filter);
return WireGuard.getAllClients({ filter, sort });
}
return WireGuard.getClientsForUser(user.id, filter);
return WireGuard.getClientsForUser(user.id, { filter, sort });
}
);

151
src/server/database/repositories/client/service.ts

@ -4,6 +4,7 @@ import { client } from './schema';
import type {
ClientCreateFromExistingType,
ClientCreateType,
ClientQueryType,
UpdateClientType,
} from './types';
import type { DBType } from '#db/sqlite';
@ -18,63 +19,9 @@ function createPreparedStatement(db: DBType) {
},
})
.prepare(),
findAllPublic: db.query.client
.findMany({
with: {
oneTimeLink: true,
},
columns: {
privateKey: false,
preSharedKey: false,
},
})
.prepare(),
findById: db.query.client
.findFirst({ where: eq(client.id, sql.placeholder('id')) })
.prepare(),
findByUserId: db.query.client
.findMany({
where: eq(client.userId, sql.placeholder('userId')),
with: { oneTimeLink: true },
columns: {
privateKey: false,
preSharedKey: false,
},
})
.prepare(),
findAllPublicFiltered: db.query.client
.findMany({
where: or(
like(client.name, sql.placeholder('filter')),
like(client.ipv4Address, sql.placeholder('filter')),
like(client.ipv6Address, sql.placeholder('filter'))
),
with: {
oneTimeLink: true,
},
columns: {
privateKey: false,
preSharedKey: false,
},
})
.prepare(),
findByUserIdFiltered: db.query.client
.findMany({
where: and(
eq(client.userId, sql.placeholder('userId')),
or(
like(client.name, sql.placeholder('filter')),
like(client.ipv4Address, sql.placeholder('filter')),
like(client.ipv6Address, sql.placeholder('filter'))
)
),
with: { oneTimeLink: true },
columns: {
privateKey: false,
preSharedKey: false,
},
})
.prepare(),
toggle: db
.update(client)
.set({ enabled: sql.placeholder('enabled') as never as boolean })
@ -96,15 +43,6 @@ export class ClientService {
this.#statements = createPreparedStatement(db);
}
async getForUser(userId: ID) {
const result = await this.#statements.findByUserId.execute({ userId });
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}));
}
/**
* Never return values directly from this function. Use {@link getAllPublic} instead.
*/
@ -120,25 +58,40 @@ export class ClientService {
/**
* Returns all clients without sensitive data
*/
async getAllPublic() {
const result = await this.#statements.findAllPublic.execute();
return result.map((row) => ({
...row,
createdAt: new Date(row.createdAt),
updatedAt: new Date(row.updatedAt),
}));
}
async getAllPublic({ filter, sort }: ClientQueryType) {
const filters = [];
/**
* Get clients based on user ID and filter conditions
*/
async getForUserFiltered(userId: ID, filter: string) {
const filterPattern = `%${filter.toLowerCase()}%`;
if (filter?.trim()) {
const filterPattern = `%${filter?.toLowerCase()}%`;
filters.push(
or(
like(client.name, filterPattern),
like(client.ipv4Address, filterPattern),
like(client.ipv6Address, filterPattern)
)
);
}
const result = await this.#statements.findByUserIdFiltered.execute({
userId,
filter: filterPattern,
});
const result = await this.#db.query.client
.findMany({
with: {
oneTimeLink: true,
},
where: and(...filters),
columns: {
privateKey: false,
preSharedKey: false,
},
orderBy: (t, { asc, desc }) => {
if (sort === 'desc') {
return desc(t.name);
} else {
// default to asc
return asc(t.name);
}
},
})
.execute();
return result.map((row) => ({
...row,
@ -148,14 +101,40 @@ export class ClientService {
}
/**
* Get all clients based on filter conditions without sensitive data
* Returns all clients without sensitive data belonging to user
*/
async getAllPublicFiltered(filter: string) {
const filterPattern = `%${filter.toLowerCase()}%`;
async getAllForUser(userId: ID, { filter, sort }: ClientQueryType) {
const filters = [];
const result = await this.#statements.findAllPublicFiltered.execute({
filter: filterPattern,
});
if (filter?.trim()) {
const filterPattern = `%${filter?.toLowerCase()}%`;
filters.push(
or(
like(client.name, filterPattern),
like(client.ipv4Address, filterPattern),
like(client.ipv6Address, filterPattern)
)
);
}
const result = await this.#db.query.client
.findMany({
where: and(eq(client.userId, userId), ...filters),
with: { oneTimeLink: true },
columns: {
privateKey: false,
preSharedKey: false,
},
orderBy: (t, { asc, desc }) => {
if (sort === 'desc') {
return desc(t.name);
} else {
// default to asc
return asc(t.name);
}
},
})
.execute();
return result.map((row) => ({
...row,

10
src/server/database/repositories/client/types.ts

@ -45,8 +45,6 @@ const address6 = z
.pipe(controlStringRefine)
.refine((v) => isIPv6(v));
const filter = z.string().pipe(safeStringRefine).optional();
const serverAllowedIps = z.array(AddressSchema, {
message: t('zod.client.serverAllowedIps'),
});
@ -58,8 +56,13 @@ export const ClientCreateSchema = z.object({
export type ClientCreateType = z.infer<typeof ClientCreateSchema>;
const filter = z.string().pipe(safeStringRefine);
const sort = z.enum(['asc', 'desc']);
export const ClientQuerySchema = z.object({
filter: filter,
filter: filter.optional(),
sort: sort.optional(),
});
export type ClientQueryType = z.infer<typeof ClientQuerySchema>;
@ -93,7 +96,6 @@ export const ClientUpdateSchema = schemaForType<UpdateClientType>()(
})
);
// TODO: investigate if coerce is bad
const clientId = z.coerce.number({ message: t('zod.client.id') });
export const ClientGetSchema = z.object({

19
src/server/utils/WireGuard.ts

@ -1,6 +1,7 @@
import fs from 'node:fs/promises';
import { createDebug } from 'obug';
import type { InterfaceType } from '#db/repositories/interface/types';
import type { ClientQueryType } from '#db/repositories/client/types';
const WG_DEBUG = createDebug('WireGuard');
@ -78,15 +79,10 @@ class WireGuard {
WG_DEBUG('Config synced successfully.');
}
async getClientsForUser(userId: ID, filter?: string) {
async getClientsForUser(userId: ID, query: ClientQueryType) {
const wgInterface = await Database.interfaces.get();
let dbClients;
if (filter?.trim()) {
dbClients = await Database.clients.getForUserFiltered(userId, filter);
} else {
dbClients = await Database.clients.getForUser(userId);
}
const dbClients = await Database.clients.getAllForUser(userId, query);
const clients = dbClients.map((client) => ({
...client,
@ -126,15 +122,10 @@ class WireGuard {
return clientDump;
}
async getAllClients(filter?: string) {
async getAllClients(query: ClientQueryType = {}) {
const wgInterface = await Database.interfaces.get();
let dbClients;
if (filter?.trim()) {
dbClients = await Database.clients.getAllPublicFiltered(filter);
} else {
dbClients = await Database.clients.getAllPublic();
}
const dbClients = await Database.clients.getAllPublic(query);
const clients = dbClients.map((client) => ({
...client,

2
src/server/utils/config.ts

@ -44,7 +44,7 @@ export const WG_ENV = {
DISABLE_IPV6: process.env.DISABLE_IPV6 === 'true',
WG_EXECUTABLE: await detectAwg(),
DISABLE_VERSION_CHECK: process.env.DISABLE_VERSION_CHECK === 'true',
/** List of enabled OAuth providers */
/** List of enabled and configured OAuth providers */
OAUTH_PROVIDERS: oauthProviders,
/** List of allowed OAuth domains */
OAUTH_ALLOWED_DOMAINS: process.env.OAUTH_ALLOWED_DOMAINS?.split(',').map(

34
src/server/utils/oauth.ts

@ -12,6 +12,11 @@ type OAuthConfig = {
userInfoFlow?: 'github';
};
type ConfiguredOAuthConfig = OAuthConfig & {
clientId: string;
clientSecret: string;
};
const GoogleConfig: OAuthConfig = {
friendlyName: 'Google',
server: 'https://accounts.google.com',
@ -63,22 +68,23 @@ export function isValidOauthProvider(
}
export function isConfiguredOauthProvider(
oauthProvider: (typeof OAUTH_PROVIDERS)[OAUTH_PROVIDER]
): oauthProvider is (typeof OAUTH_PROVIDERS)[OAUTH_PROVIDER] & {
clientId: string;
clientSecret: string;
} {
oauthProvider: OAuthConfig
): oauthProvider is ConfiguredOAuthConfig {
if (!oauthProvider.clientId || !oauthProvider.clientSecret) {
return false;
}
return true;
}
function isEnabledProvider(provider: OAUTH_PROVIDER) {
return WG_ENV.OAUTH_PROVIDERS?.includes(provider);
function getEnabledOauthProvider(provider: OAUTH_PROVIDER) {
if (!WG_ENV.OAUTH_PROVIDERS?.includes(provider)) {
return;
}
// WG_ENV.OAUTH_PROVIDERS is filtered to configured providers during startup.
return OAUTH_PROVIDERS[provider] as ConfiguredOAuthConfig;
}
// TODO: simplify logic between WG_ENV.OAUTH_PROVIDERS and buildOauthConfig
export async function buildOauthConfig(event: H3Event) {
const provider = getRouterParam(event, 'provider');
if (!provider || !isValidOauthProvider(provider)) {
@ -88,22 +94,14 @@ export async function buildOauthConfig(event: H3Event) {
});
}
if (!isEnabledProvider(provider)) {
const oauthProvider = getEnabledOauthProvider(provider);
if (!oauthProvider) {
throw createError({
statusCode: 403,
statusMessage: 'Provider is not enabled',
});
}
const oauthProvider = OAUTH_PROVIDERS[provider];
if (!isConfiguredOauthProvider(oauthProvider)) {
throw createError({
statusCode: 500,
statusMessage: 'Provider is not configured',
});
}
const config = await client.discovery(
new URL(oauthProvider.server),
oauthProvider.clientId,

3
src/server/utils/session.ts

@ -3,11 +3,12 @@ import type { UserType } from '#db/repositories/user/types';
export type WGSession = Partial<{
userId: ID;
// TODO: add pending login expiration
pendingLogin: {
type: 'password' | 'oauth';
userId: ID;
remember: boolean;
/** in milliseconds */
expires_at: number;
};
oauth_verifier: string;
oauth_nonce: string;

Loading…
Cancel
Save