├── .eslintignore ├── src ├── components │ ├── ui │ │ ├── TextButton │ │ │ ├── style.css │ │ │ └── index.js │ │ ├── Modal │ │ │ ├── Footer.js │ │ │ ├── Content.js │ │ │ ├── Wrapper.js │ │ │ ├── index.css │ │ │ └── index.js │ │ ├── Link.js │ │ ├── NotificationStack │ │ │ ├── index.css │ │ │ └── index.js │ │ ├── ConfirmModal.js │ │ └── SteppedModal │ │ │ └── index.js │ ├── auth │ │ ├── ScopeModal │ │ │ ├── index.css │ │ │ └── index.js │ │ ├── style.css │ │ ├── formUnlock.js │ │ ├── formLogin2FA.js │ │ ├── formLogin.js │ │ └── formSignU2F.js │ ├── settings │ │ └── security │ │ │ ├── index.css │ │ │ ├── TwoFactor │ │ │ ├── AddU2FModal │ │ │ │ ├── sign-u2f.png │ │ │ │ ├── index.css │ │ │ │ ├── index.js │ │ │ │ ├── presentation.js │ │ │ │ ├── formName.js │ │ │ │ └── formRegisterKey.js │ │ │ ├── SetupTOTPModal │ │ │ │ ├── index.css │ │ │ │ ├── index.js │ │ │ │ ├── presentation.js │ │ │ │ ├── confirmCode.js │ │ │ │ └── sharedSecret.js │ │ │ ├── style.css │ │ │ ├── SaveRecoveryCodeModal │ │ │ │ ├── index.css │ │ │ │ ├── index.js │ │ │ │ ├── formTestCode.js │ │ │ │ └── presentation.js │ │ │ ├── U2FKeyList │ │ │ │ ├── style.css │ │ │ │ └── U2FKeyList.js │ │ │ └── index.js │ │ │ └── index.js │ ├── header │ │ ├── style.css │ │ └── index.js │ └── app.js ├── assets │ ├── favicon.ico │ └── icons │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── mstile-150x150.png │ │ ├── apple-touch-icon.png │ │ ├── android-chrome-192x192.png │ │ └── android-chrome-512x512.png ├── routes │ ├── home │ │ ├── style.css │ │ └── index.js │ ├── settings │ │ ├── index.css │ │ └── index.js │ └── dashboard │ │ └── index.js ├── tests │ ├── __mocks__ │ │ ├── index.js │ │ ├── u2fApi.js │ │ ├── devtools.js │ │ ├── ReactModalMocks.js │ │ ├── fileMocks.js │ │ └── browserMocks.js │ ├── __snapshots__ │ │ └── header.test.js.snap │ ├── header.test.js │ ├── components │ │ ├── auth │ │ │ ├── formLogin2FA.test.js │ │ │ ├── __snapshots__ │ │ │ │ ├── formSignU2F.test.js.snap │ │ │ │ └── formLogin2FA.test.js.snap │ │ │ └── formSignU2F.test.js │ │ └── settings │ │ │ └── security │ │ │ └── TwoFactor │ │ │ ├── AddU2FModal │ │ │ ├── presentation.test.js │ │ │ ├── __snapshots__ │ │ │ │ ├── formName.test.js.snap │ │ │ │ ├── presentation.test.js.snap │ │ │ │ └── formRegisterKey.test.js.snap │ │ │ ├── formName.test.js │ │ │ └── formRegisterKey.test.js │ │ │ ├── SetupTOTPModal │ │ │ ├── presentation.test.js │ │ │ ├── __snapshots__ │ │ │ │ ├── presentation.test.js.snap │ │ │ │ └── sharedSecret.test.js.snap │ │ │ └── sharedSecret.test.js │ │ │ ├── U2FKeyList.test.js │ │ │ ├── __snapshots__ │ │ │ ├── U2FKeyList.test.js.snap │ │ │ └── index.test.js.snap │ │ │ ├── index.test.js │ │ │ └── SaveRecoveryCodeModal │ │ │ ├── __snapshots__ │ │ │ ├── presentation.test.js.snap │ │ │ └── formTestCode.test.js.snap │ │ │ ├── formTestCode.test.js │ │ │ └── presentation.test.js │ ├── helpers │ │ ├── stateFormatter.test.js │ │ ├── __snapshots__ │ │ │ └── stateFormatter.test.js.snap │ │ └── u2f.test.js │ ├── testsHelpers │ │ └── storeTools.js │ └── actions │ │ └── authentication.test.js ├── app-id.json ├── helpers │ ├── toActions.js │ ├── notification.js │ ├── plan.js │ ├── text.js │ ├── store.js │ ├── stateFormatter.js │ └── u2f.js ├── index.js ├── lib │ └── hookErrorActions.js ├── manifest.json ├── .babelrc ├── style │ └── index.css └── actions │ ├── scope.js │ ├── settings │ ├── addU2FKey.js │ ├── enableTOTP.js │ └── index.js │ └── authentication.js ├── .gitignore ├── .prettierrc ├── .editorconfig ├── circle.yml ├── env ├── configDefault.js ├── config.constants.js └── config.js ├── preact.config.js ├── .github └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── tasks ├── helpers │ └── log.js ├── setupConfig.js └── deploy.js ├── LICENSE ├── README.md └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | src/tests 2 | -------------------------------------------------------------------------------- /src/components/ui/TextButton/style.css: -------------------------------------------------------------------------------- 1 | .a { 2 | } 3 | -------------------------------------------------------------------------------- /src/components/auth/ScopeModal/index.css: -------------------------------------------------------------------------------- 1 | .scopeFormModal { 2 | } 3 | -------------------------------------------------------------------------------- /src/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protonmail/account/master/src/assets/favicon.ico -------------------------------------------------------------------------------- /src/routes/home/style.css: -------------------------------------------------------------------------------- 1 | .home { 2 | padding: 56px 20px; 3 | min-height: 100%; 4 | width: 100%; 5 | } 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /build 3 | /*.log 4 | *.lock 5 | package-lock.json 6 | src/config.js 7 | coverage 8 | dist 9 | -------------------------------------------------------------------------------- /src/assets/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/favicon-16x16.png -------------------------------------------------------------------------------- /src/assets/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/favicon-32x32.png -------------------------------------------------------------------------------- /src/assets/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/mstile-150x150.png -------------------------------------------------------------------------------- /src/assets/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /src/tests/__mocks__/index.js: -------------------------------------------------------------------------------- 1 | import './ReactModalMocks'; 2 | import './browserMocks'; 3 | import './devtools'; 4 | import './u2fApi'; 5 | -------------------------------------------------------------------------------- /src/assets/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /src/assets/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protonmail/account/master/src/assets/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /src/components/settings/security/index.css: -------------------------------------------------------------------------------- 1 | .top { 2 | display: flex; 3 | width: 100%; 4 | } 5 | 6 | .panel { 7 | flex: 1; 8 | } 9 | -------------------------------------------------------------------------------- /src/tests/__mocks__/u2fApi.js: -------------------------------------------------------------------------------- 1 | import { isSupported } from 'u2f-api'; 2 | 3 | jest.mock('u2f-api'); 4 | isSupported.mockImplementation(() => true); 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "arrowParens": "always", 4 | "singleQuote": true, 5 | "tabWidth": 4, 6 | "proseWrap": "never" 7 | } 8 | -------------------------------------------------------------------------------- /src/tests/__mocks__/devtools.js: -------------------------------------------------------------------------------- 1 | import devtools from 'unistore/devtools'; 2 | 3 | jest.mock('unistore/devtools'); 4 | devtools.mockImplementation((store) => store); 5 | -------------------------------------------------------------------------------- /src/tests/__mocks__/ReactModalMocks.js: -------------------------------------------------------------------------------- 1 | import ReactModal from 'react-modal'; 2 | jest.mock('react-modal'); 3 | 4 | ReactModal.setAppElement.mockImplementation((store) => store); 5 | -------------------------------------------------------------------------------- /src/components/auth/style.css: -------------------------------------------------------------------------------- 1 | .twoFactorOption { 2 | border-bottom: 1px solid black; 3 | border-left: 1px solid black; 4 | padding: 5px; 5 | margin-left: 10px; 6 | } 7 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/AddU2FModal/sign-u2f.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/protonmail/account/master/src/components/settings/security/TwoFactor/AddU2FModal/sign-u2f.png -------------------------------------------------------------------------------- /src/components/ui/Modal/Footer.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import styles from './index.css'; 4 | 5 | const Footer = ({ children }) => ; 6 | 7 | export default Footer; 8 | -------------------------------------------------------------------------------- /src/tests/__mocks__/fileMocks.js: -------------------------------------------------------------------------------- 1 | // This fixed an error related to the CSS and loading gif breaking my Jest test 2 | // See https://facebook.github.io/jest/docs/en/webpack.html#handling-static-assets 3 | module.exports = 'test-file-stub'; 4 | -------------------------------------------------------------------------------- /src/components/ui/Link.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | const Link = ({ href, children }) => ( 4 | 5 | {children} 6 | 7 | ); 8 | 9 | export default Link; 10 | -------------------------------------------------------------------------------- /src/app-id.json: -------------------------------------------------------------------------------- 1 | { 2 | "trustedFacets": [ 3 | { 4 | "version": { 5 | "major": 1, 6 | "minor": 0 7 | }, 8 | "ids": ["https://account.protonmail.red"] 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/components/ui/Modal/Content.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import styles from './index.css'; 4 | 5 | const ModalContent = (props) =>
{props.children}
; 6 | 7 | export default ModalContent; 8 | -------------------------------------------------------------------------------- /src/routes/settings/index.css: -------------------------------------------------------------------------------- 1 | .security { 2 | margin-top: 100px; 3 | display: flex 4 | } 5 | 6 | .nav { 7 | flex: 1; 8 | } 9 | 10 | .selected { 11 | color: black; 12 | text-underline: none; 13 | } 14 | 15 | .content { 16 | flex: 10; 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 4 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /src/tests/__snapshots__/header.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Initial Test of the Header Header renders 3 nav items 1`] = ` 4 |
5 |

Proton Account

6 | 9 |
10 | `; 11 | -------------------------------------------------------------------------------- /src/components/ui/Modal/Wrapper.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import styles from './index.css'; 3 | 4 | const Wrapper = ({ children, onSubmit, onReset }) => ( 5 |
6 | {children} 7 |
8 | ); 9 | 10 | export default Wrapper; 11 | -------------------------------------------------------------------------------- /src/tests/header.test.js: -------------------------------------------------------------------------------- 1 | import render from 'preact-render-to-string'; 2 | 3 | import Header from '../components/header'; 4 | 5 | describe('Initial Test of the Header', () => { 6 | test('Header renders 3 nav items', () => { 7 | const tree = render(
); 8 | expect(tree).toMatchSnapshot(); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /src/helpers/toActions.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add suffix Actions at the end of each functions. 3 | * As unistore merge states and actions it prevents conflicts 4 | * @param {Object} actions 5 | * @return {Object} 6 | */ 7 | export default function toActions(actions = {}) { 8 | return Object.keys(actions).reduce((acc, key) => { 9 | acc[`${key}Action`] = actions[key]; 10 | return acc; 11 | }, {}); 12 | } 13 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/SetupTOTPModal/index.css: -------------------------------------------------------------------------------- 1 | .grid { 2 | width: 50% 3 | } 4 | 5 | .row { 6 | display: flex; 7 | flex-direction: row; 8 | justify-content: space-between; 9 | } 10 | 11 | .label { 12 | margin: auto 0; 13 | flex: 1; 14 | } 15 | 16 | .value { 17 | flex: 3 18 | } 19 | 20 | 21 | .result{ 22 | flex: 2; 23 | } 24 | 25 | .description { 26 | flex: 2; 27 | } 28 | 29 | .code { 30 | } 31 | -------------------------------------------------------------------------------- /src/helpers/notification.js: -------------------------------------------------------------------------------- 1 | import appDispatcher from 'frontend-commons/src/utils/appDispatcher'; 2 | 3 | const dispatcher = appDispatcher('notification'); 4 | 5 | const action = (type) => (message, opt = {}) => dispatcher.input(type, { message, opt }); 6 | export const success = action('success'); 7 | export const error = (err) => action('error')(err.message, err); 8 | export const info = action('info'); 9 | 10 | export const onInput = dispatcher.onInput; 11 | -------------------------------------------------------------------------------- /src/helpers/plan.js: -------------------------------------------------------------------------------- 1 | const searchParams = new URLSearchParams(window.location.search); 2 | 3 | export const getDefaultConfigPlan = ({ Cycle = 12, Currency = 'EUR' } = {}) => ({ 4 | Currency: searchParams.get('currency') || Currency, 5 | Cycle: +(searchParams.get('billing') || Cycle), 6 | Name: searchParams.get('plan') || 'vpnplus' 7 | }); 8 | 9 | export const cycleString = ({ Cycle }, isMonth = false) => (Cycle === 12 && !isMonth ? 'year' : 'month'); 10 | -------------------------------------------------------------------------------- /circle.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: circleci/node:10.9.0-stretch-browsers 6 | steps: 7 | - checkout 8 | - restore_cache: 9 | key: npm-deps-{{ checksum "package.json" }} 10 | - run: npm i 11 | - save_cache: 12 | key: npm-deps-{{ checksum "package.json" }} 13 | paths: 14 | - node_modules 15 | - run: npm run lint 16 | - run: npm test 17 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/style.css: -------------------------------------------------------------------------------- 1 | .twoFactor { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: stretch; 5 | } 6 | 7 | .item { 8 | display: flex; 9 | flex-direction: row; 10 | justify-content: space-around; 11 | align-items: center; 12 | } 13 | 14 | .lastItem:after { 15 | content: ""; 16 | flex: 2; 17 | } 18 | 19 | .description { 20 | flex: 2; 21 | } 22 | 23 | .action { 24 | flex: 1; 25 | } 26 | -------------------------------------------------------------------------------- /src/helpers/text.js: -------------------------------------------------------------------------------- 1 | import { saveAs } from 'file-saver'; 2 | 3 | /** 4 | * Download the given lines as a file. 5 | * @param {string} name - the name of the file 6 | * @param {string[]} lines - the lines to put in the file 7 | * @param {string} [type='text/plain;charset=utf-8'] - the mime type of the file 8 | */ 9 | export const downloadAsFile = (name, lines = [], type = 'text/plain;charset=utf-8') => { 10 | const blob = new Blob([lines.join('\r\n')], { type }); 11 | saveAs(blob, name); 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/ui/TextButton/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import style from './style.css'; 4 | 5 | /** 6 | * Button that has the appearance of a link. 7 | * @param {Function} onClick - handle for the click event. 8 | * @param {preact.Component[]} children 9 | * @returns {preact.Component} 10 | */ 11 | const TextButton = ({ onClick, children }) => ( 12 | 13 | {children} 14 | 15 | ); 16 | 17 | export default TextButton; 18 | -------------------------------------------------------------------------------- /env/configDefault.js: -------------------------------------------------------------------------------- 1 | const PACKAGE = require('../package'); 2 | 3 | module.exports = { 4 | app_version: PACKAGE.version, 5 | api_version: '3', 6 | date_version: new Date().toDateString(), 7 | year: new Date().getFullYear(), 8 | clientID: 'WebVPN', 9 | clientSecret: 'e601ca139540a6e55a25071c3a5b9557', 10 | articleLink: 'https://protonmail.com/blog/protonmail-v3-14-release-notes/', 11 | changelogPath: '', 12 | url: 'https://account.protonmail.red', 13 | loadI18n: false, 14 | translations: [] 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/SaveRecoveryCodeModal/index.css: -------------------------------------------------------------------------------- 1 | .list { 2 | display: flex; 3 | flex-direction: row; 4 | flex-wrap: wrap; 5 | align-self: stretch; 6 | padding: 0 5%; 7 | } 8 | 9 | .item { 10 | width: 15%; 11 | margin-left: 10%; 12 | } 13 | 14 | .code { 15 | } 16 | 17 | .actions { 18 | display: flex; 19 | justify-content: space-around; 20 | align-self: stretch; 21 | text-align: left; 22 | } 23 | 24 | .description { 25 | flex: 2; 26 | } 27 | .result{ 28 | flex: 2; 29 | } 30 | -------------------------------------------------------------------------------- /src/tests/components/auth/formLogin2FA.test.js: -------------------------------------------------------------------------------- 1 | import render from 'preact-render-to-string'; 2 | 3 | import FormLogin2FA from '../../../components/auth/formLogin2FA'; 4 | import { renderProvided } from '../../testsHelpers/storeTools'; 5 | 6 | describe('Testing FromLogin2FA', () => { 7 | test('without U2F', () => { 8 | expect(render()).toMatchSnapshot(); 9 | }); 10 | 11 | test('with U2F', () => { 12 | expect(renderProvided()).toMatchSnapshot(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Provider } from 'unistore/full/preact'; 3 | 4 | import App from './components/app'; 5 | import hookErrorActions from './lib/hookErrorActions'; 6 | import store from './helpers/store'; 7 | 8 | import './style'; 9 | 10 | hookErrorActions(store, (e) => { 11 | console.log('-------- ¯\\_(ツ)_/¯ -------'); 12 | console.error(e); 13 | console.log('---------------------------'); 14 | }); 15 | 16 | const main = () => ( 17 | 18 | 19 | 20 | ); 21 | 22 | export default main; 23 | -------------------------------------------------------------------------------- /preact.config.js: -------------------------------------------------------------------------------- 1 | const preactCliLodash = require('preact-cli-lodash'); 2 | 3 | module.exports = function (config, env, helpers) { 4 | config.node.process = 'mock'; 5 | config.node.Buffer = true; 6 | // console.log(config); 7 | 8 | preactCliLodash(config); 9 | 10 | const loaders = config.module.loaders; 11 | const babelLoader = loaders.find(loader => loader.loader === 'babel-loader'); 12 | babelLoader.exclude = /node_modules/; 13 | 14 | const [ { index = null } = {} ] = helpers.getPluginsByName(config, 'UglifyJsPlugin'); 15 | (index !== null) && config.plugins.splice(index, 1); 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/ui/NotificationStack/index.css: -------------------------------------------------------------------------------- 1 | .notificationStack { 2 | position: fixed; 3 | width: 40%; 4 | margin: 0 auto; 5 | left: 0; 6 | right: 0; 7 | top: 20px; 8 | z-index: 51; 9 | display: flex; 10 | flex-direction: column; 11 | justify-content: stretch; 12 | } 13 | 14 | .notification { 15 | flex: 1; 16 | margin: 1px 0; 17 | list-style-type: none; 18 | } 19 | 20 | .success { 21 | background-color: #52ff49; 22 | } 23 | 24 | .error { 25 | background-color: #ff4646; 26 | } 27 | 28 | .info { 29 | background-color: #a7cfff; 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/hookErrorActions.js: -------------------------------------------------------------------------------- 1 | const noop = () => {}; 2 | 3 | /** 4 | * Manage errors from actions 5 | * -- Mutate the store -- 6 | * @link {https://github.com/developit/unistore/issues/83#issuecomment-389848046} 7 | * @param {Object} store 8 | * @param {Function} hook 9 | * @return {void} 10 | */ 11 | export default function hookErrorActions(store, hook = noop) { 12 | const action = store.action; 13 | store.action = function(...args) { 14 | const fn = action.apply(this, args); 15 | return function(...args) { 16 | fn.apply(this, args).catch(hook); 17 | }; 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "proton-acount", 3 | "short_name": "proton-acount", 4 | "start_url": "/", 5 | "display": "standalone", 6 | "orientation": "portrait", 7 | "background_color": "#fff", 8 | "theme_color": "#673ab8", 9 | "icons": [ 10 | { 11 | "src": "/assets/icons/android-chrome-192x192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "/assets/icons/android-chrome-512x512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /src/tests/__mocks__/browserMocks.js: -------------------------------------------------------------------------------- 1 | // Mock Browser API's which are not supported by JSDOM, e.g. ServiceWorker, LocalStorage 2 | /** 3 | * An example how to mock localStorage is given below 👇 4 | */ 5 | 6 | /* 7 | // Mocks localStorage 8 | const localStorageMock = (function() { 9 | let store = {}; 10 | 11 | return { 12 | getItem: (key) => store[key] || null, 13 | setItem: (key, value) => store[key] = value.toString(), 14 | clear: () => store = {} 15 | }; 16 | 17 | })(); 18 | 19 | Object.defineProperty(window, 'localStorage', { 20 | value: localStorageMock 21 | }); */ 22 | window.addEventListener = jest.fn(); 23 | window.sessionStorage = { 24 | clear: jest.fn() 25 | }; 26 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/U2FKeyList/style.css: -------------------------------------------------------------------------------- 1 | .list { 2 | display: flex; 3 | flex-direction: column; 4 | padding: 0; 5 | } 6 | 7 | .listHeader { 8 | display: flex; 9 | flex-direction: row; 10 | justify-content: space-between; 11 | } 12 | 13 | .listElement { 14 | display: flex; 15 | justify-content: space-between; 16 | align-items: center; 17 | border: 1px solid black; 18 | padding: 2px; 19 | } 20 | 21 | .listElement:not(:last-child){ 22 | border-bottom: 0; 23 | } 24 | 25 | .listElementHeaderCompromised { 26 | text-decoration: line-through; 27 | } 28 | 29 | .listElementHeader { 30 | margin-right: 'auto' 31 | } 32 | 33 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/AddU2FModal/index.css: -------------------------------------------------------------------------------- 1 | .name { 2 | display: flex; 3 | justify-content: space-between; 4 | align-self: stretch; 5 | } 6 | 7 | .nameLabel { 8 | flex: 1; 9 | } 10 | 11 | .nameInputContainer { 12 | flex: 4; 13 | } 14 | 15 | .nameTextInput { 16 | width: 100% 17 | } 18 | 19 | .container { 20 | align-items: stretch; 21 | } 22 | 23 | .status { 24 | margin: auto 20%; 25 | } 26 | 27 | .row{ 28 | display: flex; 29 | flex-direction: row; 30 | justify-content: space-between; 31 | } 32 | 33 | .text { 34 | flex: 1; 35 | } 36 | 37 | .text:first-child { 38 | text-align: left; 39 | } 40 | 41 | .text.nameLabel { 42 | color: #9592d1; 43 | } 44 | -------------------------------------------------------------------------------- /src/tests/components/auth/__snapshots__/formSignU2F.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`testing FormSignU2F component... U2F not available on this browser 1`] = ` 4 |
5 |

Your browser is not supported, please use another 2FA method instead

6 |
7 | `; 8 | 9 | exports[`testing FormSignU2F component... correct state 1`] = ` 10 |
11 |

Activate your security key...

12 |
13 | `; 14 | 15 | exports[`testing FormSignU2F component... error state 1`] = ` 16 |
17 |

Activate your security key...

18 |

19 | An error occurred. 20 | 21 |

22 |
23 | `; 24 | 25 | exports[`testing FormSignU2F component... success state 1`] = ` 26 |
27 |

Success

28 |
29 | `; 30 | -------------------------------------------------------------------------------- /src/components/settings/security/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import TwoFactorSettings from './TwoFactor'; 4 | 5 | import styles from './index.css'; 6 | 7 | /** 8 | * Manages the setting security view. 9 | * @return {Component} 10 | */ 11 | const view = ({ user }) => ( 12 |
13 |

Security

14 |
15 |
16 | 17 |
18 | 19 |
20 |

Session Management

21 |
22 |
23 |
24 |

Authentication Logs

25 |
26 |
27 | ); 28 | 29 | export default view; 30 | -------------------------------------------------------------------------------- /src/routes/home/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { connect } from 'unistore/full/preact'; 3 | 4 | import style from './style'; 5 | import authActions from '../../actions/authentication'; 6 | import FormLogin from '../../components/auth/formLogin'; 7 | import FormUnlock from '../../components/auth/formUnlock'; 8 | import FormLogin2FA from '../../components/auth/formLogin2FA'; 9 | 10 | export default connect( 11 | ['auth'], 12 | authActions 13 | )(({ auth, loginAction, login2FAAction, unlockAction }) => { 14 | console.log('LOGIN', auth); 15 | return ( 16 |
17 | {auth.step === 'login' && } 18 | {auth.step === 'unlock' && } 19 | {auth.step === '2fa' && } 20 |
21 | ); 22 | }); 23 | -------------------------------------------------------------------------------- /src/routes/dashboard/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { connect } from 'unistore/full/preact'; 3 | import TextButton from '../../components/ui/TextButton'; 4 | import { info } from '../../helpers/notification'; 5 | 6 | export default connect(['auth', 'config'])(({ auth, config }) => { 7 | console.log('DASHBOARD', auth); 8 | return ( 9 |
10 |

11 | Bonjour {auth.user.Name} 12 |

13 | 14 | { 16 | const id = Math.floor(Math.random() * 100) + 1; 17 | info('Test notification ' + id, { id }); 18 | }} 19 | > 20 | Send a test notification 21 | 22 | 23 |
{JSON.stringify(config, null, 2)}
24 |
25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /src/tests/helpers/stateFormatter.test.js: -------------------------------------------------------------------------------- 1 | import { extended } from '../../helpers/stateFormatter'; 2 | 3 | const state = { 4 | a: { 5 | c: { 6 | e: 1, 7 | g: 'arabica' 8 | } 9 | }, 10 | b: { 11 | d: { 12 | f: 2 13 | }, 14 | h: 256 15 | } 16 | }; 17 | 18 | describe('"extended" tests for stateFormatter', () => { 19 | test('only includes root element', () => { 20 | expect(extended(state, 'a', { d: 1 })).toMatchSnapshot(); 21 | }); 22 | test('includes all non root element', () => { 23 | expect(extended(state, 'b', { h: 256 })).toMatchSnapshot(); 24 | }); 25 | test('correctly updates element', () => { 26 | expect(extended(state, 'b.d', { f: -1 })).toMatchSnapshot(); 27 | }); 28 | test('correctly behave when array is given', () => { 29 | expect(extended(state, ['b.d', 'f'], { n: -1 })).toMatchSnapshot(); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** 21 | If applicable, add screenshots to help explain your problem. 22 | 23 | **Desktop (please complete the following information):** 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | - Device: [e.g. iPhone6] 30 | - OS: [e.g. iOS8.1] 31 | - Browser [e.g. stock browser, safari] 32 | - Version [e.g. 22] 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /src/components/ui/Modal/index.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | top: 50%; 3 | left: 50%; 4 | right: auto; 5 | bottom: auto; 6 | width: 50%; 7 | margin-right: -50%; 8 | transform: translate(-50%, -50%); 9 | display: flex; 10 | flex-direction: column; 11 | align-items: stretch; 12 | padding: 0; 13 | position: absolute; 14 | border: 1px solid #ccc; 15 | background: #fff; 16 | overflow: auto; 17 | -webkit-overflow-scrolling: touch; 18 | border-radius: 4px; 19 | outline: none; 20 | } 21 | 22 | .header { 23 | border-bottom: 1px solid #ccc; 24 | text-align: center; 25 | } 26 | 27 | .content { 28 | display: flex; 29 | flex-direction: column; 30 | align-items: center; 31 | justify-content: space-around; 32 | padding: 20px; 33 | } 34 | 35 | .wrapper { 36 | } 37 | 38 | .footer { 39 | display: flex; 40 | justify-content: space-between; 41 | padding: 20px; 42 | border-top: #ccc solid 1px; 43 | } 44 | -------------------------------------------------------------------------------- /src/helpers/store.js: -------------------------------------------------------------------------------- 1 | import { createStore } from 'unistore/full/preact'; 2 | import devtools from 'unistore/devtools'; 3 | 4 | import authActions from '../actions/authentication'; 5 | import settingsActions from '../actions/settings'; 6 | import scopeActions from '../actions/scope'; 7 | 8 | export const initialState = { 9 | auth: { 10 | isLoggedIn: false, 11 | user: {}, 12 | step: 'login', 13 | twoFactorResponse: {} 14 | }, 15 | scope: { 16 | creds: null, 17 | response: null 18 | }, 19 | settings: { 20 | addU2FKey: {}, 21 | reset2FARecoveryCodes: {}, 22 | setupTOTP: {} 23 | } 24 | }; 25 | const store = process.env.NODE_ENV === 'production' ? createStore(initialState) : devtools(createStore(initialState)); 26 | 27 | export const actions = (store) => ({ 28 | ...authActions(store), 29 | ...settingsActions(store), 30 | ...scopeActions(store) 31 | }); 32 | 33 | export default store; 34 | -------------------------------------------------------------------------------- /src/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "preact-cli/babel", 5 | { 6 | "modules": "commonjs" 7 | } 8 | ], 9 | [ 10 | "env", 11 | { 12 | "targets": { 13 | "browsers": [ 14 | "ie 11" 15 | ] 16 | } 17 | } 18 | ] 19 | ], 20 | "plugins": [ 21 | [ 22 | "babel-plugin-transform-react-jsx", 23 | { 24 | "pragma": "h" 25 | } 26 | ], 27 | "babel-plugin-transform-object-rest-spread", 28 | "babel-plugin-lodash", 29 | [ 30 | "babel-plugin-transform-runtime", 31 | { 32 | "helpers": false, 33 | "polyfill": false, 34 | "regenerator": true 35 | } 36 | ], 37 | "transform-class-properties" 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /src/tests/helpers/__snapshots__/stateFormatter.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`"extended" tests for stateFormatter correctly behave when array is given 1`] = ` 4 | Object { 5 | "b.d": Object { 6 | "f": Object { 7 | "n": -1, 8 | }, 9 | }, 10 | } 11 | `; 12 | 13 | exports[`"extended" tests for stateFormatter correctly updates element 1`] = ` 14 | Object { 15 | "b": Object { 16 | "d": Object { 17 | "f": -1, 18 | }, 19 | "h": 256, 20 | }, 21 | } 22 | `; 23 | 24 | exports[`"extended" tests for stateFormatter includes all non root element 1`] = ` 25 | Object { 26 | "b": Object { 27 | "d": Object { 28 | "f": 2, 29 | }, 30 | "h": 256, 31 | }, 32 | } 33 | `; 34 | 35 | exports[`"extended" tests for stateFormatter only includes root element 1`] = ` 36 | Object { 37 | "a": Object { 38 | "c": Object { 39 | "e": 1, 40 | "g": "arabica", 41 | }, 42 | "d": 1, 43 | }, 44 | } 45 | `; 46 | -------------------------------------------------------------------------------- /src/components/auth/ScopeModal/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import ScopeFormModal from './ScopeFormModal'; 4 | import store from '../../../helpers/store'; 5 | import actions from '../../../actions/scope'; 6 | 7 | export const steps = (scope) => { 8 | if (scope !== 'password') { 9 | return []; 10 | } 11 | return [ 12 | { 13 | title: 'Confirm your identity', 14 | component({ onNextStep, onPreviousStep, onSkipStep, message }) { 15 | return ( 16 | 22 | ); 23 | } 24 | } 25 | ]; 26 | }; 27 | 28 | export const beforeDismiss = () => { 29 | const { resetScopeStateAction } = actions(store); 30 | resetScopeStateAction(store.getState()); 31 | }; 32 | -------------------------------------------------------------------------------- /tasks/helpers/log.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk'); 2 | 3 | const warn = (msg) => { 4 | console.log(); 5 | console.log(`${chalk.magenta('⚠')} ${chalk.magenta(msg)}.`); 6 | console.log(); 7 | }; 8 | 9 | const success = (msg, { time } = {}) => { 10 | const txt = chalk.green(` ${chalk.bold('✓')} ${msg} `); 11 | const message = [txt, time && `(${time})`].filter(Boolean).join(''); 12 | console.log(); 13 | console.log(message); 14 | }; 15 | 16 | const title = (msg) => { 17 | console.log('~', chalk.bgYellow(chalk.black(msg)), '~'); 18 | console.log(); 19 | }; 20 | 21 | const json = (data) => { 22 | console.log(); 23 | console.log(JSON.stringify(data, null, 2)); 24 | console.log(); 25 | }; 26 | 27 | const error = (e) => { 28 | console.log(); 29 | console.log(chalk.red(' ⚠'), chalk.red(e.message)); 30 | console.log(); 31 | console.log(); 32 | process.exit(1); 33 | }; 34 | 35 | module.exports = { 36 | success, 37 | title, 38 | error, 39 | json, 40 | warn 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/auth/formUnlock.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | const formUnlock = ({ unlock }) => { 4 | const model = {}; 5 | 6 | return ( 7 |
(e.preventDefault(), unlock(model))}> 8 |
9 |
10 | 11 | { 20 | model.passwordUnlock = value; 21 | }} 22 | /> 23 |
24 |
25 | 26 | 27 |
28 | ); 29 | }; 30 | 31 | export default formUnlock; 32 | -------------------------------------------------------------------------------- /src/components/header/style.css: -------------------------------------------------------------------------------- 1 | .header { 2 | position: fixed; 3 | left: 0; 4 | top: 0; 5 | width: 100%; 6 | height: 56px; 7 | padding: 0; 8 | background: #673AB7; 9 | box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); 10 | z-index: 50; 11 | } 12 | 13 | .header h1 { 14 | float: left; 15 | margin: 0; 16 | padding: 0 15px; 17 | font-size: 24px; 18 | line-height: 56px; 19 | font-weight: 400; 20 | color: #FFF; 21 | } 22 | 23 | .header nav { 24 | float: right; 25 | font-size: 100%; 26 | } 27 | 28 | .header nav a, .header button { 29 | display: inline-block; 30 | border: 0; 31 | height: 56px; 32 | line-height: 56px; 33 | padding: 0 15px; 34 | min-width: 50px; 35 | text-align: center; 36 | font-size: 1em; 37 | cursor: pointer; 38 | background: rgba(255,255,255,0); 39 | text-decoration: none; 40 | color: #FFF; 41 | will-change: background-color; 42 | } 43 | 44 | .header nav a:hover, 45 | .header nav a:active, 46 | .header nav button:hover, 47 | .header nav button:active { 48 | background: rgba(0,0,0,0.2); 49 | } 50 | 51 | .header nav button.active, 52 | .header nav a.active { 53 | background: rgba(0,0,0,0.4); 54 | } 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/style/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | width: 100%; 4 | padding: 0; 5 | margin: 0; 6 | background: #FAFAFA; 7 | font-family: 'Helvetica Neue', arial, sans-serif; 8 | font-weight: 400; 9 | color: #444; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | * { 15 | box-sizing: border-box; 16 | } 17 | 18 | #app { 19 | height: 100%; 20 | } 21 | 22 | .badge { 23 | border-radius: 2px; 24 | margin: 2px; 25 | padding: 2px 5px; 26 | color: white; 27 | font-weight: bold; 28 | } 29 | 30 | .badge-danger { 31 | background-color: red; 32 | } 33 | 34 | .alert { 35 | border-radius: 2px; 36 | margin: 2px; 37 | padding: 10px 15px; 38 | color: #629347; 39 | font-weight: bold; 40 | } 41 | 42 | .alert-info { 43 | background-color: #dfeed7; 44 | } 45 | 46 | .form-row { 47 | display: flex; 48 | align-self: stretch; 49 | justify-content: space-around; 50 | flex: 1; 51 | align-items: center; 52 | } 53 | 54 | .form-row:before, .form-row:after { 55 | content: ''; 56 | flex: 1; 57 | } 58 | 59 | .form-row > * { 60 | flex: 2; 61 | } 62 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/SaveRecoveryCodeModal/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import Presentation from './presentation'; 4 | import TestCode from './formTestCode'; 5 | import { steps as ScopeModal, beforeDismiss as beforeDismissScopeModal } from '../../../../auth/ScopeModal'; 6 | import store from '../../../../../helpers/store'; 7 | import actions from '../../../../../actions/settings'; 8 | 9 | export const steps = [ 10 | ...ScopeModal('password'), 11 | { 12 | title: 'Save your recovery codes', 13 | mustSucceed: true, 14 | component({ onNextStep, onPreviousStep, onReset }) { 15 | return ; 16 | } 17 | }, 18 | { 19 | title: 'Test your recovery codes', 20 | mustSucceed: true, 21 | component({ onNextStep, onPreviousStep }) { 22 | return ; 23 | } 24 | } 25 | ]; 26 | 27 | export const beforeDismiss = () => { 28 | beforeDismissScopeModal(); 29 | const { resetStoreAction } = actions(store); 30 | 31 | resetStoreAction(store.getState(), ['reset2FARecoveryCodes']); 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/header/index.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { Link } from 'preact-router/match'; 3 | 4 | import style from './style'; 5 | 6 | export default class Header extends Component { 7 | render({ isLoggedIn, logout }) { 8 | return ( 9 |
10 |

Proton Account

11 | 29 |
30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/AddU2FModal/presentation.test.js: -------------------------------------------------------------------------------- 1 | import render from 'preact-render-to-string'; 2 | import { deep } from 'preact-render-spy'; 3 | 4 | import Presentation from '../../../../../../components/settings/security/TwoFactor/AddU2FModal/presentation'; 5 | 6 | describe('AddU2FModal Presentation step', () => { 7 | test('regular display', () => { 8 | expect(render()).toMatchSnapshot(); 9 | }); 10 | test('display with message', () => { 11 | expect(render()).toMatchSnapshot(); 12 | }); 13 | 14 | test('submit and cancel', () => { 15 | const onSubmit = jest.fn(); 16 | const onCancel = jest.fn(); 17 | const context = deep(); 18 | 19 | const event = { preventDefault: () => undefined }; 20 | 21 | context.find('form').simulate('submit', event); 22 | expect(onSubmit).toHaveBeenCalled(); 23 | expect(onCancel).not.toHaveBeenCalled(); 24 | 25 | context.find('form').simulate('reset', event); 26 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event 27 | expect(onCancel).toHaveBeenCalled(); // only once 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/SetupTOTPModal/presentation.test.js: -------------------------------------------------------------------------------- 1 | import render from 'preact-render-to-string'; 2 | import { deep } from 'preact-render-spy'; 3 | 4 | import Presentation from '../../../../../../components/settings/security/TwoFactor/SetupTOTPModal/presentation'; 5 | 6 | describe('SetupTOTPModal Presentation step', () => { 7 | test('regular display', () => { 8 | expect(render()).toMatchSnapshot(); 9 | }); 10 | test('display with message', () => { 11 | expect(render()).toMatchSnapshot(); 12 | }); 13 | 14 | test('submit and cancel', () => { 15 | const onSubmit = jest.fn(); 16 | const onCancel = jest.fn(); 17 | const context = deep(); 18 | 19 | const event = { preventDefault: () => undefined }; 20 | 21 | context.find('form').simulate('submit', event); 22 | expect(onSubmit).toHaveBeenCalled(); 23 | expect(onCancel).not.toHaveBeenCalled(); 24 | 25 | context.find('form').simulate('reset', event); 26 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event 27 | expect(onCancel).toHaveBeenCalled(); // only once 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/AddU2FModal/__snapshots__/formName.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`AddU2FModal SharedSecret step display 1`] = ` 4 |
5 |
6 |
7 | 8 |
9 | 11 |
12 |
13 |
14 |
15 | 16 | 17 |
18 |
19 | `; 20 | 21 | exports[`AddU2FModal SharedSecret step display 2`] = ` 22 |
23 |
24 |
25 | 26 |
27 | 29 |
30 |
31 |
32 |
33 | 34 | 35 |
36 |
37 | `; 38 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/SetupTOTPModal/__snapshots__/presentation.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SetupTOTPModal Presentation step display with message 1`] = ` 4 |
5 |
6 |
This is a message
7 |
8 |
9 | 10 | 11 |
12 |
13 | `; 14 | 15 | exports[`SetupTOTPModal Presentation step regular display 1`] = ` 16 |
17 |
18 |

This wizard will enable Two Factor Authentication (2FA) on your ProtonMail 19 | account. 2FA will make your ProtonMail account more secure so we recommend 20 | enabling it.

21 |

22 | If you have never used 2FA before, we strongly recommend you reading our 23 | 2FA Guide first. 24 |

25 |
26 | 2FA GUIDE 28 |
29 |
30 |
31 | 32 | 33 |
34 |
35 | `; 36 | -------------------------------------------------------------------------------- /src/tests/components/auth/__snapshots__/formLogin2FA.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Testing FromLogin2FA with U2F 1`] = ` 4 |
5 |
6 | Choose one of the following 2FA methods 7 |
8 | 9 |
10 | 13 |
14 |
15 |
16 | 17 |
18 |

Activate your security key...

19 |
20 |
21 |
22 | 23 |
24 | `; 25 | 26 | exports[`Testing FromLogin2FA without U2F 1`] = ` 27 |
28 |
29 | Choose one of the following 2FA methods 30 |
31 | 32 |
33 | 36 |
37 |
38 |
39 | 40 |
41 | `; 42 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/AddU2FModal/__snapshots__/presentation.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`AddU2FModal Presentation step display with message 1`] = ` 4 |
5 |
6 |
This is a message
7 |
8 |
9 | 10 | 11 |
12 |
13 | `; 14 | 15 | exports[`AddU2FModal Presentation step regular display 1`] = ` 16 |
17 |
18 |

This wizard will add a new security key to your Proton Account.

19 |

Please note that you will not be able to access your account if you loose 20 | your U2F device and your recovery codes. We recommend setting up a second 21 | 2FA method as a backup.

22 |

23 | If you have never used 2FA before, we strongly recommend you reading our 24 | 2FA Guide first. 25 |

26 |
27 | READ U2F GUIDE 28 |
29 |
30 |
31 | 32 | 33 |
34 |
35 | `; 36 | -------------------------------------------------------------------------------- /tasks/setupConfig.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const chalk = require('chalk'); 6 | const dedent = require('dedent'); 7 | 8 | const env = require('../env/config'); 9 | const PATH_CONFIG = path.resolve('./src/config.js'); 10 | const { CONFIG } = env.getConfig(); 11 | 12 | fs.writeFileSync(PATH_CONFIG, `export default ${JSON.stringify(CONFIG, null, 4)};`); 13 | /** 14 | * Fuck you webpack 15 | * thx https://github.com/webpack/watchpack/issues/25#issuecomment-357483744 16 | */ 17 | const now = Date.now() / 1000; 18 | const then = now - 11; 19 | fs.utimesSync(PATH_CONFIG, then, then); 20 | env.argv.debug && console.log(`${JSON.stringify(CONFIG, null, 2)}`); 21 | 22 | if (process.env.NODE_ENV !== 'dist' && process.env.NODE_ENV_MODE !== 'config') { 23 | 24 | if (!env.hasEnv() && !env.isWebClient()) { 25 | console.log(); 26 | console.log(dedent` 27 | ${chalk.bgMagenta('⚠ No env.json detected')} 28 | ➙ Please check the wiki to create it 29 | `); 30 | console.log(); 31 | } 32 | 33 | portfinder.getPortPromise().then((port) => { 34 | process.env.NODE_ENV_PORT = port; 35 | console.log(dedent` 36 | ${chalk.green('✓')} Generate configuration 37 | ➙ Api: ${chalk.bgYellow(chalk.black(CONFIG.apiUrl))} 38 | ➙ Sentry: ${chalk.yellow(process.env.NODE_ENV_SENTRY)} 39 | `); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /src/components/ui/Modal/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import ReactModal from 'react-modal'; 3 | 4 | import ModalStyles from './index.css'; 5 | 6 | /** 7 | * Creates a new modal, using a predefined style. 8 | * @param {boolean} isOpen - Whether the modal is open or not. 9 | * @param {string} title - Title of the modal. 10 | * @param {Component} children 11 | * @param {Function} onRequestClose - called after the modal is closed. 12 | * @param {Function} onBeforeClose - called after the modal is closed. 13 | * @param {Function} onAfterOpen - called after the modal is opened. 14 | * @param {String} contentLabel- the content label. If not given, title is used. 15 | * @return {ReactModal} the modal components. 16 | * @function 17 | */ 18 | const Modal = ({ isOpen, title, children, onRequestClose, onAfterOpen, contentLabel }) => { 19 | ReactModal.setAppElement('#app'); 20 | return ( 21 | 28 |
29 |

{title}

30 |
31 | {children} 32 |
33 | ); 34 | }; 35 | 36 | export default Modal; 37 | export Content from './Content'; 38 | export Footer from './Footer'; 39 | export Wrapper from './Wrapper'; 40 | -------------------------------------------------------------------------------- /src/components/auth/formLogin2FA.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import FormSignU2F from './formSignU2F'; 4 | 5 | import style from './style.css'; 6 | 7 | const formLogin2FA = ({ login2FA, twoFactorData: { isTOTP = true, U2F: U2FRequest } }) => { 8 | const model = {}; 9 | return ( 10 |
(e.preventDefault(), login2FA(model))}> 11 |
12 | Choose one of the following 2FA methods 13 |
14 | 15 |
16 | { 27 | model.password2FA = value; 28 | }} 29 | /> 30 |
31 |
32 | {U2FRequest && ( 33 |
34 | 35 | 36 |
37 | )} 38 |
39 | 40 | 41 |
42 | ); 43 | }; 44 | 45 | export default formLogin2FA; 46 | -------------------------------------------------------------------------------- /src/components/auth/formLogin.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | const formLogin = ({ login }) => { 4 | const model = { 5 | username: 'dew1527087668398', 6 | password: 'test' 7 | }; 8 | 9 | return ( 10 |
(e.preventDefault(), login(model))}> 11 |
12 |
13 | 14 | { 25 | model.username = value; 26 | }} 27 | /> 28 |
29 | 30 |
31 | 32 | { 39 | model.password = value; 40 | }} 41 | /> 42 |
43 |
44 | 45 | 46 |
47 | ); 48 | }; 49 | 50 | export default formLogin; 51 | -------------------------------------------------------------------------------- /src/components/app.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Router, route } from 'preact-router'; 3 | import { connect } from 'unistore/full/preact'; 4 | import appProvider from 'frontend-commons/src/appProvider'; 5 | import { isLoggedIn as isAuthenticated } from 'frontend-commons/src/user/model'; 6 | 7 | import config from '../config'; 8 | import Header from './header'; 9 | import Home from '../routes/home'; 10 | import Settings from '../routes/settings'; 11 | import Dashboard from '../routes/dashboard'; 12 | import authActions from '../actions/authentication'; 13 | import NotificationStack from './ui/NotificationStack'; 14 | 15 | appProvider.setConfig(config); 16 | 17 | export default connect( 18 | 'auth', 19 | authActions 20 | )(({ auth, loadAuthUserAction, logoutAction }) => { 21 | /** 22 | * Check if the user is authenticated or not. 23 | * Redirect to the login if he is not or load the config 24 | * @link {https://github.com/developit/preact-router#detecting-route-changes} 25 | */ 26 | const onChangeRoute = async ({ previous, url }) => { 27 | if (!isAuthenticated()) { 28 | return route('/'); 29 | } 30 | 31 | if (url !== previous && previous !== '/' && !auth.isLoggedIn) { 32 | await loadAuthUserAction(auth.user); 33 | } 34 | }; 35 | return ( 36 |
37 |
38 | 39 | 40 | 41 | 42 | 43 | 44 |
45 | ); 46 | }); 47 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/AddU2FModal/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import Presentation from './presentation'; 4 | import FormName from './formName'; 5 | import RegisterKeyForm from './formRegisterKey'; 6 | import { steps as RecoveryCodeSteps, beforeDismiss as beforeDismissRecoveryCodeModal } from '../SaveRecoveryCodeModal'; 7 | import { steps as ScopeModal, beforeDismiss as beforeDismissScopeModal } from '../../../../auth/ScopeModal/index'; 8 | import store from '../../../../../helpers/store'; 9 | import actions from '../../../../../actions/settings'; 10 | 11 | export const steps = [ 12 | { 13 | title: 'Register new U2F Key', 14 | component({ onNextStep, onPreviousStep, message }) { 15 | return ; 16 | } 17 | }, 18 | ...ScopeModal('password'), 19 | { 20 | title: 'Name your U2F Key', 21 | component({ onNextStep, onPreviousStep }) { 22 | return ; 23 | } 24 | }, 25 | { 26 | title: 'Register new U2F Key', 27 | component({ onNextStep, onPreviousStep, forbidClosure, onReset }) { 28 | return ( 29 | 35 | ); 36 | } 37 | }, 38 | ...RecoveryCodeSteps 39 | ]; 40 | 41 | export const beforeDismiss = () => { 42 | beforeDismissScopeModal(); 43 | beforeDismissRecoveryCodeModal(); 44 | const { resetStoreAction } = actions(store); 45 | resetStoreAction(store.getState(), ['addU2FKey']); 46 | }; 47 | -------------------------------------------------------------------------------- /src/tests/helpers/u2f.test.js: -------------------------------------------------------------------------------- 1 | import appProvider from 'frontend-commons/src/appProvider'; 2 | import { sign, register } from 'u2f-api'; 3 | 4 | import { ERROR_CODE, getErrorMessage, registerU2F, signU2F } from '../../helpers/u2f'; 5 | 6 | jest.mock('frontend-commons/src/appProvider'); 7 | jest.mock('u2f-api'); 8 | 9 | describe('u2f helper', () => { 10 | beforeAll(() => 11 | appProvider.getConfig.mockImplementation(() => ({ 12 | appId: 'https://example.com', 13 | timeout: 30 14 | }))); 15 | afterAll(() => appProvider.getConfig.mockRestore()); 16 | 17 | test('getErrorMessage', () => { 18 | for (const key in ERROR_CODE) { 19 | const code = ERROR_CODE[key]; 20 | if (code) { 21 | expect(getErrorMessage(code, true)).toBeDefined(); 22 | expect(getErrorMessage(code, false)).toBeDefined(); 23 | } else { 24 | expect(() => { 25 | getErrorMessage(code); 26 | }).toThrow(); 27 | } 28 | } 29 | 30 | expect(() => { 31 | getErrorMessage('I do not exist'); 32 | }).toThrow(); 33 | }); 34 | 35 | test('registerU2F', async () => { 36 | register.mockImplementation(() => ({})); 37 | 38 | await registerU2F({ 39 | RegisteredKeys: [{ Version: 'U2F', KeyHandle: 'flemme' }], 40 | Challenge: 'challenge', 41 | Versions: ['U2F_V2'] 42 | }); 43 | 44 | expect(register).toBeCalled(); 45 | 46 | register.mockRestore(); 47 | }); 48 | test('signU2F', async () => { 49 | sign.mockImplementation(() => ({})); 50 | 51 | await signU2F({ 52 | RegisteredKeys: [{ Version: 'U2F', KeyHandle: 'flemme' }], 53 | Challenge: 'challenge' 54 | }); 55 | 56 | expect(sign).toBeCalled(); 57 | 58 | sign.mockRestore(); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/SetupTOTPModal/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import Presentation from './presentation'; 4 | import SharedSecret from './sharedSecret'; 5 | import { steps as RecoveryCodeSteps, beforeDismiss as beforeDismissRecoveryCodeModal } from '../SaveRecoveryCodeModal'; 6 | import { steps as ScopeModal, beforeDismiss as beforeDismissScopeModal } from '../../../../auth/ScopeModal/index'; 7 | import store from '../../../../../helpers/store'; 8 | import actions from '../../../../../actions/settings'; 9 | import ConfirmCode from './confirmCode'; 10 | 11 | export const steps = [ 12 | { 13 | title: 'Set Up Two Factor Authentication', 14 | component({ onNextStep, onPreviousStep, message }) { 15 | return ; 16 | } 17 | }, 18 | ...ScopeModal('password'), 19 | { 20 | title: 'Set Up Two Factor Authentication', 21 | component({ onNextStep, onPreviousStep }) { 22 | return ; 23 | } 24 | }, 25 | { 26 | title: 'Confirm your new method', 27 | component({ onNextStep, onPreviousStep, onReset, forbidClosure }) { 28 | return ( 29 | 35 | ); 36 | } 37 | }, 38 | ...RecoveryCodeSteps 39 | ]; 40 | 41 | export const beforeDismiss = (success = false, reset = false) => { 42 | beforeDismissScopeModal(); 43 | beforeDismissRecoveryCodeModal(); 44 | if (success || !reset) { 45 | const { resetStoreAction } = actions(store); 46 | resetStoreAction(store.getState(), ['setupTOTP']); 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/AddU2FModal/formName.test.js: -------------------------------------------------------------------------------- 1 | import render from 'preact-render-to-string'; 2 | import { deep } from 'preact-render-spy'; 3 | 4 | import { FormName } from '../../../../../../components/settings/security/TwoFactor/AddU2FModal/formName'; 5 | 6 | describe('AddU2FModal SharedSecret step', () => { 7 | test('display', () => { 8 | expect(render()).toMatchSnapshot(); 9 | expect( 10 | render() 11 | ).toMatchSnapshot(); 12 | }); 13 | 14 | test('input event', () => { 15 | const context = deep(); 16 | context.find('input').simulate('input', { target: { value: 'Something' } }); 17 | }); 18 | 19 | test('submit and cancel', () => { 20 | const onSubmit = jest.fn(); 21 | const onCancel = jest.fn(); 22 | const addU2FKeyLabelAction = jest.fn(); 23 | const context = deep( 24 | 32 | ); 33 | 34 | const event = { preventDefault: () => undefined }; 35 | 36 | context.find('form').simulate('submit', event); 37 | expect(onSubmit).toHaveBeenCalled(); 38 | expect(addU2FKeyLabelAction).toHaveBeenLastCalledWith('Test for key'); 39 | expect(onCancel).not.toHaveBeenCalled(); 40 | 41 | context.find('form').simulate('reset', event); 42 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event 43 | expect(addU2FKeyLabelAction).toHaveBeenCalledTimes(1); 44 | expect(onCancel).toHaveBeenCalled(); // only once 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/routes/settings/index.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { Link } from 'preact-router'; 3 | import { connect } from 'unistore/full/preact'; 4 | 5 | import Security from '../../components/settings/security'; 6 | 7 | import styles from './index.css'; 8 | 9 | /** 10 | * Renders the content of the selected setting (using the URI). 11 | * @param {String} setting - the route of the setting to render 12 | * @param {Object} user 13 | * @returns {Component} 14 | */ 15 | function renderContent(setting, user) { 16 | if (setting === 'security') { 17 | return ; 18 | } 19 | return ( 20 |

21 | Paramètres pour {user.Username} 22 |

23 | ); 24 | } 25 | 26 | export default connect(['auth', 'config'])(({ side, config }) => { 27 | if (!config) { 28 | return null; 29 | } 30 | const { 31 | settings: { user } 32 | } = config; 33 | console.log('SETTINGS', user); 34 | 35 | if (!Object.keys(user).length) { 36 | return null; 37 | } 38 | 39 | // @TODO fix padding 40 | return ( 41 |
42 |
43 |
    44 |
  • 45 | 46 | {' '} 47 | Home{' '} 48 | 49 |
  • 50 |
  • 51 | 52 | {' '} 53 | Security{' '} 54 | 55 |
  • 56 |
57 |
58 |
59 | {renderContent(side, user)} 60 |
61 |
62 | ); 63 | }); 64 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/U2FKeyList.test.js: -------------------------------------------------------------------------------- 1 | import render from 'preact-render-to-string'; 2 | import { deep } from 'preact-render-spy'; 3 | 4 | import { U2FKeyList } from '../../../../../components/settings/security/TwoFactor/U2FKeyList/U2FKeyList'; 5 | 6 | jest.mock('../../../../../actions/settings'); 7 | 8 | import { deepProvider, renderProvided, shallowProvider } from '../../../../testsHelpers/storeTools'; 9 | import store from '../../../../../helpers/store'; 10 | 11 | describe('U2FKeyList test', () => { 12 | test('with no key', () => { 13 | expect(render()).toMatchSnapshot(); 14 | }); 15 | 16 | test('with keys', () => { 17 | const keys = [ 18 | { KeyHandle: '1', Compromised: false, Label: 'First key' }, 19 | { KeyHandle: '2', Compromised: false, Label: 'Second key' }, 20 | { KeyHandle: '3', Compromised: true, Label: 'Compromised key' }, 21 | { KeyHandle: '4', Compromised: false, Label: 'Last key' } 22 | ]; 23 | expect(render()).toMatchSnapshot(); 24 | }); 25 | 26 | test('opening modal', () => { 27 | const keys = [ 28 | { KeyHandle: '1', Compromised: false, Label: 'First key' }, 29 | { KeyHandle: '2', Compromised: false, Label: 'Second key' }, 30 | { KeyHandle: '3', Compromised: true, Label: 'Compromised key' }, 31 | { KeyHandle: '4', Compromised: false, Label: 'Last key' } 32 | ]; 33 | const context = deep(); 34 | expect(context.state('confirmDeleteModal')).toBeFalsy(); 35 | context 36 | .find('a') 37 | .first() 38 | .simulate('click'); 39 | expect(context).toMatchSnapshot(); 40 | expect(context.state('confirmDeleteModal')).toMatchObject( 41 | expect.objectContaining({ 42 | KeyHandle: expect.any(String), 43 | Label: expect.any(String) 44 | }) 45 | ); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/__snapshots__/U2FKeyList.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`U2FKeyList test opening modal 1`] = ` 4 | preact-render-spy (1 nodes) 5 | ------- 6 | 49 | 50 | `; 51 | 52 | exports[`U2FKeyList test with keys 1`] = ` 53 |
    54 |
  • 55 |
    First key
    56 | Delete 57 |
  • 58 |
  • 59 |
    Second key
    60 | Delete 61 |
  • 62 |
  • 63 |
    Compromised key
    64 |
    Compromised
    65 | Delete 66 |
  • 67 |
  • 68 |
    Last key
    69 | Delete 70 |
  • 71 |
72 | `; 73 | 74 | exports[`U2FKeyList test with no key 1`] = `
    `; 75 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/AddU2FModal/presentation.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal'; 4 | import Link from '../../../../ui/Link'; 5 | 6 | /** 7 | * Shows information about the U2F Key usage. 8 | * @param {Object} props 9 | * @param {Function} props.onSubmit - triggers the next step. 10 | * @param {Function} props.onCancel - triggers the previous step. 11 | * @return {*} 12 | */ 13 | const Presentation = ({ onSubmit, onCancel, message }) => ( 14 | { 16 | e.preventDefault(); 17 | onSubmit(); 18 | }} 19 | onReset={(e) => { 20 | e.preventDefault(); 21 | onCancel(); 22 | }} 23 | > 24 | {message ? ( 25 | 26 |
    {message}
    27 |
    28 | ) : ( 29 | 30 |

    This wizard will add a new security key to your Proton Account.

    31 |

    32 | Please note that you will not be able to access your account if you loose your U2F device and your 33 | recovery codes. We recommend setting up a second 2FA method as a backup. 34 |

    35 |

    36 | 37 | If you have never used 2FA before, we strongly recommend you reading our 2FA Guide first. 38 | 39 |

    40 |
    41 | READ U2F GUIDE 42 |
    43 |
    44 | )} 45 | 46 | 49 | 52 | 53 |
    54 | ); 55 | 56 | export default Presentation; 57 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/SetupTOTPModal/presentation.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | 3 | import Link from '../../../../ui/Link'; 4 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal'; 5 | 6 | /** 7 | * Shows information about the U2F Key usage. 8 | * @param {Object} props 9 | * @param {Function} props.onSubmit - triggers the next step. 10 | * @param {Function} props.onCancel - triggers the previous step. 11 | * @return {*} 12 | */ 13 | const Presentation = ({ onSubmit, onCancel, message }) => ( 14 | { 16 | e.preventDefault(); 17 | onSubmit(); 18 | }} 19 | onReset={(e) => { 20 | e.preventDefault(); 21 | onCancel(); 22 | }} 23 | > 24 | {message ? ( 25 | 26 |
    {message}
    27 |
    28 | ) : ( 29 | 30 |

    31 | This wizard will enable Two Factor Authentication (2FA) on your ProtonMail account. 2FA will make 32 | your ProtonMail account more secure so we recommend enabling it.{' '} 33 |

    34 |

    35 | 36 | If you have never used 2FA before, we strongly recommend you reading our 2FA Guide first. 37 | 38 |

    39 |
    40 | 41 | 2FA GUIDE 42 | 43 |
    44 |
    45 | )} 46 | 47 | 50 | 53 | 54 |
    55 | ); 56 | 57 | export default Presentation; 58 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/index.test.js: -------------------------------------------------------------------------------- 1 | import { isSupported } from 'u2f-api'; 2 | 3 | import TwoFactorSettings from '../../../../../components/settings/security/TwoFactor'; 4 | import { renderProvided } from '../../../../testsHelpers/storeTools'; 5 | 6 | describe('TwoFactorSettings', () => { 7 | test('2FA disabled', () => { 8 | expect(renderProvided()).toMatchSnapshot(); 9 | }); 10 | 11 | test('TOTP only enable', () => { 12 | expect(renderProvided()).toMatchSnapshot(); 13 | }); 14 | 15 | test('U2F only enable', () => { 16 | const keys = [ 17 | { KeyHandle: '1', Compromised: false, Label: 'First key' }, 18 | { KeyHandle: '2', Compromised: false, Label: 'Second key' }, 19 | { KeyHandle: '3', Compromised: true, Label: 'Compromised key' }, 20 | { KeyHandle: '4', Compromised: false, Label: 'Last key' } 21 | ]; 22 | 23 | expect(renderProvided()).toMatchSnapshot(); 24 | }); 25 | 26 | test('Everything enable', () => { 27 | const keys = [ 28 | { KeyHandle: '1', Compromised: false, Label: 'First key' }, 29 | { KeyHandle: '2', Compromised: false, Label: 'Second key' }, 30 | { KeyHandle: '3', Compromised: true, Label: 'Compromised key' }, 31 | { KeyHandle: '4', Compromised: false, Label: 'Last key' } 32 | ]; 33 | 34 | expect(renderProvided()).toMatchSnapshot(); 35 | }); 36 | 37 | test('U2F not supported', () => { 38 | const keys = [ 39 | { KeyHandle: '1', Compromised: false, Label: 'First key' }, 40 | { KeyHandle: '2', Compromised: false, Label: 'Second key' } 41 | ]; 42 | 43 | isSupported.mockReturnValueOnce(false); 44 | 45 | expect(renderProvided()).toMatchSnapshot(); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/helpers/stateFormatter.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Format the state and extend it as it's not recursive with unistore 3 | * @param {Object} state 4 | * @param {String} key 5 | * @param {Object} value 6 | * @return {Object} new state 7 | * @function 8 | */ 9 | export const toState = (state, key, value) => ({ [key]: { ...state[key], ...value } }); 10 | 11 | /** 12 | * Format the state and extends using the dot notation. 13 | * @param {Object} state - the state to update 14 | * @param {string|string[]} extendedKey - the key to update in the state. It can be either a string, either a list of string. 15 | * If it's a string, it corresponds to a path, the `.` separates the keys. If it's an array, each element is a key. 16 | * @param {Object} value - the updated value. 17 | * @returns {Object} new state. 18 | * @example 19 | * > extended( 20 | * { 21 | * a: 1, 22 | * b: { 23 | * c: { 24 | * d: 2, 25 | * e: 3 26 | * }, 27 | * f: { 28 | * g: 4 29 | * } 30 | * } 31 | * }, 32 | * 'b.c', 33 | * { d: 42, f: 25 } 34 | * ); 35 | * // returns 36 | * { 37 | * // a removed, because root keys are handled by unistore 38 | * b: { 39 | * c: { 40 | * d: 42, // updated 41 | * f: 25, // new 42 | * e: 3 // unchanged 43 | * }, 44 | * f: { // f is not removed, because subkeys are not handled by unistore 45 | * g: 4 46 | * } 47 | * } 48 | * } 49 | * 50 | * @example 51 | * // beware! 52 | * > extended({...sameAsBefore }, ['b.c'], { d: 42, f: 25 }) 53 | * { 'b.c': { d: 42, f: 25 } } // the new key 'b.c' is created 54 | * @function 55 | */ 56 | export const extended = (state, extendedKey, value) => { 57 | if (!Array.isArray(extendedKey)) { 58 | return extended(state, extendedKey.split('.'), value); 59 | } 60 | if (!extendedKey.length) { 61 | return value; 62 | } 63 | 64 | const [firstKey, ...rest] = extendedKey; 65 | return toState(state, firstKey, extended(state[firstKey] || {}, rest, value)); 66 | }; 67 | -------------------------------------------------------------------------------- /src/components/ui/NotificationStack/index.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | 3 | import { onInput } from '../../../helpers/notification'; 4 | import styles from './index.css'; 5 | 6 | const EXPIRATION_TIME = 5000; 7 | const MAX_STACK_SIZE = 5; 8 | 9 | /** 10 | * Displays all the current notifications. 11 | */ 12 | export default class NotificationStack extends Component { 13 | constructor(props) { 14 | super(props); 15 | this.state = { 16 | stack: [], 17 | popping: false 18 | }; 19 | onInput((notification) => { 20 | const removed = this.state.stack[MAX_STACK_SIZE - 1]; 21 | const newNotification = { 22 | notification, 23 | timeoutId: this.popNotification() 24 | }; 25 | this.setState({ 26 | stack: [newNotification, ...this.state.stack.slice(0, MAX_STACK_SIZE - 1)] 27 | }); 28 | if (removed) { 29 | clearTimeout(removed.timeoutId); 30 | } 31 | }); 32 | } 33 | 34 | /** 35 | * Deletes the associated notification. 36 | * @return {number} the setTimeout id. 37 | */ 38 | popNotification() { 39 | const id = setTimeout(() => { 40 | const stack = this.state.stack.filter(({ timeoutId }) => timeoutId !== id); 41 | this.setState({ stack }); 42 | clearTimeout(id); 43 | }, EXPIRATION_TIME); 44 | return id; 45 | } 46 | 47 | render() { 48 | const notifications = this.state.stack; 49 | if (notifications && notifications.length) { 50 | return ( 51 |
      {notifications.map(NotificationStack.renderNotification)}
    52 | ); 53 | } 54 | return null; 55 | } 56 | 57 | static renderNotification({ 58 | notification: { 59 | type, 60 | data: { message, opt } 61 | } 62 | }) { 63 | const classes = [styles.notification]; 64 | if (styles[type]) { 65 | classes.push(styles[type]); 66 | } 67 | return ( 68 |
  • 69 |

    {message}

    70 |
  • 71 | ); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/AddU2FModal/formName.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { connect } from 'unistore/full/preact'; 3 | 4 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal'; 5 | import settingsActions from '../../../../../actions/settings'; 6 | 7 | import styles from './index.css'; 8 | 9 | /** 10 | * Label form for the U2F key. 11 | */ 12 | export const FormName = ({ onSubmit, onCancel, addU2FKeyLabelAction, settings: { addU2FKey: addU2FKeyStore } }) => { 13 | const model = { label: addU2FKeyStore.response ? addU2FKeyStore.response.label : '' }; 14 | return ( 15 | { 17 | e.preventDefault(); 18 | addU2FKeyLabelAction(model.label); 19 | onSubmit(); 20 | }} 21 | onReset={(e) => { 22 | e.preventDefault(); 23 | onCancel(); 24 | }} 25 | > 26 | 27 |
    28 | 31 |
    32 | { 34 | model.label = value; 35 | }} 36 | value={model.label} 37 | type="name" 38 | id="inputName" 39 | required 40 | placeholder="Name" 41 | className={styles.nameTextInput} 42 | ref={(input) => input && input.focus()} 43 | /> 44 |
    45 |
    46 |
    47 | 48 | 51 | 54 | 55 |
    56 | ); 57 | }; 58 | 59 | export default connect( 60 | 'settings', 61 | settingsActions 62 | )(FormName); 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # account [![CircleCI](https://circleci.com/gh/ProtonMail/account.svg?style=svg)](https://circleci.com/gh/ProtonMail/account) 2 | 3 | 4 | ## Install 5 | 6 | - `$ npm i` 7 | 8 | > You need node > 8 and npm >= 5 9 | 10 | ## Dev 11 | 12 | - `npm run dev` 13 | 14 | > You must have the lib **frontend-commons**. As it's not available yet, install it via npm link ;) 15 | 16 | > You must also set up the file `src/config.js`. 17 | ### U2F 18 | 19 | First add in `src/config.js` the `u2f` object, with value. This value should also be compatible with the server side: 20 | ```json 21 | { 22 | "appId": "the route to the app id", 23 | "timeout": 1 // timeout value 24 | } 25 | ``` 26 | 27 | #### Testing 28 | 29 | Testing U2F can be complicated: the server must accept a U2F binding on the localhost domain. 30 | For that, the domain must use the app id https://localhost. 31 | 32 | In order to test U2F without having a physical device, the chrome extension 33 | [virtual-u2f](https://github.com/ProtonMail/virtual-u2f) can be used. This 34 | extension is only suitable for testing purposes. It is not possible to combine 35 | this extension with regular U2F token. 36 | 37 | Mac users can also try the [SoftU2F](https://github.com/github/SoftU2F) project (maintained by github, more secure than virtual-u2f). 38 | Linux users can try the equivalent [Rust U2F](https://github.com/danstiner/rust-u2f) project (unstable). 39 | 40 | #### App ID 41 | 42 | The app id specification are [very strict](https://fidoalliance.org/specs/fido-u2f-v1.2-ps-20170411/fido-appid-and-facets-v1.2-ps-20170411.html#the-appid-and-facetid-assertions). Some rules are: 43 | 44 | * The AppID must be served under HTTPS; 45 | * The AppID can be an URL (eg https://account.proton.me) or a path to a json file (eg https://account.proton.me/app-id.json, with the **MIME Content-Type of `application/fido.trusted-apps+json`**); 46 | * An application using the AppID can be under the AppID domain, but the opposite if not possible: 47 | 48 | If the AppId is https://account.proton.me/app-id.json: 49 | * a service under https://account.proton.me will be able to use the AppID 50 | * a service under https://service.account.proton.me will be able to use the AppID 51 | * a service under https://pay.proton.me will be **not** able to use the AppID 52 | 53 | *Deleting the AppID or moving it to another route will deactivate every registered key*. It is possible to use a 3XX redirection (see 9. from the doc for more info). 54 | 55 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/SaveRecoveryCodeModal/__snapshots__/presentation.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SaveRecoveryCodeModal presentation display 1`] = ` 4 |
    5 |
    6 |

    Please keep your recovery codes in a safe place. Otherwise, you can permanently 7 | lose access to your account if you loose your 2FA device

    8 |

    Each recovery code can only be used once

    9 |
      10 |
    1. 11 |
      5bb72b16
      12 |
    2. 13 |
    3. 14 |
      87739d2c
      15 |
    4. 16 |
    5. 17 |
      6c11bf08
      18 |
    6. 19 |
    7. 20 |
      37b9d9f5
      21 |
    8. 22 |
    9. 23 |
      8561a630
      24 |
    10. 25 |
    11. 26 |
      4684bfc5
      27 |
    12. 28 |
    13. 29 |
      8a7a4335
      30 |
    14. 31 |
    15. 32 |
      5c7235c5
      33 |
    16. 34 |
    17. 35 |
      542a1827
      36 |
    18. 37 |
    19. 38 |
      52d64018
      39 |
    20. 40 |
    21. 41 |
      945dcb5d
      42 |
    22. 43 |
    23. 44 |
      3bfe23c9
      45 |
    24. 46 |
    47 |
    48 | DOWNLOAD CODES 49 | COPY CODES 50 |
    51 |
    52 |
    53 | 54 | 55 |
    56 |
    57 | `; 58 | 59 | exports[`SaveRecoveryCodeModal presentation loading display 1`] = ` 60 |
    61 |
    62 |

    Please keep your recovery codes in a safe place. Otherwise, you can permanently 63 | lose access to your account if you loose your 2FA device

    64 |

    Each recovery code can only be used once

    65 |

    Loading...

    66 |
    67 |
    68 | 69 | 70 |
    71 |
    72 | `; 73 | -------------------------------------------------------------------------------- /src/components/auth/formSignU2F.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { connect } from 'unistore/full/preact'; 3 | import { isSupported } from 'u2f-api'; 4 | 5 | import authActions from './../../actions/authentication'; 6 | import { ERROR_CODE, getErrorMessage } from '../../helpers/u2f'; 7 | 8 | /** 9 | * Form for the login 2FA action. 10 | */ 11 | export class FormSignU2F extends Component { 12 | sendSignRequest() { 13 | const { success, U2FResponse: { metaData: { code } = {} } = {} } = this.props.auth.twoFactorResponse; 14 | 15 | if (!success && code) { 16 | if (code === ERROR_CODE.TIMEOUT) { 17 | // we need an updated auth/info 18 | // the timeout is 1 minute on the client side, it's better 19 | // to redo the whole process because the challenge expire after 2 minutes. 20 | // funny thing, firefox uses the errorno OTHER_ERROR (1) 21 | return this.props.abortLoginAction(); 22 | } 23 | } 24 | 25 | this.props.loginU2FAction(); 26 | } 27 | 28 | componentDidMount() { 29 | this.sendSignRequest(); 30 | } 31 | 32 | renderCodes(code) { 33 | if (code) { 34 | return ( 35 |

    36 | {getErrorMessage(code)}. 37 | 40 |

    41 | ); 42 | } 43 | } 44 | 45 | render() { 46 | if (!isSupported()) { 47 | return ( 48 |
    49 |

    Your browser is not supported, please use another 2FA method instead

    50 |
    51 | ); 52 | } 53 | 54 | const { success, U2FResponse = {} } = this.props.auth.twoFactorResponse; 55 | 56 | if (success && !U2FResponse.metaData) { 57 | return ( 58 |
    59 |

    Success

    60 |
    61 | ); 62 | } 63 | 64 | const { metaData: { code } = {} } = U2FResponse; 65 | 66 | return ( 67 |
    68 |

    Activate your security key...

    69 | {!success && this.renderCodes(code)} 70 |
    71 | ); 72 | } 73 | } 74 | 75 | export default connect( 76 | 'auth', 77 | authActions 78 | )(FormSignU2F); 79 | -------------------------------------------------------------------------------- /src/components/ui/ConfirmModal.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import _ from 'lodash'; 3 | 4 | import { Content, Footer, Wrapper } from './Modal'; 5 | import SteppedModal from './SteppedModal'; 6 | import { steps as scopeModalSteps, beforeDismiss as beforeDismissScopeModal } from '../auth/ScopeModal'; 7 | 8 | export default class ConfirmModal extends Component { 9 | // for some reason, using stateless components produces a Build error... 10 | render() { 11 | const { 12 | children, 13 | title, 14 | isOpen, 15 | onConfirm, 16 | onAfterClose, 17 | scope = _.noop, 18 | onCancel = _.noop, 19 | cancelText = 'Cancel', 20 | confirmText = 'Confirm' 21 | } = this.props; 22 | const steps = [ 23 | { 24 | title, 25 | component: ({ onNextStep, onPreviousStep }) => ( 26 | { 28 | e.preventDefault(); 29 | onNextStep(); 30 | }} 31 | onReset={(e) => { 32 | e.preventDefault(); 33 | if (onCancel) { 34 | onPreviousStep(); 35 | } 36 | onAfterClose(); 37 | }} 38 | > 39 | {children} 40 |
    41 | 44 | 47 |
    48 |
    49 | ) 50 | } 51 | ]; 52 | 53 | if (scope) { 54 | steps.push(...scopeModalSteps(scope)); 55 | } 56 | 57 | const beforeDismiss = async (success) => { 58 | if (success) { 59 | await onConfirm(); 60 | } 61 | if (scope) { 62 | beforeDismissScopeModal(); 63 | } 64 | }; 65 | 66 | return ( 67 | 68 | ); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /env/config.constants.js: -------------------------------------------------------------------------------- 1 | const ENV = (() => { 2 | try { 3 | return require('./env.json'); 4 | } catch (e) { 5 | return {}; 6 | } 7 | })(); 8 | 9 | const AUTOPREFIXER_CONFIG = { 10 | browsers: ['last 2 versions', 'iOS >= 8', 'Safari >= 8'] 11 | }; 12 | 13 | const STATS_ID = { 14 | a: { 15 | siteId: 5, // the id of the global (total) piwik site 16 | abSiteId: 8 // the id of the piwik site that is configured to only track requests that touch this FE version 17 | }, 18 | b: { 19 | siteId: 5, // the id of the global (total) piwik site 20 | abSiteId: 9 // the id of the piwik site that is configured to only track requests that touch this FE version 21 | } 22 | }; 23 | 24 | const API_TARGETS = { 25 | prod: 'https://protonmail.blue/api', 26 | local: 'https://protonmail.dev/api', 27 | localhost: 'https://localhost/api', 28 | host: 'https://mail.protonmail.host/api', 29 | vagrant: 'https://172.28.128.3/api', 30 | build: '/api' 31 | }; 32 | 33 | Object.keys(ENV).forEach((key) => { 34 | if (ENV[key].api) { 35 | API_TARGETS[key] = ENV[key].api; 36 | } 37 | }); 38 | 39 | const SENTRY_CONFIG = Object.keys(ENV) 40 | .reduce((acc, env) => (acc[env] = ENV[env].sentry, acc), {}); 41 | 42 | const PROD_STAT_MACHINE = { 43 | isEnabled: false, 44 | statsHost: 'stats.protonmail.ch', 45 | domains: ['*.protonmail.com', '*.mail.protonmail.com'], 46 | cookieDomain: '*.protonmail.com' 47 | }; 48 | 49 | const HOST_STAT_MACHINE = { 50 | isEnabled: false, 51 | statsHost: 'stats.protonmail.host', 52 | domains: ['*.protonmail.host', '*.mail.protonmail.host'], 53 | cookieDomain: '*.protonmail.host' 54 | }; 55 | 56 | const NO_STAT_MACHINE = { isEnabled: false }; 57 | 58 | const STATS_CONFIG = { 59 | beta: NO_STAT_MACHINE, 60 | prod: NO_STAT_MACHINE, 61 | dev: NO_STAT_MACHINE, 62 | host: NO_STAT_MACHINE 63 | }; 64 | 65 | /** 66 | * The configuration for U2F. 67 | * FIXME Do not modify this value once used in production! See README.md, section app_id for more information. 68 | * @type {{appId: string, timeout: number}} 69 | */ 70 | const U2F_CONFIG = { 71 | appId: 'https://account.protonmail.red/app-id.json', 72 | timeout: 60 73 | }; 74 | 75 | const TOR_URL = 'https://protonirockerxow.onion/'; 76 | 77 | module.exports = { 78 | TOR_URL, 79 | AUTOPREFIXER_CONFIG, 80 | STATS_ID, 81 | API_TARGETS, 82 | PROD_STAT_MACHINE, 83 | HOST_STAT_MACHINE, 84 | NO_STAT_MACHINE, 85 | STATS_CONFIG, 86 | SENTRY_CONFIG, 87 | U2F_CONFIG 88 | }; 89 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/U2FKeyList/U2FKeyList.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { connect } from 'unistore/full/preact'; 3 | 4 | import settingsActions from '../../../../../actions/settings'; 5 | import ConfirmModal from '../../../../ui/ConfirmModal'; 6 | import TextButton from '../../../../ui/TextButton'; 7 | 8 | import style from './style.css'; 9 | 10 | export class U2FKeyList extends Component { 11 | /** 12 | * renders an U2F Key. 13 | * @param {Object} u2fKey - the U2F Key to render. 14 | * @param {Int} u2fKey.Compromised - whether the key is compromised or not. 15 | * @param {String} u2fKey.KeyHandle - The key handle of the current key. 16 | * @param {String} u2fKey.Label - The label of the current key. 17 | * @return {Component} 18 | */ 19 | renderU2FKey(u2fKey) { 20 | const headerClasses = [style.listElementHeader]; 21 | if (u2fKey.Compromised) { 22 | headerClasses.push(style.listElementHeaderCompromised); 23 | } 24 | 25 | return ( 26 |
  • 27 |
    {u2fKey.Label}
    28 | {!!u2fKey.Compromised &&
    Compromised
    } 29 | 31 | this.setState({ 32 | confirmDeleteModal: u2fKey 33 | }) 34 | } 35 | > 36 | Delete 37 | 38 |
  • 39 | ); 40 | } 41 | 42 | render() { 43 | const confirmDeleteModal = this.state.confirmDeleteModal; 44 | 45 | const closeModal = () => { 46 | this.setState({ confirmDeleteModal: '' }); 47 | }; 48 | return ( 49 |
      50 | this.props.deleteU2FKeyAction(confirmDeleteModal)} 56 | onCancel={() => {}} 57 | > 58 |
      59 | Are you sure you want to delete the key{' '} 60 | {confirmDeleteModal ? confirmDeleteModal.Label : ''}? 61 |
      62 |
      63 | {this.props.U2FKeys.map((u2fKey) => this.renderU2FKey(u2fKey))} 64 |
    65 | ); 66 | } 67 | } 68 | 69 | export default connect( 70 | 'settings', 71 | settingsActions 72 | )(U2FKeyList); 73 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/SaveRecoveryCodeModal/formTestCode.test.js: -------------------------------------------------------------------------------- 1 | import { deep } from 'preact-render-spy'; 2 | import render from 'preact-render-to-string'; 3 | 4 | import { FormTestCode } from '../../../../../../components/settings/security/TwoFactor/SaveRecoveryCodeModal/formTestCode'; 5 | 6 | const renderFormTestCode = (props = {}, { code = '', result = undefined } = {}) => { 7 | const response = code ? { code } : undefined; 8 | return ( 9 | 15 | ); 16 | }; 17 | 18 | describe('test for SaveRecoveryCodeModal SaveCode step', () => { 19 | test('initial display', () => { 20 | expect(render(renderFormTestCode())).toMatchSnapshot(); 21 | }); 22 | 23 | test('ongoing display', () => { 24 | expect(render(renderFormTestCode({}, { code: '945dc' }))).toMatchSnapshot(); 25 | }); 26 | 27 | test('success display', () => { 28 | expect(render(renderFormTestCode({}, { result: true }))).toMatchSnapshot(); 29 | }); 30 | 31 | test('failure display', () => { 32 | expect(render(renderFormTestCode({}, { result: false, code: '945dc945dc' }))).toMatchSnapshot(); 33 | }); 34 | 35 | test('on input test', () => { 36 | const reset2FARecoveryCodesCheckNewCodeAction = jest.fn(); 37 | const context = deep( 38 | renderFormTestCode( 39 | { reset2FARecoveryCodesCheckNewCodeAction }, 40 | { 41 | result: false, 42 | code: '945dc' 43 | } 44 | ) 45 | ); 46 | 47 | context.find('input').simulate('input', { target: { value: '945dcc' } }); 48 | expect(reset2FARecoveryCodesCheckNewCodeAction).not.toHaveBeenCalled(); 49 | 50 | context.find('input').simulate('input', { target: { value: '12345678' } }); 51 | expect(reset2FARecoveryCodesCheckNewCodeAction).toHaveBeenCalledWith('12345678'); 52 | }); 53 | 54 | test('submit and cancel', () => { 55 | const onSubmit = jest.fn(); 56 | const onCancel = jest.fn(); 57 | const context = deep( 58 | renderFormTestCode( 59 | { onSubmit, onCancel }, 60 | { 61 | result: false, 62 | code: '945dc' 63 | } 64 | ) 65 | ); 66 | 67 | const event = { preventDefault: () => undefined }; 68 | 69 | context.find('form').simulate('submit', event); 70 | expect(onSubmit).toHaveBeenCalled(); 71 | expect(onCancel).not.toHaveBeenCalled(); 72 | 73 | context.find('form').simulate('reset', event); 74 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event 75 | expect(onCancel).toHaveBeenCalled(); // only once 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/actions/scope.js: -------------------------------------------------------------------------------- 1 | import { authInfo } from 'frontend-commons/src/crypto/srp'; 2 | 3 | import { signU2F } from '../helpers/u2f'; 4 | import toActions from '../helpers/toActions'; 5 | import { toState, extended } from '../helpers/stateFormatter'; 6 | 7 | /** 8 | * @link{https://github.com/developit/unistore#usage} 9 | */ 10 | export default (store) => { 11 | /** 12 | * initializate the unscope process. 13 | * @param {Object} state 14 | * @returns {Promise} 15 | */ 16 | async function unscopeInit(state) { 17 | if (state.scope.used) { 18 | return; 19 | } 20 | if (!state.scope.response) { 21 | const authData = await authInfo(state.auth.Name); 22 | store.setState(toState(state, 'scope', { response: authData, creds: {} })); 23 | } 24 | } 25 | 26 | /** 27 | * Sets the password (and the twoFactorCode, if given) 28 | * @param {Object} state 29 | * @param {Object} opt 30 | * @param {string} opt.password - the password 31 | * @param {?string} twoFactorCode - the 2FA code (TOTP or recovery code). 32 | */ 33 | async function unscopePassword(state, { password, twoFactorCode = undefined }) { 34 | const creds = { password }; 35 | if (twoFactorCode !== undefined) { 36 | creds.twoFactorCode = twoFactorCode; 37 | } 38 | 39 | store.setState(extended(state, 'scope.creds', creds)); 40 | } 41 | 42 | /** 43 | * sends and store a sign request to the U2F API. 44 | * @param {Object} state 45 | * @returns {Promise} 46 | */ 47 | async function unscopeU2F(state) { 48 | store.setState(toState(state, 'scope', { U2FRequest: { status: 'pending' } })); 49 | try { 50 | const response = await signU2F(state.scope.response['2FA'].U2F); 51 | store.setState(extended(state, 'scope.creds', { U2F: response })); 52 | } catch (error) { 53 | const { metaData: { code } = {} } = error; 54 | if (!code) { 55 | throw error; 56 | } 57 | store.setState(toState(store.getState(), 'scope', { U2FRequest: { status: 'failure', error } })); 58 | } 59 | } 60 | 61 | /** 62 | * reset the two factor data (code and U2F). 63 | * @param {Object} state 64 | */ 65 | async function unscopeResetTwoFactor(state) { 66 | return store.setState( 67 | toState(state, 'scope', { 68 | U2FRequest: {}, 69 | creds: { 70 | password: state.scope.creds.password 71 | } 72 | }) 73 | ); 74 | } 75 | 76 | /** 77 | * reset the state for the scope 78 | * @param {Object} state 79 | */ 80 | async function resetScopeState(state) { 81 | store.setState({ scope: {} }); 82 | } 83 | 84 | return toActions({ 85 | unscopeInit, 86 | unscopePassword, 87 | unscopeU2F, 88 | resetScopeState, 89 | unscopeResetTwoFactor 90 | }); 91 | }; 92 | -------------------------------------------------------------------------------- /src/helpers/u2f.js: -------------------------------------------------------------------------------- 1 | import { sign, register } from 'u2f-api'; 2 | import appProvider from 'frontend-commons/src/appProvider'; 3 | 4 | export const ERROR_CODE = { 5 | SUCCESS: 0, 6 | OTHER_ERROR: 1, 7 | BAD_REQUEST: 2, 8 | CONFIGURATION_UNSUPPORTED: 3, 9 | DEVICE_INELIGIBLE: 4, 10 | TIMEOUT: 5 11 | }; 12 | 13 | const ERROR_MAP = { 14 | [ERROR_CODE.SUCCESS]: () => { 15 | throw new Error('This is a success!'); 16 | }, 17 | [ERROR_CODE.OTHER_ERROR]: () => 'An error occurred', 18 | [ERROR_CODE.BAD_REQUEST]: () => 'An internal error occurred.', 19 | [ERROR_CODE.CONFIGURATION_UNSUPPORTED]: () => 'This security key is not supported.', 20 | [ERROR_CODE.DEVICE_INELIGIBLE]: (register) => 21 | register ? 'This security key is already registered for your account!' : 'This security key is not recognized.', 22 | [ERROR_CODE.TIMEOUT]: () => 23 | 'Looks like you are taking too long to respond, please try again with a bit of motivation.' 24 | }; 25 | 26 | /** 27 | * Sends a SIGN request to the U2F device 28 | * @param {Object} U2FRequest - the U2FRequest 29 | * @param {Object[]} U2FRequest.RegisteredKeys - the registered keys 30 | * @param {String} U2FRequest.Challenge - the challenge 31 | * @return {Promise} 32 | */ 33 | export async function signU2F({ RegisteredKeys: registeredKeys, Challenge: challenge }) { 34 | const { appId, timeout } = appProvider.getConfig('u2f'); 35 | 36 | const signRequest = registeredKeys.map(({ Version: version, KeyHandle: keyHandle }) => ({ 37 | version, 38 | keyHandle, 39 | appId, 40 | challenge 41 | })); 42 | 43 | return await sign(signRequest, timeout); 44 | } 45 | 46 | /** 47 | * Sends a REGISTER request to the U2F device. 48 | * @param {Object} U2FRequest - the U2FRequest 49 | * @param {Object[]} U2FRequest.RegisteredKeys - the registered keys 50 | * @param {String} U2FRequest.Challenge - the challenge 51 | * @param {String[]} U2FRequest.Versions - the different versions accepted by the server 52 | * @return {Promise} 53 | */ 54 | export async function registerU2F({ RegisteredKeys: registeredKeys, Challenge: challenge, Versions: versions }) { 55 | const { appId, timeout } = appProvider.getConfig('u2f'); 56 | const signRequest = registeredKeys.map(({ Version: version, KeyHandle: keyHandle }) => ({ 57 | version, 58 | keyHandle 59 | })); 60 | 61 | const registerRequests = versions.map((version) => ({ 62 | version, 63 | challenge, 64 | appId 65 | })); 66 | 67 | return await register(registerRequests, signRequest, timeout); 68 | } 69 | 70 | /** 71 | * Computes an error message from a U2F error code. 72 | * @param {number} errorCode - an error code. 73 | * @param register 74 | * @return {*} 75 | */ 76 | export function getErrorMessage(errorCode, register = false) { 77 | if (ERROR_MAP[errorCode]) { 78 | return ERROR_MAP[errorCode](register); 79 | } 80 | throw new Error(`The error code "${errorCode}" does not exist`); 81 | } 82 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/SetupTOTPModal/sharedSecret.test.js: -------------------------------------------------------------------------------- 1 | import render from 'preact-render-to-string'; 2 | import { deep } from 'preact-render-spy'; 3 | 4 | import { SharedSecret } from '../../../../../../components/settings/security/TwoFactor/SetupTOTPModal/sharedSecret'; 5 | 6 | describe('SetupTOTP SharedSecret step', () => { 7 | test('regular display', () => { 8 | expect( 9 | render( 10 | 19 | ) 20 | ).toMatchSnapshot(); 21 | 22 | expect(render()).toMatchSnapshot(); 23 | }); 24 | 25 | test('raw display', () => { 26 | const createSharedSecretAction = jest.fn(); 27 | 28 | const tree = deep( 29 | 42 | ); 43 | tree.find('a') 44 | .first() 45 | .simulate('click'); 46 | expect(tree).toMatchSnapshot(); 47 | expect(createSharedSecretAction).toHaveBeenCalledTimes(1); 48 | 49 | const groot = deep(); 50 | groot 51 | .find('a') 52 | .first() 53 | .simulate('click'); 54 | expect(groot).toMatchSnapshot(); 55 | expect(createSharedSecretAction).toHaveBeenCalledTimes(2); 56 | }); 57 | 58 | test('submit and cancel', () => { 59 | const createSharedSecretAction = jest.fn(); 60 | 61 | const onSubmit = jest.fn(); 62 | const onCancel = jest.fn(); 63 | const context = deep( 64 | 70 | ); 71 | 72 | const event = { preventDefault: () => undefined }; 73 | 74 | context.find('form').simulate('submit', event); 75 | expect(onSubmit).toHaveBeenCalled(); 76 | expect(onCancel).not.toHaveBeenCalled(); 77 | 78 | context.find('form').simulate('reset', event); 79 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event 80 | expect(onCancel).toHaveBeenCalled(); // only once 81 | 82 | expect(createSharedSecretAction).toHaveBeenCalledTimes(1); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/tests/testsHelpers/storeTools.js: -------------------------------------------------------------------------------- 1 | import render from 'preact-render-to-string'; 2 | import { deep } from 'preact-render-spy'; 3 | 4 | import store from '../../helpers/store'; 5 | import { Provider } from 'unistore/full/preact'; 6 | import defaultStore from '../../helpers/store'; 7 | 8 | /** 9 | * Shallow-renders the component, embedded into a depth. Uses `preact-render-spy`. 10 | * @param {Component} component 11 | * @param {Object?} store - default to the exported value from the helpers/store 12 | * @param {int?} depth - the depth, initial value to 2, to bypass the provider 13 | * @return {RenderContext} 14 | * @function 15 | */ 16 | export const shallowProvider = (component, store = defaultStore, depth = 2) => { 17 | return deep( 18 | 19 | {component} 20 | , 21 | { depth } 22 | ); 23 | }; 24 | 25 | /** 26 | * Deep-renders of the component, embedded into a depth. Uses `preact-render-spy` 27 | * @param {Component} component 28 | * @param {Object?} store - default to the exported value from the helpers/store 29 | * @param {int?} depth - the depth, initial value to 2, to bypass the provider 30 | * @return {RenderContext} 31 | * @function 32 | */ 33 | export const deepProvider = (component, store = defaultStore, depth = undefined) => { 34 | if (depth) { 35 | return deep( 36 | 37 | {component} 38 | , 39 | { depth } 40 | ); 41 | } 42 | return deep( 43 | 44 | {component} 45 | 46 | ); 47 | }; 48 | 49 | /** 50 | * renders the component embedded inside a Provider. Uses `preact-render-to-string` 51 | * @param {Component} component - the component to render 52 | * @param {Object?} store - the store, default to the exported value from the helpers/store 53 | * @return {*} 54 | * @function 55 | */ 56 | export const renderProvided = (component, store = defaultStore) => { 57 | return render( 58 | 59 | {component} 60 | 61 | ); 62 | }; 63 | 64 | /** 65 | * Waits until the store is updated. The function will attached a listener (taken from the parameters) to each of the next update. Done is called once the last listener was invoked. 66 | * @param {Function} done - to be called once each listener have been consumed. It accepts one optional argument: the error. 67 | * @param {Function} action - the listener to be attached to the next update of the state. 68 | * @param {...Function} nextActions - a listener to be attached to the $n+1$ store update. 69 | * @function 70 | */ 71 | export const waitForNewState = (done, action, ...nextActions) => { 72 | const next = nextActions.length ? () => waitForNewState(done, ...nextActions) : done; 73 | 74 | const subscriber = (state) => { 75 | try { 76 | action(state); 77 | store.unsubscribe(subscriber); 78 | next(); 79 | } catch (e) { 80 | done(e); 81 | } 82 | }; 83 | store.subscribe(subscriber); 84 | }; 85 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/SaveRecoveryCodeModal/formTestCode.js: -------------------------------------------------------------------------------- 1 | import { h } from 'preact'; 2 | import { connect } from 'unistore/full/preact'; 3 | 4 | import settingsActions from '../../../../../actions/settings'; 5 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal'; 6 | 7 | import styles from './index.css'; 8 | 9 | const renderInfo = (result) => { 10 | if (typeof result === 'undefined') { 11 | // result is undefined until the input is lower than 6 characters. 12 | return

    Your recovery code will not be erased

    ; 13 | } 14 | 15 | return

    {result ? '✓ Test succeeded' : '⚠ Please test your recovery code to proceed'}

    ; 16 | }; 17 | 18 | /** 19 | * Modal Form to test that a given code is one of the new recovery code. 20 | * @param {Object} props 21 | * @param {Function} props.onSubmit - triggers the next step. 22 | * @param {Function} props.onCancel - triggers the previous step. 23 | * @returns {ModalWrapper} 24 | */ 25 | export const FormTestCode = ({ 26 | onSubmit, 27 | onCancel, 28 | settings: { 29 | reset2FARecoveryCodes: { result, response = {} } 30 | }, 31 | reset2FARecoveryCodesCheckNewCodeAction 32 | }) => { 33 | const model = { code: response.code }; 34 | 35 | return ( 36 | { 38 | e.preventDefault(); 39 | onSubmit(); 40 | }} 41 | onReset={(e) => { 42 | e.preventDefault(); 43 | onCancel(); 44 | }} 45 | > 46 | 47 |

    48 | Test your recovery codes by entering one of your codes below. If you did not save your recovery 49 | codes, go back and save them. 50 |

    51 |
    52 | 53 |
    54 | { 56 | model.code = code; 57 | if (code.length === 8) { 58 | reset2FARecoveryCodesCheckNewCodeAction(model.code); 59 | } 60 | }} 61 | required 62 | value={model.code} 63 | type="code" 64 | id="verifyCode" 65 | placeholder="Code" 66 | disabled={!!result} 67 | autoFocus 68 | /> 69 |
    70 |
    71 |
    {renderInfo(result)}
    72 |
    73 | 74 | 77 | 80 | 81 |
    82 | ); 83 | }; 84 | 85 | export default connect( 86 | 'settings', 87 | settingsActions 88 | )(FormTestCode); 89 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/SetupTOTPModal/confirmCode.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { connect } from 'unistore/full/preact'; 3 | 4 | import settingsActions from '../../../../../actions/settings'; 5 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal'; 6 | 7 | import styles from './index.css'; 8 | 9 | export class ConfirmCode extends Component { 10 | constructor(props) { 11 | super(props); 12 | const { 13 | settings: { setupTOTP: { request: { TOTPCode } = {} } = {} } 14 | } = props; 15 | this.state = { 16 | code: TOTPCode 17 | }; 18 | } 19 | 20 | componentWillReceiveProps(newProps) { 21 | const { 22 | settings: { setupTOTP: { status, error } = {} } 23 | } = newProps; 24 | 25 | if (status && status !== this.props.settings.setupTOTP.status) { 26 | if (status === 'success') { 27 | this.props.onSubmit(); 28 | } 29 | if (status === 'failure') { 30 | this.props.onReset(error ? error.message || error : undefined); 31 | } 32 | this.setState({ loading: false }); 33 | } 34 | } 35 | 36 | onSubmit(e) { 37 | e.preventDefault(); 38 | this.setState({ loading: true }); 39 | this.props.forbidClosure(); 40 | this.props.enableTOTPAction(this.state.code); 41 | } 42 | 43 | render() { 44 | return ( 45 | { 47 | this.onSubmit(e); 48 | }} 49 | onReset={(e) => { 50 | e.preventDefault(); 51 | this.props.onCancel(); 52 | }} 53 | > 54 | 55 |

    Test your new 2FA method:

    56 |
    57 | 58 |
    59 | { 61 | this.setState({ code }); 62 | }} 63 | required 64 | value={this.state.code} 65 | disabled={this.state.loading} 66 | type="code" 67 | id="verifyCode" 68 | placeholder="Code" 69 | minLength="6" 70 | maxLength="6" 71 | autoFocus 72 | /> 73 |
    74 |
    75 |
    76 | 77 | 80 | 83 | 84 |
    85 | ); 86 | } 87 | } 88 | 89 | export default connect( 90 | 'settings', 91 | settingsActions 92 | )(ConfirmCode); 93 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/SaveRecoveryCodeModal/__snapshots__/formTestCode.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`test for SaveRecoveryCodeModal SaveCode step failure display 1`] = ` 4 |
    5 |
    6 |

    Test your recovery codes by entering one of your codes below. If you did 7 | not save your recovery codes, go back and save them.

    8 |
    9 | 10 |
    11 | 13 |
    14 |
    15 |
    16 |

    ⚠ Please test your recovery code to proceed

    17 |
    18 |
    19 |
    20 | 21 | 22 |
    23 |
    24 | `; 25 | 26 | exports[`test for SaveRecoveryCodeModal SaveCode step initial display 1`] = ` 27 |
    28 |
    29 |

    Test your recovery codes by entering one of your codes below. If you did 30 | not save your recovery codes, go back and save them.

    31 |
    32 | 33 |
    34 | 36 |
    37 |
    38 |
    39 |

    Your recovery code will not be erased

    40 |
    41 |
    42 |
    43 | 44 | 45 |
    46 |
    47 | `; 48 | 49 | exports[`test for SaveRecoveryCodeModal SaveCode step ongoing display 1`] = ` 50 |
    51 |
    52 |

    Test your recovery codes by entering one of your codes below. If you did 53 | not save your recovery codes, go back and save them.

    54 |
    55 | 56 |
    57 | 59 |
    60 |
    61 |
    62 |

    Your recovery code will not be erased

    63 |
    64 |
    65 |
    66 | 67 | 68 |
    69 |
    70 | `; 71 | 72 | exports[`test for SaveRecoveryCodeModal SaveCode step success display 1`] = ` 73 |
    74 |
    75 |

    Test your recovery codes by entering one of your codes below. If you did 76 | not save your recovery codes, go back and save them.

    77 |
    78 | 79 |
    80 | 82 |
    83 |
    84 |
    85 |

    ✓ Test succeeded

    86 |
    87 |
    88 |
    89 | 90 | 91 |
    92 |
    93 | `; 94 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/SaveRecoveryCodeModal/presentation.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { connect } from 'unistore/full/preact'; 3 | 4 | import settingsActions from '../../../../../actions/settings'; 5 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal'; 6 | import TextButton from '../../../../ui/TextButton'; 7 | import { CopyToClipboard } from 'react-copy-to-clipboard'; 8 | import { downloadAsFile } from '../../../../../helpers/text'; 9 | 10 | import styles from './index.css'; 11 | 12 | export class Presentation extends Component { 13 | componentDidMount() { 14 | this.props.reset2FARecoveryCodesInitAction(); 15 | } 16 | 17 | componentWillReceiveProps(newProps) { 18 | const { 19 | settings: { 20 | reset2FARecoveryCodes: { error } 21 | } 22 | } = newProps; 23 | 24 | if (this.props.settings.reset2FARecoveryCodes.error !== error && error) { 25 | this.props.onReset(error.message); 26 | } 27 | } 28 | 29 | /** 30 | * generates a TXT file, containing the codes, and download it on the browser. 31 | */ 32 | downloadClicked() { 33 | const { 34 | settings: { 35 | reset2FARecoveryCodes: { request: { codes } = {} } 36 | } 37 | } = this.props; 38 | 39 | downloadAsFile('proton-recovery-codes.txt', codes); 40 | } 41 | 42 | renderCodes(codes = []) { 43 | if (!codes.length) { 44 | return

    Loading...

    ; 45 | } 46 | return [ 47 |
      48 | {codes.map((code) => ( 49 |
    1. 50 |
      {code}
      51 |
    2. 52 | ))} 53 |
    , 54 |
    55 | this.downloadClicked()}>DOWNLOAD CODES 56 | 57 | COPY CODES 58 | 59 |
    60 | ]; 61 | } 62 | 63 | /** 64 | * renders the content of the modal. 65 | * @returns {ModalContent} 66 | */ 67 | renderContent() { 68 | const { 69 | settings: { 70 | reset2FARecoveryCodes: { request: { codes } = {} } 71 | } 72 | } = this.props; 73 | 74 | return ( 75 | 76 |

    77 | Please keep your recovery codes in a safe place. Otherwise, you can permanently lose access to your 78 | account if you loose your 2FA device 79 |

    80 |

    Each recovery code can only be used once

    81 | {this.renderCodes(codes)} 82 |
    83 | ); 84 | } 85 | 86 | render() { 87 | return ( 88 | { 90 | e.preventDefault(); 91 | this.props.onSubmit(); 92 | }} 93 | onReset={(e) => { 94 | e.preventDefault(); 95 | this.props.onCancel(); 96 | }} 97 | > 98 | {this.renderContent()} 99 | 100 | 103 | 106 | 107 | 108 | ); 109 | } 110 | } 111 | 112 | export default connect( 113 | 'settings', 114 | settingsActions 115 | )(Presentation); 116 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/SaveRecoveryCodeModal/presentation.test.js: -------------------------------------------------------------------------------- 1 | import render from 'preact-render-to-string'; 2 | import { deep } from 'preact-render-spy'; 3 | import { saveAs } from 'file-saver'; 4 | 5 | import { Presentation } from '../../../../../../components/settings/security/TwoFactor/SaveRecoveryCodeModal/presentation'; 6 | 7 | jest.mock('file-saver'); 8 | 9 | const renderPresentation = ({ codes, error } = {}, props = {}) => { 10 | return ( 11 | null} 13 | {...props} 14 | settings={{ 15 | reset2FARecoveryCodes: { 16 | error, 17 | request: { 18 | codes: codes || [ 19 | '5bb72b16', 20 | '87739d2c', 21 | '6c11bf08', 22 | '37b9d9f5', 23 | '8561a630', 24 | '4684bfc5', 25 | '8a7a4335', 26 | '5c7235c5', 27 | '542a1827', 28 | '52d64018', 29 | '945dcb5d', 30 | '3bfe23c9' 31 | ] 32 | } 33 | } 34 | }} 35 | /> 36 | ); 37 | }; 38 | 39 | describe('SaveRecoveryCodeModal presentation', () => { 40 | test('display', () => { 41 | expect(render(renderPresentation())).toMatchSnapshot(); 42 | }); 43 | 44 | test('loading display', () => { 45 | expect( 46 | render( 47 | null} 49 | settings={{ 50 | reset2FARecoveryCodes: {} 51 | }} 52 | /> 53 | ) 54 | ).toMatchSnapshot(); 55 | }); 56 | 57 | test('download button', () => { 58 | const saveAsAction = jest.fn(); 59 | saveAs.mockImplementation(saveAsAction); 60 | 61 | const context = deep(renderPresentation()); 62 | context 63 | .find('a') 64 | .first() 65 | .simulate('click'); 66 | expect(saveAsAction).toBeCalled(); 67 | }); 68 | 69 | test('submit and cancel', () => { 70 | const onSubmit = jest.fn(); 71 | const onCancel = jest.fn(); 72 | const context = deep(renderPresentation(undefined, { onSubmit, onCancel })); 73 | 74 | const event = { preventDefault: () => undefined }; 75 | 76 | context.find('form').simulate('submit', event); 77 | expect(onSubmit).toHaveBeenCalled(); 78 | expect(onCancel).not.toHaveBeenCalled(); 79 | 80 | context.find('form').simulate('reset', event); 81 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event 82 | expect(onCancel).toHaveBeenCalled(); // only once 83 | }); 84 | 85 | test('reset when error received', () => { 86 | const onReset = jest.fn(); 87 | const context = deep(renderPresentation(undefined, { onReset })); 88 | expect(onReset).toHaveBeenCalledTimes(0); 89 | 90 | let error = new Error('TestError'); 91 | context.render(renderPresentation({ error }, { onReset })); 92 | expect(onReset).toHaveBeenCalledTimes(1); 93 | expect(onReset).toHaveBeenLastCalledWith(error.message); 94 | 95 | // calling with same props 96 | context.render(renderPresentation({ error }, { onReset })); 97 | expect(onReset).toHaveBeenCalledTimes(1); // not recalled 98 | 99 | error = new Error("It's not the intended behavior... "); 100 | context.render(renderPresentation({ error }, { onReset })); 101 | expect(onReset).toHaveBeenCalledTimes(2); // recalled 102 | expect(onReset).toHaveBeenLastCalledWith(error.message); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/tests/components/auth/formSignU2F.test.js: -------------------------------------------------------------------------------- 1 | import { isSupported } from 'u2f-api'; 2 | 3 | import ConnectedFormSignU2F, { FormSignU2F } from '../../../components/auth/formSignU2F'; 4 | import { ERROR_CODE } from '../../../helpers/u2f'; 5 | import store, { initialState } from '../../../helpers/store'; 6 | import { shallow } from 'preact-render-spy'; 7 | import { renderProvided, shallowProvider } from '../../testsHelpers/storeTools'; 8 | 9 | jest.mock('../../../actions/authentication'); 10 | 11 | describe('testing FormSignU2F component...', () => { 12 | afterEach(() => { 13 | store.setState(initialState, true); 14 | }); 15 | 16 | test('error state', () => { 17 | const auth = { 18 | twoFactorResponse: { 19 | U2FResponse: { 20 | metaData: { 21 | code: 1 22 | } 23 | } 24 | } 25 | }; 26 | store.setState({ auth }); 27 | expect(renderProvided(, store)).toMatchSnapshot(); 28 | }); 29 | 30 | test('U2F not available on this browser', () => { 31 | isSupported.mockReturnValueOnce(false); 32 | const auth = { 33 | twoFactorResponse: {} 34 | }; 35 | store.setState({ auth }); 36 | expect(renderProvided(, store)).toMatchSnapshot(); 37 | }); 38 | 39 | test('correct state', () => { 40 | const auth = { 41 | twoFactorResponse: {} 42 | }; 43 | store.setState({ auth }); 44 | expect(renderProvided(, store)).toMatchSnapshot(); 45 | }); 46 | 47 | test('success state', () => { 48 | const auth = { 49 | twoFactorResponse: { success: true } 50 | }; 51 | store.setState({ auth }); 52 | expect(renderProvided(, store)).toMatchSnapshot(); 53 | }); 54 | 55 | test('retry button', () => { 56 | const auth = { 57 | twoFactorResponse: { 58 | U2FResponse: { 59 | metaData: { 60 | code: ERROR_CODE.OTHER_ERROR 61 | } 62 | } 63 | } 64 | }; 65 | 66 | const loginU2FAction = jest.fn(); 67 | const abortLoginAction = jest.fn(); 68 | 69 | const component = shallow( 70 | 71 | ); 72 | 73 | component.find('[onClick]').simulate('click'); 74 | expect(loginU2FAction).toHaveBeenCalledTimes(2); 75 | expect(abortLoginAction).toHaveBeenCalledTimes(0); 76 | }); 77 | 78 | test('retry button when timeout', () => { 79 | const auth = { 80 | twoFactorResponse: { 81 | U2FResponse: { 82 | metaData: { 83 | code: ERROR_CODE.TIMEOUT 84 | } 85 | } 86 | } 87 | }; 88 | 89 | const loginU2FAction = jest.fn(); 90 | const abortLoginAction = jest.fn(); 91 | 92 | const component = shallow( 93 | 94 | ); 95 | 96 | component.find('[onClick]').simulate('click'); 97 | expect(loginU2FAction).toHaveBeenCalledTimes(0); 98 | expect(abortLoginAction).toHaveBeenCalledTimes(2); 99 | }); 100 | 101 | test('mount with empty state', () => { 102 | const auth = { 103 | twoFactorResponse: { 104 | success: false 105 | } 106 | }; 107 | 108 | const loginU2FAction = jest.fn(); 109 | const abortLoginAction = jest.fn(); 110 | 111 | const component = shallow( 112 | 113 | ); 114 | 115 | expect(loginU2FAction).toHaveBeenCalledTimes(1); 116 | expect(abortLoginAction).toHaveBeenCalledTimes(0); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /tasks/deploy.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const os = require('os'); 4 | const Listr = require('listr'); 5 | const execa = require('execa'); 6 | const chalk = require('chalk'); 7 | const del = require('del'); 8 | const UpdaterRenderer = require('listr-update-renderer'); 9 | const moment = require('moment'); 10 | 11 | const { success, error, warn, json } = require('./helpers/log'); 12 | 13 | const env = require('../env/config'); 14 | const { CONFIG, branch } = env.getConfig('dist'); 15 | 16 | const push = (branch) => { 17 | const commands = ['cd dist']; 18 | if (os.platform() === 'linux') { 19 | commands.push('git ls-files --deleted -z | xargs -r -0 git rm'); 20 | } else { 21 | commands.push('(git ls-files --deleted -z || echo:) | xargs -0 git rm'); 22 | } 23 | commands.push('git add --all'); 24 | commands.push('git commit -m "New Release"'); 25 | commands.push(`git push origin ${branch}`); 26 | commands.push('cd ..'); 27 | commands.push(`git push origin ${branch}`); 28 | return execa.shell(commands.join(' && '), { shell: '/bin/bash' }); 29 | }; 30 | 31 | const pullDist = (branch) => { 32 | const commands = [ 33 | `git fetch origin ${branch}:${branch}`, 34 | `git clone file://$PWD --depth 1 --single-branch --branch ${branch} dist`, 35 | 'cd dist', 36 | 'rm -rf *' 37 | ].join(' && '); 38 | return execa.shell(commands, { shell: '/bin/bash' }); 39 | }; 40 | 41 | const checkEnv = async () => { 42 | try { 43 | await execa.shell('[ -e ./env/env.json ]', { shell: '/bin/bash' }); 44 | } catch (e) { 45 | throw new Error('You must have env.json to deploy. Cf the wiki'); 46 | } 47 | }; 48 | 49 | const getTasks = (branch, { isCI, flowType = 'single' }) => [ 50 | { 51 | title: 'Clear previous dist', 52 | task: async () => { 53 | await del(['dist'], { dryRun: false }); 54 | isCI && execa.shell('mkdir dist'); 55 | } 56 | }, 57 | { 58 | title: 'Setup config', 59 | enabled: () => !isCI, 60 | task() { 61 | return execa('tasks/setupConfig.js', process.argv.slice(2)); 62 | } 63 | }, 64 | { 65 | title: `Pull dist branch ${branch}`, 66 | enabled: () => !isCI, 67 | task: () => pullDist(branch) 68 | }, 69 | { 70 | title: 'Build the application', 71 | task() { 72 | const args = process.argv.slice(2); 73 | return execa('npm', ['run', 'build', ...args, '--', '--no-clean', '--dest', 'dist']); 74 | } 75 | }, 76 | { 77 | title: 'Move static files to root', 78 | task() { 79 | return execa.shell('cp src/app-id.json dist/app-id.json', { shell: '/bin/bash' }); 80 | } 81 | }, 82 | { 83 | title: `Push dist to ${branch}`, 84 | enabled: () => !isCI, 85 | task: () => push(branch) 86 | } 87 | ]; 88 | 89 | 90 | // Custom local deploy for the CI 91 | const isCI = process.env.NODE_ENV_DIST === 'ci'; 92 | 93 | if (!branch && !isCI) { 94 | throw new Error('You must define a branch name. --branch=XXX'); 95 | } 96 | 97 | process.env.NODE_ENV_BRANCH = branch; 98 | process.env.NODE_ENV_API = CONFIG.apiUrl; 99 | 100 | !isCI && console.log(`➙ Branch: ${chalk.bgYellow(chalk.black(branch))}`); 101 | console.log(`➙ API: ${chalk.bgYellow(chalk.black(CONFIG.apiUrl))}`); 102 | console.log(`➙ SENTRY: ${chalk.bgYellow(chalk.black(process.env.NODE_ENV_SENTRY))}`); 103 | console.log(''); 104 | 105 | env.argv.debug && json(CONFIG); 106 | 107 | const start = moment(Date.now()); 108 | const tasks = new Listr(getTasks(branch, { isCI }), { 109 | renderer: UpdaterRenderer, 110 | collapse: false 111 | }); 112 | 113 | tasks 114 | .run() 115 | .then(() => { 116 | const now = moment(Date.now()); 117 | const total = now.diff(start, 'seconds'); 118 | const time = total > 60 ? moment.utc(total * 1000).format('mm:ss') : `${total}s`; 119 | 120 | !isCI && success('App deployment done', { time }); 121 | isCI && success(`Build CI app to the directory: ${chalk.bold('dist')}`, { time }); 122 | }) 123 | .catch(error); 124 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/AddU2FModal/formRegisterKey.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { connect } from 'unistore/full/preact'; 3 | 4 | import settingsActions from '../../../../../actions/settings'; 5 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal'; 6 | import TextButton from '../../../../ui/TextButton'; 7 | import { getErrorMessage } from '../../../../../helpers/u2f'; 8 | 9 | import styles from './index.css'; 10 | import image from './sign-u2f.png'; 11 | 12 | /** 13 | * Modal Form to register a new U2F Key. 14 | * 15 | * Fetches the challenge from the server, forward it to U2F API, and sends back the answer to the API. 16 | */ 17 | export class FormRegisterKey extends Component { 18 | /** 19 | * when next button is pressed. 20 | * @param {Event} e 21 | */ 22 | onSubmit(e) { 23 | e.preventDefault(); 24 | this.props.onSubmit(); 25 | } 26 | 27 | componentDidMount() { 28 | this.props.addU2FKeyRegisterAction(); 29 | } 30 | 31 | componentWillReceiveProps(newProps) { 32 | const { 33 | settings: { 34 | addU2FKey: { status: newStatus } 35 | } 36 | } = newProps; 37 | 38 | const { 39 | settings: { 40 | addU2FKey: { status } 41 | } 42 | } = this.props; 43 | 44 | if (newStatus !== status && newStatus === 'finished') { 45 | this.props.forbidClosure(); 46 | } 47 | } 48 | 49 | /** 50 | * Renders the status field. 51 | * @return {*} 52 | */ 53 | renderStatus() { 54 | const { 55 | settings: { 56 | addU2FKey: { response: { name } = {}, status, error } 57 | } 58 | } = this.props; 59 | 60 | if (status !== 'failure') { 61 | return ( 62 |
    63 |
    64 | Activate your key 65 | 66 | {status || 'fetching'} 67 | ... 68 | 69 |
    70 | 71 |
    72 | Name 73 | {name} 74 |
    75 |
    76 | ); 77 | } 78 | const { metaData: { code } = {} } = error; 79 | if (code) { 80 | return ( 81 |
    82 | {getErrorMessage(code, true)} 83 | this.props.addU2FKeyRegisterAction()}>Retry 84 |
    85 | ); 86 | } 87 | 88 | this.props.onReset(error.message); 89 | } 90 | 91 | render() { 92 | const { 93 | settings: { 94 | addU2FKey: { status } 95 | } 96 | } = this.props; 97 | 98 | return ( 99 | this.onSubmit(e)} 101 | onReset={(e) => { 102 | e.preventDefault(); 103 | this.props.onCancel(); 104 | }} 105 | > 106 | 107 | 108 | 109 | {this.renderStatus()} 110 | 111 | 112 | 115 | 118 | 119 | 120 | ); 121 | } 122 | } 123 | 124 | export default connect( 125 | ['scope', 'settings'], 126 | settingsActions 127 | )(FormRegisterKey); 128 | -------------------------------------------------------------------------------- /src/actions/settings/addU2FKey.js: -------------------------------------------------------------------------------- 1 | import { addU2FKey, getAddU2FChallenge } from 'frontend-commons/src/settings/security'; 2 | 3 | import { toState, extended } from '../../helpers/stateFormatter'; 4 | import { registerU2F } from '../../helpers/u2f'; 5 | 6 | /** 7 | * @link { https://github.com/developit/unistore#usage } 8 | */ 9 | export default (store) => { 10 | /** 11 | * update the `addU2FKey` state. 12 | * @param state 13 | * @param {Object} data the data to update. 14 | * @return {Object} new state 15 | * @private 16 | */ 17 | function updateAddU2FKeyState(state, data) { 18 | store.setState(extended(state, 'settings.addU2FKey', data)); 19 | return store.getState(); 20 | } 21 | 22 | /** 23 | * Stores a name for a new U2F key. Erases any ongoing registration on the same browser. 24 | * @param state 25 | * @param {String} label 26 | */ 27 | async function addU2FKeyLabel(state, label) { 28 | // erases any registering key. Not an issue, because the registration is supposed to be after the name setup. 29 | store.setState(extended(state, 'settings.addU2FKey', { response: { label } })); 30 | } 31 | 32 | /** 33 | * Fetches the challenge for the registration of a new key. If a response is already stored, using this one instead. 34 | * @return {Promise} 35 | * @private 36 | */ 37 | async function fetchU2FRegisterChallenge() { 38 | const state = store.getState(); 39 | const { errorCode, request } = state.settings.addU2FKey; 40 | 41 | if (!(errorCode && request && Object.keys(request).length)) { 42 | await updateAddU2FKeyState(state, { status: 'fetching' }); 43 | 44 | await updateAddU2FKeyState(state, { request: await getAddU2FChallenge(true), status: 'pending' }); 45 | } else { 46 | // if failure, no need to refetch the challenge 47 | await updateAddU2FKeyState(state, { 48 | status: 'pending' 49 | }); 50 | } 51 | } 52 | 53 | /** 54 | * Sends the request to the U2F API. 55 | * @return {Promise} 56 | * @private 57 | */ 58 | async function callU2FRegisterAPI() { 59 | const state = store.getState(); 60 | const request = state.settings.addU2FKey.request; 61 | const u2fResponse = await registerU2F(request); 62 | 63 | await updateAddU2FKeyState(state, { 64 | u2fResponse, 65 | status: 'success' 66 | }); 67 | return u2fResponse; 68 | } 69 | 70 | /** 71 | * post the U2F response to the API. 72 | * @return {Promise} 73 | * @private 74 | */ 75 | async function postResponse() { 76 | const state = store.getState(); 77 | 78 | const { response, u2fResponse } = state.settings.addU2FKey; 79 | 80 | const data = { 81 | ...response, 82 | ...u2fResponse 83 | }; 84 | 85 | const { 86 | data: { TwoFactorRecoveryCodes, UserSettings } 87 | } = await addU2FKey(data, state.scope.creds, state.scope.response); 88 | 89 | const newState = { 90 | ...toState(state, 'settings', { 91 | addU2FKey: toState(state.settings, 'addU2FKey', { status: 'finished' }).addU2FKey, 92 | reset2FARecoveryCodes: toState(state.settings, 'reset2FARecoveryCodes', { 93 | request: { codes: TwoFactorRecoveryCodes } 94 | }).reset2FARecoveryCodes 95 | }), 96 | ...extended(state, 'config.settings.user', { ...UserSettings }), 97 | ...toState(state, 'scope', { used: true }) 98 | }; 99 | 100 | return store.setState(newState); 101 | } 102 | 103 | /** 104 | * Fetches and sends a U2F register request to the U2F API. 105 | * @param state 106 | * @returns {Promise} 107 | */ 108 | async function addU2FKeyRegister(state) { 109 | await fetchU2FRegisterChallenge(); 110 | 111 | try { 112 | await callU2FRegisterAPI(); 113 | await postResponse(); 114 | } catch (e) { 115 | return await updateAddU2FKeyState(store.getState(), { status: 'failure', error: e }); 116 | } 117 | } 118 | 119 | return { 120 | addU2FKeyLabel, 121 | addU2FKeyRegister 122 | }; 123 | }; 124 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/SetupTOTPModal/sharedSecret.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { connect } from 'unistore/full/preact'; 3 | import QRCode from 'qrcode.react'; 4 | 5 | import { Content as ModalContent, Footer as ModalFooter, Wrapper as ModalWrapper } from '../../../../ui/Modal'; 6 | import TextButton from '../../../../ui/TextButton'; 7 | import settingsActions from '../../../../../actions/settings'; 8 | 9 | import styles from './index.css'; 10 | 11 | export class SharedSecret extends Component { 12 | state = { showingQRCode: true }; 13 | 14 | componentDidMount() { 15 | this.props.createSharedSecretAction(); 16 | } 17 | 18 | renderSwitchModeButton() { 19 | return ( 20 |

    21 | this.setState({ showingQRCode: !this.state.showingQRCode })}> 22 | {this.state.showingQRCode ? 'Enter key manually instead' : 'Scan QR code'} 23 | 24 |

    25 | ); 26 | } 27 | 28 | renderQRCode() { 29 | const { setupTOTP: { request: { qrURI } = {} } = {} } = this.props.settings; 30 | 31 | return ( 32 | 33 |

    34 | {this.props.message || 35 | 'Scan this QR code with your two factor authentication device to set up your account. '} 36 |

    37 | {this.renderSwitchModeButton()} 38 | {qrURI ? :

    Loading...

    } 39 |
    40 | ); 41 | } 42 | 43 | renderRawInformation() { 44 | const { setupTOTP: { request: { interval, digits, secret } = {} } = {} } = this.props.settings; 45 | 46 | return ( 47 | 48 |

    49 | {this.props.message || 50 | 'Manually enter this information into your two factor authentication device to set up your account. '} 51 |

    52 | {this.renderSwitchModeButton()} 53 | 54 | {secret ? ( 55 |
    56 |
    57 | 58 | 59 |
    {secret}
    60 |
    61 |
    62 |
    63 | 64 | 65 |
    {interval} seconds
    66 |
    67 |
    68 |
    69 | 70 | 71 |
    {digits} digits
    72 |
    73 |
    74 |
    75 | ) : ( 76 |

    Loading...

    77 | )} 78 |
    79 | ); 80 | } 81 | 82 | render() { 83 | return ( 84 | { 86 | e.preventDefault(); 87 | this.props.onSubmit(); 88 | }} 89 | onReset={(e) => { 90 | e.preventDefault(); 91 | this.props.onCancel(); 92 | }} 93 | > 94 | {this.state.showingQRCode ? this.renderQRCode() : this.renderRawInformation()} 95 | 96 | 99 | 102 | 103 | 104 | ); 105 | } 106 | } 107 | 108 | export default connect( 109 | 'settings', 110 | settingsActions 111 | )(SharedSecret); 112 | -------------------------------------------------------------------------------- /src/tests/actions/authentication.test.js: -------------------------------------------------------------------------------- 1 | import * as userConnector from 'frontend-commons/src/auth/userConnector'; 2 | 3 | import store, { initialState } from '../../helpers/store'; 4 | import { ERROR_CODE, signU2F } from '../../helpers/u2f'; 5 | import authenticationActions from '../../actions/authentication'; 6 | import { waitForNewState } from '../testsHelpers/storeTools'; 7 | 8 | jest.mock('../../helpers/u2f'); 9 | jest.mock('frontend-commons/src/auth/userConnector'); 10 | jest.mock('frontend-commons/src/user/model'); 11 | jest.mock('frontend-commons/src/appProvider'); 12 | 13 | describe('authentication actions test', () => { 14 | const actions = authenticationActions(store); 15 | 16 | describe('loginU2F action', () => { 17 | afterAll(() => { 18 | signU2F.mockRestore(); 19 | }); 20 | 21 | beforeEach(() => { 22 | signU2F.mockClear(); 23 | }); 24 | 25 | test('success', async () => { 26 | const U2FResponse = { 27 | something: 'something' 28 | }; 29 | 30 | signU2F.mockImplementation(() => U2FResponse); 31 | userConnector.login2FA.mockImplementation(() => ({ 32 | config: { 33 | isUnlockable: true 34 | }, 35 | user: { halli: 'hallo' } 36 | })); 37 | 38 | await actions.loginU2FAction({ 39 | ...store.getState(), 40 | auth: { 41 | twoFactorData: { 42 | U2F: { 43 | something: 'somethat' 44 | } 45 | } 46 | } 47 | }); 48 | 49 | expect(signU2F).toBeCalled(); 50 | expect(signU2F).toHaveBeenCalledWith({ 51 | something: 'somethat' 52 | }); 53 | 54 | expect(userConnector.login2FA).toBeCalled(); 55 | expect(userConnector.login2FA).toHaveBeenCalledWith({ U2FResponse }); 56 | 57 | userConnector.login2FA.mockRestore(); 58 | }); 59 | 60 | test('failure U2F error', async (done) => { 61 | const error = { 62 | metaData: { 63 | code: ERROR_CODE.CONFIGURATION_UNSUPPORTED 64 | } 65 | }; 66 | signU2F.mockImplementation(async () => { 67 | throw error; 68 | }); 69 | waitForNewState( 70 | done, 71 | (state) => expect(state.auth.twoFactorResponse.U2FResponse).toEqual({}), 72 | (state) => 73 | expect(state.auth.twoFactorResponse).toEqual({ 74 | success: false, 75 | U2FResponse: error 76 | }) 77 | ); 78 | await actions.loginU2FAction({ 79 | ...store.getState(), 80 | auth: { 81 | twoFactorData: { 82 | U2F: { 83 | something: 'somethat' 84 | } 85 | } 86 | } 87 | }); 88 | }); 89 | test('failure non-U2F error', async () => { 90 | const errorMessage = 'Some random error'; 91 | signU2F.mockImplementation(async () => { 92 | throw new Error(errorMessage); 93 | }); 94 | 95 | try { 96 | await actions.loginU2FAction({ 97 | ...store.getState(), 98 | auth: { 99 | twoFactorData: { 100 | U2F: { 101 | something: 'somethat' 102 | } 103 | } 104 | } 105 | }); 106 | } catch (e) { 107 | expect(e.message).toMatch(errorMessage); 108 | } 109 | }); 110 | }); 111 | 112 | test('abortLogin test', async () => { 113 | await actions.abortLoginAction({ 114 | ...store.getState(), 115 | auth: { 116 | twoFactorData: { 117 | U2F: { 118 | something: 'somethat' 119 | } 120 | } 121 | } 122 | }); 123 | expect(store.getState()).toEqual(initialState); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "proton-account", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "scripts": { 7 | "start": "if-env NODE_ENV=production && npm run -s serve || npm run -s dev", 8 | "build": "preact build --no-prerender", 9 | "config": "cross-env NODE_ENV=dev NODE_ENV_MODE=config tasks/setupConfig.js", 10 | "deploy": "cross-env NODE_ENV=dist tasks/deploy.js", 11 | "serve": "preact build --no-prerender && preact serve", 12 | "dev": "preact watch -p 3000 --no-prerender", 13 | "lint": "eslint src --quiet", 14 | "test": "cross-env NODE_ENV=test jest ./tests", 15 | "pretty": "prettier -c --write $(find src -type f -name '*.js')" 16 | }, 17 | "lint-staged": { 18 | "*.js": [ 19 | "prettier -c --write", 20 | "git add" 21 | ] 22 | }, 23 | "eslintConfig": { 24 | "extends": [ 25 | "eslint-config-synacor", 26 | "standard-preact" 27 | ], 28 | "rules": { 29 | "indent": [ 30 | "error", 31 | 4 32 | ], 33 | "brace-style": [ 34 | "error", 35 | "1tbs" 36 | ], 37 | "jsx-quotes": [ 38 | "error", 39 | "prefer-double" 40 | ], 41 | "lines-around-comment": [ 42 | 0 43 | ], 44 | "react/jsx-indent": [ 45 | 2, 46 | 4 47 | ], 48 | "react/jsx-indent-props": [ 49 | 2, 50 | 4 51 | ], 52 | "react/sort-comp": [ 53 | 0 54 | ] 55 | } 56 | }, 57 | "eslintIgnore": [ 58 | "build/*" 59 | ], 60 | "devDependencies": { 61 | "babel-plugin-lodash": "^3.3.2", 62 | "babel-plugin-transform-class-properties": "^6.24.1", 63 | "babel-plugin-transform-react-jsx": "^6.24.1", 64 | "babel-plugin-transform-runtime": "^6.23.0", 65 | "chalk": "^2.4.1", 66 | "cross-env": "^5.2.0", 67 | "dedent": "^0.7.0", 68 | "del": "^3.0.0", 69 | "eslint": "^5.6.1", 70 | "eslint-config-standard-preact": "^1.1.6", 71 | "eslint-config-synacor": "^3.0.3", 72 | "execa": "^1.0.0", 73 | "husky": "^1.1.0", 74 | "identity-obj-proxy": "^3.0.0", 75 | "if-env": "^1.0.0", 76 | "jest": "^23.6.0", 77 | "jest-localstorage-mock": "^2.2.0", 78 | "jest-serializer-html-string": "^1.0.1", 79 | "lint-staged": "^7.3.0", 80 | "listr": "^0.14.1", 81 | "listr-update-renderer": "^0.4.0", 82 | "minimist": "^1.2.0", 83 | "preact-cli": "^2.1.0", 84 | "preact-render-spy": "^1.2.1", 85 | "preact-render-to-string": "^3.7.0", 86 | "prettier": "^1.14.3" 87 | }, 88 | "dependencies": { 89 | "c-3po": "^0.8.1", 90 | "lodash": "^4.17.11", 91 | "file-saver": "^1.3.8", 92 | "moment": "^2.22.2", 93 | "hi-base32": "^0.5.0", 94 | "preact": "^8.2.6", 95 | "preact-cli-lodash": "^1.1.0", 96 | "preact-compat": "^3.17.0", 97 | "react-modal": "^3.6.1", 98 | "preact-router": "^2.5.7", 99 | "qrcode.react": "^0.8.0", 100 | "react-copy-to-clipboard": "^5.0.1", 101 | "u2f-api": "^1.0.6", 102 | "unistore": "^3.1.0" 103 | }, 104 | "jest": { 105 | "snapshotSerializers": [ 106 | "preact-render-spy/snapshot", 107 | "jest-serializer-html-string" 108 | ], 109 | "setupFiles": [ 110 | "/src/tests/__mocks__", 111 | "jest-localstorage-mock" 112 | ], 113 | "testURL": "http://localhost:8080", 114 | "moduleFileExtensions": [ 115 | "js", 116 | "jsx" 117 | ], 118 | "transform": { 119 | "^.+\\.jsx$": "babel-jest", 120 | "^.+\\.js$": "babel-jest" 121 | }, 122 | "testPathIgnorePatterns": [], 123 | "transformIgnorePatterns": [ 124 | "/node_modules/(?!frontend-commons).+\\.js$" 125 | ], 126 | "moduleNameMapper": { 127 | "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/src/tests/__mocks__/fileMocks.js", 128 | "\\.(css|less|scss)$": "identity-obj-proxy", 129 | "^./style$": "identity-obj-proxy", 130 | "^preact$": "/node_modules/preact/dist/preact.min.js", 131 | "^react$": "preact-compat", 132 | "^react-dom$": "preact-compat", 133 | "^create-react-class$": "preact-compat/lib/create-react-class", 134 | "^react-addons-css-transition-group$": "preact-css-transition-group" 135 | }, 136 | "globals": { 137 | "window": {} 138 | } 139 | }, 140 | "husky": { 141 | "hooks": { 142 | "pre-commit": "lint-staged" 143 | } 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/actions/settings/enableTOTP.js: -------------------------------------------------------------------------------- 1 | import base32 from 'hi-base32'; 2 | import webcrypto from 'frontend-commons/src/crypto/webcrypto'; 3 | import { enableTOTP as enableTOTPApi } from 'frontend-commons/src/settings/security'; 4 | 5 | import { extended, toState } from '../../helpers/stateFormatter'; 6 | 7 | export default (store) => { 8 | /** 9 | * Creates a shared secret for TOTP. 10 | * @param {Object} state 11 | * @return {Promise} 12 | */ 13 | async function createSharedSecret(state) { 14 | if (state.settings.setupTOTP && state.settings.setupTOTP.request && state.settings.setupTOTP.request.qrURI) { 15 | return; 16 | } 17 | const randomBytes = webcrypto.getRandomValues(new Uint8Array(20)); 18 | const sharedSecret = base32.encode(randomBytes); 19 | 20 | const primaryAddress = state.auth.user.Addresses && state.auth.user.Addresses.find(({ Keys }) => !!Keys); 21 | const identifier = primaryAddress ? primaryAddress.Email : state.auth.user.Name + '@protonmail'; 22 | 23 | const interval = 30; 24 | const digits = 6; 25 | const qrURI = `otpauth://totp/${identifier}?secret=${sharedSecret}&issuer=ProtonMail&algorithm=SHA1&digits=${digits}&period=${interval}`; 26 | 27 | store.setState( 28 | extended(state, 'settings.setupTOTP', { 29 | request: { 30 | qrURI, 31 | interval, 32 | digits, 33 | secret: sharedSecret 34 | }, 35 | status: 'init' 36 | }) 37 | ); 38 | } 39 | 40 | /** 41 | * verifies the state and the code. 42 | * @param state 43 | * @param code 44 | * @return {Promise} if the verification is valid 45 | */ 46 | async function verifyParamsBeforeEnabling(state, code) { 47 | if ( 48 | !state.settings.setupTOTP || 49 | !state.settings.setupTOTP.request || 50 | !state.settings.setupTOTP.request.secret 51 | ) { 52 | throw new Error('Please create a secret before enabling TOTP'); // this happens when enableTOTP is called before createSharedSecret 53 | } 54 | if (!code || code.length !== 6) { 55 | store.setState( 56 | extended(state, 'settings.setupTOTP', { 57 | status: 'failure', 58 | error: 'The code is not valid' 59 | }) 60 | ); 61 | return false; 62 | } 63 | return true; 64 | } 65 | 66 | /** 67 | * Enables TOTP on the backend side. 68 | * @param state 69 | * @param code 70 | * @return {Promise} 71 | */ 72 | async function enableTOTP(state, code) { 73 | if (!(await verifyParamsBeforeEnabling(state, code))) { 74 | return; 75 | } 76 | const data = { 77 | TOTPConfirmation: code, 78 | TOTPSharedSecret: state.settings.setupTOTP.request.secret 79 | }; 80 | 81 | store.setState(extended(state, 'settings.setupTOTP', { status: 'fetching' })); 82 | try { 83 | const { 84 | data: { TwoFactorRecoveryCodes, UserSettings } 85 | } = await enableTOTPApi(data, state.scope.creds, state.scope.response); 86 | 87 | const newState = { 88 | settings: { 89 | reset2FARecoveryCodes: { 90 | request: { 91 | codes: TwoFactorRecoveryCodes 92 | } 93 | }, 94 | setupTOTP: { 95 | status: 'success', 96 | request: { 97 | ...state.settings.setupTOTP.request, 98 | TOTPConfirmation: code 99 | } 100 | } 101 | }, 102 | ...extended(state, 'config.settings.user', { ...UserSettings }), 103 | ...toState(state, 'scope', { used: true }) 104 | }; 105 | store.setState(newState); 106 | } catch (e) { 107 | return store.setState( 108 | extended(state, 'settings.setupTOTP', { 109 | status: 'failure', 110 | error: e 111 | }) 112 | ); 113 | } 114 | } 115 | 116 | return { 117 | createSharedSecret, 118 | enableTOTP 119 | }; 120 | }; 121 | -------------------------------------------------------------------------------- /env/config.js: -------------------------------------------------------------------------------- 1 | const extend = require('lodash/extend'); 2 | const { execSync } = require('child_process'); 3 | const argv = require('minimist')(process.argv.slice(2)); 4 | const CONFIG_DEFAULT = require('./configDefault'); 5 | 6 | const { 7 | STATS_CONFIG, 8 | STATS_ID, 9 | NO_STAT_MACHINE, 10 | API_TARGETS, 11 | AUTOPREFIXER_CONFIG, 12 | SENTRY_CONFIG, 13 | TOR_, 14 | U2F_CONFIG 15 | } = require('./config.constants'); 16 | 17 | const hasEnv = () => Object.keys(SENTRY_CONFIG).length; 18 | const isProdBranch = (branch = process.env.NODE_ENV_BRANCH) => /-prod/.test(branch); 19 | const isTorBranch = (branch = process.env.NODE_ENV_BRANCH) => /-tor$/.test(branch); 20 | const typeofBranch = (branch = process.env.NODE_ENV_BRANCH) => { 21 | const [, type] = (branch || '').match(/deploy-(\w+)/) || []; 22 | if (/dev|beta|prod/.test(type)) { 23 | return type; 24 | } 25 | 26 | if (isTorBranch(branch)) { 27 | return 'prod'; 28 | } 29 | 30 | if (type === 'alpha') { 31 | return 'red'; 32 | } 33 | 34 | if (type) { 35 | return 'blue'; 36 | } 37 | return 'dev'; 38 | }; 39 | 40 | const getStatsConfig = (deployBranch = '') => { 41 | const [, host = 'dev', subhost = 'a'] = deployBranch.split('-'); 42 | return extend({}, STATS_CONFIG[host], STATS_ID[subhost]) || NO_STAT_MACHINE; 43 | }; 44 | 45 | const getDefaultApiTarget = (defaultType = 'dev') => { 46 | if (!hasEnv()) { 47 | return 'prod'; 48 | } 49 | 50 | if (process.env.NODE_ENV === 'dist') { 51 | const [, type] = (argv.branch || '').match(/\w+-(beta|prod)/) || []; 52 | if (type) { 53 | return type; 54 | } 55 | 56 | if (/red|alpha/.test(argv.branch || '')) { 57 | return 'dev'; 58 | } 59 | 60 | return 'build'; 61 | } 62 | 63 | return defaultType; 64 | }; 65 | 66 | const isDistRelease = () => { 67 | return ['prod', 'beta'].includes(argv.api) || process.env.NODE_ENV === 'dist'; 68 | }; 69 | 70 | const getEnv = () => { 71 | if (isDistRelease()) { 72 | return argv.api || getDefaultApiTarget(); 73 | } 74 | return argv.api || 'local'; 75 | }; 76 | 77 | const apiUrl = (type = getDefaultApiTarget(), branch = '') => { 78 | // Cannot override the branch when you deploy to live 79 | if (isProdBranch(branch) || isTorBranch(branch)) { 80 | return API_TARGETS.build; 81 | } 82 | return API_TARGETS[type] || API_TARGETS.dev; 83 | }; 84 | 85 | const buildHost = () => { 86 | if (isTorBranch()) { 87 | return TOR_URL; 88 | } 89 | const host = isProdBranch() ? API_TARGETS.prod : process.env.NODE_ENV_API || apiUrl(); 90 | return host.replace(/\api$/, ''); 91 | }; 92 | 93 | /** 94 | * Get correct sentry URL config for the current env 95 | * - on dev it's based on the API you specify 96 | * - on deploy it's based on the branch name 97 | * @return {String} 98 | */ 99 | const sentryURL = () => { 100 | if (process.env.NODE_ENV === 'dist') { 101 | const env = typeofBranch(argv.branch); 102 | process.env.NODE_ENV_SENTRY = env; 103 | return SENTRY_CONFIG[env]; 104 | } 105 | const env = getDefaultApiTarget(argv.api); 106 | process.env.NODE_ENV_SENTRY = env; 107 | return SENTRY_CONFIG[env]; 108 | }; 109 | 110 | const getHostURL = (encoded) => { 111 | const url = '/assets/host.png'; 112 | 113 | if (encoded) { 114 | const encoder = (input) => `%${input.charCodeAt(0).toString(16)}`; 115 | return url 116 | .split('/') 117 | .map((chunk) => { 118 | if (chunk === '/') { 119 | return chunk; 120 | } 121 | return chunk 122 | .split('') 123 | .map(encoder) 124 | .join(''); 125 | }) 126 | .join('/'); 127 | } 128 | return url; 129 | }; 130 | 131 | const getConfig = (env = process.env.NODE_ENV) => { 132 | const CONFIG = extend({}, CONFIG_DEFAULT, { 133 | debug: env === 'dist' ? false : 'debug-app' in argv ? argv['debug-app'] : true, 134 | apiUrl: apiUrl(argv.api, argv.branch), 135 | sentryUrl: sentryURL(), 136 | app_version: argv['app-version'] || CONFIG_DEFAULT.app_version, 137 | api_version: `${argv['api-version'] || CONFIG_DEFAULT.api_version}`, 138 | articleLink: argv.article || CONFIG_DEFAULT.articleLink, 139 | statsConfig: getStatsConfig(argv.branch), 140 | u2f: U2F_CONFIG 141 | }); 142 | return extend({ CONFIG }, { branch: argv.branch }); 143 | }; 144 | 145 | module.exports = { 146 | AUTOPREFIXER_CONFIG, 147 | getHostURL, 148 | getConfig, 149 | isDistRelease, 150 | getStatsConfig, 151 | argv, 152 | getEnv, 153 | hasEnv 154 | }; 155 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/AddU2FModal/__snapshots__/formRegisterKey.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`AddU2FModal step FormRegisterKey failure display with U2F error 1`] = ` 4 |
    5 |
    6 | 7 |
    8 | An error occurred 9 | Retry 10 |
    11 |
    12 |
    13 | 14 | 15 |
    16 |
    17 | `; 18 | 19 | exports[`AddU2FModal step FormRegisterKey initial display 1`] = ` 20 |
    21 |
    22 | 23 |
    24 |
    25 | Activate your key 26 | fetching... 27 |
    28 |
    29 | Name 30 | 31 |
    32 |
    33 |
    34 |
    35 | 36 | 37 |
    38 |
    39 | `; 40 | 41 | exports[`AddU2FModal step FormRegisterKey initial display 2`] = ` 42 |
    43 |
    44 | 45 |
    46 |
    47 | Activate your key 48 | fetching... 49 |
    50 |
    51 | Name 52 | Test name 53 |
    54 |
    55 |
    56 |
    57 | 58 | 59 |
    60 |
    61 | `; 62 | 63 | exports[`AddU2FModal step FormRegisterKey updating status 1`] = ` 64 | preact-render-spy (1 nodes) 65 | ------- 66 |
    71 |
    72 | 73 |
    74 |
    75 | Activate your key 76 | fetching... 77 |
    78 |
    79 | Name 80 | 81 |
    82 |
    83 |
    84 |
    85 | 92 | 99 |
    100 |
    101 | 102 | `; 103 | 104 | exports[`AddU2FModal step FormRegisterKey updating status 2`] = ` 105 | preact-render-spy (1 nodes) 106 | ------- 107 |
    112 |
    113 | 114 |
    115 |
    116 | Activate your key 117 | pending... 118 |
    119 |
    120 | Name 121 | 122 |
    123 |
    124 |
    125 |
    126 | 133 | 140 |
    141 |
    142 | 143 | `; 144 | 145 | exports[`AddU2FModal step FormRegisterKey updating status 3`] = ` 146 | preact-render-spy (1 nodes) 147 | ------- 148 |
    153 |
    154 | 155 |
    156 |
    157 | Activate your key 158 | finished... 159 |
    160 |
    161 | Name 162 | 163 |
    164 |
    165 |
    166 |
    167 | 174 | 181 |
    182 |
    183 | 184 | `; 185 | -------------------------------------------------------------------------------- /src/components/ui/SteppedModal/index.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | 3 | import Modal from '../Modal'; 4 | 5 | /** 6 | * Modal in several steps. 7 | * @param props.isOpen - whether the step modal should be opened or not. 8 | * @param {Object[]} props.steps - the different steps to be proceeded. 9 | * @param {Component} props.steps[].components - the components of the current step. 10 | * @param {Function} props.onRequestClose - to be called when the callback is closed. 11 | * @param {?Function} props.onAfterOpen - to be called after the modal is opened. 12 | */ 13 | export default class SteppedModal extends Component { 14 | state = { 15 | step: -1, 16 | previousAction: 'enter', 17 | mustSucceed: false 18 | }; 19 | 20 | /** 21 | * called after the SteppedModal is opened. 22 | */ 23 | onAfterOpen() { 24 | this.setState({ step: 0 }); 25 | 26 | if (this.props.onAfterOpen) { 27 | this.props.onAfterOpen(); 28 | } 29 | } 30 | 31 | /** 32 | * called after the SteppedModal is closed. 33 | * @param {Event} requestClosed - the event. 34 | * @param {Boolean} lastStepSuccess - whether the last step succeeded. 35 | */ 36 | onRequestClose(requestClosed = null, lastStepSuccess = false) { 37 | if (!(this.state.mustSucceed || this.props.steps[this.state.step].mustSucceed) || lastStepSuccess) { 38 | this.setState({ step: -1 }); 39 | 40 | if (this.props.beforeDismiss) { 41 | this.props.beforeDismiss(lastStepSuccess); 42 | } 43 | this.props.onRequestClose(); 44 | } 45 | } 46 | 47 | onSkipStep() { 48 | if (this.state.previousAction === 'next' || this.state.previousAction === 'enter') { 49 | return this.onNextStep(); 50 | } 51 | return this.onPreviousStep(); 52 | } 53 | 54 | /** 55 | * Triggers the next step. If the last step is reached, closes the modal. 56 | */ 57 | onNextStep() { 58 | const state = this.state; 59 | 60 | if (this.state.step + 1 >= this.props.steps.length) { 61 | // if last step 62 | this.onRequestClose(null, true); 63 | } else { 64 | this.setState({ 65 | step: state.step + 1, 66 | previousAction: 'next', 67 | mustSucceed: false, 68 | message: null 69 | }); 70 | } 71 | } 72 | 73 | /** 74 | * Triggers the previous step. If the current step is the first step, the modal is closed. 75 | */ 76 | onPreviousStep() { 77 | const state = this.state; 78 | if (this.state.step <= 0) { 79 | this.onRequestClose(); 80 | } else { 81 | this.setState({ 82 | step: state.step - 1, 83 | previousAction: 'previous', 84 | mustSucceed: false, 85 | message: null 86 | }); 87 | } 88 | } 89 | 90 | onReset(message = undefined) { 91 | if (this.props.beforeDismiss) { 92 | this.props.beforeDismiss(false, true); 93 | } 94 | this.setState({ 95 | message, 96 | step: 0, 97 | previousAction: 'enter', 98 | mustSucceed: false 99 | }); 100 | } 101 | 102 | /** 103 | * if triggered, it will no longer be possible to close the modal during the current step. 104 | */ 105 | forbidClosure() { 106 | this.setState({ mustSucceed: true }); 107 | } 108 | 109 | /** 110 | * Computes the title of the current step. 111 | * @return {String} the current step. 112 | */ 113 | computeCurrentTitle() { 114 | if (this.state.step >= this.props.steps.length || this.state.step === -1) { 115 | return 'Loading'; 116 | } 117 | return this.props.steps[this.state.step].title; 118 | } 119 | 120 | /** 121 | * renders the current step. 122 | * @return {Component} 123 | */ 124 | renderCurrentStep() { 125 | if (this.state.step >= this.props.steps.length || this.state.step === -1) { 126 | return null; 127 | } 128 | return this.props.steps[this.state.step].component({ 129 | onNextStep: () => this.onNextStep(), 130 | onPreviousStep: () => this.onPreviousStep(), 131 | onSkipStep: () => this.onSkipStep(), 132 | onReset: (message) => this.onReset(message), 133 | forbidClosure: () => this.forbidClosure(), 134 | message: this.state.message 135 | }); 136 | } 137 | 138 | render() { 139 | if (!this.props.steps || !this.props.steps.length) return null; 140 | return ( 141 | this.onAfterOpen()} 144 | onRequestClose={(requestClosed = null, lastStepSuccess = false) => 145 | this.onRequestClose(requestClosed, lastStepSuccess) 146 | } 147 | title={this.computeCurrentTitle()} 148 | > 149 | {this.renderCurrentStep()} 150 | 151 | ); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/AddU2FModal/formRegisterKey.test.js: -------------------------------------------------------------------------------- 1 | import render from 'preact-render-to-string'; 2 | import { deep } from 'preact-render-spy'; 3 | 4 | import { FormRegisterKey } from '../../../../../../components/settings/security/TwoFactor/AddU2FModal/formRegisterKey'; 5 | import { ERROR_CODE } from '../../../../../../helpers/u2f'; 6 | 7 | describe('AddU2FModal step FormRegisterKey', () => { 8 | test('initial display', () => { 9 | expect(render()).toMatchSnapshot(); 10 | 11 | const settings = { addU2FKey: { response: { name: 'Test name' }, status: undefined, error: undefined } }; 12 | expect(render()).toMatchSnapshot(); 13 | }); 14 | 15 | test('failure display with U2F error', () => { 16 | const settings = { 17 | addU2FKey: { 18 | response: { name: 'Test name' }, 19 | status: 'failure', 20 | error: { metaData: { code: ERROR_CODE.OTHER_ERROR } } 21 | } 22 | }; 23 | expect(render()).toMatchSnapshot(); 24 | }); 25 | 26 | test('failure display with non-U2F error', () => { 27 | const onReset = jest.fn(); 28 | const settings = { 29 | addU2FKey: { 30 | response: { name: 'Test name' }, 31 | status: 'failure', 32 | error: new Error('Random error?./') 33 | } 34 | }; 35 | render(); 36 | expect(onReset).toBeCalledWith(settings.addU2FKey.error.message); 37 | }); 38 | 39 | test('addU2FAction is correctly called', () => { 40 | const addU2FKeyRegisterAction = jest.fn(); 41 | const settings = { 42 | addU2FKey: { 43 | response: { name: 'Test name' }, 44 | status: 'failure', 45 | error: { metaData: { code: ERROR_CODE.OTHER_ERROR } } 46 | } 47 | }; 48 | const context = deep(); 49 | expect(addU2FKeyRegisterAction).toHaveBeenCalledTimes(1); 50 | 51 | context 52 | .find('a') 53 | .first() 54 | .simulate('click'); 55 | expect(addU2FKeyRegisterAction).toHaveBeenCalledTimes(2); 56 | }); 57 | 58 | test('submit and cancel', () => { 59 | const addU2FKeyRegisterAction = jest.fn(); 60 | 61 | const onSubmit = jest.fn(); 62 | const onCancel = jest.fn(); 63 | 64 | const event = { preventDefault: () => undefined }; 65 | 66 | const context = deep( 67 | 73 | ); 74 | 75 | context.find('form').simulate('submit', event); 76 | expect(onSubmit).toHaveBeenCalled(); 77 | expect(onCancel).not.toHaveBeenCalled(); 78 | 79 | context.find('form').simulate('reset', event); 80 | expect(onSubmit).toHaveBeenCalledTimes(1); // the submit event 81 | expect(onCancel).toHaveBeenCalled(); // only once 82 | 83 | expect(addU2FKeyRegisterAction).toHaveBeenCalledTimes(1); 84 | }); 85 | 86 | test('updating status', () => { 87 | const addU2FKeyRegisterAction = jest.fn(); 88 | const forbidClosure = jest.fn(); 89 | 90 | const context = deep( 91 | 96 | ); 97 | 98 | expect(context).toMatchSnapshot(); 99 | expect(forbidClosure).toHaveBeenCalledTimes(0); 100 | 101 | context.render( 102 | 107 | ); 108 | expect(context).toMatchSnapshot(); 109 | expect(forbidClosure).toHaveBeenCalledTimes(0); 110 | 111 | context.render( 112 | 117 | ); 118 | expect(context).toMatchSnapshot(); 119 | expect(forbidClosure).toHaveBeenCalledTimes(1); 120 | 121 | context.render( 122 | 127 | ); 128 | // second time with the same status, we don't expect forbidClosure is recalled 129 | expect(forbidClosure).toHaveBeenCalledTimes(1); 130 | }); 131 | }); 132 | -------------------------------------------------------------------------------- /src/actions/authentication.js: -------------------------------------------------------------------------------- 1 | import { route } from 'preact-router'; 2 | import { addLocale, useLocale } from 'c-3po'; 3 | import { loadExtendedConfig } from 'frontend-commons/src/user/model'; 4 | import * as userConnector from 'frontend-commons/src/auth/userConnector'; 5 | import appProvider from 'frontend-commons/src/appProvider'; 6 | 7 | import { signU2F } from '../helpers/u2f'; 8 | import toActions from '../helpers/toActions'; 9 | import { extended, toState } from '../helpers/stateFormatter'; 10 | import { initialState } from '../helpers/store'; 11 | 12 | /** 13 | * @link { https://github.com/developit/unistore#usage } 14 | */ 15 | const actions = (store) => { 16 | async function loadUserConfig(state, user) { 17 | const { organization, payment, settings } = await loadExtendedConfig(user); 18 | appProvider.loadI18n(settings.user.Locale); 19 | store.setState({ 20 | config: { settings, payment, organization } 21 | }); 22 | } 23 | 24 | async function logout(state) { 25 | await userConnector.logout(); 26 | const data = toState(state, 'auth', { 27 | user: {}, 28 | isLoggedIn: false, 29 | step: 'login' 30 | }); 31 | store.setState({ config: null }); 32 | store.setState(data); 33 | route('/', data); 34 | } 35 | 36 | async function unlock(state, opt) { 37 | const { user } = await userConnector.unlock(opt); 38 | await loadUserConfig(state, user); 39 | toState(state, 'auth', { 40 | user, 41 | isLoggedIn: true, 42 | step: '' 43 | }); 44 | store.setState( 45 | toState(state, 'auth', { 46 | user, 47 | isLoggedIn: true, 48 | step: '' 49 | }) 50 | ); 51 | 52 | route('/dashboard', store.getState()); 53 | } 54 | 55 | /** 56 | * Login using a U2F key. 57 | * @param state 58 | * @returns {Promise<*>} calls @link{login2FA} 59 | */ 60 | async function loginU2F(state) { 61 | try { 62 | store.setState(extended(state, 'auth.twoFactorResponse', { U2FResponse: {} })); 63 | const result = await signU2F(state.auth.twoFactorData.U2F); 64 | return login2FA(state, { U2FResponse: result }); 65 | } catch (e) { 66 | const { metaData: { code } = {} } = e; 67 | if (!code) { 68 | throw e; 69 | } 70 | return store.setState( 71 | toState(state, 'auth', { 72 | twoFactorResponse: { 73 | success: false, 74 | U2FResponse: e 75 | } 76 | }) 77 | ); 78 | } 79 | } 80 | 81 | async function login2FA(state, opt) { 82 | const { config, user } = await userConnector.login2FA(opt); 83 | 84 | if (config) { 85 | if (config.isUnlockable) { 86 | return store.setState( 87 | toState(state, 'auth', { 88 | user, 89 | isLoggedIn: false, 90 | step: 'unlock' 91 | }) 92 | ); 93 | } 94 | } 95 | 96 | const data = toState(state, 'auth', { 97 | user, 98 | isLoggedIn: true, 99 | step: '' 100 | }); 101 | store.setState(data); 102 | await loadUserConfig(state, user); 103 | route('dashboard', data); 104 | } 105 | 106 | async function login(state, opt) { 107 | console.log('login.load', opt); 108 | const { config, user } = await userConnector.login(opt); 109 | console.log('login.success', { config, user, opt }); 110 | 111 | if (config) { 112 | const data = { user, isLoggedIn: false }; 113 | 114 | if (config.is2FA) { 115 | data.step = '2fa'; 116 | data.twoFactorData = { ...config.twoFactorData }; 117 | } 118 | 119 | if (config.isUnlockable) { 120 | data.step = 'unlock'; 121 | } 122 | return store.setState(toState(state, 'auth', data)); 123 | } 124 | 125 | store.setState( 126 | toState(state, 'auth', { 127 | user, 128 | isLoggedIn: true, 129 | step: '' 130 | }) 131 | ); 132 | 133 | !opt.raw && (await loadUserConfig(state, user)); 134 | route('/dashboard', store.getState()); 135 | } 136 | 137 | async function abortLogin(state) { 138 | store.setState(initialState, true); 139 | route('/', initialState); 140 | } 141 | 142 | async function loadAuthUser(state) { 143 | appProvider.setAppI18n(addLocale, useLocale); 144 | const data = await userConnector.reloadAuth(); 145 | const isLoggedIn = !!Object.keys(data.user).length; 146 | store.setState( 147 | toState(state, 'auth', { 148 | ...data, 149 | isLoggedIn 150 | }) 151 | ); 152 | 153 | isLoggedIn && (await loadUserConfig(state, data.user)); 154 | } 155 | 156 | return toActions({ 157 | loadUserConfig, 158 | login, 159 | logout, 160 | unlock, 161 | login2FA, 162 | abortLogin, 163 | loadAuthUser, 164 | loginU2F 165 | }); 166 | }; 167 | 168 | export default actions; 169 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/SetupTOTPModal/__snapshots__/sharedSecret.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`SetupTOTP SharedSecret step raw display 1`] = ` 4 | preact-render-spy (1 nodes) 5 | ------- 6 |
    11 |
    12 |

    13 | Manually enter this information into your two factor authentication device to set up your account. 14 |

    15 |

    16 | 21 | Scan QR code 22 | 23 |

    24 |
    25 |
    26 | 27 | 28 |
    THIS_IS_SECRET
    29 |
    30 |
    31 |
    32 | 33 | 34 |
    60 seconds
    35 |
    36 |
    37 |
    38 | 39 | 40 |
    25.3 digits
    41 |
    42 |
    43 |
    44 |
    45 |
    46 | 52 | 58 |
    59 |
    60 | 61 | `; 62 | 63 | exports[`SetupTOTP SharedSecret step raw display 2`] = ` 64 | preact-render-spy (1 nodes) 65 | ------- 66 |
    71 |
    72 |

    73 | Manually enter this information into your two factor authentication device to set up your account. 74 |

    75 |

    76 | 81 | Scan QR code 82 | 83 |

    84 |

    Loading...

    85 |
    86 |
    87 | 93 | 99 |
    100 |
    101 | 102 | `; 103 | 104 | exports[`SetupTOTP SharedSecret step regular display 1`] = ` 105 |
    106 |
    107 |

    Scan this QR code with your two factor authentication device to set up 108 | your account.

    109 |

    110 | Enter key manually instead 111 |

    112 | 113 | 114 | 115 | 116 |
    117 |
    118 | 119 | 120 |
    121 |
    122 | `; 123 | 124 | exports[`SetupTOTP SharedSecret step regular display 2`] = ` 125 |
    126 |
    127 |

    Scan this QR code with your two factor authentication device to set up 128 | your account.

    129 |

    130 | Enter key manually instead 131 |

    132 |

    Loading...

    133 |
    134 |
    135 | 136 | 137 |
    138 |
    139 | `; 140 | -------------------------------------------------------------------------------- /src/actions/settings/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | disableTOTP as disableTOTPApi, 3 | disableTwoFactor as disableTwoFactorApi, 4 | removeU2FKey, 5 | resetRecoveryCodes 6 | } from 'frontend-commons/src/settings/security'; 7 | 8 | import addU2FKeyActions from './addU2FKey'; 9 | import enableTOTPActions from './enableTOTP'; 10 | import toActions from '../../helpers/toActions'; 11 | import { error, success } from '../../helpers/notification'; 12 | import { toState, extended } from '../../helpers/stateFormatter'; 13 | 14 | /** 15 | * @link { https://github.com/developit/unistore#usage } 16 | */ 17 | const actions = (store) => { 18 | /** 19 | * Resets the state for the actions 20 | * @param {Object} state 21 | * @param {string[]} actions - the actions to reset (for the settings store only) 22 | * @returns {Promise} 23 | */ 24 | async function resetStore(state, actions) { 25 | const newState = actions.reduce((acc, action) => { 26 | if (state.settings[action]) { 27 | return { 28 | settings: { 29 | ...state.settings, 30 | ...acc.settings, 31 | [action]: {} 32 | } 33 | }; 34 | } 35 | return acc; 36 | }, {}); 37 | 38 | if (Object.keys(newState).length) { 39 | return store.setState(newState); 40 | } 41 | } 42 | 43 | /** 44 | * Initializes the reset2FARecoveryCodes procedure. 45 | * 46 | * Fetches new codes if none are already in the state. 47 | * @param {Object} state 48 | * @returns {Promise} 49 | */ 50 | async function reset2FARecoveryCodesInit(state) { 51 | const { 52 | settings: { 53 | reset2FARecoveryCodes: { request: { codes } = {} } 54 | } 55 | } = state; 56 | 57 | try { 58 | if (!codes || !codes.length) { 59 | const response = await resetRecoveryCodes(state.scope.creds, state.scope.response); 60 | store.setState( 61 | toState( 62 | state, 63 | 'settings', 64 | toState(state.settings, 'reset2FARecoveryCodes', { 65 | request: { codes: response.data.TwoFactorRecoveryCodes } 66 | }) 67 | ) 68 | ); 69 | } 70 | } catch (e) { 71 | store.setState(extended(state, 'settings.reset2FARecoveryCodes', { error: e })); 72 | } 73 | } 74 | 75 | /** 76 | * Verifies a given code is in the new codes. 77 | * @param {Object} state 78 | * @param {string} code - the code to verify. 79 | * @returns {Promise} 80 | */ 81 | async function reset2FARecoveryCodesCheckNewCode(state, code) { 82 | const { 83 | settings: { 84 | reset2FARecoveryCodes: { request: { codes = [] } = {} } 85 | } 86 | } = state; 87 | 88 | return store.setState( 89 | extended(state, 'settings.reset2FARecoveryCodes', { 90 | result: codes.indexOf(code) >= 0, 91 | response: { code } 92 | }) 93 | ); 94 | } 95 | 96 | /** 97 | * Updates the settings from the store. 98 | * @param {Object} state 99 | * @param {Object} result 100 | * @param {Object} result.data 101 | * @param {Object} result.data.UserSettings - the new UserSettings 102 | * @private 103 | */ 104 | function updateUserSettingsFromResponse(state, result) { 105 | store.setState(extended(state, 'config.settings.user', { ...result.data.UserSettings })); 106 | } 107 | 108 | /** 109 | * Sends a delete request to the API. 110 | * @param {Object} state 111 | * @param {Object} u2fKey - the U2F Key to delete. 112 | * @param {String} u2fKey.KeyHandle - the key handle of the U2F Key. 113 | * @returns {Promise} 114 | */ 115 | async function deleteU2FKey(state, u2fKey) { 116 | try { 117 | updateUserSettingsFromResponse( 118 | state, 119 | await removeU2FKey(u2fKey.KeyHandle, state.scope.creds, state.scope.response) 120 | ); 121 | success('Your key was successfully deleted'); 122 | } catch (e) { 123 | error(e); 124 | } 125 | } 126 | 127 | /** 128 | * Sends a delete request for TOTP to the API 129 | * @param {Object} state 130 | * @return {Promise} 131 | */ 132 | async function disableTOTP(state) { 133 | try { 134 | updateUserSettingsFromResponse(state, await disableTOTPApi(state.scope.creds, state.scope.response)); 135 | success('2FA via application was successfully disabled'); 136 | } catch (e) { 137 | error(e); 138 | } 139 | } 140 | 141 | /** 142 | * Disables Two Factor 143 | * @param {Object} state 144 | * @returns {Promise} 145 | */ 146 | async function disableTwoFactor(state) { 147 | try { 148 | updateUserSettingsFromResponse(state, await disableTwoFactorApi(state.scope.creds, state.scope.response)); 149 | success('Two Factor authentication was successfully disabled'); 150 | } catch (e) { 151 | error(e); 152 | } 153 | } 154 | 155 | return toActions({ 156 | reset2FARecoveryCodesCheckNewCode, 157 | reset2FARecoveryCodesInit, 158 | resetStore, 159 | deleteU2FKey, 160 | disableTOTP, 161 | disableTwoFactor, 162 | ...enableTOTPActions(store), 163 | ...addU2FKeyActions(store) 164 | }); 165 | }; 166 | 167 | export default actions; 168 | -------------------------------------------------------------------------------- /src/tests/components/settings/security/TwoFactor/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`TwoFactorSettings 2FA disabled 1`] = ` 4 |
    5 |

    Two-Factor Authentication

    6 |
    Two-factor authentication is currently off.
    7 |
    8 |
    2FA via Application
    9 | 10 |
    11 |
    12 |
    2FA via Security Key
    13 | 14 |
    15 |
      16 |
      17 | `; 18 | 19 | exports[`TwoFactorSettings Everything enable 1`] = ` 20 |
      21 |

      Two-Factor Authentication

      22 |
      Two-factor authentication is currently on. 23 | Turn off 24 |
      25 |
      26 |
      2FA via Application
      27 | 28 |
      29 | Regenerate recovery codes 30 | i 32 |
      33 |
      34 |
      35 |
      2FA via Security Key
      36 | 37 |
      38 |
        39 |
      • 40 |
        First key
        41 | Delete 42 |
      • 43 |
      • 44 |
        Second key
        45 | Delete 46 |
      • 47 |
      • 48 |
        Compromised key
        49 |
        Compromised
        50 | Delete 51 |
      • 52 |
      • 53 |
        Last key
        54 | Delete 55 |
      • 56 |
      57 |
      58 | `; 59 | 60 | exports[`TwoFactorSettings TOTP only enable 1`] = ` 61 |
      62 |

      Two-Factor Authentication

      63 |
      Two-factor authentication is currently on. 64 | Turn off 65 |
      66 |
      67 |
      2FA via Application
      68 | 69 |
      70 | Regenerate recovery codes 71 | i 73 |
      74 |
      75 |
      76 |
      2FA via Security Key
      77 | 78 |
      79 |
        80 |
        81 | `; 82 | 83 | exports[`TwoFactorSettings U2F not supported 1`] = ` 84 |
        85 |

        Two-Factor Authentication

        86 |
        Two-factor authentication is currently on. 87 | Turn off 88 |
        89 |
        90 |
        2FA via Application
        91 | 92 |
        93 | Regenerate recovery codes 94 | i 96 |
        97 |
        98 |
        99 |
        2FA via Security Key
        100 | 101 |
        102 |
          103 |
        • 104 |
          First key
          105 | Delete 106 |
        • 107 |
        • 108 |
          Second key
          109 | Delete 110 |
        • 111 |
        112 |
        113 | `; 114 | 115 | exports[`TwoFactorSettings U2F only enable 1`] = ` 116 |
        117 |

        Two-Factor Authentication

        118 |
        Two-factor authentication is currently on. 119 | Turn off 120 |
        121 |
        122 |
        2FA via Application
        123 | 124 |
        125 | Regenerate recovery codes 126 | i 128 |
        129 |
        130 |
        131 |
        2FA via Security Key
        132 | 133 |
        134 |
          135 |
        • 136 |
          First key
          137 | Delete 138 |
        • 139 |
        • 140 |
          Second key
          141 | Delete 142 |
        • 143 |
        • 144 |
          Compromised key
          145 |
          Compromised
          146 | Delete 147 |
        • 148 |
        • 149 |
          Last key
          150 | Delete 151 |
        • 152 |
        153 |
        154 | `; 155 | -------------------------------------------------------------------------------- /src/components/settings/security/TwoFactor/index.js: -------------------------------------------------------------------------------- 1 | import { h, Component } from 'preact'; 2 | import { connect } from 'unistore/full/preact'; 3 | import { isSupported } from 'u2f-api'; 4 | 5 | import U2FKeyList from './U2FKeyList/U2FKeyList'; 6 | import SteppedModal from '../../../ui/SteppedModal'; 7 | import ConfirmModal from '../../../ui/ConfirmModal'; 8 | import TextButton from '../../../ui/TextButton'; 9 | import settingsAction from '../../../../actions/settings'; 10 | import { 11 | steps as SaveRecoveryCodesSteps, 12 | beforeDismiss as SaveRecoveryCodesBeforeDismiss 13 | } from './SaveRecoveryCodeModal'; 14 | import { beforeDismiss as AddU2FModalBeforeDismiss, steps as AddU2FModalSteps } from './AddU2FModal'; 15 | import { beforeDismiss as SetupTOTPModalBeforeDismiss, steps as SetupTOTPModalSteps } from './SetupTOTPModal'; 16 | 17 | import style from './style.css'; 18 | 19 | /** 20 | * @param {Object} props 21 | * @param {Object} props.user 22 | * @param {Int} props.user.TwoFactor - whether 2FA is active or not. 23 | * @param {Int} props.user.TOTP - whether TOTP is active or not. 24 | * @param {Object[]} props.user.U2FKeys - the list of U2FKeys. 25 | * @param {Int} props.user.U2FKeys[].Compromised - whether the key is Compromised or not. 26 | * @param {String} props.user.U2FKeys[].KeyHandle - the KeyHandle of the U2FKey. 27 | * @param {String} props.user.U2FKeys[].Label - the Label of the Key. 28 | */ 29 | class TwoFactorSettings extends Component { 30 | state = { 31 | modal: '', 32 | SaveRecoveryCodesModalOpen: false, 33 | U2FModalOpen: false, 34 | DisableU2FModalOpen: false 35 | }; 36 | 37 | openModal(modalName) { 38 | this.setState({ modal: modalName }); 39 | } 40 | 41 | closeModal() { 42 | this.setState({ modal: '' }); 43 | } 44 | 45 | renderDisableTwoFactorModal() { 46 | return ( 47 | this.closeModal()} 52 | onConfirm={() => { 53 | this.props.disableTwoFactorAction(); 54 | }} 55 | onCancel={() => {}} 56 | > 57 |
        Are you sure you want to disable totally Two Factor Authentication?
        58 |
        59 | ); 60 | } 61 | 62 | renderDisableTOTPModal() { 63 | return ( 64 | this.closeModal()} 69 | onConfirm={() => { 70 | this.props.disableTOTPAction(); 71 | }} 72 | onCancel={() => {}} 73 | > 74 |
        Are you sure you want to disable 2FA via application?
        75 |
        76 | ); 77 | } 78 | 79 | renderAddU2FModal() { 80 | return ( 81 | this.closeModal()} 84 | steps={AddU2FModalSteps} 85 | beforeDismiss={AddU2FModalBeforeDismiss} 86 | /> 87 | ); 88 | } 89 | 90 | renderSetupTOTPModal() { 91 | return ( 92 | this.closeModal()} 95 | steps={SetupTOTPModalSteps} 96 | beforeDismiss={SetupTOTPModalBeforeDismiss} 97 | /> 98 | ); 99 | } 100 | 101 | renderSaveRecoveryCodesModal() { 102 | return ( 103 | this.closeModal()} 106 | beforeDismiss={SaveRecoveryCodesBeforeDismiss} 107 | steps={SaveRecoveryCodesSteps} 108 | /> 109 | ); 110 | } 111 | 112 | render() { 113 | const { TwoFactor, TOTP, U2FKeys = [] } = this.props; 114 | 115 | const u2fClasses = [style.item]; 116 | if (TwoFactor) { 117 | u2fClasses.push(style.lastItem); 118 | } 119 | 120 | return ( 121 |
        122 | {this.renderDisableTwoFactorModal()} 123 | {this.renderSetupTOTPModal()} 124 | {this.renderDisableTOTPModal()} 125 | {this.renderAddU2FModal()} 126 | {this.renderSaveRecoveryCodesModal()} 127 |

        Two-Factor Authentication

        128 |
        129 | Two-factor authentication is currently {TwoFactor ? 'on' : 'off'}.{' '} 130 | {!!TwoFactor && this.openModal('Disable2FA')}>Turn off} 131 |
        132 |
        133 |
        2FA via Application
        134 | 137 | {!!TwoFactor && ( 138 |
        139 | this.openModal('SaveRecoveryCodes')}> 140 | Regenerate recovery codes 141 | 142 | 147 | i 148 | 149 |
        150 | )} 151 |
        152 |
        153 |
        2FA via Security Key
        154 | 161 |
        162 | 163 |
        164 | ); 165 | } 166 | } 167 | 168 | export default connect( 169 | 'settings', 170 | settingsAction 171 | )(TwoFactorSettings); 172 | --------------------------------------------------------------------------------