Files
VRCX/src/stores/auth.js
2026-01-18 08:06:52 +13:00

903 lines
32 KiB
JavaScript

import { reactive, ref, watch } from 'vue';
import { defineStore } from 'pinia';
import { toast } from 'vue-sonner';
import { useI18n } from 'vue-i18n';
import Noty from 'noty';
import { closeWebSocket, initWebsocket } from '../service/websocket';
import { AppDebug } from '../service/appConfig';
import { authRequest } from '../api';
import { database } from '../service/database';
import { escapeTag } from '../shared/utils';
import { request } from '../service/request';
import { useAdvancedSettingsStore } from './settings/advanced';
import { useModalStore } from './modal';
import { useNotificationStore } from './notification';
import { useUpdateLoopStore } from './updateLoop';
import { useUserStore } from './user';
import { watchState } from '../service/watchState';
import configRepository from '../service/config';
import security from '../service/security';
import webApiService from '../service/webapi';
export const useAuthStore = defineStore('Auth', () => {
const advancedSettingsStore = useAdvancedSettingsStore();
const notificationStore = useNotificationStore();
const userStore = useUserStore();
const updateLoopStore = useUpdateLoopStore();
const modalStore = useModalStore();
const { t } = useI18n();
const state = reactive({
autoLoginAttempts: new Set(),
enableCustomEndpoint: false,
cachedConfig: {}
});
const loginForm = ref({
loading: false,
username: '',
password: '',
endpoint: '',
websocket: '',
saveCredentials: false,
lastUserLoggedIn: ''
});
const enablePrimaryPasswordDialog = ref({
visible: false,
password: '',
rePassword: '',
beforeClose(done) {
// $app._data.enablePrimaryPassword = false;
done();
}
});
const credentialsToSave = ref(null);
const twoFactorAuthDialogVisible = ref(false);
const cachedConfig = ref({});
const enableCustomEndpoint = ref(false);
const attemptingAutoLogin = ref(false);
watch(
[() => watchState.isLoggedIn, () => userStore.currentUser],
([isLoggedIn, currentUser]) => {
twoFactorAuthDialogVisible.value = false;
if (isLoggedIn) {
updateStoredUser(currentUser);
new Noty({
type: 'success',
text: `Hello there, <strong>${escapeTag(
currentUser.displayName
)}</strong>!`
}).show();
}
},
{ flush: 'sync' }
);
watch(
() => watchState.isFriendsLoaded,
(isFriendsLoaded) => {
if (isFriendsLoaded) {
initWebsocket();
AppApi.IPCAnnounceStart();
}
},
{ flush: 'sync' }
);
async function init() {
const [lastUserLoggedIn, enableCustomEndpoint] = await Promise.all([
configRepository.getString('lastUserLoggedIn', ''),
configRepository.getBool('VRCX_enableCustomEndpoint', false)
]);
loginForm.value.lastUserLoggedIn = lastUserLoggedIn;
state.enableCustomEndpoint = enableCustomEndpoint;
}
init();
async function getAllSavedCredentials() {
let savedCredentials = {};
try {
savedCredentials = JSON.parse(
await configRepository.getString('savedCredentials', '{}')
);
let edited = false;
for (const userId in savedCredentials) {
// fix goofy typo
if (savedCredentials[userId].loginParmas) {
savedCredentials[userId].loginParams =
savedCredentials[userId].loginParmas;
delete savedCredentials[userId].loginParmas;
edited = true;
}
// fix missing fields
if (!savedCredentials[userId].loginParams.endpoint) {
savedCredentials[userId].loginParams.endpoint = '';
edited = true;
}
if (!savedCredentials[userId].loginParams.websocket) {
savedCredentials[userId].loginParams.websocket = '';
edited = true;
}
}
if (edited) {
await configRepository.setString(
'savedCredentials',
JSON.stringify(savedCredentials)
);
}
} catch (e) {
console.error('Failed to get saved credentials:', e);
}
return savedCredentials;
}
async function getSavedCredentials(userId) {
const savedCredentials = await getAllSavedCredentials();
return savedCredentials[userId];
}
async function handleLogoutEvent() {
if (watchState.isLoggedIn) {
new Noty({
type: 'success',
text: `See you again, <strong>${escapeTag(
userStore.currentUser.displayName
)}</strong>!`
}).show();
}
userStore.userDialog.visible = false;
watchState.isLoggedIn = false;
watchState.isFriendsLoaded = false;
watchState.isFavoritesLoaded = false;
notificationStore.notificationInitStatus = false;
await updateStoredUser(userStore.currentUser);
webApiService.clearCookies();
loginForm.value.lastUserLoggedIn = '';
await configRepository.remove('lastUserLoggedIn');
// workerTimers.setTimeout(() => location.reload(), 500);
attemptingAutoLogin.value = false;
state.autoLoginAttempts.clear();
closeWebSocket();
}
/**
* Automatically logs in the last user after the app is mounted.
* @returns {Promise<void>}
*/
async function autoLoginAfterMounted() {
if (
!advancedSettingsStore.enablePrimaryPassword &&
(await configRepository.getString('lastUserLoggedIn')) !== null
) {
const user = await getSavedCredentials(
loginForm.value.lastUserLoggedIn
);
if (user?.loginParams?.endpoint) {
AppDebug.endpointDomain = user.loginParams.endpoint;
AppDebug.websocketDomain = user.loginParams.websocket;
}
// login at startup
loginForm.value.loading = true;
authRequest
.getConfig()
.catch((err) => {
loginForm.value.loading = false;
throw err;
})
.then(() => {
userStore
.getCurrentUser()
.finally(() => {
loginForm.value.loading = false;
})
.catch((err) => {
updateLoopStore.nextCurrentUserRefresh = 60; // 1min
console.error(err);
});
});
}
}
async function clearCookiesTryLogin() {
await webApiService.clearCookies();
if (loginForm.value.lastUserLoggedIn) {
const user = await getSavedCredentials(
loginForm.value.lastUserLoggedIn
);
if (user) {
delete user.cookies;
await relogin(user);
}
}
}
async function resendEmail2fa() {
if (loginForm.value.lastUserLoggedIn) {
const user = await getSavedCredentials(
loginForm.value.lastUserLoggedIn
);
if (user) {
await webApiService.clearCookies();
delete user.cookies;
relogin(user).then(() => {
new Noty({
type: 'success',
text: 'Email 2FA resent.'
}).show();
});
return;
}
}
new Noty({
type: 'error',
text: 'Cannot send 2FA email without saved credentials. Please login again.'
}).show();
}
function enablePrimaryPasswordChange() {
advancedSettingsStore.enablePrimaryPassword =
!advancedSettingsStore.enablePrimaryPassword;
enablePrimaryPasswordDialog.value.password = '';
enablePrimaryPasswordDialog.value.rePassword = '';
if (advancedSettingsStore.enablePrimaryPassword) {
enablePrimaryPasswordDialog.value.visible = true;
} else {
modalStore
.prompt({
title: t('prompt.primary_password.header'),
description: t('prompt.primary_password.description'),
pattern: /[\s\S]{1,32}/
})
.then(async ({ ok, value }) => {
if (!ok) {
advancedSettingsStore.enablePrimaryPassword = true;
advancedSettingsStore.setEnablePrimaryPasswordConfigRepository(
true
);
return;
}
const savedCredentials = JSON.parse(
await configRepository.getString(
'savedCredentials',
'{}'
)
);
for (const userId in savedCredentials) {
security
.decrypt(
savedCredentials[userId].loginParams.password,
value
)
.then(async (pt) => {
credentialsToSave.value = {
username:
savedCredentials[userId].loginParams
.username,
password: pt
};
await updateStoredUser(
savedCredentials[userId].user
);
await configRepository.setBool(
'enablePrimaryPassword',
false
);
})
.catch(async () => {
advancedSettingsStore.enablePrimaryPassword = true;
advancedSettingsStore.setEnablePrimaryPasswordConfigRepository(
true
);
});
}
})
.catch((err) => {
console.error(err);
advancedSettingsStore.enablePrimaryPassword = true;
advancedSettingsStore.setEnablePrimaryPasswordConfigRepository(
true
);
});
}
}
async function setPrimaryPassword() {
await configRepository.setBool(
'enablePrimaryPassword',
advancedSettingsStore.enablePrimaryPassword
);
enablePrimaryPasswordDialog.value.visible = false;
if (advancedSettingsStore.enablePrimaryPassword) {
const key = enablePrimaryPasswordDialog.value.password;
const savedCredentials = await getAllSavedCredentials();
for (const userId in savedCredentials) {
security
.encrypt(savedCredentials[userId].loginParams.password, key)
.then((ct) => {
credentialsToSave.value = {
username:
savedCredentials[userId].loginParams.username,
password: ct
};
updateStoredUser(savedCredentials[userId].user);
});
}
}
}
async function updateStoredUser(user) {
const savedCredentials = await getAllSavedCredentials();
if (credentialsToSave.value) {
savedCredentials[user.id] = {
user,
loginParams: {
username: '',
password: '',
endpoint: '',
websocket: '',
...credentialsToSave.value
}
};
credentialsToSave.value = null;
} else if (typeof savedCredentials[user.id] !== 'undefined') {
savedCredentials[user.id].user = user;
savedCredentials[user.id].cookies =
await webApiService.getCookies();
}
const jsonCredentialsArray = JSON.stringify(savedCredentials);
await configRepository.setString(
'savedCredentials',
jsonCredentialsArray
);
loginForm.value.lastUserLoggedIn = user.id;
await configRepository.setString('lastUserLoggedIn', user.id);
}
async function migrateStoredUsers() {
const savedCredentials = await getAllSavedCredentials();
for (const name in savedCredentials) {
const userId = savedCredentials[name]?.user?.id;
if (userId && userId !== name) {
savedCredentials[userId] = savedCredentials[name];
delete savedCredentials[name];
}
}
await configRepository.setString(
'savedCredentials',
JSON.stringify(savedCredentials)
);
}
function checkPrimaryPassword(args) {
return new Promise((resolve, reject) => {
if (!advancedSettingsStore.enablePrimaryPassword) {
resolve(args.password);
return;
}
modalStore
.prompt({
title: t('prompt.primary_password.header'),
description: t('prompt.primary_password.description'),
pattern: /[\s\S]{1,32}/
})
.then(({ ok, value }) => {
if (!ok) {
reject(new Error('primary password prompt cancelled'));
return;
}
security
.decrypt(args.password, value)
.then(resolve)
.catch(reject);
})
.catch(reject);
});
}
async function toggleCustomEndpoint() {
await configRepository.setBool(
'VRCX_enableCustomEndpoint',
state.enableCustomEndpoint
);
loginForm.value.endpoint = '';
loginForm.value.websocket = '';
}
function logout() {
modalStore
.confirm({
description: 'Continue? Logout',
title: 'Confirm'
})
.then(({ ok }) => {
if (!ok) return;
const existingStyle = document.getElementById(
'login-container-style'
);
if (existingStyle) {
existingStyle.parentNode.removeChild(existingStyle);
}
handleLogoutEvent();
})
.catch(() => {});
}
async function relogin(user) {
const { loginParams } = user;
if (user.cookies) {
await webApiService.setCookies(user.cookies);
}
loginForm.value.lastUserLoggedIn = user.user.id; // for resend email 2fa
if (loginParams.endpoint) {
AppDebug.endpointDomain = loginParams.endpoint;
AppDebug.websocketDomain = loginParams.websocket;
} else {
AppDebug.endpointDomain = AppDebug.endpointDomainVrchat;
AppDebug.websocketDomain = AppDebug.websocketDomainVrchat;
}
return new Promise((resolve, reject) => {
loginForm.value.loading = true;
if (advancedSettingsStore.enablePrimaryPassword) {
checkPrimaryPassword(loginParams)
.then((pwd) => {
return authRequest
.getConfig()
.catch((err) => {
reject(err);
})
.then(() => {
authLogin({
username: loginParams.username,
password: pwd,
cipher: loginParams.password,
endpoint: loginParams.endpoint,
websocket: loginParams.websocket
})
.catch((err2) => {
reject(err2);
})
.then(() => {
resolve();
});
});
})
.catch((_) => {
toast.error('Incorrect primary password');
reject(_);
});
} else {
authRequest
.getConfig()
.catch((err) => {
reject(err);
})
.then(() => {
authLogin({
username: loginParams.username,
password: loginParams.password,
endpoint: loginParams.endpoint,
websocket: loginParams.websocket
})
.catch((err2) => {
handleLogoutEvent();
reject(err2);
})
.then(() => {
resolve();
});
});
}
}).finally(() => (loginForm.value.loading = false));
}
async function deleteSavedLogin(userId) {
const savedCredentials = await getAllSavedCredentials();
delete savedCredentials[userId];
// Disable primary password when no account is available.
if (Object.keys(savedCredentials).length === 0) {
advancedSettingsStore.enablePrimaryPassword = false;
advancedSettingsStore.setEnablePrimaryPasswordConfigRepository(
false
);
}
await configRepository.setString(
'savedCredentials',
JSON.stringify(savedCredentials)
);
new Noty({
type: 'success',
text: 'Account removed.'
}).show();
}
async function login() {
// TODO: remove/refactor saveCredentials & primaryPassword (security)
await webApiService.clearCookies();
if (!loginForm.value.loading) {
loginForm.value.loading = true;
if (loginForm.value.endpoint) {
AppDebug.endpointDomain = loginForm.value.endpoint;
AppDebug.websocketDomain = loginForm.value.websocket;
} else {
AppDebug.endpointDomain = AppDebug.endpointDomainVrchat;
AppDebug.websocketDomain = AppDebug.websocketDomainVrchat;
}
authRequest
.getConfig()
.catch((err) => {
loginForm.value.loading = false;
throw err;
})
.then((args) => {
if (
loginForm.value.saveCredentials &&
advancedSettingsStore.enablePrimaryPassword
) {
modalStore
.prompt({
title: t('prompt.primary_password.header'),
description: t(
'prompt.primary_password.description'
),
pattern: /[\s\S]{1,32}/
})
.then(async ({ ok, value }) => {
if (!ok) return;
const savedCredentials = JSON.parse(
await configRepository.getString(
'savedCredentials'
)
);
const saveCredential =
savedCredentials[
Object.keys(savedCredentials)[0]
];
security
.decrypt(
saveCredential.loginParams.password,
value
)
.then(() => {
security
.encrypt(
loginForm.value.password,
value
)
.then((pwd) => {
authLogin({
username:
loginForm.value
.username,
password:
loginForm.value
.password,
endpoint:
loginForm.value
.endpoint,
websocket:
loginForm.value
.websocket,
saveCredentials:
loginForm.value
.saveCredentials,
cipher: pwd
});
});
});
})
.finally(() => {
loginForm.value.loading = false;
})
.catch(() => {});
return args;
}
authLogin({
username: loginForm.value.username,
password: loginForm.value.password,
endpoint: loginForm.value.endpoint,
websocket: loginForm.value.websocket,
saveCredentials: loginForm.value.saveCredentials
}).finally(() => {
loginForm.value.loading = false;
});
return args;
});
}
}
function promptTOTP() {
if (twoFactorAuthDialogVisible.value) {
return;
}
AppApi.FlashWindow();
twoFactorAuthDialogVisible.value = true;
modalStore
.prompt({
title: t('prompt.totp.header'),
description: t('prompt.totp.description'),
cancelText: t('prompt.totp.use_otp'),
confirmText: t('prompt.totp.verify'),
pattern: /^[0-9]{6}$/,
errorMessage: t('prompt.totp.input_error')
})
.then(({ ok, reason, value }) => {
twoFactorAuthDialogVisible.value = false;
if (reason === 'cancel') {
promptOTP();
return;
}
if (!ok) return;
authRequest
.verifyTOTP({
code: value.trim()
})
.catch((err) => {
console.error(err);
clearCookiesTryLogin();
})
.then(() => {
userStore.getCurrentUser();
});
})
.catch(() => {
twoFactorAuthDialogVisible.value = false;
});
}
function promptOTP() {
if (twoFactorAuthDialogVisible.value) {
return;
}
twoFactorAuthDialogVisible.value = true;
modalStore
.prompt({
title: t('prompt.otp.header'),
description: t('prompt.otp.description'),
cancelText: t('prompt.otp.use_totp'),
confirmText: t('prompt.otp.verify'),
pattern: /^[a-z0-9]{4}-[a-z0-9]{4}$/,
errorMessage: t('prompt.otp.input_error')
})
.then(({ ok, reason, value }) => {
twoFactorAuthDialogVisible.value = false;
if (reason === 'cancel') {
promptTOTP();
return;
}
if (!ok) return;
authRequest
.verifyOTP({
code: value.trim()
})
.catch((err) => {
console.error(err);
clearCookiesTryLogin();
})
.then(() => {
userStore.getCurrentUser();
});
})
.catch(() => {
twoFactorAuthDialogVisible.value = false;
});
}
function promptEmailOTP() {
if (twoFactorAuthDialogVisible.value) {
return;
}
AppApi.FlashWindow();
twoFactorAuthDialogVisible.value = true;
modalStore
.prompt({
title: t('prompt.email_otp.header'),
description: t('prompt.email_otp.description'),
cancelText: t('prompt.email_otp.resend'),
confirmText: t('prompt.email_otp.verify'),
pattern: /^[0-9]{6}$/,
errorMessage: t('prompt.email_otp.input_error')
})
.then(({ ok, reason, value }) => {
twoFactorAuthDialogVisible.value = false;
if (reason === 'cancel') {
resendEmail2fa();
return;
}
if (!ok) return;
authRequest
.verifyEmailOTP({
code: value.trim()
})
.catch((err) => {
console.error(err);
promptEmailOTP();
})
.then(() => {
userStore.getCurrentUser();
});
})
.catch(() => {
twoFactorAuthDialogVisible.value = false;
});
}
/**
* @param {{ username: string, password: string, endpoint: string, websocket: string, saveCredentials?: any, cipher?: string }} params credential to login
* @returns {Promise<{origin: boolean, json: any}>}
*/
function authLogin(params) {
let {
username,
password,
endpoint,
websocket,
saveCredentials,
cipher
} = params;
const auth = btoa(
`${encodeURIComponent(username)}:${encodeURIComponent(password)}`
);
if (saveCredentials) {
params.saveCredentials = false;
if (cipher) {
password = cipher;
}
credentialsToSave.value = {
username,
password,
endpoint,
websocket
};
}
return request('auth/user', {
method: 'GET',
headers: {
Authorization: `Basic ${auth}`
}
}).then((json) => {
const args = {
json,
origin: true
};
handleCurrentUserUpdate(json);
return args;
});
}
function handleCurrentUserUpdate(json) {
if (
json.requiresTwoFactorAuth &&
json.requiresTwoFactorAuth.includes('emailOtp')
) {
promptEmailOTP();
} else if (json.requiresTwoFactorAuth) {
promptTOTP();
} else {
updateLoopStore.nextCurrentUserRefresh = 420; // 7mins
userStore.applyCurrentUser(json);
initWebsocket();
}
}
async function handleAutoLogin() {
if (attemptingAutoLogin.value) {
return;
}
attemptingAutoLogin.value = true;
const user = await getSavedCredentials(
loginForm.value.lastUserLoggedIn
);
if (!user) {
attemptingAutoLogin.value = false;
return;
}
if (advancedSettingsStore.enablePrimaryPassword) {
console.error(
'Primary password is enabled, this disables auto login.'
);
attemptingAutoLogin.value = false;
handleLogoutEvent();
return;
}
const attemptsInLastHour = Array.from(state.autoLoginAttempts).filter(
(timestamp) => timestamp > new Date().getTime() - 3600000
).length;
if (attemptsInLastHour >= 3) {
console.error(
'More than 3 auto login attempts within the past hour, logging out instead of attempting auto login.'
);
attemptingAutoLogin.value = false;
handleLogoutEvent();
AppApi.FlashWindow();
return;
}
state.autoLoginAttempts.add(new Date().getTime());
console.log('Attempting automatic login...');
relogin(user)
.then(() => {
if (AppDebug.errorNoty) {
AppDebug.errorNoty.close();
}
AppDebug.errorNoty = new Noty({
type: 'success',
text: 'Automatically logged in.'
}).show();
console.log('Automatically logged in.');
})
.catch((err) => {
if (AppDebug.errorNoty) {
AppDebug.errorNoty.close();
}
AppDebug.errorNoty = new Noty({
type: 'error',
text: 'Failed to login automatically.'
}).show();
console.error('Failed to login automatically.', err);
})
.finally(() => {
if (!navigator.onLine) {
AppDebug.errorNoty = new Noty({
type: 'error',
text: `You're offline.`
}).show();
console.error(`You're offline.`);
}
});
}
async function loginComplete() {
await database.initUserTables(userStore.currentUser.id);
watchState.isLoggedIn = true;
AppApi.CheckGameRunning(); // restore state from hot-reload
}
return {
state,
loginForm,
enablePrimaryPasswordDialog,
credentialsToSave,
twoFactorAuthDialogVisible,
cachedConfig,
enableCustomEndpoint,
attemptingAutoLogin,
clearCookiesTryLogin,
resendEmail2fa,
enablePrimaryPasswordChange,
setPrimaryPassword,
updateStoredUser,
migrateStoredUsers,
checkPrimaryPassword,
autoLoginAfterMounted,
toggleCustomEndpoint,
logout,
relogin,
deleteSavedLogin,
login,
handleAutoLogin,
handleLogoutEvent,
handleCurrentUserUpdate,
loginComplete,
getAllSavedCredentials
};
});