diff --git a/package-lock.json b/package-lock.json index a8c3f218..a6010318 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3896,6 +3896,11 @@ "randomfill": "^1.0.3" } }, + "crypto-js": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz", + "integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg==" + }, "css-color-names": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz", @@ -11218,6 +11223,21 @@ "moment": "^2.19.2" } }, + "vue-pincode-input": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/vue-pincode-input/-/vue-pincode-input-0.4.0.tgz", + "integrity": "sha512-A1DtG9J+o5AhdxRcgmuW8xxh7w5+GGVd6Di1J3n12xI99qSyKm9kBNJx4AzrNDgzr2TwUbleAdw7YTy3B68ohQ==", + "requires": { + "core-js": "^2.6.5" + }, + "dependencies": { + "core-js": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz", + "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==" + } + } + }, "vue-style-loader": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/vue-style-loader/-/vue-style-loader-4.1.2.tgz", diff --git a/package.json b/package.json index 6c541d18..ae504c92 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "dependencies": { "axios": "^0.21.0", "core-js": "^3.6.5", + "crypto-js": "^4.0.0", "framework7": "^5.7.14", "framework7-icons": "^3.0.1", "framework7-vue": "^5.7.14", @@ -20,7 +21,8 @@ "vue-clipboard2": "^0.3.1", "vue-i18n": "^8.22.1", "vue-i18n-filter": "^0.1.6", - "vue-moment": "^4.1.0" + "vue-moment": "^4.1.0", + "vue-pincode-input": "^0.4.0" }, "devDependencies": { "@vue/cli-plugin-babel": "~4.5.0", diff --git a/src/Mobile.vue b/src/Mobile.vue index bc05dd56..aa023b52 100644 --- a/src/Mobile.vue +++ b/src/Mobile.vue @@ -57,6 +57,16 @@ body { margin-left: 0; } +.vue-pincode-input { + margin: 3px !important; + padding: 5px !important; + box-shadow: 0 0 3px rgba(0,0,0,.5) !important; +} + +.list-item-pincode-input .item-inner { + justify-content: center; +} + .work-break-all { word-break: break-all; } diff --git a/src/consts/licenses.js b/src/consts/licenses.js index 3354cdfe..509872fc 100644 --- a/src/consts/licenses.js +++ b/src/consts/licenses.js @@ -117,6 +117,12 @@ const licenses = [ url: 'https://github.com/Inndy/vue-clipboard2', licenseUrl: 'https://github.com/Inndy/vue-clipboard2/blob/master/LICENSE' }, + { + name: 'vue-pincode-input', + copyright: 'Copyright (c) 2019 Maxim Noverin', + url: 'https://github.com/Seokky/vue-pincode-input', + licenseUrl: 'https://github.com/Seokky/vue-pincode-input/blob/master/LICENSE' + }, { name: 'core-js', copyright: 'Copyright (c) 2014-2020 Denis Pushkarev', @@ -153,6 +159,12 @@ const licenses = [ url: 'https://momentjs.com', licenseUrl: 'https://github.com/moment/moment/blob/develop/LICENSE' }, + { + name: 'crypto-js', + copyright: 'Copyright (c) 2009-2013 Jeff Mott, Copyright (c) 2013-2016 Evan Vosberg', + url: 'https://github.com/brix/crypto-js', + licenseUrl: 'https://github.com/brix/crypto-js/blob/develop/LICENSE' + }, { name: 'js-cookie', copyright: 'Copyright (c) 2018 Copyright 2018 Klaus Hartl, Fagner Brack, GitHub Contributors', diff --git a/src/lib/settings.js b/src/lib/settings.js index 5aa9ff0f..1d451a67 100644 --- a/src/lib/settings.js +++ b/src/lib/settings.js @@ -5,6 +5,7 @@ const serverSettingsCookieKey = 'ACP_SETTINGS'; const defaultSettings = { lang: 'en', + applicationLock: false, autoUpdateExchangeRatesData: true, thousandsSeparator: true, currencyDisplayMode: 'code', // or 'none' or 'name' @@ -73,6 +74,8 @@ function clearSettings() { export default { getLanguage: () => getOriginalOption('lang'), setLanguage: value => setOption('lang', value), + isEnableApplicationLock: () => getOption('applicationLock'), + setEnableApplicationLock: value => setOption('applicationLock', value), isAutoUpdateExchangeRatesData: () => getOption('autoUpdateExchangeRatesData'), setAutoUpdateExchangeRatesData: value => setOption('autoUpdateExchangeRatesData', value), isEnableThousandsSeparator: () => getOption('thousandsSeparator'), diff --git a/src/lib/userstate.js b/src/lib/userstate.js index ae0957e5..b7d9c55e 100644 --- a/src/lib/userstate.js +++ b/src/lib/userstate.js @@ -1,10 +1,34 @@ -import utils from './utils.js' +import CryptoJS from 'crypto-js'; + +import settings from './settings.js'; +import utils from './utils.js'; + +const APP_LOCK_SECRET_BASE_STRING_PREFIX = 'LAB_LOCK_SECRET_'; const tokenLocalStorageKey = 'lab_user_token'; const userInfoLocalStorageKey = 'lab_user_info'; +const tokenSessionStorageKey = 'lab_user_session_token'; +const appLockSecretSessionStorageKey = 'lab_user_app_lock_secret'; + +function getAppLockSecret(pinCode) { + return CryptoJS.SHA256(APP_LOCK_SECRET_BASE_STRING_PREFIX + pinCode).toString(); +} + +function getEncryptedToken(token, secret) { + return CryptoJS.AES.encrypt(token, secret).toString(); +} + +function getDecryptedToken(encryptedToken, secret) { + const bytes = CryptoJS.AES.decrypt(encryptedToken, secret); + return bytes.toString(CryptoJS.enc.Utf8); +} function getToken() { - return localStorage.getItem(tokenLocalStorageKey); + if (settings.isEnableApplicationLock()) { + return sessionStorage.getItem(tokenSessionStorageKey); + } else { + return localStorage.getItem(tokenLocalStorageKey); + } } function getUserInfo() { @@ -13,12 +37,58 @@ function getUserInfo() { } function isUserLogined() { - return !!getToken(); + return !!localStorage.getItem(tokenLocalStorageKey); +} + +function isUserUnlocked() { + if (!isUserLogined()) { + return false; + } + + if (!settings.isEnableApplicationLock()) { + return true; + } + + return !!sessionStorage.getItem(appLockSecretSessionStorageKey) && !!sessionStorage.getItem(tokenSessionStorageKey); +} + +function unlockToken(pinCode) { + const encryptedToken = localStorage.getItem(tokenLocalStorageKey); + const secret = getAppLockSecret(pinCode); + const token = getDecryptedToken(encryptedToken, secret); + + sessionStorage.setItem(appLockSecretSessionStorageKey, secret); + sessionStorage.setItem(tokenSessionStorageKey, token); +} + +function encryptToken(pinCode) { + const token = localStorage.getItem(tokenLocalStorageKey); + const secret = getAppLockSecret(pinCode); + const encryptedToken = getEncryptedToken(token, secret); + + sessionStorage.setItem(appLockSecretSessionStorageKey, secret); + sessionStorage.setItem(tokenSessionStorageKey, token); + localStorage.setItem(tokenLocalStorageKey, encryptedToken); +} + +function decryptToken() { + const token = sessionStorage.getItem(tokenSessionStorageKey); + + localStorage.setItem(tokenLocalStorageKey, token); + sessionStorage.removeItem(tokenSessionStorageKey); + sessionStorage.removeItem(appLockSecretSessionStorageKey); } function updateToken(token) { if (utils.isString(token)) { - localStorage.setItem(tokenLocalStorageKey, token); + if (settings.isEnableApplicationLock()) { + sessionStorage.setItem(tokenSessionStorageKey, token); + + const secret = sessionStorage.getItem(appLockSecretSessionStorageKey); + localStorage.setItem(tokenLocalStorageKey, getEncryptedToken(token, secret)); + } else { + localStorage.setItem(tokenLocalStorageKey, token); + } } } @@ -41,6 +111,8 @@ function updateTokenAndUserInfo(item) { } function clearTokenAndUserInfo() { + sessionStorage.removeItem(tokenSessionStorageKey); + sessionStorage.removeItem(appLockSecretSessionStorageKey); localStorage.removeItem(tokenLocalStorageKey); localStorage.removeItem(userInfoLocalStorageKey); } @@ -49,6 +121,10 @@ export default { getToken, getUserInfo, isUserLogined, + isUserUnlocked, + unlockToken, + encryptToken, + decryptToken, updateToken, updateUserInfo, updateTokenAndUserInfo, diff --git a/src/locales/en.js b/src/locales/en.js index 28277978..70184142 100644 --- a/src/locales/en.js +++ b/src/locales/en.js @@ -275,6 +275,8 @@ export default { 'Statistics': 'Statistics', 'Settings': 'Settings', 'Back': 'Back', + 'Unlock': 'Unlock', + 'Re-login': 'Re-login', 'Username': 'Username', 'Your username': 'Your username', 'Your username or email': 'Your username or email', @@ -309,6 +311,7 @@ export default { 'Unable to verify': 'Unable to verify', 'Use a backup code': 'Use a backup code', 'Use a passcode': 'Use a passcode', + 'PIN code is wrong': 'PIN code is wrong', 'Sign Up': 'Sign Up', 'Transaction Details': 'Transaction Details', 'Account List': 'Account List', @@ -405,6 +408,9 @@ export default { 'Log Out': 'Log Out', 'Are you sure you want to log out?': 'Are you sure you want to log out?', 'Unable to logout': 'Unable to logout', + 'Application Lock': 'Application Lock', + 'PIN Code': 'PIN Code', + 'Please input a new PIN code. PIN code would encrypt your local data, so you need input this PIN code when you launch this app. If this PIN code is lost, you should re-login.': 'Please input a new PIN code. PIN code would encrypt your local data, so you need input this PIN code when you launch this app. If this PIN code is lost, you should re-login.', 'Exchange Rates Data': 'Exchange Rates Data', 'Base Currency': 'Base Currency', 'Last Updated': 'Last Updated', diff --git a/src/locales/zh_Hans.js b/src/locales/zh_Hans.js index dd81e739..90083bc1 100644 --- a/src/locales/zh_Hans.js +++ b/src/locales/zh_Hans.js @@ -275,6 +275,8 @@ export default { 'Statistics': '统计', 'Settings': '设置', 'Back': '返回', + 'Unlock': '解锁', + 'Re-login': '重新登陆', 'Username': '用户名', 'Your username': '你的用户名', 'Your username or email': '你的用户名或注册邮箱', @@ -309,6 +311,7 @@ export default { 'Unable to verify': '无法验证', 'Use a backup code': '使用备用码', 'Use a passcode': '使用验证码', + 'PIN code is wrong': 'PIN码错误', 'Sign Up': '注册', 'Transaction Details': '交易详情', 'Account List': '账户列表', @@ -405,6 +408,9 @@ export default { 'Log Out': '退出登录', 'Are you sure you want to log out?': '您确定是否要退出登录?', 'Unable to logout': '无法退出登录', + 'Application Lock': '应用锁', + 'PIN Code': 'PIN码', + 'Please input a new PIN code. PIN code would encrypt your local data, so you need input this PIN code when you launch this app. If this PIN code is lost, you should re-login.': '请输入一个新的PIN码,PIN码会加密你的本地数据,所以您每次打开应用时都需要输入PIN码。如果您忘记了PIN码,您需要重新登录。', 'Exchange Rates Data': '汇率数据', 'Base Currency': '基准货币', 'Last Updated': '最后更新', diff --git a/src/mobile-main.js b/src/mobile-main.js index 748b2f97..aae2a63e 100644 --- a/src/mobile-main.js +++ b/src/mobile-main.js @@ -3,6 +3,7 @@ import VueI18n from 'vue-i18n'; import VueI18nFilter from 'vue-i18n-filter' import Framework7 from 'framework7/framework7.esm.bundle.js'; import Framework7Vue from 'framework7-vue/framework7-vue.esm.bundle.js'; +import PincodeInput from 'vue-pincode-input'; import VueMoment from 'vue-moment'; import VueClipboard from 'vue-clipboard2'; @@ -34,6 +35,7 @@ Vue.use(VueI18n); Vue.use(VueI18nFilter); Vue.use(VueMoment, { moment }); Vue.use(VueClipboard); +Vue.component('PincodeInput', PincodeInput); Framework7.use(Framework7Vue); const i18n = new VueI18n(getI18nOptions()); @@ -161,14 +163,16 @@ Vue.filter('tokenIcon', (value) => tokenIconFilter(value)); Vue.prototype.$setLanguage(settings.getLanguage() || getDefaultLanguage()); -// refresh token if user is logined -if (userstate.isUserLogined()) { - services.refreshToken(); -} +if (!settings.isEnableApplicationLock()) { + // refresh token if user is logined + if (userstate.isUserLogined()) { + services.refreshToken(); + } -// auto refresh exchange rates data -if (settings.isAutoUpdateExchangeRatesData()) { - services.autoRefreshLatestExchangeRates(); + // auto refresh exchange rates data + if (settings.isAutoUpdateExchangeRatesData()) { + services.autoRefreshLatestExchangeRates(); + } } new Vue({ diff --git a/src/router/mobile.js b/src/router/mobile.js index 6a7eb6f8..94d75389 100644 --- a/src/router/mobile.js +++ b/src/router/mobile.js @@ -3,6 +3,7 @@ import userState from "../lib/userstate.js"; import HomePage from '../views/mobile/Home.vue'; import LoginPage from '../views/mobile/Login.vue'; import SignUpPage from '../views/mobile/Signup.vue'; +import UnlockPage from '../views/mobile/Unlock.vue'; import TransactionDetailPage from '../views/mobile/transactions/Detail.vue'; import TransactionNewPage from '../views/mobile/transactions/New.vue'; @@ -13,6 +14,7 @@ import AccountEditPage from '../views/mobile/accounts/AccountEdit.vue'; import StatisticsOverviewPage from '../views/mobile/statistics/Overview.vue'; import SettingsPage from '../views/mobile/Settings.vue'; +import ApplicationLockPage from '../views/mobile/ApplicationLock.vue'; import ExchangeRatesPage from "../views/mobile/ExchangeRates.vue"; import AboutPage from "../views/mobile/About.vue"; import UserProfilePage from "../views/mobile/users/UserProfile.vue"; @@ -22,25 +24,73 @@ import SessionListPage from "../views/mobile/users/SessionList.vue"; function checkLogin(to, from, resolve, reject) { const router = this; - if (userState.isUserLogined()) { - resolve(); + if (!userState.isUserLogined()) { + reject(); + router.navigate('/login', { + clearPreviousHistory: true, + pushState: false + }); return; } - reject(); - router.navigate('/login'); + if (!userState.isUserUnlocked()) { + reject(); + router.navigate('/unlock', { + clearPreviousHistory: true, + pushState: false + }); + return; + } + + resolve(); +} + +function checkLocked(to, from, resolve, reject) { + const router = this; + + if (!userState.isUserLogined()) { + reject(); + router.navigate('/login', { + clearPreviousHistory: true, + pushState: false + }); + return; + } + + if (userState.isUserUnlocked()) { + reject(); + router.navigate('/', { + clearPreviousHistory: true, + pushState: false + }); + return; + } + + resolve(); } function checkNotLogin(to, from, resolve, reject) { const router = this; - if (!userState.isUserLogined()) { - resolve(); + if (userState.isUserLogined() && !userState.isUserUnlocked()) { + reject(); + router.navigate('/unlock', { + clearPreviousHistory: true, + pushState: false + }); return; } - reject(); - router.navigate('/'); + if (userState.isUserLogined()) { + reject(); + router.navigate('/', { + clearPreviousHistory: true, + pushState: false + }); + return; + } + + resolve(); } const routes = [ @@ -59,6 +109,11 @@ const routes = [ component: SignUpPage, beforeEnter: checkNotLogin }, + { + path: '/unlock', + component: UnlockPage, + beforeEnter: checkLocked + }, { path: '/transaction/details', component: TransactionDetailPage, @@ -94,6 +149,11 @@ const routes = [ component: SettingsPage, beforeEnter: checkLogin }, + { + path: '/app_lock', + component: ApplicationLockPage, + beforeEnter: checkLogin + }, { path: '/exchange_rates', component: ExchangeRatesPage, diff --git a/src/views/mobile/ApplicationLock.vue b/src/views/mobile/ApplicationLock.vue new file mode 100644 index 00000000..afac2759 --- /dev/null +++ b/src/views/mobile/ApplicationLock.vue @@ -0,0 +1,80 @@ + + + diff --git a/src/views/mobile/Login.vue b/src/views/mobile/Login.vue index 3257728b..ec5eb927 100644 --- a/src/views/mobile/Login.vue +++ b/src/views/mobile/Login.vue @@ -185,13 +185,14 @@ export default { return; } + self.$settings.setEnableApplicationLock(false); self.$user.updateTokenAndUserInfo(data.result); if (self.$settings.isAutoUpdateExchangeRatesData()) { self.$services.autoRefreshLatestExchangeRates(); } - router.navigate('/'); + router.refreshPage(); }).catch(error => { self.logining = false; self.$hideLoading(); @@ -256,6 +257,7 @@ export default { return; } + self.$settings.setEnableApplicationLock(false); self.$user.updateTokenAndUserInfo(data.result); if (self.$settings.isAutoUpdateExchangeRatesData()) { @@ -263,7 +265,7 @@ export default { } self.show2faSheet = false; - router.navigate('/'); + router.refreshPage(); }).catch(error => { self.verifying = false; self.$hideLoading(); diff --git a/src/views/mobile/Settings.vue b/src/views/mobile/Settings.vue index cca66766..e51c970f 100644 --- a/src/views/mobile/Settings.vue +++ b/src/views/mobile/Settings.vue @@ -29,6 +29,8 @@ + + @@ -100,6 +102,9 @@ export default { this.$setLanguage(value); } }, + isEnableApplicationLock() { + return this.$settings.isEnableApplicationLock(); + }, exchangeRatesLastUpdateDate() { const exchangeRates = this.$exchangeRates.getExchangeRates(); return exchangeRates && exchangeRates.date ? this.$moment(exchangeRates.date).format(this.$t('format.date.long')) : ''; diff --git a/src/views/mobile/Signup.vue b/src/views/mobile/Signup.vue index 7f07aa21..2ceb2ce1 100644 --- a/src/views/mobile/Signup.vue +++ b/src/views/mobile/Signup.vue @@ -157,6 +157,8 @@ export default { return; } + self.$settings.setEnableApplicationLock(false); + if (self.$utilities.isString(data.result.token)) { self.$user.updateTokenAndUserInfo(data.result); } diff --git a/src/views/mobile/Unlock.vue b/src/views/mobile/Unlock.vue new file mode 100644 index 00000000..192458b8 --- /dev/null +++ b/src/views/mobile/Unlock.vue @@ -0,0 +1,64 @@ + + +