Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Unified server side sessions for JI and API #6403

Merged
merged 9 commits into from Sep 27, 2022
@@ -0,0 +1,99 @@
"""dropped session_nonce from journalist table and revoked tokens table due to new session implementation
legoktm marked this conversation as resolved.
Show resolved Hide resolved

Revision ID: c5a02eb52f2d
Revises: b7f98cfd6a70
Create Date: 2022-04-16 21:25:22.398189

"""
import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "c5a02eb52f2d"
down_revision = "b7f98cfd6a70"
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("revoked_tokens")
with op.batch_alter_table("journalists", schema=None) as batch_op:
batch_op.drop_column("session_nonce")

# ### end Alembic commands ###


def downgrade() -> None:
"""This would have been the easy way, however previous does not have
default value and thus up/down assertion fails"""
# op.add_column('journalists', sa.Column('session_nonce', sa.Integer(), nullable=False, server_default='0'))

conn = op.get_bind()
conn.execute("PRAGMA legacy_alter_table=ON")
# Save existing journalist table.
op.rename_table("journalists", "journalists_tmp")

# Add nonce column.
op.add_column("journalists_tmp", sa.Column("session_nonce", sa.Integer()))

# Populate nonce column.
journalists = conn.execute(sa.text("SELECT * FROM journalists_tmp")).fetchall()

for journalist in journalists:
conn.execute(
sa.text(
"""UPDATE journalists_tmp SET session_nonce=0 WHERE
id=:id"""
).bindparams(id=journalist.id)
)

# Now create new table with null constraint applied.
op.create_table(
"journalists",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("uuid", sa.String(length=36), nullable=False),
sa.Column("username", sa.String(length=255), nullable=False),
sa.Column("first_name", sa.String(length=255), nullable=True),
sa.Column("last_name", sa.String(length=255), nullable=True),
sa.Column("pw_salt", sa.Binary(), nullable=True),
sa.Column("pw_hash", sa.Binary(), nullable=True),
sa.Column("passphrase_hash", sa.String(length=256), nullable=True),
sa.Column("is_admin", sa.Boolean(), nullable=True),
sa.Column("session_nonce", sa.Integer(), nullable=False),
sa.Column("otp_secret", sa.String(length=32), nullable=True),
sa.Column("is_totp", sa.Boolean(), nullable=True),
sa.Column("hotp_counter", sa.Integer(), nullable=True),
sa.Column("last_token", sa.String(length=6), nullable=True),
sa.Column("created_on", sa.DateTime(), nullable=True),
sa.Column("last_access", sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("username"),
sa.UniqueConstraint("uuid"),
)

conn.execute(
"""
INSERT INTO journalists
SELECT id, uuid, username, first_name, last_name, pw_salt, pw_hash,
passphrase_hash, is_admin, session_nonce, otp_secret, is_totp,
hotp_counter, last_token, created_on, last_access
FROM journalists_tmp
"""
)

# Now delete the old table.
op.drop_table("journalists_tmp")

op.create_table(
"revoked_tokens",
sa.Column("id", sa.INTEGER(), nullable=False),
sa.Column("journalist_id", sa.INTEGER(), nullable=False),
sa.Column("token", sa.TEXT(), nullable=False),
sa.ForeignKeyConstraint(
["journalist_id"],
["journalists.id"],
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token"),
)
2 changes: 1 addition & 1 deletion securedrop/i18n.py
Expand Up @@ -214,7 +214,7 @@ def get_locale(config: SDConfig) -> str:
- config.FALLBACK_LOCALE
"""
preferences = []
if session.get("locale"):
if session and session.get("locale"):
preferences.append(session.get("locale"))
if request.args.get("l"):
preferences.insert(0, request.args.get("l"))
Expand Down
49 changes: 12 additions & 37 deletions securedrop/journalist_app/__init__.py
@@ -1,25 +1,21 @@
# -*- coding: utf-8 -*-

import typing
from datetime import datetime, timedelta, timezone
from datetime import datetime
from os import path
from pathlib import Path

import i18n
import template_filters
import version
from db import db
from flask import Flask, flash, g, json, redirect, render_template, request, session, url_for
from flask import Flask, abort, g, json, redirect, render_template, request, url_for
from flask_babel import gettext
from flask_wtf.csrf import CSRFError, CSRFProtect
from journalist_app import account, admin, api, col, main
from journalist_app.utils import (
JournalistInterfaceSessionInterface,
cleanup_expired_revoked_tokens,
get_source,
logged_in,
)
from models import InstanceConfig, Journalist
from journalist_app.sessions import Session, session
lsd-cat marked this conversation as resolved.
Show resolved Hide resolved
from journalist_app.utils import get_source
from models import InstanceConfig
from werkzeug.exceptions import default_exceptions

# https://www.python.org/dev/peps/pep-0484/#runtime-or-type-checking
Expand All @@ -35,6 +31,7 @@
from werkzeug.exceptions import HTTPException # noqa: F401

_insecure_views = ["main.login", "static"]
_insecure_api_views = ["api.get_token", "api.get_endpoints"]
# Timezone-naive datetime format expected by SecureDrop Client
API_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"

Expand Down Expand Up @@ -63,8 +60,8 @@ def create_app(config: "SDConfig") -> Flask:
)

app.config.from_object(config.JOURNALIST_APP_FLASK_CONFIG_CLS)
app.session_interface = JournalistInterfaceSessionInterface()

Session(app)
csrf = CSRFProtect(app)

app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
Expand All @@ -86,9 +83,8 @@ def default(self, obj: "Any") -> "Any":
@app.errorhandler(CSRFError) # type: ignore
def handle_csrf_error(e: CSRFError) -> "Response":
app.logger.error("The CSRF token is invalid.")
session.clear()
msg = gettext("You have been logged out due to inactivity.")
flash(msg, "error")
session.destroy(("error", msg), session.get("locale"))
return redirect(url_for("main.login"))

def _handle_http_exception(
Expand Down Expand Up @@ -118,35 +114,13 @@ def _handle_http_exception(
app.jinja_env.filters["html_datetime_format"] = template_filters.html_datetime_format
app.jinja_env.add_extension("jinja2.ext.do")

@app.before_first_request
def expire_blacklisted_tokens() -> None:
cleanup_expired_revoked_tokens()

@app.before_request
def update_instance_config() -> None:
InstanceConfig.get_default(refresh=True)

@app.before_request
def setup_g() -> "Optional[Response]":
"""Store commonly used values in Flask's special g object"""
if "expires" in session and datetime.now(timezone.utc) >= session["expires"]:
session.clear()
flash(gettext("You have been logged out due to inactivity."), "error")

uid = session.get("uid", None)
if uid:
user = Journalist.query.get(uid)
if user and "nonce" in session and session["nonce"] != user.session_nonce:
session.clear()
flash(gettext("You have been logged out due to password change"), "error")

session["expires"] = datetime.now(timezone.utc) + timedelta(
minutes=getattr(config, "SESSION_EXPIRATION_MINUTES", 120)
)

uid = session.get("uid", None)
if uid:
g.user = Journalist.query.get(uid) # pylint: disable=assigning-non-slot

i18n.set_locale(config)

Expand All @@ -163,9 +137,10 @@ def setup_g() -> "Optional[Response]":
app.logger.error("Site logo not found.")

if request.path.split("/")[1] == "api":
pass # We use the @token_required decorator for the API endpoints
else: # We are not using the API
if request.endpoint not in _insecure_views and not logged_in():
if request.endpoint not in _insecure_api_views and not session.logged_in():
abort(403)
else:
if request.endpoint not in _insecure_views and not session.logged_in():
return redirect(url_for("main.login"))

if request.method == "POST":
Expand Down
26 changes: 13 additions & 13 deletions securedrop/journalist_app/account.py
Expand Up @@ -3,8 +3,9 @@

import werkzeug
from db import db
from flask import Blueprint, flash, g, redirect, render_template, request, session, url_for
from flask import Blueprint, current_app, flash, g, redirect, render_template, request, url_for
from flask_babel import gettext
from journalist_app.sessions import session
from journalist_app.utils import (
set_diceware_password,
set_name,
Expand All @@ -29,29 +30,28 @@ def edit() -> str:
def change_name() -> werkzeug.Response:
first_name = request.form.get("first_name")
last_name = request.form.get("last_name")
set_name(g.user, first_name, last_name)
set_name(session.get_user(), first_name, last_name)
return redirect(url_for("account.edit"))

@view.route("/new-password", methods=("POST",))
def new_password() -> werkzeug.Response:
user = g.user
user = session.get_user()
current_password = request.form.get("current_password")
token = request.form.get("token")
error_message = gettext("Incorrect password or two-factor code.")
# If the user is validated, change their password
if validate_user(user.username, current_password, token, error_message):
password = request.form.get("password")
set_diceware_password(user, password)
session.pop("uid", None)
session.pop("expires", None)
return redirect(url_for("main.login"))
if set_diceware_password(user, password):
current_app.session_interface.logout_user(user.id) # type: ignore
return redirect(url_for("main.login"))
return redirect(url_for("account.edit"))

@view.route("/2fa", methods=("GET", "POST"))
def new_two_factor() -> Union[str, werkzeug.Response]:
if request.method == "POST":
token = request.form["token"]
if g.user.verify_token(token):
if session.get_user().verify_token(token):
flash(
gettext("Your two-factor credentials have been reset successfully."),
"notification",
Expand All @@ -63,22 +63,22 @@ def new_two_factor() -> Union[str, werkzeug.Response]:
"error",
)

return render_template("account_new_two_factor.html", user=g.user)
return render_template("account_new_two_factor.html", user=session.get_user())

@view.route("/reset-2fa-totp", methods=["POST"])
def reset_two_factor_totp() -> werkzeug.Response:
g.user.is_totp = True
g.user.regenerate_totp_shared_secret()
session.get_user().is_totp = True
session.get_user().regenerate_totp_shared_secret()
db.session.commit()
return redirect(url_for("account.new_two_factor"))

@view.route("/reset-2fa-hotp", methods=["POST"])
def reset_two_factor_hotp() -> Union[str, werkzeug.Response]:
otp_secret = request.form.get("otp_secret", None)
if otp_secret:
if not validate_hotp_secret(g.user, otp_secret):
if not validate_hotp_secret(session.get_user(), otp_secret):
return render_template("account_edit_hotp_secret.html")
g.user.set_hotp_secret(otp_secret)
session.get_user().set_hotp_secret(otp_secret)
db.session.commit()
return redirect(url_for("account.new_two_factor"))
else:
Expand Down