diff --git a/admin/package.json b/admin/package.json index 5c800e75..2bee53ac 100644 --- a/admin/package.json +++ b/admin/package.json @@ -39,7 +39,9 @@ "@types/dotenv": "^4.0.3", "@types/lodash": "^4.14.112", "@types/numeral": "^0.0.25", + "@types/qrcode.react": "^0.8.2", "@types/react": "16.4.18", + "@types/react-copy-to-clipboard": "^4.2.6", "@types/react-dom": "16.0.9", "@types/react-helmet": "^5.0.7", "@types/react-redux": "^6.0.2", @@ -80,8 +82,10 @@ "moment": "^2.22.2", "prettier": "^1.13.4", "prettier-package-json": "^1.6.0", + "qrcode.react": "^0.9.3", "query-string": "6.1.0", "react": "16.5.2", + "react-copy-to-clipboard": "^5.0.1", "react-dev-utils": "^5.0.2", "react-dom": "16.5.2", "react-easy-state": "^6.0.4", diff --git a/admin/src/Routes.tsx b/admin/src/Routes.tsx index 69dcf36b..5026b646 100644 --- a/admin/src/Routes.tsx +++ b/admin/src/Routes.tsx @@ -6,6 +6,7 @@ import { Switch, Route, RouteComponentProps, withRouter } from 'react-router'; import Template from 'components/Template'; import store from './store'; import Login from 'components/Login'; +import MFAuth from 'components/MFAuth'; import Home from 'components/Home'; import Users from 'components/Users'; import UserDetail from 'components/UserDetail'; @@ -19,6 +20,7 @@ import Contributions from 'components/Contributions'; import ContributionForm from 'components/ContributionForm'; import ContributionDetail from 'components/ContributionDetail'; import Moderation from 'components/Moderation'; +import Settings from 'components/Settings'; import 'styles/style.less'; @@ -26,14 +28,17 @@ type Props = RouteComponentProps; class Routes extends React.Component { render() { - const { hasCheckedLogin, isLoggedIn } = store; + const { hasCheckedLogin, isLoggedIn, is2faAuthed } = store; if (!hasCheckedLogin) { return
checking auth status...
; } + return ( diff --git a/admin/src/components/Login/index.tsx b/admin/src/components/Login/index.tsx index 55fe6cdb..2782b49c 100644 --- a/admin/src/components/Login/index.tsx +++ b/admin/src/components/Login/index.tsx @@ -13,37 +13,42 @@ class Login extends React.Component { render() { return (
-

Login

-
- this.setState({ username: e.currentTarget.value })} - /> -
-
- this.setState({ password: e.currentTarget.value })} - /> -
- {store.loginError && ( -
- -
+ {store.isLoggedIn && !store.is2faAuthed &&

Requires 2FA setup or verify.

} + {!store.isLoggedIn && ( + <> +

Login

+
+ this.setState({ username: e.currentTarget.value })} + /> +
+
+ this.setState({ password: e.currentTarget.value })} + /> +
+ {store.loginError && ( +
+ +
+ )} +
+ +
+ )} -
- -
); } diff --git a/admin/src/components/MFAuth/index.less b/admin/src/components/MFAuth/index.less new file mode 100644 index 00000000..92282cd2 --- /dev/null +++ b/admin/src/components/MFAuth/index.less @@ -0,0 +1,83 @@ +.MFAuth { + max-width: 500px; + margin: 2rem auto; + + h1 { + margin-top: 1rem; + font-size: 1.5rem; + } + h2 { + font-size: 1.15rem; + } + + & .ant-alert { + margin-bottom: 0.8rem; + } + + &-codes { + margin: 1.5rem 0; + font-size: 1rem; + font-family: 'Courier New', Courier, monospace; + font-weight: bold; + + ul { + margin-bottom: 0; + + li { + display: inline-block; + text-align: center; + width: 50%; + } + } + + &-qrcode { + min-height: 250px; + display: flex; + justify-content: center; + align-items: center; + } + } + + .is-active { + color: #1890ff; + } + + &-verify { + display: flex; + & > div { + white-space: nowrap; + line-height: 2rem; + margin-right: 0.5rem; + font-weight: bold; + } + } + + &-controls { + display: flex; + margin-top: 1rem; + justify-content: flex-end; + + &-label { + white-space: nowrap; + line-height: 2rem; + font-weight: bold; + } + + & > * + * { + margin-left: 0.5rem; + } + } + + ol { + margin-left: 1rem; + margin-bottom: 2rem; + + & > li { + margin: 1rem 0; + } + } + + & > div { + margin-bottom: 0.5rem; + } +} diff --git a/admin/src/components/MFAuth/index.tsx b/admin/src/components/MFAuth/index.tsx new file mode 100644 index 00000000..b236a6c4 --- /dev/null +++ b/admin/src/components/MFAuth/index.tsx @@ -0,0 +1,424 @@ +import React, { ReactNode } from 'react'; +import { Redirect, withRouter, RouteComponentProps } from 'react-router-dom'; +import { view } from 'react-easy-state'; +import CopyToClipboard from 'react-copy-to-clipboard'; +import QRCode from 'qrcode.react'; +import { Input, Button, Alert, Spin, message, Card, Icon } from 'antd'; +import store, { + get2fa, + get2faInit, + post2faEnable, + post2faVerify, + refresh, + handleApiError, +} from 'src/store'; +import { downloadString } from 'src/util/file'; +import './index.less'; + +interface OwnProps { + isReset?: boolean; +} + +type Props = OwnProps & RouteComponentProps; + +const STATE = { + // remote + isLoginFresh: false, + has2fa: false, + backupCodes: [], + totpSecret: '', + totpUri: '', + is2faAuthed: false, + backupCodeCount: 0, + isEmailVerified: false, + // local + loaded: false, + stepOutlineComplete: false, + initializing: false, + stepRecoveryCodesComplete: false, + stepTotpComplete: false, + password: '', + verifyCode: '', + isVerifying: false, + showQrCode: true, +}; +type State = typeof STATE; + +class MFAuth extends React.Component { + state = STATE; + componentDidMount() { + this.update2faStateFromServer(); + } + render() { + const { + loaded, + stepOutlineComplete, + password, + stepRecoveryCodesComplete, + stepTotpComplete, + verifyCode, + isLoginFresh, + has2fa, + is2faAuthed, + backupCodes, + initializing, + totpSecret, + totpUri, + showQrCode, + isVerifying, + backupCodeCount, + isEmailVerified, + } = this.state; + const { isReset } = this.props; + + const emailNotVerifiedWarning = loaded && + !isEmailVerified && ( + + You must verify your email in order to act as admin. You should have + received an email with instructions when you signed up. + + } + /> + ); + + const lowBackupCodesWarning = loaded && + has2fa && + backupCodeCount < 5 && ( + + You only have {backupCodeCount} recovery codes remaining! Generate + new codes after you sign-in. + + } + /> + ); + + const wrap = (children: ReactNode) => ( +
+ {emailNotVerifiedWarning || ( + <> +

+ {isReset ? 'Reset two-factor authentication' : 'Two-factor authentication'} +

+ {children} + + )} +
+ ); + + // LOADING + if (!loaded) { + return wrap(); + } + + // STEP 0. (if login is stale) + if ((!has2fa || isReset) && !isLoginFresh) { + return wrap( + <> +

+ Please verify your password +

+

+ Too much time has elapsed since you last affirmed your credentials, please + enter your password below. +

+ +
+ this.setState({ password: e.target.value })} + value={password} + autoFocus={true} + /> + +
+ , + ); + } + + // STEP 1 (outline) + if ((!has2fa || isReset) && !stepOutlineComplete) { + return wrap( +
+ {!has2fa && ( + Administration requires two-factor authentication setup.} + /> + )} + {isReset && ( + + Your current recovery codes and authenticator app setup will be + invalidated when you continue. + + } + /> + )} +

1. Two-factor Authentication Setup

+

Please be prepared to perform the following steps:

+
    +
  1. Save two-factor recovery codes
  2. +
  3. + Setup up TOTP authentication device, typically a smartphone with Google + Authenticator, Authy, 1Password or other compatible authenticator app. +
  4. +
+
+ {isReset && } + +
+
, + ); + } + + // STEP 2 (recovery codes) + if ((!has2fa || isReset) && !stepRecoveryCodesComplete) { + return wrap( + ((initializing || !backupCodes.length) && ( + + )) || ( +
+

2. Recovery codes

+

+ Please copy, download or print these codes and keep them safe. Treat them + with the same care as passwords. +

+ message.success('Copied!', 2)} + > + + , + + downloadString( + backupCodes.join('\n'), + 'zcash-grants-recovery-codes.txt', + ) + } + type="download" + title="download codes" + />, + ]} + > +
    + {backupCodes.map(c => ( +
  • {c}
  • + ))} +
+
+
+ + +
+
+ ), + ); + } + + // STEP 4 (totp setup/verify) + if ((!has2fa || isReset) && !stepTotpComplete) { + return wrap( +
+

3. Set up Authenticator

+

+ Please scan the barcode with your athenticator application. If you cannot + scan, please{' '} + this.setState({ showQrCode: false })}> + enter the text code + {' '} + instead. +

+ this.setState({ showQrCode: true })} + />, + this.setState({ showQrCode: false })} + />, + ]} + > +
+ {showQrCode ? : totpSecret} +
+
+
+
Enter code from application
+ this.setState({ verifyCode: e.target.value })} + onPressEnter={this.handleEnable} + autoFocus={true} + /> +
+
+ + +
+
, + ); + } + + // unauthed + if (has2fa && !is2faAuthed) { + return wrap( + <> + {lowBackupCodesWarning} +

Two-Factor authentication required

+

+ Enter the current code from your authenticator application. Enter a recovery + code if you do not have access to your authenticator application. +

+
+
+
Enter code from application
+ this.setState({ verifyCode: e.target.value })} + onPressEnter={this.handleVerify} + autoFocus={true} + /> + +
+ , + ); + } + + return isReset ? : 'should not get here'; + } + + private update2faStateFromServer = () => { + get2fa() + .then(state => { + this.setState({ + loaded: true, + ...state, + }); + }) + .catch(handleApiError); + }; + + private handleCancel = async () => { + const { isReset } = this.props; + if (isReset) { + message.info('Canceled two-factor reset'); + this.props.history.replace('/settings'); + } else { + this.setState({ ...STATE }); + this.update2faStateFromServer(); + } + }; + + private handleReadSetup = async () => { + this.setState({ stepOutlineComplete: true }); + this.loadSetup(); + }; + + private handleSubmitPassword = async () => { + const { password } = this.state; + try { + // refresh the login + await refresh(password); + // will set fresh login + await this.update2faStateFromServer(); + // will load setup info + this.loadSetup(); + } catch (e) { + handleApiError(e); + } + }; + + private loadSetup = async () => { + this.setState({ initializing: true }); + try { + const setup = await get2faInit(); + this.setState({ + ...setup, + }); + } catch (e) { + handleApiError(e); + } + this.setState({ initializing: false }); + }; + + private handleEnable = async () => { + const { backupCodes, totpSecret, verifyCode } = this.state; + if (verifyCode.length === 0) return; // for pressEnter + this.setState({ isVerifying: true }); + try { + await post2faEnable({ backupCodes, totpSecret, verifyCode }); + message.success('Two-factor setup complete!'); + store.checkLogin(); // should return authenticated status + this.setState({ stepTotpComplete: true }); + } catch (e) { + handleApiError(e); + } + this.setState({ isVerifying: false }); + }; + + private handleVerify = async () => { + const { verifyCode } = this.state; + if (verifyCode.length === 0) return; // for pressEnter + this.setState({ isVerifying: true }); + try { + await post2faVerify({ verifyCode }); + message.success('Two-factor authentication verified'); + store.checkLogin(); // should return authenticated status + } catch (e) { + handleApiError(e); + } + this.setState({ isVerifying: false }); + }; +} + +export default withRouter(view(MFAuth)); diff --git a/admin/src/components/Settings/index.less b/admin/src/components/Settings/index.less new file mode 100644 index 00000000..421169d3 --- /dev/null +++ b/admin/src/components/Settings/index.less @@ -0,0 +1,11 @@ +.Settings { + max-width: 600px; + + h1 { + font-size: 1.5rem; + } + + & > div { + margin-bottom: 0.5rem; + } +} diff --git a/admin/src/components/Settings/index.tsx b/admin/src/components/Settings/index.tsx new file mode 100644 index 00000000..984aabaf --- /dev/null +++ b/admin/src/components/Settings/index.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { view } from 'react-easy-state'; +import { Link } from 'react-router-dom'; +import { Button } from 'antd'; +import './index.less'; + +class Settings extends React.Component { + state = { + username: '', + password: '', + }; + + render() { + return ( +
+
+

Two-Factor Authentication

+

+ This will require saving new recovery codes and setting up an Authenticator + application.{' '} + Current recovery and Authenticator codes will be invalidated. +

+ + + +
+
+ ); + } +} + +export default view(Settings); diff --git a/admin/src/components/Template/index.less b/admin/src/components/Template/index.less index 5170fa89..61058495 100644 --- a/admin/src/components/Template/index.less +++ b/admin/src/components/Template/index.less @@ -10,6 +10,10 @@ text-align: center; font-size: 1.5rem; } + + & .ant-menu-item-divider { + background-color: #3d3d3d; + } } &-layout { diff --git a/admin/src/components/Template/index.tsx b/admin/src/components/Template/index.tsx index 4a7f0205..70f840ce 100644 --- a/admin/src/components/Template/index.tsx +++ b/admin/src/components/Template/index.tsx @@ -75,6 +75,13 @@ class Template extends React.Component { Moderation + + + + + Settings + + Logout diff --git a/admin/src/components/UserDetail/index.tsx b/admin/src/components/UserDetail/index.tsx index 02426dcc..6fa12059 100644 --- a/admin/src/components/UserDetail/index.tsx +++ b/admin/src/components/UserDetail/index.tsx @@ -99,6 +99,33 @@ class UserDetailNaked extends React.Component {
); + const renderAdminControl = () => ( +
+ {u.isAdmin ? 'Remove admin privileges?' : 'Add admin privileges?'}} + okText="ok" + cancelText="cancel" + > + {' '} + + + Admin{' '} + + Admin User +
User will be able to log into this (admin) interface with full + privileges. +
+ } + /> + +
+ ); + const renderBanControl = () => (
{ {renderDelete()} {renderSilenceControl()} {renderBanControl()} + {renderAdminControl()} @@ -306,6 +334,22 @@ class UserDetailNaked extends React.Component { } }; + private handleToggleAdmin = async () => { + if (store.userDetail) { + const ud = store.userDetail; + const newAdmin = !ud.isAdmin; + await store.editUser(ud.userid, { isAdmin: newAdmin }); + if (store.userSaved) { + message.success( + <> + {ud.displayName} {newAdmin ? 'made admin' : 'no longer admin'} + , + 2, + ); + } + } + }; + private handleToggleBan = () => { if (store.userDetail) { const ud = store.userDetail; diff --git a/admin/src/store.ts b/admin/src/store.ts index 7cbe93c6..83ea4a45 100644 --- a/admin/src/store.ts +++ b/admin/src/store.ts @@ -25,17 +25,48 @@ async function login(username: string, password: string) { username, password, }); - return data.isLoggedIn; + return data; +} + +export async function refresh(password: string) { + const { data } = await api.post('/admin/refresh', { + password, + }); + return data; } async function logout() { const { data } = await api.get('/admin/logout'); - return data.isLoggedIn; + return data; } async function checkLogin() { const { data } = await api.get('/admin/checklogin'); - return data.isLoggedIn; + return data; +} + +export async function get2fa() { + const { data } = await api.get('/admin/2fa'); + return data; +} + +export async function get2faInit() { + const { data } = await api.get('/admin/2fa/init'); + return data; +} + +export async function post2faEnable(args: { + backupCodes: string[]; + totpSecret: string; + verifyCode: string; +}) { + const { data } = await api.post('/admin/2fa/enable', args); + return data; +} + +export async function post2faVerify(args: { verifyCode: string }) { + const { data } = await api.post('/admin/2fa/verify', args); + return data; } async function fetchStats() { @@ -169,6 +200,7 @@ const app = store({ hasCheckedLogin: false, isLoggedIn: false, + is2faAuthed: false, loginError: '', generalError: [] as string[], statsFetched: false, @@ -269,13 +301,17 @@ const app = store({ // Auth async checkLogin() { - app.isLoggedIn = await checkLogin(); + const res = await checkLogin(); + app.isLoggedIn = res.isLoggedIn; + app.is2faAuthed = res.is2faAuthed; app.hasCheckedLogin = true; }, async login(username: string, password: string) { try { - app.isLoggedIn = await login(username, password); + const res = await login(username, password); + app.isLoggedIn = res.isLoggedIn; + app.is2faAuthed = res.is2faAuthed; } catch (e) { app.loginError = e.response.data.message; } @@ -283,7 +319,9 @@ const app = store({ async logout() { try { - app.isLoggedIn = await logout(); + const res = await logout(); + app.isLoggedIn = res.isLoggedIn; + app.is2faAuthed = res.is2faAuthed; } catch (e) { app.generalError.push(e.toString()); } @@ -615,7 +653,7 @@ const app = store({ }); // Utils -function handleApiError(e: AxiosError) { +export function handleApiError(e: AxiosError) { if (e.response && e.response.data!.message) { app.generalError.push(e.response!.data.message); } else if (e.response && e.response.data!.data!) { diff --git a/admin/src/types.ts b/admin/src/types.ts index 8359345b..1a481d11 100644 --- a/admin/src/types.ts +++ b/admin/src/types.ts @@ -172,6 +172,7 @@ export interface User { silenced: boolean; banned: boolean; bannedReason: string; + isAdmin: boolean; } export interface EmailExample { diff --git a/admin/src/util/file.ts b/admin/src/util/file.ts new file mode 100644 index 00000000..05e82849 --- /dev/null +++ b/admin/src/util/file.ts @@ -0,0 +1,12 @@ +export function downloadString(text: string, fileName: string) { + const blob = new Blob([text], { type: 'text/plain' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = fileName; + a.dataset.downloadurl = ['text/plain', a.download, a.href].join(':'); + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(a.href), 1500); +} diff --git a/admin/yarn.lock b/admin/yarn.lock index 351a2880..f3573209 100644 --- a/admin/yarn.lock +++ b/admin/yarn.lock @@ -1062,10 +1062,24 @@ version "15.5.6" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.5.6.tgz#9c03d3fed70a8d517c191b7734da2879b50ca26c" +"@types/qrcode.react@^0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@types/qrcode.react/-/qrcode.react-0.8.2.tgz#35f6e2e454970b6a8404a834c9e1edc2e7f1c105" + integrity sha512-nxGOQzQBV3Ny1g7uMGa3jTAi7SNHUUJ91K7EMO1FEQtb38A4vwq3pZvz0QcfIN7ypP4xTwl7G6NIQMCZZQoXIQ== + dependencies: + "@types/react" "*" + "@types/query-string@6.1.0": version "6.1.0" resolved "https://registry.yarnpkg.com/@types/query-string/-/query-string-6.1.0.tgz#5f721f9503bdf517d474c66cf4423da5dd2d5698" +"@types/react-copy-to-clipboard@^4.2.6": + version "4.2.6" + resolved "https://registry.yarnpkg.com/@types/react-copy-to-clipboard/-/react-copy-to-clipboard-4.2.6.tgz#d1374550dec803f17f26ec71b62783c5737bfc02" + integrity sha512-v4/yLsuPf8GSFuTy9fA1ABpL5uuy04vwW7qs+cfxSe1UU/M/KK95rF3N3GRseismoK9tA28SvpwVsAg/GWoF3A== + dependencies: + "@types/react" "*" + "@types/react-dom@16.0.9": version "16.0.9" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-16.0.9.tgz#73ceb7abe6703822eab6600e65c5c52efd07fb91" @@ -8806,6 +8820,19 @@ q@^1.1.2: version "1.5.1" resolved "https://registry.yarnpkg.com/q/-/q-1.5.1.tgz#7e32f75b41381291d04611f1bf14109ac00651d7" +qr.js@0.0.0: + version "0.0.0" + resolved "https://registry.yarnpkg.com/qr.js/-/qr.js-0.0.0.tgz#cace86386f59a0db8050fa90d9b6b0e88a1e364f" + integrity sha1-ys6GOG9ZoNuAUPqQ2baw6IoeNk8= + +qrcode.react@^0.9.3: + version "0.9.3" + resolved "https://registry.yarnpkg.com/qrcode.react/-/qrcode.react-0.9.3.tgz#91de1287912bdc5ccfb3b091737b828d6ced60c5" + integrity sha512-gGd30Ez7cmrKxyN2M3nueaNLk/f9J7NDRgaD5fVgxGpPLsYGWMn9UQ+XnDpv95cfszTQTdaf4QGLNMf3xU0hmw== + dependencies: + prop-types "^15.6.0" + qr.js "0.0.0" + qs@6.5.2, qs@^6.4.0, qs@^6.5.1, qs@^6.5.2, qs@~6.5.2: version "6.5.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" @@ -9336,6 +9363,7 @@ react-container-query@^0.11.0: react-copy-to-clipboard@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.0.1.tgz#8eae107bb400be73132ed3b6a7b4fb156090208e" + integrity sha512-ELKq31/E3zjFs5rDWNCfFL4NvNFQvGRoJdAKReD/rUPA+xxiLPQmZBZBvy2vgH7V0GE9isIQpT9WXbwIVErYdA== dependencies: copy-to-clipboard "^3" prop-types "^15.5.8" diff --git a/backend/.env.example b/backend/.env.example index 230144c4..0c54b4ec 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -25,9 +25,6 @@ LINKEDIN_CLIENT_SECRET=linkedin-client-secret BLOCKCHAIN_REST_API_URL="http://localhost:5051" BLOCKCHAIN_API_SECRET="ef0b48e41f78d3ae85b1379b386f1bca" -# run `flask gen-admin-auth` to create new password for admin -ADMIN_PASS_HASH=18f97883b93a975deb9e29257a341a447302040da59cdc2d10ff65a5e57cc197 - # Blockchain explorer to link to. Top for mainnet, bottom for testnet. # EXPLORER_URL="https://explorer.zcha.in/" EXPLORER_URL="https://testnet.zcha.in/" diff --git a/backend/grant/admin/__init__.py b/backend/grant/admin/__init__.py index 22fbb7e0..14cd5bd9 100644 --- a/backend/grant/admin/__init__.py +++ b/backend/grant/admin/__init__.py @@ -1,2 +1 @@ -from . import commands from . import views diff --git a/backend/grant/admin/commands.py b/backend/grant/admin/commands.py deleted file mode 100644 index acf25122..00000000 --- a/backend/grant/admin/commands.py +++ /dev/null @@ -1,20 +0,0 @@ -import getpass - -import click -from grant.settings import SECRET_KEY -from grant.utils.admin import generate_admin_password_hash - - -@click.command() -def gen_admin_auth(): - """Generate admin authentication password hash (ADMIN_PASS_HASH)""" - sk_mask_middle = (len(SECRET_KEY) - 2) * '*' - sk_mask = f'{SECRET_KEY[0]}{sk_mask_middle}{SECRET_KEY[-1]}' - print(f'\nEnter SECRET_KEY for target environment or hit enter to use current ({sk_mask})\n') - salt = getpass.getpass('SECRET_KEY (salt):') - if not salt: - print('using default SECRET_KEY for salt') - salt = SECRET_KEY - password = getpass.getpass('Admin Password:') - pass_hash = generate_admin_password_hash(password, salt) - print(f'Please set environment variable\n\n\tADMIN_PASS_HASH={pass_hash}\n') diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index fb6422b9..3dce4316 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -1,5 +1,5 @@ from functools import reduce -from flask import Blueprint, request +from flask import Blueprint, request, session from flask_yoloapi import endpoint, parameter from decimal import Decimal from datetime import datetime @@ -21,7 +21,8 @@ from grant.proposal.models import ( from grant.milestone.models import Milestone from grant.user.models import User, UserSettings, admin_users_schema, admin_user_schema from grant.rfp.models import RFP, admin_rfp_schema, admin_rfps_schema -from grant.utils.admin import admin_auth_required, admin_is_authed, admin_login, admin_logout +import grant.utils.admin as admin +import grant.utils.auth as auth from grant.utils.misc import make_url from grant.utils.enums import ( ProposalStatus, @@ -40,10 +41,27 @@ from .example_emails import example_email_args blueprint = Blueprint('admin', __name__, url_prefix='/api/v1/admin') +def make_2fa_state(): + return { + "isLoginFresh": admin.is_auth_fresh(), + "has2fa": admin.has_2fa_setup(), + "is2faAuthed": admin.admin_is_2fa_authed(), + "backupCodeCount": admin.backup_code_count(), + "isEmailVerified": auth.is_email_verified(), + } + + +def make_login_state(): + return { + "isLoggedIn": admin.admin_is_authed(), + "is2faAuthed": admin.admin_is_2fa_authed() + } + + @blueprint.route("/checklogin", methods=["GET"]) @endpoint.api() def loggedin(): - return {"isLoggedIn": admin_is_authed()} + return make_login_state() @blueprint.route("/login", methods=["POST"]) @@ -52,22 +70,75 @@ def loggedin(): parameter('password', type=str, required=False), ) def login(username, password): - if admin_login(username, password): - return {"isLoggedIn": True} + if auth.auth_user(username, password): + if admin.admin_is_authed(): + return make_login_state() + return {"message": "Username or password incorrect."}, 401 + + +@blueprint.route("/refresh", methods=["POST"]) +@endpoint.api( + parameter('password', type=str, required=True), +) +def refresh(password): + if auth.refresh_auth(password): + return make_login_state() else: return {"message": "Username or password incorrect."}, 401 +@blueprint.route("/2fa", methods=["GET"]) +@endpoint.api() +def get_2fa(): + if not admin.admin_is_authed(): + return {"message": "Must be authenticated"}, 403 + return make_2fa_state() + + +@blueprint.route("/2fa/init", methods=["GET"]) +@endpoint.api() +def get_2fa_init(): + admin.throw_on_2fa_not_allowed() + return admin.make_2fa_setup() + + +@blueprint.route("/2fa/enable", methods=["POST"]) +@endpoint.api( + parameter('backupCodes', type=list, required=True), + parameter('totpSecret', type=str, required=True), + parameter('verifyCode', type=str, required=True), +) +def post_2fa_enable(backup_codes, totp_secret, verify_code): + admin.throw_on_2fa_not_allowed() + admin.check_and_set_2fa_setup(backup_codes, totp_secret, verify_code) + db.session.commit() + return make_2fa_state() + + +@blueprint.route("/2fa/verify", methods=["POST"]) +@endpoint.api( + parameter('verifyCode', type=str, required=True), +) +def post_2fa_verify(verify_code): + admin.throw_on_2fa_not_allowed(allow_stale=True) + admin.admin_auth_2fa(verify_code) + db.session.commit() + return make_2fa_state() + + @blueprint.route("/logout", methods=["GET"]) @endpoint.api() def logout(): - admin_logout() - return {"isLoggedIn": False} + admin.logout() + return { + "isLoggedIn": False, + "is2faAuthed": False + } @blueprint.route("/stats", methods=["GET"]) @endpoint.api() -@admin_auth_required +@admin.admin_auth_required def stats(): user_count = db.session.query(func.count(User.id)).scalar() proposal_count = db.session.query(func.count(Proposal.id)).scalar() @@ -109,7 +180,7 @@ def stats(): @blueprint.route('/users/', methods=['DELETE']) @endpoint.api() -@admin_auth_required +@admin.admin_auth_required def delete_user(user_id): user = User.query.filter(User.id == user_id).first() if not user: @@ -127,7 +198,7 @@ def delete_user(user_id): parameter('search', type=str, required=False), parameter('sort', type=str, required=False) ) -@admin_auth_required +@admin.admin_auth_required def get_users(page, filters, search, sort): filters_workaround = request.args.getlist('filters[]') page = pagination.user( @@ -143,7 +214,7 @@ def get_users(page, filters, search, sort): @blueprint.route('/users/', methods=['GET']) @endpoint.api() -@admin_auth_required +@admin.admin_auth_required def get_user(id): user_db = User.query.filter(User.id == id).first() if user_db: @@ -164,23 +235,24 @@ def get_user(id): parameter('silenced', type=bool, required=False), parameter('banned', type=bool, required=False), parameter('bannedReason', type=str, required=False), + parameter('isAdmin', type=bool, required=False) ) -@admin_auth_required -def edit_user(user_id, silenced, banned, banned_reason): +@admin.admin_auth_required +def edit_user(user_id, silenced, banned, banned_reason, is_admin): user = User.query.filter(User.id == user_id).first() if not user: return {"message": f"Could not find user with id {id}"}, 404 if silenced is not None: - user.silenced = silenced - db.session.add(user) + user.set_silenced(silenced) if banned is not None: if banned and not banned_reason: # if banned true, provide reason return {"message": "Please include reason for banning"}, 417 - user.banned = banned - user.banned_reason = banned_reason - db.session.add(user) + user.set_banned(banned, banned_reason) + + if is_admin is not None: + user.set_admin(is_admin) db.session.commit() return admin_user_schema.dump(user) @@ -193,7 +265,7 @@ def edit_user(user_id, silenced, banned, banned_reason): @endpoint.api( parameter('search', type=str, required=False), ) -@admin_auth_required +@admin.admin_auth_required def get_arbiters(search): results = [] error = None @@ -217,7 +289,7 @@ def get_arbiters(search): parameter('proposalId', type=int, required=True), parameter('userId', type=int, required=True) ) -@admin_auth_required +@admin.admin_auth_required def set_arbiter(proposal_id, user_id): proposal = Proposal.query.filter(Proposal.id == proposal_id).first() if not proposal: @@ -262,7 +334,7 @@ def set_arbiter(proposal_id, user_id): parameter('search', type=str, required=False), parameter('sort', type=str, required=False) ) -@admin_auth_required +@admin.admin_auth_required def get_proposals(page, filters, search, sort): filters_workaround = request.args.getlist('filters[]') page = pagination.proposal( @@ -278,7 +350,7 @@ def get_proposals(page, filters, search, sort): @blueprint.route('/proposals/', methods=['GET']) @endpoint.api() -@admin_auth_required +@admin.admin_auth_required def get_proposal(id): proposal = Proposal.query.filter(Proposal.id == id).first() if proposal: @@ -288,7 +360,7 @@ def get_proposal(id): @blueprint.route('/proposals/', methods=['DELETE']) @endpoint.api() -@admin_auth_required +@admin.admin_auth_required def delete_proposal(id): return {"message": "Not implemented."}, 400 @@ -297,7 +369,7 @@ def delete_proposal(id): @endpoint.api( parameter('contributionMatching', type=float, required=False, default=None) ) -@admin_auth_required +@admin.admin_auth_required def update_proposal(id, contribution_matching): proposal = Proposal.query.filter(Proposal.id == id).first() if proposal: @@ -315,7 +387,7 @@ def update_proposal(id, contribution_matching): parameter('isApprove', type=bool, required=True), parameter('rejectReason', type=str, required=False) ) -@admin_auth_required +@admin.admin_auth_required def approve_proposal(id, is_approve, reject_reason=None): proposal = Proposal.query.filter_by(id=id).first() if proposal: @@ -330,7 +402,7 @@ def approve_proposal(id, is_approve, reject_reason=None): @endpoint.api( parameter('txId', type=str, required=True), ) -@admin_auth_required +@admin.admin_auth_required def paid_milestone_payout_request(id, mid, tx_id): proposal = Proposal.query.filter_by(id=id).first() if not proposal: @@ -368,7 +440,7 @@ def paid_milestone_payout_request(id, mid, tx_id): @blueprint.route('/email/example/', methods=['GET']) @endpoint.api() -@admin_auth_required +@admin.admin_auth_required def get_email_example(type): email = generate_email(type, example_email_args.get(type)) if email['info'].get('subscription'): @@ -382,7 +454,7 @@ def get_email_example(type): @blueprint.route('/rfps', methods=['GET']) @endpoint.api() -@admin_auth_required +@admin.admin_auth_required def get_rfps(): rfps = RFP.query.all() return admin_rfps_schema.dump(rfps) @@ -398,7 +470,7 @@ def get_rfps(): parameter('matching', type=bool, default=False), parameter('dateCloses', type=int), ) -@admin_auth_required +@admin.admin_auth_required def create_rfp(date_closes, **kwargs): rfp = RFP( **kwargs, @@ -411,7 +483,7 @@ def create_rfp(date_closes, **kwargs): @blueprint.route('/rfps/', methods=['GET']) @endpoint.api() -@admin_auth_required +@admin.admin_auth_required def get_rfp(rfp_id): rfp = RFP.query.filter(RFP.id == rfp_id).first() if not rfp: @@ -431,7 +503,7 @@ def get_rfp(rfp_id): parameter('dateCloses', type=int), parameter('status', type=str), ) -@admin_auth_required +@admin.admin_auth_required def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_closes, status): rfp = RFP.query.filter(RFP.id == rfp_id).first() if not rfp: @@ -461,7 +533,7 @@ def update_rfp(rfp_id, title, brief, content, category, bounty, matching, date_c @blueprint.route('/rfps/', methods=['DELETE']) @endpoint.api() -@admin_auth_required +@admin.admin_auth_required def delete_rfp(rfp_id): rfp = RFP.query.filter(RFP.id == rfp_id).first() if not rfp: @@ -482,7 +554,7 @@ def delete_rfp(rfp_id): parameter('search', type=str, required=False), parameter('sort', type=str, required=False) ) -@admin_auth_required +@admin.admin_auth_required def get_contributions(page, filters, search, sort): filters_workaround = request.args.getlist('filters[]') page = pagination.contribution( @@ -503,7 +575,7 @@ def get_contributions(page, filters, search, sort): parameter('amount', type=str, required=True), parameter('txId', type=str, required=False), ) -@admin_auth_required +@admin.admin_auth_required def create_contribution(proposal_id, user_id, status, amount, tx_id): # Some fields set manually since we're admin, and normally don't do this contribution = ProposalContribution( @@ -526,7 +598,7 @@ def create_contribution(proposal_id, user_id, status, amount, tx_id): @blueprint.route('/contributions/', methods=['GET']) @endpoint.api() -@admin_auth_required +@admin.admin_auth_required def get_contribution(contribution_id): contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first() if not contribution: @@ -544,7 +616,7 @@ def get_contribution(contribution_id): parameter('txId', type=str, required=False), parameter('refundTxId', type=str, required=False), ) -@admin_auth_required +@admin.admin_auth_required def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_id, refund_tx_id): contribution = ProposalContribution.query.filter(ProposalContribution.id == contribution_id).first() if not contribution: @@ -572,7 +644,7 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_ if not ContributionStatus.includes(status): return {"message": "Invalid status"}, 400 contribution.status = status - # Amount (must be a Decimal parseable) + # Amount (must be a Decimal parseable) if amount: try: contribution.amount = str(Decimal(amount)) @@ -605,7 +677,7 @@ def edit_contribution(contribution_id, proposal_id, user_id, status, amount, tx_ parameter('search', type=str, required=False), parameter('sort', type=str, required=False) ) -@admin_auth_required +@admin.admin_auth_required def get_comments(page, filters, search, sort): filters_workaround = request.args.getlist('filters[]') page = pagination.comment( @@ -623,7 +695,7 @@ def get_comments(page, filters, search, sort): parameter('hidden', type=bool, required=False), parameter('reported', type=bool, required=False), ) -@admin_auth_required +@admin.admin_auth_required def edit_comment(comment_id, hidden, reported): comment = Comment.query.filter(Comment.id == comment_id).first() if not comment: diff --git a/backend/grant/app.py b/backend/grant/app.py index 7f8632c6..560581d1 100644 --- a/backend/grant/app.py +++ b/backend/grant/app.py @@ -89,5 +89,4 @@ def register_commands(app): app.cli.add_command(proposal.commands.create_proposal) app.cli.add_command(proposal.commands.create_proposals) app.cli.add_command(user.commands.delete_user) - app.cli.add_command(admin.commands.gen_admin_auth) app.cli.add_command(task.commands.create_task) diff --git a/backend/grant/settings.py b/backend/grant/settings.py index dd105f7d..1dcf6701 100644 --- a/backend/grant/settings.py +++ b/backend/grant/settings.py @@ -52,8 +52,6 @@ LINKEDIN_CLIENT_SECRET = env.str("LINKEDIN_CLIENT_SECRET") BLOCKCHAIN_REST_API_URL = env.str("BLOCKCHAIN_REST_API_URL") BLOCKCHAIN_API_SECRET = env.str("BLOCKCHAIN_API_SECRET") -ADMIN_PASS_HASH = env.str("ADMIN_PASS_HASH") - EXPLORER_URL = env.str("EXPLORER_URL", default="https://explorer.zcha.in/") PROPOSAL_STAKING_AMOUNT = Decimal(env.str("PROPOSAL_STAKING_AMOUNT")) diff --git a/backend/grant/user/models.py b/backend/grant/user/models.py index 6c026e9e..7ef72395 100644 --- a/backend/grant/user/models.py +++ b/backend/grant/user/models.py @@ -1,6 +1,6 @@ from flask_security import UserMixin, RoleMixin from flask_security.core import current_user -from flask_security.utils import hash_password, verify_and_update_password, login_user, logout_user +from flask_security.utils import hash_password, verify_and_update_password, login_user from grant.comment.models import Comment from grant.email.models import EmailVerification, EmailRecovery from grant.email.send import send_email @@ -13,6 +13,7 @@ from grant.extensions import ma, db, security from grant.utils.misc import make_url from grant.utils.social import generate_social_url from grant.utils.upload import extract_avatar_filename, construct_avatar_url +from grant.utils import totp_2fa from sqlalchemy.ext.hybrid import hybrid_property @@ -108,6 +109,9 @@ class User(db.Model, UserMixin): display_name = db.Column(db.String(255), unique=False, nullable=True) title = db.Column(db.String(255), unique=False, nullable=True) active = db.Column(db.Boolean, default=True) + is_admin = db.Column(db.Boolean, default=False, nullable=False, server_default=db.text("FALSE")) + totp_secret = db.Column(db.String(255), nullable=True) + backup_codes = db.Column(db.String(), nullable=True) # moderation silenced = db.Column(db.Boolean, default=False) @@ -176,10 +180,6 @@ class User(db.Model, UserMixin): def get_by_email(email_address: str): return security.datastore.get_user(email_address) - @staticmethod - def logout_current_user(): - logout_user() # logs current user out - def check_password(self, password: str): return verify_and_update_password(password, self) @@ -246,6 +246,31 @@ class User(db.Model, UserMixin): db.session.add(self) db.session.flush() + def set_admin(self, is_admin: bool): + # TODO: audit entry & possibly email user + self.is_admin = is_admin + db.session.add(self) + db.session.flush() + + def set_2fa(self, codes, secret): + self.totp_secret = secret + self.backup_codes = totp_2fa.serialize_backup_codes(codes) + db.session.add(self) + db.session.flush() + + def set_serialized_backup_codes(self, codes): + self.backup_codes = codes + db.session.add(self) + db.session.flush() + + def has_2fa(self): + return self.totp_secret is not None + + def get_backup_code_count(self): + if not self.backup_codes: + return 0 + return len(totp_2fa.deserialize_backup_codes(self.backup_codes)) + class SelfUserSchema(ma.Schema): class Meta: @@ -263,6 +288,7 @@ class SelfUserSchema(ma.Schema): "silenced", "banned", "banned_reason", + "is_admin", ) social_medias = ma.Nested("SocialMediaSchema", many=True) diff --git a/backend/grant/user/views.py b/backend/grant/user/views.py index 03968ce2..8e86c118 100644 --- a/backend/grant/user/views.py +++ b/backend/grant/user/views.py @@ -13,7 +13,7 @@ from grant.proposal.models import ( user_proposals_schema, user_proposal_arbiters_schema ) -from grant.utils.auth import requires_auth, requires_same_user_auth, get_authed_user, throw_on_banned +import grant.utils.auth as auth from grant.utils.exceptions import ValidationException from grant.utils.social import verify_social, get_social_login_url, VerifySocialException from grant.utils.upload import remove_avatar, sign_avatar_upload, AvatarException @@ -54,7 +54,7 @@ def get_users(proposal_id): @blueprint.route("/me", methods=["GET"]) -@requires_auth +@auth.requires_auth @endpoint.api() def get_me(): dumped_user = self_user_schema.dump(g.current_user) @@ -73,7 +73,7 @@ def get_user(user_id, with_proposals, with_comments, with_funded, with_pending, user = User.get_by_id(user_id) if user: result = user_schema.dump(user) - authed_user = get_authed_user() + authed_user = auth.get_authed_user() is_self = authed_user and authed_user.id == user.id if with_proposals: proposals = Proposal.get_by_user(user) @@ -141,18 +141,12 @@ def create_user( parameter('password', type=str, required=True) ) def auth_user(email, password): - existing_user = User.get_by_email(email) - if not existing_user: - return {"message": "No user exists with that email"}, 400 - if not existing_user.check_password(password): - return {"message": "Invalid password"}, 403 - throw_on_banned(existing_user) - existing_user.login() - return self_user_schema.dump(existing_user) + authed_user = auth.auth_user(email, password) + return self_user_schema.dump(authed_user) @blueprint.route("/me/password", methods=["PUT"]) -@requires_auth +@auth.requires_auth @endpoint.api( parameter('currentPassword', type=str, required=True), parameter('password', type=str, required=True), @@ -165,7 +159,7 @@ def update_user_password(current_password, password): @blueprint.route("/me/email", methods=["PUT"]) -@requires_auth +@auth.requires_auth @endpoint.api( parameter('email', type=str, required=True), parameter('password', type=str, required=True) @@ -178,7 +172,7 @@ def update_user_email(email, password): @blueprint.route("/me/resend-verification", methods=["PUT"]) -@requires_auth +@auth.requires_auth @endpoint.api() def resend_email_verification(): g.current_user.send_verification_email() @@ -186,15 +180,15 @@ def resend_email_verification(): @blueprint.route("/logout", methods=["POST"]) -@requires_auth +@auth.requires_auth @endpoint.api() def logout_user(): - User.logout_current_user() + auth.logout_current_user() return None, 200 @blueprint.route("/social//authurl", methods=["GET"]) -@requires_auth +@auth.requires_auth @endpoint.api() def get_user_social_auth_url(service): try: @@ -205,7 +199,7 @@ def get_user_social_auth_url(service): @blueprint.route("/social//verify", methods=["POST"]) -@requires_auth +@auth.requires_auth @endpoint.api( parameter('code', type=str, required=True) ) @@ -239,7 +233,7 @@ def recover_user(email): existing_user = User.get_by_email(email) if not existing_user: return {"message": "No user exists with that email"}, 400 - throw_on_banned(existing_user) + auth.throw_on_banned(existing_user) existing_user.send_recovery_email() return None, 200 @@ -253,7 +247,7 @@ def recover_email(code, password): if er: if er.is_expired(): return {"message": "Reset code expired"}, 401 - throw_on_banned(er.user) + auth.throw_on_banned(er.user) er.user.set_password(password) db.session.delete(er) db.session.commit() @@ -263,7 +257,7 @@ def recover_email(code, password): @blueprint.route("/avatar", methods=["POST"]) -@requires_auth +@auth.requires_auth @endpoint.api( parameter('mimetype', type=str, required=True) ) @@ -277,7 +271,7 @@ def upload_avatar(mimetype): @blueprint.route("/avatar", methods=["DELETE"]) -@requires_auth +@auth.requires_auth @endpoint.api( parameter('url', type=str, required=True) ) @@ -287,8 +281,8 @@ def delete_avatar(url): @blueprint.route("/", methods=["PUT"]) -@requires_auth -@requires_same_user_auth +@auth.requires_auth +@auth.requires_same_user_auth @endpoint.api( parameter('displayName', type=str, required=True), parameter('title', type=str, required=True), @@ -328,7 +322,7 @@ def update_user(user_id, display_name, title, social_medias, avatar): @blueprint.route("//invites", methods=["GET"]) -@requires_same_user_auth +@auth.requires_same_user_auth @endpoint.api() def get_user_invites(user_id): invites = ProposalTeamInvite.get_pending_for_user(g.current_user) @@ -336,7 +330,7 @@ def get_user_invites(user_id): @blueprint.route("//invites//respond", methods=["PUT"]) -@requires_same_user_auth +@auth.requires_same_user_auth @endpoint.api( parameter('response', type=bool, required=True) ) @@ -357,14 +351,14 @@ def respond_to_invite(user_id, invite_id, response): @blueprint.route("//settings", methods=["GET"]) -@requires_same_user_auth +@auth.requires_same_user_auth @endpoint.api() def get_user_settings(user_id): return user_settings_schema.dump(g.current_user.settings) @blueprint.route("//settings", methods=["PUT"]) -@requires_same_user_auth +@auth.requires_same_user_auth @endpoint.api( parameter('emailSubscriptions', type=dict), parameter('refundAddress', type=str) @@ -385,7 +379,7 @@ def set_user_settings(user_id, email_subscriptions, refund_address): @blueprint.route("//arbiter/", methods=["PUT"]) -@requires_same_user_auth +@auth.requires_same_user_auth @endpoint.api( parameter('isAccept', type=bool) ) diff --git a/backend/grant/utils/admin.py b/backend/grant/utils/admin.py index 27ea3d62..5ece3053 100644 --- a/backend/grant/utils/admin.py +++ b/backend/grant/utils/admin.py @@ -1,45 +1,118 @@ from functools import wraps +from datetime import datetime + +from .auth import auth_user, get_authed_user, throw_on_banned, is_auth_fresh, AuthException, logout_current_user, is_email_verified +from .totp_2fa import gen_backup_codes, gen_otp_secret, gen_uri, verify_totp, verify_and_update_backup_codes from hashlib import sha256 from flask import session -from grant.settings import SECRET_KEY, ADMIN_PASS_HASH - -admin_auth = { - "username": "admin", - "password": ADMIN_PASS_HASH, - "salt": SECRET_KEY -} - - -def generate_admin_password_hash(password, salt=None): - if not salt: - salt = admin_auth['salt'] # do this in body to catch testing patch - pass_salt = ('%s%s' % (password, salt)).encode('utf-8') - pass_hash = sha256(pass_salt).hexdigest() - return pass_hash - - -def admin_login(username, password): - pass_hash = generate_admin_password_hash(password) - if username == admin_auth['username'] and pass_hash == admin_auth['password']: - session['admin_username'] = username - return True - return False - - -def admin_logout(): - del session['admin_username'] - return True +from grant.settings import SECRET_KEY +from grant.user.models import User def admin_is_authed(): - return 'admin_username' in session + user = get_authed_user() + return user and user.is_admin or False + + +def admin_is_2fa_authed(): + return 'admin_2fa_authed' in session + + +def admin_set_2fa_session(ok: bool): + if ok: + session['admin_2fa_authed'] = datetime.now() + else: + session.pop('admin_2fa_authed', None) + + +def has_2fa_setup(): + user = get_authed_user() + return user.has_2fa() + + +def backup_code_count(): + user = get_authed_user() + return user.get_backup_code_count() + + +def logout(): + # for admin we remove the 2fa auth + admin_set_2fa_session(False) + # and the normal flask-security logout + logout_current_user() + + +def admin_auth_2fa(code: str): + user = get_authed_user() + if not user.totp_secret: + raise AuthException("User 2fa is not set up, cannot perform 2fa authentication") + + # try TOTP code + ok = verify_totp(user.totp_secret, code) + + # try backup codes + if not ok: + updated_hashes = verify_and_update_backup_codes(code, user.backup_codes) + if updated_hashes is not None: # could be empty list + user.set_serialized_backup_codes(updated_hashes) + ok = True + + # totp and backup both failed + if not ok: + raise AuthException("Bad 2fa code") + + admin_set_2fa_session(ok) + return ok + + +def hash_2fa_setup(codes: tuple, secret: str): + return sha256((''.join(codes) + secret).encode()).hexdigest() + + +def make_2fa_setup(): + codes = gen_backup_codes() + secret = gen_otp_secret() + uri = gen_uri(secret, get_authed_user().email_address) + session['2fa_setup_hash'] = hash_2fa_setup(codes, secret) + return { + "backupCodes": codes, + "totpSecret": secret, + "totpUri": uri, + } + + +def throw_on_2fa_not_allowed(allow_stale=False): + if not admin_is_authed(): + raise AuthException("Must be authenticated") + if not allow_stale and not is_auth_fresh(): + raise AuthException("Login stale") + if not is_email_verified(): + raise AuthException("Email must be verified") + + +def check_and_set_2fa_setup(codes: tuple, secret: str, verify: str): + if '2fa_setup_hash' not in session: + raise AuthException("Could not find a setup hash to check") + existing_hash = session['2fa_setup_hash'] + incomming_hash = hash_2fa_setup(codes, secret) + if existing_hash != incomming_hash: + raise AuthException("Bad hash on 2fa setup") + user = get_authed_user() + # 1. verify code + if not verify_totp(secret, verify): + raise AuthException("Bad verification code") + # 2. save setup to db + user.set_2fa(codes, secret) + # 3. set authed in session + admin_set_2fa_session(True) def admin_auth_required(f): @wraps(f) def decorated(*args, **kwargs): - if admin_is_authed(): + user = get_authed_user() + if admin_is_authed() and admin_is_2fa_authed() and is_email_verified(): return f(*args, **kwargs) else: return {"message": "Authentication required"}, 401 diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index c5722989..68bab2d5 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -1,8 +1,10 @@ from functools import wraps +from datetime import datetime, timedelta import sentry_sdk -from flask import request, g, jsonify +from flask import request, g, jsonify, session from flask_security.core import current_user +from flask_security.utils import logout_user from grant.proposal.models import Proposal from grant.settings import BLOCKCHAIN_API_SECRET from grant.user.models import User @@ -12,7 +14,7 @@ class AuthException(Exception): pass -# use with: @blueprint.errorhandler(AuthException) +# use with app.register_error_handler (app.py) def handle_auth_error(e): return jsonify(message=str(e)), 403 @@ -26,6 +28,44 @@ def throw_on_banned(user): raise AuthException("You are banned") +def is_auth_fresh(minutes: int=20): + if 'last_login_time' in session: + last = session['last_login_time'] + now = datetime.now() + return now - last < timedelta(minutes=minutes) + + +def is_email_verified(): + user = get_authed_user() + return user.email_verification.has_verified + + +def auth_user(email, password): + existing_user = User.get_by_email(email) + if not existing_user: + raise AuthException("No user exists with that email") + if not existing_user.check_password(password): + raise AuthException("Invalid password") + throw_on_banned(existing_user) + existing_user.login() + session['last_login_time'] = datetime.now() + return existing_user + + +def logout_current_user(): + logout_user() + + +def refresh_auth(password): + user = get_authed_user() + if not user: + raise AuthException("Not logged in") + if not user.check_password(password): + raise AuthException("Bad password") + session['last_login_time'] = datetime.now() + return True + + def requires_auth(f): @wraps(f) def decorated(*args, **kwargs): diff --git a/backend/grant/utils/misc.py b/backend/grant/utils/misc.py index c3b88a2c..5416924f 100644 --- a/backend/grant/utils/misc.py +++ b/backend/grant/utils/misc.py @@ -7,6 +7,7 @@ import time from grant.settings import SITE_URL epoch = datetime.datetime.utcfromtimestamp(0) +RANDOM_CHARS = string.ascii_letters + string.digits def dt_from_ms(ms): @@ -24,10 +25,14 @@ def dt_to_unix(dt): def gen_random_code(length=32): return ''.join( - [random.choice(string.ascii_letters + string.digits) for n in range(length)] + [random.choice(RANDOM_CHARS) for n in range(length)] ) +def clean_random_code(code: str): + return ''.join(c for c in code if c in RANDOM_CHARS) + + def make_url(path: str): return f'{SITE_URL}{path}' diff --git a/backend/grant/utils/totp_2fa.py b/backend/grant/utils/totp_2fa.py new file mode 100644 index 00000000..e1e8eed9 --- /dev/null +++ b/backend/grant/utils/totp_2fa.py @@ -0,0 +1,55 @@ +import pyotp +from flask_security.utils import hash_password, verify_password +from .misc import gen_random_code, clean_random_code + +BACKUP_CODE_COUNT = 16 +ISSUER = 'Zcash Grants' + + +def gen_backup_code(): + return f'{gen_random_code(5)}-{gen_random_code(5)}'.lower() + + +def gen_backup_codes(): + return [gen_backup_code() for x in range(BACKUP_CODE_COUNT)] + + +def hash_backup_codes(codes): + return [hash_password(clean_random_code(c)) for c in codes] + + +def serialize_backup_codes(codes: tuple): + hashed = hash_backup_codes(codes) + return ','.join(hashed) + + +def deserialize_backup_codes(codes: str): + return codes.split(',') + + +def verify_and_update_backup_codes(code: str, serialized_codes: str): + hashed = deserialize_backup_codes(serialized_codes) + for i, hc in enumerate(hashed): + if verify_password(clean_random_code(code), hc): + del hashed[i] + return ','.join(hashed) + + return None + + +def gen_otp_secret(): + return pyotp.random_base32() + + +def verify_totp(secret: str, code: str): + totp = pyotp.TOTP(secret) + return totp.verify(code) + + +def current_totp(secret: str): + totp = pyotp.TOTP(secret) + return totp.now() + + +def gen_uri(secret: str, email: str): + return pyotp.totp.TOTP(secret).provisioning_uri(email, issuer_name=ISSUER) diff --git a/backend/migrations/versions/4e5d9f481f22_.py b/backend/migrations/versions/4e5d9f481f22_.py new file mode 100644 index 00000000..7a91f734 --- /dev/null +++ b/backend/migrations/versions/4e5d9f481f22_.py @@ -0,0 +1,28 @@ +"""user is_admin field + +Revision ID: 4e5d9f481f22 +Revises: 3514aaf4648f +Create Date: 2019-02-20 11:30:30.376869 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4e5d9f481f22' +down_revision = '3514aaf4648f' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('is_admin', sa.Boolean(), server_default=sa.text('FALSE'), nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'is_admin') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/9ad68ecf85aa_.py b/backend/migrations/versions/9ad68ecf85aa_.py new file mode 100644 index 00000000..750116cc --- /dev/null +++ b/backend/migrations/versions/9ad68ecf85aa_.py @@ -0,0 +1,30 @@ +"""2fa user fields: backup_codes & totp_secret + +Revision ID: 9ad68ecf85aa +Revises: 4e5d9f481f22 +Create Date: 2019-02-21 13:26:32.715454 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9ad68ecf85aa' +down_revision = '4e5d9f481f22' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('user', sa.Column('backup_codes', sa.String(), nullable=True)) + op.add_column('user', sa.Column('totp_secret', sa.String(length=255), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('user', 'totp_secret') + op.drop_column('user', 'backup_codes') + # ### end Alembic commands ### diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt index 77c087e0..fdf93c60 100644 --- a/backend/requirements/prod.txt +++ b/backend/requirements/prod.txt @@ -70,3 +70,6 @@ Flask-Security==3.0.0 # oauth requests-oauthlib==1.0.0 + +# 2fa - totp +pyotp==2.2.7 diff --git a/backend/tests/admin/test_api.py b/backend/tests/admin/test_api.py index e91496cd..418d3bc5 100644 --- a/backend/tests/admin/test_api.py +++ b/backend/tests/admin/test_api.py @@ -1,69 +1,231 @@ +import json from grant.utils.enums import ProposalStatus -from grant.utils.admin import generate_admin_password_hash +import grant.utils.admin as admin +from grant.utils import totp_2fa from grant.user.models import admin_user_schema -from grant.proposal.models import proposal_schema +from grant.proposal.models import proposal_schema, db from mock import patch from ..config import BaseProposalCreatorConfig from ..test_data import mock_blockchain_api_requests - -plaintext_mock_password = "p4ssw0rd" -mock_admin_auth = { - "username": "admin", - "password": "20cc8f433a1d6400aed9850504c33bfe51ace17ed15d62b0e046b9d7bc4b893b", - "salt": "s4lt" +json_checklogin = { + "isLoggedIn": False, + "is2faAuthed": False, +} +json_checklogin_true = { + "isLoggedIn": True, + "is2faAuthed": True, +} +json_2fa = { + "isLoginFresh": True, + "has2fa": False, + "is2faAuthed": False, + "backupCodeCount": 0, + "isEmailVerified": True, } class TestAdminAPI(BaseProposalCreatorConfig): - @patch.dict('grant.utils.admin.admin_auth', mock_admin_auth) + + def p(self, path, data): + return self.app.post(path, data=json.dumps(data), content_type="application/json") + def login_admin(self): - return self.app.post( - "/api/v1/admin/login", - data={ - "username": mock_admin_auth["username"], - "password": plaintext_mock_password - } - ) + # set admin + self.user.set_admin(True) + db.session.commit() - @patch.dict('grant.utils.admin.admin_auth', mock_admin_auth) - def test_generate_password_hash(self): - # default salt - res = generate_admin_password_hash(plaintext_mock_password) - self.assertEqual(res, mock_admin_auth['password']) - # specific salt - res = generate_admin_password_hash(plaintext_mock_password, mock_admin_auth['salt']) - self.assertEqual(res, mock_admin_auth['password']) - # bad salt - res = generate_admin_password_hash(plaintext_mock_password, 'badsalt') - self.assertNotEqual(res, mock_admin_auth['password']) - # bad pass - res = generate_admin_password_hash('badpassword', mock_admin_auth['salt']) - self.assertNotEqual(res, mock_admin_auth['password']) + # login + r = self.p("/api/v1/admin/login", { + "username": self.user.email_address, + "password": self.user_password + }) + self.assert200(r) - def test_login(self): - resp = self.login_admin() - self.assert200(resp) + # 2fa on the natch + r = self.app.get("/api/v1/admin/2fa") + self.assert200(r) - def test_checklogin_loggedin(self): - self.login_admin() - resp = self.app.get("/api/v1/admin/checklogin") - self.assert200(resp) - self.assertTrue(resp.json["isLoggedIn"]) + # ... init + r = self.app.get("/api/v1/admin/2fa/init") + self.assert200(r) - def test_checklogin_loggedout(self): - resp = self.app.get("/api/v1/admin/checklogin") - self.assert200(resp) - self.assertFalse(resp.json["isLoggedIn"]) + codes = r.json['backupCodes'] + secret = r.json['totpSecret'] + uri = r.json['totpUri'] - def test_logout(self): - self.login_admin() - resp = self.app.get("/api/v1/admin/logout") - self.assert200(resp) - self.assertFalse(resp.json["isLoggedIn"]) - cl_resp = self.app.get("/api/v1/admin/checklogin") - self.assertFalse(cl_resp.json["isLoggedIn"]) + # ... enable/verify + r = self.p("/api/v1/admin/2fa/enable", { + "backupCodes": codes, + "totpSecret": secret, + "verifyCode": totp_2fa.current_totp(secret) + }) + self.assert200(r) + return r + + def r(self, method, path, data=None): + if not data: + return method(path) + + return method(path, data=data) + + def assert_autherror(self, resp, contains): + # this should be 403 + self.assert500(resp) + print(f'...check that [{resp.json["data"]}] contains [{contains}]') + self.assertTrue(contains in resp.json['data']) + + # happy path (mostly) + def test_admin_2fa_setup_flow(self): + # 1. initial checklogin + r = self.app.get("/api/v1/admin/checklogin") + self.assert200(r) + self.assertEqual(json_checklogin, r.json, msg="initial login") + + def send_login(): + return self.p("/api/v1/admin/login", { + "username": self.user.email_address, + "password": self.user_password + }) + + # 2. login attempt (is_admin = False) + r = send_login() + self.assert401(r) + + # 3. make user admin + self.user.set_admin(True) + db.session.commit() + + # 4. login again + r = send_login() + self.assert200(r) + json_checklogin['isLoggedIn'] = True + self.assertEqual(json_checklogin, r.json, msg="login again") + + # 5. get 2fa state (fresh login) + r = self.app.get("/api/v1/admin/2fa") + self.assert200(r) + self.assertEqual(json_2fa, r.json, msg="get 2fa state") + + # 6. get 2fa setup + r = self.app.get("/api/v1/admin/2fa/init") + self.assert200(r) + self.assertTrue('backupCodes' in r.json) + self.assertTrue('totpSecret' in r.json) + self.assertTrue('totpUri' in r.json) + + codes = r.json['backupCodes'] + secret = r.json['totpSecret'] + uri = r.json['totpUri'] + + # 7. enable 2fa (bad hash) + r = self.p("/api/v1/admin/2fa/enable", { + "backupCodes": ['bad-code'], + "totpSecret": "BADSECRET", + "verifyCode": "123456" + }) + self.assert_autherror(r, 'Bad hash') + + # 8. enable 2fa (bad verification code) + r = self.p("/api/v1/admin/2fa/enable", { + "backupCodes": codes, + "totpSecret": secret, + "verifyCode": "123456" + }) + self.assert_autherror(r, 'Bad verification code') + + # 9. enable 2fa (success) + r = self.p("/api/v1/admin/2fa/enable", { + "backupCodes": codes, + "totpSecret": secret, + "verifyCode": totp_2fa.current_totp(secret) + }) + self.assert200(r) + json_2fa['has2fa'] = True + json_2fa['is2faAuthed'] = True + json_2fa['backupCodeCount'] = 16 + self.assertEquals(json_2fa, r.json) + + # 10. check login (logged in) + r = self.app.get("/api/v1/admin/checklogin") + self.assert200(r) + self.assertEqual(json_checklogin_true, r.json, msg="checklogin - logged in") + + # 11. 2fa state (logged in & verified) + r = self.app.get("/api/v1/admin/2fa") + self.assert200(r) + self.assertEqual(json_2fa, r.json, msg="get 2fa state (logged in)") + + # 12. logout + r = self.app.get("/api/v1/admin/logout") + self.assert200(r) + json_checklogin['isLoggedIn'] = False + self.assertEquals(json_checklogin, r.json) + + # 13. 2fa state (logged out) + r = self.app.get("/api/v1/admin/2fa") + self.assert403(r) + + # 14. 2fa verify (fail; logged out) + r = self.p("/api/v1/admin/2fa/verify", {'verifyCode': totp_2fa.current_totp(secret)}) + self.assert_autherror(r, 'Must be auth') + + # 15. login + r = send_login() + self.assert200(r) + + # 16. check login (logged in, not verified) + r = self.app.get("/api/v1/admin/checklogin") + self.assert200(r) + json_checklogin['isLoggedIn'] = True + self.assertEqual(json_checklogin, r.json, msg="checklogin - logged in, not verified") + + # 17. 2fa state (logged in, not verified) + r = self.app.get("/api/v1/admin/2fa") + self.assert200(r) + json_2fa['is2faAuthed'] = False + self.assertEqual(json_2fa, r.json, msg="get 2fa state (logged in, not verified)") + + # 18. 2fa verify (success: logged in) + r = self.p("/api/v1/admin/2fa/verify", {'verifyCode': totp_2fa.current_totp(secret)}) + self.assert200(r) + json_2fa['is2faAuthed'] = True + self.assertEqual(json_2fa, r.json) + + # 19. check login (natural login and verify) + r = self.app.get("/api/v1/admin/checklogin") + self.assert200(r) + self.assertEqual(json_checklogin_true, r.json, msg="checklogin - logged in") + + # 20. logout + r = self.app.get("/api/v1/admin/logout") + self.assert200(r) + + # 21. login + r = send_login() + self.assert200(r) + + # 22. 2fa verify (use backup code) + r = self.p("/api/v1/admin/2fa/verify", {'verifyCode': codes[0]}) + self.assert200(r) + json_2fa['is2faAuthed'] = True + json_2fa['backupCodeCount'] = json_2fa['backupCodeCount'] - 1 + self.assertEqual(json_2fa, r.json) + + # 23. logout + r = self.app.get("/api/v1/admin/logout") + self.assert200(r) + + # 24. login + r = send_login() + self.assert200(r) + + # 25. 2fa verify (fail: re-use backup code) + r = self.p("/api/v1/admin/2fa/verify", {'verifyCode': codes[0]}) + self.assert_autherror(r, 'Bad 2fa code') + + # Here ends the epic of Loginomesh. def test_get_users(self): self.login_admin() diff --git a/backend/tests/user/test_user_api.py b/backend/tests/user/test_user_api.py index e16098af..6e67478c 100644 --- a/backend/tests/user/test_user_api.py +++ b/backend/tests/user/test_user_api.py @@ -104,8 +104,10 @@ class TestUserAPI(BaseUserConfig): }), content_type="application/json" ) - self.assert403(user_auth_resp) - self.assertTrue(user_auth_resp.json['message'] is not None) + # self.assert403(user_auth_resp) + # self.assertTrue(user_auth_resp.json['message'] is not None) + self.assert500(user_auth_resp) + self.assertIn('Invalid pass', user_auth_resp.json['data']) def test_user_auth_bad_email(self): user_auth_resp = self.app.post( @@ -116,8 +118,10 @@ class TestUserAPI(BaseUserConfig): }), content_type="application/json" ) - self.assert400(user_auth_resp) - self.assertTrue(user_auth_resp.json['message'] is not None) + # self.assert400(user_auth_resp) + # self.assertTrue(user_auth_resp.json['message'] is not None) + self.assert500(user_auth_resp) + self.assertIn('No user', user_auth_resp.json['data']) def test_user_auth_banned(self): self.user.set_banned(True, 'reason for banning')