Browse Source

various improvements (#2671)

* improve oauth config logic

* expire pending login

* move sort to backend
pull/2551/merge
Bernd Storath 2 days 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> <template>
<BasePrimaryButton @click="toggleSort"> <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" /> <IconsArrowUp v-else class="mr-2 w-4" />
<span class="text-sm">{{ $t('client.sort') }}</span> <span class="text-sm">{{ $t('client.sort') }}</span>
</BasePrimaryButton> </BasePrimaryButton>
@ -11,7 +11,7 @@ const globalStore = useGlobalStore();
const clientsStore = useClientsStore(); const clientsStore = useClientsStore();
function toggleSort() { function toggleSort() {
globalStore.sortClient = !globalStore.sortClient; globalStore.sortClient = globalStore.sortClient === 'asc' ? 'desc' : 'asc';
clientsStore.refresh().catch(console.error); clientsStore.refresh().catch(console.error);
} }
</script> </script>

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

@ -65,6 +65,14 @@ const _submit = useSubmit(
type: 'error', type: 'error',
}); });
return; 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; 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 clients = ref<null | LocalClient[]>(null);
const clientsPersist = ref<Record<string, ClientPersist>>({}); const clientsPersist = ref<Record<string, ClientPersist>>({});
const searchParams = ref({ const filter = ref<string | undefined>(undefined);
filter: undefined as string | undefined,
}); const searchParams = computed(() => ({
filter: filter.value,
sort: globalStore.sortClient,
}));
const { data: _clients, refresh: _refresh } = useFetch('/api/client', { const { data: _clients, refresh: _refresh } = useFetch('/api/client', {
method: 'get', method: 'get',
@ -43,7 +46,7 @@ export const useClientsStore = defineStore('Clients', () => {
// TODO: rewrite // TODO: rewrite
async function refresh({ updateCharts = false } = {}) { async function refresh({ updateCharts = false } = {}) {
await _refresh(); await _refresh();
let transformedClients = _clients.value?.map((client) => { const transformedClients = _clients.value?.map((client) => {
let avatar = undefined; let avatar = undefined;
if (client.name.includes('@') && client.name.includes('.')) { if (client.name.includes('@') && client.name.includes('.')) {
avatar = `https://gravatar.com/avatar/${sha256(client.name.toLowerCase().trim())}.jpg`; 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; clients.value = transformedClients ?? null;
} }
function setSearchQuery(filter: string) { function setSearchQuery(query: string) {
clients.value = null; clients.value = null;
searchParams.value.filter = filter || undefined; filter.value = query || undefined;
} }
return { clients, clientsPersist, refresh, _clients, setSearchQuery }; 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', { const uiShowCharts = useCookie<boolean>('uiShowCharts', {
default: () => false, default: () => false,

3
src/i18n/locales/en.json

@ -84,7 +84,8 @@
"rememberMeDesc": "Stay logged after closing the browser", "rememberMeDesc": "Stay logged after closing the browser",
"insecure": "You can't log in with an insecure connection. Use HTTPS.", "insecure": "You can't log in with an insecure connection. Use HTTPS.",
"2faRequired": "Two Factor Authentication is required", "2faRequired": "Two Factor Authentication is required",
"2faWrong": "Two Factor Authentication is wrong" "2faWrong": "Two Factor Authentication is wrong",
"loginExpired": "Login expired. Try again"
}, },
"client": { "client": {
"empty": "There are no clients yet.", "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', type: 'oauth',
userId: result.userId, userId: result.userId,
remember: false, remember: false,
// 5min
expires_at: Date.now() + 5 * 60 * 1000,
}, },
oauth_nonce: undefined, oauth_nonce: undefined,
oauth_state: undefined, oauth_state: undefined,

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

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

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

@ -32,6 +32,8 @@ export default defineEventHandler(async (event) => {
type: 'password', type: 'password',
userId: result.userId, userId: result.userId,
remember, remember,
// 5min
expires_at: Date.now() + 5 * 60 * 1000,
}, },
}); });
return { status: 'TOTP_REQUIRED' as const }; 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', 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 { return {
type: session.data.pendingLogin.type, 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', 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( const totpStatus = await Database.users.validateTotpCode(
pendingLogin.userId, pendingLogin.userId,

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

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

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

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

19
src/server/utils/WireGuard.ts

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

2
src/server/utils/config.ts

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

34
src/server/utils/oauth.ts

@ -12,6 +12,11 @@ type OAuthConfig = {
userInfoFlow?: 'github'; userInfoFlow?: 'github';
}; };
type ConfiguredOAuthConfig = OAuthConfig & {
clientId: string;
clientSecret: string;
};
const GoogleConfig: OAuthConfig = { const GoogleConfig: OAuthConfig = {
friendlyName: 'Google', friendlyName: 'Google',
server: 'https://accounts.google.com', server: 'https://accounts.google.com',
@ -63,22 +68,23 @@ export function isValidOauthProvider(
} }
export function isConfiguredOauthProvider( export function isConfiguredOauthProvider(
oauthProvider: (typeof OAUTH_PROVIDERS)[OAUTH_PROVIDER] oauthProvider: OAuthConfig
): oauthProvider is (typeof OAUTH_PROVIDERS)[OAUTH_PROVIDER] & { ): oauthProvider is ConfiguredOAuthConfig {
clientId: string;
clientSecret: string;
} {
if (!oauthProvider.clientId || !oauthProvider.clientSecret) { if (!oauthProvider.clientId || !oauthProvider.clientSecret) {
return false; return false;
} }
return true; return true;
} }
function isEnabledProvider(provider: OAUTH_PROVIDER) { function getEnabledOauthProvider(provider: OAUTH_PROVIDER) {
return WG_ENV.OAUTH_PROVIDERS?.includes(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) { export async function buildOauthConfig(event: H3Event) {
const provider = getRouterParam(event, 'provider'); const provider = getRouterParam(event, 'provider');
if (!provider || !isValidOauthProvider(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({ throw createError({
statusCode: 403, statusCode: 403,
statusMessage: 'Provider is not enabled', 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( const config = await client.discovery(
new URL(oauthProvider.server), new URL(oauthProvider.server),
oauthProvider.clientId, oauthProvider.clientId,

3
src/server/utils/session.ts

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

Loading…
Cancel
Save