def test_throttle_login(journalist_app, test_journo): with journalist_app.app_context(): journalist = test_journo['journalist'] for _ in range(Journalist._MAX_LOGIN_ATTEMPTS_PER_PERIOD): Journalist.throttle_login(journalist) with pytest.raises(LoginThrottledException): Journalist.throttle_login(journalist)
def set_diceware_password(user: Journalist, password: Optional[str]) -> bool: try: user.set_password(password) except PasswordError: flash( gettext( 'The password you submitted is invalid. Password not changed.' ), 'error') return False try: db.session.commit() except Exception: flash( gettext( 'There was an error, and the new password might not have been ' 'saved correctly. To prevent you from getting locked ' 'out of your account, you should reset your password again.'), 'error') current_app.logger.error('Failed to update a valid password.') return False # using Markup so the HTML isn't escaped flash( Markup("<p>" + gettext("Password updated. Don't forget to " "save it in your KeePassX database. New password:") + ' <span><code>{}</code></span></p>'.format(password)), 'success') return True
def new_journalist(self): # Make a diceware-like password pw = ' '.join([ random_chars(3, nullable=False, chars=DICEWARE_SAFE_CHARS) for _ in range(7) ]) journalist = Journalist(username=random_chars(random.randint(3, 32), nullable=False), password=pw, is_admin=random_bool()) if random_bool(): # to add legacy passwords back in journalist.passphrase_hash = None journalist.pw_salt = random_chars(32, nullable=False).encode('utf-8') journalist.pw_hash = random_chars(64, nullable=False).encode('utf-8') journalist.is_admin = bool_or_none() journalist.is_totp = bool_or_none() journalist.hotp_counter = (random.randint(-1000, 1000) if random_bool() else None) journalist.created_on = random_datetime(nullable=True) journalist.last_access = random_datetime(nullable=True) db.session.add(journalist) db.session.flush() self.journalists.append(journalist.id)
def validate_hotp_secret(user: Journalist, otp_secret: str) -> bool: """ Validates and sets the HOTP provided by a user :param user: the change is for this instance of the User object :param otp_secret: the new HOTP secret :return: True if it validates, False if it does not """ try: user.set_hotp_secret(otp_secret) except (binascii.Error, TypeError) as e: if "Non-hexadecimal digit found" in str(e): flash( gettext("Invalid secret format: " "please only submit letters A-F and numbers 0-9."), "error") return False elif "Odd-length string" in str(e): flash( gettext("Invalid secret format: " "odd-length secret. Did you mistype the secret?"), "error") return False else: flash( gettext("An unexpected error occurred! " "Please inform your admin."), "error") current_app.logger.error( "set_hotp_secret '{}' (id {}) failed: {}".format( otp_secret, user.id, e)) return False return True
def _make_password(): while True: password = current_app.crypto_util.genrandomid(7) try: Journalist.check_password_acceptable(password) return password except PasswordError: continue
def set_name(user: Journalist, first_name: Optional[str], last_name: Optional[str]) -> None: try: user.set_name(first_name, last_name) db.session.commit() flash(gettext('Name updated.'), "success") except FirstOrLastNameError as e: flash(gettext('Name not updated: {}'.format(e)), "error")
def _get_username() -> str: while True: username = obtain_input("Username: "******"Invalid username: " + str(e)) else: return username
def make_password(config: SDConfig) -> str: while True: password = current_app.crypto_util.genrandomid( 7, i18n.get_language(config)) try: Journalist.check_password_acceptable(password) return password except PasswordError: continue
def _get_last_name() -> Optional[str]: while True: last_name = obtain_input("Last name: ") if not last_name: return None try: Journalist.check_name_acceptable(last_name) return last_name except FirstOrLastNameError as e: print("Invalid name: " + str(e))
def _get_last_name(): while True: last_name = obtain_input('Last name: ') if not last_name: return None try: Journalist.check_name_acceptable(last_name) return last_name except FirstOrLastNameError as e: print('Invalid name: ' + str(e))
def _get_first_name() -> Optional[str]: while True: first_name = obtain_input('First name: ') if not first_name: return None try: Journalist.check_name_acceptable(first_name) return first_name except FirstOrLastNameError as e: print('Invalid name: ' + str(e))
def test_totp_reuse_protections3(journalist_app, test_journo, hardening): """We want to ensure that padding has no effect on token reuse verification.""" with totp_window(): token = TOTP(test_journo["otp_secret"]).now() with journalist_app.app_context(): Journalist.login(test_journo["username"], test_journo["password"], token) with pytest.raises(BadTokenException): Journalist.login(test_journo["username"], test_journo["password"], token + " ")
def add_test_user(username: str, password: str, otp_secret: str, is_admin: bool = False, first_name: str = "", last_name: str = "") -> Journalist: user = Journalist(username=username, password=password, is_admin=is_admin, first_name=first_name, last_name=last_name) user.otp_secret = otp_secret db.session.add(user) db.session.commit() print('Test user successfully added: ' 'username={}, password={}, otp_secret={}, is_admin={}' ''.format(username, password, otp_secret, is_admin)) return user
def add_test_user(username, password, otp_secret, is_admin=False): try: user = Journalist(username=username, password=password, is_admin=is_admin) user.otp_secret = otp_secret db.session.add(user) db.session.commit() print('Test user successfully added: ' 'username={}, password={}, otp_secret={}, is_admin={}' ''.format(username, password, otp_secret, is_admin)) except IntegrityError: print("Test user already added") db.session.rollback()
def test_totp_reuse_protections2(journalist_app, test_journo, hardening): """More granular than the preceeding test, we want to make sure the right exception is being raised in the right place. """ with totp_window(): token = TOTP(test_journo["otp_secret"]).now() with journalist_app.app_context(): Journalist.login(test_journo["username"], test_journo["password"], token) with pytest.raises(BadTokenException): Journalist.login(test_journo["username"], test_journo["password"], token)
def test_valid_user_can_get_an_api_token(journalist_app, test_journo): with journalist_app.test_client() as app: valid_token = TOTP(test_journo["otp_secret"]).now() response = app.post( url_for("api.get_token"), data=json.dumps( { "username": test_journo["username"], "passphrase": test_journo["password"], "one_time_code": valid_token, } ), headers=get_api_headers(), ) assert response.json["journalist_uuid"] == test_journo["uuid"] assert ( isinstance( Journalist.validate_api_token_and_get_user(response.json["token"]), Journalist ) is True ) assert response.status_code == 200 assert response.json["journalist_first_name"] == test_journo["first_name"] assert response.json["journalist_last_name"] == test_journo["last_name"] assert_valid_timestamp(response.json["expiration"])
def get_user_object(request): """Helper function to use in token_required views that need a user object """ auth_token = request.headers.get('Authorization').split(" ")[1] user = Journalist.validate_api_token_and_get_user(auth_token) return user
def validate_user( username: str, password: Optional[str], token: Optional[str], error_message: Optional[str] = None, ) -> Optional[Journalist]: """ Validates the user by calling the login and handling exceptions :param username: Username :param password: Password :param token: Two-factor authentication token :param error_message: Localized error message string to use on failure :return: Journalist user object if successful, None otherwise. """ try: return Journalist.login(username, password, token) except ( InvalidUsernameException, InvalidOTPSecretException, BadTokenException, WrongPasswordException, LoginThrottledException, InvalidPasswordLength, ) as e: current_app.logger.error("Login for '{}' failed: {}".format( username, e)) login_flashed_msg = error_message if error_message else gettext( "Login failed.") if isinstance(e, LoginThrottledException): login_flashed_msg += " " period = Journalist._LOGIN_ATTEMPT_PERIOD # ngettext is needed although we always have period > 1 # see https://github.com/freedomofpress/securedrop/issues/2422 login_flashed_msg += ngettext( "Please wait at least {num} second before logging in again.", "Please wait at least {num} seconds before logging in again.", period, ).format(num=period) elif isinstance(e, InvalidOTPSecretException): login_flashed_msg += " " login_flashed_msg += gettext( "Your 2FA details are invalid" " - please contact an administrator to reset them.") else: try: user = Journalist.query.filter_by(username=username).one() if user.is_totp: login_flashed_msg += " " login_flashed_msg += gettext( "Please wait for a new code from your two-factor mobile" " app or security key before trying again.") except Exception: pass flash(login_flashed_msg, "error") return None
def add_user() -> Union[str, werkzeug.Response]: form = NewUserForm() if form.validate_on_submit(): form_valid = True username = request.form['username'] first_name = request.form['first_name'] last_name = request.form['last_name'] password = request.form['password'] is_admin = bool(request.form.get('is_admin')) try: otp_secret = None if request.form.get('is_hotp', False): otp_secret = request.form.get('otp_secret', '') new_user = Journalist(username=username, password=password, first_name=first_name, last_name=last_name, is_admin=is_admin, otp_secret=otp_secret) db.session.add(new_user) db.session.commit() except PasswordError: flash( gettext( 'There was an error with the autogenerated password. ' 'User not created. Please try again.'), 'error') form_valid = False except InvalidUsernameException as e: form_valid = False flash('Invalid username: '******'Username "{user}" already taken.'.format( user=username)), "error") else: flash( gettext("An error occurred saving this user" " to the database." " Please inform your admin."), "error") current_app.logger.error("Adding user " "'{}' failed: {}".format( username, e)) if form_valid: return redirect( url_for('admin.new_user_two_factor', uid=new_user.id)) password = PassphraseGenerator.get_default().generate_passphrase( preferred_language=g.localeinfo.language) return render_template("admin_add_user.html", password=password, form=form)
def register(): form = RegistrationForm(meta={'csrf': False}) if form.validate_on_submit(): try: user = Journalist(name=form.username.data, email=form.email.data, surname=form.user_surname.data) app.logger.info(f'try register user "{form.username.data}" ' f'"{form.user_surname.data}" "{form.email.data}"') user.set_password(form.password.data) db.session.add(user) db.session.commit() except Exception as e: flash("Please enter a valid registration data") app.logger.error(f'registration failed: {e}') else: flash("You successfully register!") app.logger.info('registration successful') return render_template('register.html', title='Register', form=form)
def validate_hotp_secret(user: Journalist, otp_secret: str) -> bool: """ Validates and sets the HOTP provided by a user :param user: the change is for this instance of the User object :param otp_secret: the new HOTP secret :return: True if it validates, False if it does not """ strip_whitespace = otp_secret.replace(" ", "") secret_length = len(strip_whitespace) if secret_length != HOTP_SECRET_LENGTH: flash( ngettext( "HOTP secrets are 40 characters long - you have entered {num}.", "HOTP secrets are 40 characters long - you have entered {num}.", secret_length, ).format(num=secret_length), "error", ) return False try: user.set_hotp_secret(otp_secret) except (binascii.Error, TypeError) as e: if "Non-hexadecimal digit found" in str(e): flash( gettext("Invalid HOTP secret format: " "please only submit letters A-F and numbers 0-9."), "error", ) return False else: flash( gettext("An unexpected error occurred! " "Please inform your admin."), "error") current_app.logger.error( "set_hotp_secret '{}' (id {}) failed: {}".format( otp_secret, user.id, e)) return False return True
def edit_user(user_id: int) -> Union[str, werkzeug.Response]: user = Journalist.query.get(user_id) if request.method == "POST": if request.form.get("username", None): new_username = request.form["username"] try: Journalist.check_username_acceptable(new_username) except InvalidUsernameException as e: flash( gettext("Invalid username: {message}").format( message=e), "error") return redirect(url_for("admin.edit_user", user_id=user_id)) if new_username == user.username: pass elif Journalist.query.filter_by( username=new_username).one_or_none(): flash( gettext('Username "{username}" already taken.').format( username=new_username), "error", ) return redirect(url_for("admin.edit_user", user_id=user_id)) else: user.username = new_username try: first_name = request.form["first_name"] Journalist.check_name_acceptable(first_name) user.first_name = first_name except FirstOrLastNameError as e: # Translators: Here, "{message}" explains the problem with the name. flash( gettext("Name not updated: {message}").format(message=e), "error") return redirect(url_for("admin.edit_user", user_id=user_id)) try: last_name = request.form["last_name"] Journalist.check_name_acceptable(last_name) user.last_name = last_name except FirstOrLastNameError as e: flash( gettext("Name not updated: {message}").format(message=e), "error") return redirect(url_for("admin.edit_user", user_id=user_id)) user.is_admin = bool(request.form.get("is_admin")) commit_account_changes(user) password = PassphraseGenerator.get_default().generate_passphrase( preferred_language=g.localeinfo.language) return render_template("edit_account.html", user=user, password=password)
def cleanup_expired_revoked_tokens() -> None: """Remove tokens that have now expired from the revoked token table.""" revoked_tokens = db.session.query(RevokedToken).all() for revoked_token in revoked_tokens: if Journalist.validate_token_is_not_expired_or_invalid( revoked_token.token): pass # The token has not expired, we must keep in the revoked token table. else: # The token is no longer valid, remove from the revoked token table. db.session.delete(revoked_token) db.session.commit()
def set_diceware_password(user: Journalist, password: Optional[str]) -> bool: try: # nosemgrep: python.django.security.audit.unvalidated-password.unvalidated-password user.set_password(password) except PasswordError: flash( gettext( "The password you submitted is invalid. Password not changed." ), "error") return False try: db.session.commit() except Exception: flash( gettext( "There was an error, and the new password might not have been " "saved correctly. To prevent you from getting locked " "out of your account, you should reset your password again."), "error", ) current_app.logger.error("Failed to update a valid password.") return False # using Markup so the HTML isn't escaped flash( Markup("<p>{message} <span><code>{password}</code></span></p>".format( message=Markup.escape( gettext( "Password updated. Don't forget to save it in your KeePassX database. " "New password:"******"" if password is None else password), )), "success", ) return True
def decorated_function(*args, **kwargs): try: auth_header = request.headers['Authorization'] except KeyError: return abort(403, 'API token not found in Authorization header.') if auth_header: split = auth_header.split(" ") if len(split) != 2 or split[0] != 'Token': abort(403, 'Malformed authorization header.') auth_token = split[1] else: auth_token = '' if not Journalist.validate_api_token_and_get_user(auth_token): return abort(403, 'API token is invalid or expired.') return f(*args, **kwargs)
def test_valid_user_can_get_an_api_token(journalist_app, test_journo): with journalist_app.test_client() as app: valid_token = TOTP(test_journo['otp_secret']).now() response = app.post(url_for('api.get_token'), data=json.dumps( {'username': test_journo['username'], 'passphrase': test_journo['password'], 'one_time_code': valid_token}), headers=get_api_headers()) assert response.json['journalist_uuid'] == test_journo['uuid'] assert isinstance(Journalist.validate_api_token_and_get_user( response.json['token']), Journalist) is True assert response.status_code == 200 assert response.json['journalist_first_name'] == test_journo['first_name'] assert response.json['journalist_last_name'] == test_journo['last_name']
def get_token() -> Tuple[flask.Response, int]: creds = json.loads(request.data.decode("utf-8")) username = creds.get("username", None) passphrase = creds.get("passphrase", None) one_time_code = creds.get("one_time_code", None) if username is None: abort(400, "username field is missing") if passphrase is None: abort(400, "passphrase field is missing") if one_time_code is None: abort(400, "one_time_code field is missing") try: journalist = Journalist.login(username, passphrase, one_time_code) token_expiry = datetime.now( timezone.utc) + timedelta(seconds=TOKEN_EXPIRATION_MINS * 60) response = jsonify({ "token": journalist.generate_api_token( expiration=TOKEN_EXPIRATION_MINS * 60), "expiration": token_expiry, "journalist_uuid": journalist.uuid, "journalist_first_name": journalist.first_name, "journalist_last_name": journalist.last_name, }) # Update access metadata journalist.last_access = datetime.now(timezone.utc) db.session.add(journalist) db.session.commit() return response, 200 except ( LoginThrottledException, InvalidUsernameException, BadTokenException, InvalidOTPSecretException, WrongPasswordException, ): return abort(403, "Token authentication failed.")
def _authenticate_user_from_auth_header(request: flask.Request) -> Journalist: try: auth_header = request.headers['Authorization'] except KeyError: return abort(403, 'API token not found in Authorization header.') if auth_header: split = auth_header.split(" ") if len(split) != 2 or split[0] != 'Token': abort(403, 'Malformed authorization header.') auth_token = split[1] else: auth_token = '' authenticated_user = Journalist.validate_api_token_and_get_user(auth_token) if not authenticated_user: return abort(403, 'API token is invalid or expired.') return authenticated_user
def add_journalist( username: str = "", is_admin: bool = False, first_name: str = "", last_name: str = "", progress: Optional[Tuple[int, int]] = None, ) -> Journalist: """ Adds a single journalist account. """ test_password = "******" test_otp_secret = "JHCOGO7VCER3EJ4L" if not username: username = current_app.crypto_util.display_id() journalist = Journalist( username=username, password=test_password, first_name=first_name, last_name=last_name, is_admin=is_admin, ) journalist.otp_secret = test_otp_secret if random_bool(): # to add legacy passwords back in journalist.passphrase_hash = None salt = random_chars(32).encode("utf-8") journalist.pw_salt = salt journalist.pw_hash = journalist._scrypt_hash(test_password, salt) db.session.add(journalist) attempt = JournalistLoginAttempt(journalist) attempt.timestamp = random_datetime(nullable=True) db.session.add(attempt) db.session.commit() print( "Created {}journalist{} (username={}, password={}, otp_secret={}, is_admin={})".format( "additional " if progress else "", " {}/{}".format(*progress) if progress else "", username, test_password, test_otp_secret, is_admin, ) ) return journalist
def edit_user(user_id: int) -> Union[str, werkzeug.Response]: user = Journalist.query.get(user_id) if request.method == 'POST': if request.form.get('username', None): new_username = request.form['username'] try: Journalist.check_username_acceptable(new_username) except InvalidUsernameException as e: flash('Invalid username: '******'error') return redirect(url_for("admin.edit_user", user_id=user_id)) if new_username == user.username: pass elif Journalist.query.filter_by( username=new_username).one_or_none(): flash( gettext('Username "{user}" already taken.').format( user=new_username), "error") return redirect(url_for("admin.edit_user", user_id=user_id)) else: user.username = new_username try: first_name = request.form['first_name'] Journalist.check_name_acceptable(first_name) user.first_name = first_name except FirstOrLastNameError as e: flash(gettext('Name not updated: {}'.format(e)), "error") return redirect(url_for("admin.edit_user", user_id=user_id)) try: last_name = request.form['last_name'] Journalist.check_name_acceptable(last_name) user.last_name = last_name except FirstOrLastNameError as e: flash(gettext('Name not updated: {}'.format(e)), "error") return redirect(url_for("admin.edit_user", user_id=user_id)) user.is_admin = bool(request.form.get('is_admin')) commit_account_changes(user) password = PassphraseGenerator.get_default().generate_passphrase( preferred_language=g.localeinfo.language) return render_template("edit_account.html", user=user, password=password)