Skip to content

Commit

Permalink
Merge pull request #6403 from lsd-cat/server-sessions
Browse files Browse the repository at this point in the history
Add unified server side sessions for the JI and API
  • Loading branch information
zenmonkeykstop authored Sep 27, 2022
2 parents 289c516 + 4e7a347 commit c23140c
Show file tree
Hide file tree
Showing 25 changed files with 1,784 additions and 584 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""dropped session_nonce from journalist table and revoked tokens table due to new session implementation
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
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
Original file line number Diff line number Diff line change
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
Loading

0 comments on commit c23140c

Please sign in to comment.