diff --git a/admin/src/components/MFAuth/index.tsx b/admin/src/components/MFAuth/index.tsx index 047370f8..8d6a28b0 100644 --- a/admin/src/components/MFAuth/index.tsx +++ b/admin/src/components/MFAuth/index.tsx @@ -23,6 +23,7 @@ const STATE = { totpUri: '', is2faAuthed: false, backupCodeCount: 0, + isEmailVerified: false, // local loaded: false, hasReadSetup: false, @@ -58,9 +59,24 @@ class MFAuth extends React.Component<{}, State> { showQrCode, isVerifying, backupCodeCount, + isEmailVerified, } = this.state; + 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 && ( { const wrap = (children: ReactNode) => (
- <> - {lowBackupCodesWarning} - {children} - + {emailNotVerifiedWarning || ( + <> + {lowBackupCodesWarning} + {children} + + )}
); diff --git a/backend/grant/admin/views.py b/backend/grant/admin/views.py index 7b0e99c4..5ae8c51c 100644 --- a/backend/grant/admin/views.py +++ b/backend/grant/admin/views.py @@ -41,13 +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.admin_is_authed(), - "is2faAuthed": admin.admin_is_2fa_authed(), - } + return make_login_state() @blueprint.route("/login", methods=["POST"]) @@ -58,10 +72,7 @@ def loggedin(): def login(username, password): if auth.auth_user(username, password): if admin.admin_is_authed(): - return { - "isLoggedIn": admin.admin_is_authed(), - "is2faAuthed": admin.admin_is_2fa_authed() - } + return make_login_state() return {"message": "Username or password incorrect."}, 401 @@ -71,23 +82,11 @@ def login(username, password): ) def refresh(password): if auth.refresh_auth(password): - return { - "isLoggedIn": admin.admin_is_authed(), - "is2faAuthed": admin.admin_is_2fa_authed() - } + return make_login_state() else: return {"message": "Username or password incorrect."}, 401 -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(), - } - - @blueprint.route("/2fa", methods=["GET"]) @endpoint.api() def get_2fa(): @@ -99,10 +98,7 @@ def get_2fa(): @blueprint.route("/2fa/init", methods=["GET"]) @endpoint.api() def get_2fa_init(): - if not admin.admin_is_authed(): - return {"message": "Must be authenticated"}, 403 - if not admin.is_auth_fresh(): - return {"message": "Login stale"}, 403 + admin.throw_on_2fa_not_allowed() return admin.make_2fa_setup() @@ -113,10 +109,7 @@ def get_2fa_init(): parameter('verifyCode', type=str, required=True), ) def post_2fa_enable(backup_codes, totp_secret, verify_code): - if not admin.admin_is_authed(): - return {"message": "Must be authenticated"}, 403 - if not admin.is_auth_fresh(): - return {"message": "Login stale"}, 403 + 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() @@ -127,10 +120,7 @@ def post_2fa_enable(backup_codes, totp_secret, verify_code): parameter('verifyCode', type=str, required=True), ) def post_2fa_verify(verify_code): - if not admin.admin_is_authed(): - return {"message": "Must be authenticated"}, 403 - if not admin.is_auth_fresh(): - return {"message": "Login stale"}, 403 + admin.throw_on_2fa_not_allowed() admin.admin_auth_2fa(verify_code) db.session.commit() return make_2fa_state() diff --git a/backend/grant/utils/admin.py b/backend/grant/utils/admin.py index 505063fd..48501b84 100644 --- a/backend/grant/utils/admin.py +++ b/backend/grant/utils/admin.py @@ -1,7 +1,7 @@ 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 +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 @@ -82,6 +82,15 @@ def make_2fa_setup(): } +def throw_on_2fa_not_allowed(): + if not admin_is_authed(): + raise AuthException("Must be authenticated") + if 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") diff --git a/backend/grant/utils/auth.py b/backend/grant/utils/auth.py index 2a8432f3..68bab2d5 100644 --- a/backend/grant/utils/auth.py +++ b/backend/grant/utils/auth.py @@ -35,6 +35,11 @@ def is_auth_fresh(minutes: int=20): 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: diff --git a/backend/tests/admin/test_api.py b/backend/tests/admin/test_api.py index 71e48f5f..418d3bc5 100644 --- a/backend/tests/admin/test_api.py +++ b/backend/tests/admin/test_api.py @@ -21,7 +21,8 @@ json_2fa = { "isLoginFresh": True, "has2fa": False, "is2faAuthed": False, - "backupCodeCount": 0 + "backupCodeCount": 0, + "isEmailVerified": True, } @@ -168,7 +169,7 @@ class TestAdminAPI(BaseProposalCreatorConfig): # 14. 2fa verify (fail; logged out) r = self.p("/api/v1/admin/2fa/verify", {'verifyCode': totp_2fa.current_totp(secret)}) - self.assert403(r) + self.assert_autherror(r, 'Must be auth') # 15. login r = send_login()