Browse Source

2fa for oauth, rework 2fa system

pull/2659/head
Bernd Storath 1 month ago
parent
commit
0c833f0af0
  1. 52
      src/app/pages/login/2fa.vue
  2. 7
      src/app/pages/login/index.vue
  3. 36
      src/app/stores/login.ts
  4. 14
      src/server/api/auth/[provider]/callback.get.ts
  5. 10
      src/server/api/auth/cancel.post.ts
  6. 17
      src/server/api/auth/password.post.ts
  7. 14
      src/server/api/auth/pending.get.ts
  8. 40
      src/server/api/auth/verify-2fa.post.ts
  9. 62
      src/server/database/repositories/user/service.ts
  10. 1
      src/server/database/repositories/user/types.ts
  11. 6
      src/server/utils/session.ts

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

@ -35,18 +35,20 @@
<script setup lang="ts"> <script setup lang="ts">
const toast = useToast(); const toast = useToast();
const { t } = useI18n(); const { t } = useI18n();
const loginStore = useLoginStore();
const authenticating = ref(false); const authenticating = ref(false);
const totp = ref<string>(''); const totp = ref<string>('');
if (!loginStore.hasPendingLogin) { const { error } = await useFetch('/api/auth/pending');
await navigateTo('/login'); watchEffect(() => {
} if (error.value) {
navigateTo('/login');
}
});
const _submit = useSubmit( const _submit = useSubmit(
(data) => (data) =>
$fetch('/api/auth/password', { $fetch('/api/auth/verify-2fa', {
method: 'post', method: 'post',
body: data, body: data,
}), }),
@ -54,8 +56,8 @@ const _submit = useSubmit(
revert: async (success, data) => { revert: async (success, data) => {
if (success) { if (success) {
if (data?.status === 'success') { if (data?.status === 'success') {
loginStore.clearPendingLogin();
await navigateTo('/'); await navigateTo('/');
return;
} else if (data?.status === 'INVALID_TOTP_CODE') { } else if (data?.status === 'INVALID_TOTP_CODE') {
authenticating.value = false; authenticating.value = false;
totp.value = ''; totp.value = '';
@ -65,15 +67,6 @@ const _submit = useSubmit(
type: 'error', type: 'error',
}); });
return; return;
} else if (data?.status === 'TOTP_REQUIRED') {
authenticating.value = false;
totp.value = '';
toast.showToast({
title: t('general.2fa'),
message: t('login.2faRequired'),
type: 'error',
});
return;
} }
} }
authenticating.value = false; authenticating.value = false;
@ -83,24 +76,27 @@ const _submit = useSubmit(
); );
async function submit() { async function submit() {
const challenge = loginStore.pendingChallenge; if (!totp.value || authenticating.value) return;
if (!challenge || !totp.value || authenticating.value) return;
authenticating.value = true; authenticating.value = true;
return _submit({ totpCode: totp.value });
}
if (challenge.type === 'password') { const _cancel = useSubmit(
return _submit({ (data) =>
username: challenge.username, $fetch('/api/auth/cancel', {
password: challenge.password, method: 'post',
remember: challenge.remember, body: data,
totpCode: totp.value, }),
}); {
revert: async () => {
await navigateTo('/login');
},
noSuccessToast: true,
} }
} );
async function cancel() { async function cancel() {
loginStore.clearPendingLogin(); return _cancel({});
await navigateTo('/login');
} }
</script> </script>

7
src/app/pages/login/index.vue

@ -69,7 +69,6 @@
<script setup lang="ts"> <script setup lang="ts">
const toast = useToast(); const toast = useToast();
const { t } = useI18n(); const { t } = useI18n();
const loginStore = useLoginStore();
const authenticating = ref(false); const authenticating = ref(false);
const remember = ref(false); const remember = ref(false);
@ -88,15 +87,9 @@ const _submit = useSubmit(
revert: async (success, data) => { revert: async (success, data) => {
if (success) { if (success) {
if (data?.status === 'success') { if (data?.status === 'success') {
loginStore.clearPendingLogin();
await navigateTo('/'); await navigateTo('/');
} else if (data?.status === 'TOTP_REQUIRED') { } else if (data?.status === 'TOTP_REQUIRED') {
authenticating.value = false; authenticating.value = false;
loginStore.setPendingPasswordLogin(
username.value,
password.value,
remember.value
);
await navigateTo('/login/2fa'); await navigateTo('/login/2fa');
return; return;
} else if (data?.status === 'INVALID_TOTP_CODE') { } else if (data?.status === 'INVALID_TOTP_CODE') {

36
src/app/stores/login.ts

@ -1,36 +0,0 @@
type PendingLoginChallenge = {
type: 'password';
username: string;
password: string;
remember: boolean;
};
export const useLoginStore = defineStore('Login', () => {
const pendingChallenge = ref<PendingLoginChallenge | null>(null);
const hasPendingLogin = computed(() => pendingChallenge.value !== null);
function setPendingPasswordLogin(
username: string,
password: string,
remember: boolean
) {
pendingChallenge.value = {
type: 'password',
username,
password,
remember,
};
}
function clearPendingLogin() {
pendingChallenge.value = null;
}
return {
pendingChallenge,
hasPendingLogin,
setPendingPasswordLogin,
clearPendingLogin,
};
});

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

@ -34,6 +34,18 @@ export default defineEventHandler(async (event) => {
if (!result.success) { if (!result.success) {
switch (result.error) { switch (result.error) {
case 'TOTP_REQUIRED':
await session.update({
pendingLogin: {
type: 'oauth',
userId: result.userId,
remember: false,
},
oauth_nonce: undefined,
oauth_state: undefined,
oauth_verifier: undefined,
});
return sendRedirect(event, '/login/2fa');
case 'USER_DISABLED': case 'USER_DISABLED':
throw createError({ throw createError({
statusCode: 401, statusCode: 401,
@ -56,7 +68,7 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Unexpected error', statusMessage: 'Unexpected error',
}); });
} }
assertUnreachable(result.error); assertUnreachable(result);
} }
// Create session // Create session

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

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

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

@ -8,12 +8,14 @@ export default defineEventHandler(async (event) => {
}); });
} }
const { username, password, remember, totpCode } = await readValidatedBody( const { username, password, remember } = await readValidatedBody(
event, event,
validateZod(UserLoginSchema, event) validateZod(UserLoginSchema, event)
); );
const result = await Database.users.login(username, password, totpCode); const result = await Database.users.login(username, password);
const session = await useWGSession(event, remember);
// TODO: add localization support // TODO: add localization support
@ -25,6 +27,13 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Invalid username or password', statusMessage: 'Invalid username or password',
}); });
case 'TOTP_REQUIRED': case 'TOTP_REQUIRED':
await session.update({
pendingLogin: {
type: 'password',
userId: result.userId,
remember,
},
});
return { status: 'TOTP_REQUIRED' as const }; return { status: 'TOTP_REQUIRED' as const };
case 'INVALID_TOTP_CODE': case 'INVALID_TOTP_CODE':
return { status: 'INVALID_TOTP_CODE' as const }; return { status: 'INVALID_TOTP_CODE' as const };
@ -39,13 +48,11 @@ export default defineEventHandler(async (event) => {
statusMessage: 'Unexpected error', statusMessage: 'Unexpected error',
}); });
} }
assertUnreachable(result.error); assertUnreachable(result);
} }
const user = result.user; const user = result.user;
const session = await useWGSession(event, remember);
const data = await session.update({ const data = await session.update({
userId: user.id, userId: user.id,
}); });

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

@ -0,0 +1,14 @@
export default defineEventHandler(async (event) => {
const session = await useWGSession(event);
if (!session.data.pendingLogin) {
throw createError({
statusCode: 401,
statusMessage: 'No pending authentication',
});
}
return {
type: session.data.pendingLogin.type,
};
});

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

@ -0,0 +1,40 @@
import { z } from 'zod';
const Verify2faSchema = z.object({
totpCode: z.string().min(6).max(6),
});
export default defineEventHandler(async (event) => {
const { totpCode } = await readValidatedBody(
event,
validateZod(Verify2faSchema, event)
);
const session = await useWGSession(event, false);
const pendingLogin = session.data.pendingLogin;
if (!pendingLogin) {
throw createError({
statusCode: 401,
statusMessage: 'No pending authentication',
});
}
const isValid = await Database.users.validateTotpCode(
pendingLogin.userId,
totpCode
);
if (!isValid) {
return { status: 'INVALID_TOTP_CODE' as const };
}
await session.update({
userId: pendingLogin.userId,
pendingLogin: undefined,
oauth_nonce: undefined,
oauth_state: undefined,
oauth_verifier: undefined,
});
return { status: 'success' as const };
});

62
src/server/database/repositories/user/service.ts

@ -13,10 +13,14 @@ type LoginResult =
success: false; success: false;
error: error:
| 'INCORRECT_CREDENTIALS' | 'INCORRECT_CREDENTIALS'
| 'TOTP_REQUIRED'
| 'USER_DISABLED' | 'USER_DISABLED'
| 'INVALID_TOTP_CODE' | 'INVALID_TOTP_CODE'
| 'UNEXPECTED_ERROR'; | 'UNEXPECTED_ERROR';
}
| {
success: false;
error: 'TOTP_REQUIRED';
userId: ID;
}; };
type LoginWithOAuthResult = type LoginWithOAuthResult =
@ -31,6 +35,11 @@ type LoginWithOAuthResult =
| 'USER_ALREADY_LINKED' | 'USER_ALREADY_LINKED'
| 'UNEXPECTED_ERROR' | 'UNEXPECTED_ERROR'
| 'AUTO_REGISTER_DISABLED'; | 'AUTO_REGISTER_DISABLED';
}
| {
success: false;
error: 'TOTP_REQUIRED';
userId: ID;
}; };
function createPreparedStatement(db: DBType) { function createPreparedStatement(db: DBType) {
@ -172,7 +181,7 @@ export class UserService {
return this.#statements.updateKey.execute({ id, key }); return this.#statements.updateKey.execute({ id, key });
} }
login(username: string, password: string, code: string | undefined) { login(username: string, password: string) {
return this.#db.transaction(async (tx): Promise<LoginResult> => { return this.#db.transaction(async (tx): Promise<LoginResult> => {
const txUser = await tx.query.user const txUser = await tx.query.user
.findFirst({ where: eq(user.username, username) }) .findFirst({ where: eq(user.username, username) })
@ -187,19 +196,11 @@ export class UserService {
} }
if (txUser.totpVerified) { if (txUser.totpVerified) {
if (!code) { return {
return { success: false, error: 'TOTP_REQUIRED' }; success: false,
} else { error: 'TOTP_REQUIRED',
const totpKey = txUser.totpKey; userId: txUser.id,
if (!totpKey) { };
return { success: false, error: 'UNEXPECTED_ERROR' };
}
const totp = this.#createTotp({ username: txUser.username, totpKey });
if (totp.validate({ token: code, window: 1 }) === null) {
return { success: false, error: 'INVALID_TOTP_CODE' };
}
}
} }
if (!txUser.enabled) { if (!txUser.enabled) {
@ -265,6 +266,22 @@ export class UserService {
}); });
} }
async validateTotpCode(id: ID, code: string) {
const txUser = await this.#db.query.user
.findFirst({ where: eq(user.id, id) })
.execute();
if (!txUser || !txUser.totpVerified || !txUser.totpKey) {
return false;
}
const totp = this.#createTotp({
username: txUser.username,
totpKey: txUser.totpKey,
});
return totp.validate({ token: code, window: 1 }) !== null;
}
/** /**
* Login or register user with OAuth provider. * Login or register user with OAuth provider.
* If user with the same email already exists, link account with OAuth provider. * If user with the same email already exists, link account with OAuth provider.
@ -288,6 +305,13 @@ export class UserService {
.execute(); .execute();
if (userById) { if (userById) {
if (userById.totpVerified) {
return {
success: false,
error: 'TOTP_REQUIRED',
userId: userById.id,
};
}
if (!userById.enabled) { if (!userById.enabled) {
return { success: false, error: 'USER_DISABLED' }; return { success: false, error: 'USER_DISABLED' };
} }
@ -317,6 +341,14 @@ export class UserService {
.where(eq(user.id, userByEmail.id)) .where(eq(user.id, userByEmail.id))
.execute(); .execute();
if (userByEmail.totpVerified) {
return {
success: false,
error: 'TOTP_REQUIRED',
userId: userByEmail.id,
};
}
// TODO: return updated user // TODO: return updated user
return { success: true, user: userByEmail }; return { success: true, user: userByEmail };
} }

1
src/server/database/repositories/user/types.ts

@ -25,7 +25,6 @@ export const UserLoginSchema = z.object({
username: username, username: username,
password: password, password: password,
remember: remember, remember: remember,
totpCode: totpCode.optional(),
}); });
export const UserSetupSchema = z export const UserSetupSchema = z

6
src/server/utils/session.ts

@ -3,6 +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: {
type: 'password' | 'oauth';
userId: ID;
remember: boolean;
};
oauth_verifier: string; oauth_verifier: string;
oauth_nonce: string; oauth_nonce: string;
oauth_state: string; oauth_state: string;

Loading…
Cancel
Save