Merge pull request #834 from dod-ccpo/limit-concurrent-logins

Prevent multiple active sessions
This commit is contained in:
richard-dds 2019-05-29 16:19:58 -04:00 committed by GitHub
commit 6a504fdf89
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 130 additions and 9 deletions

View File

@ -0,0 +1,28 @@
"""add_user_last_session_id
Revision ID: ab1167fc8260
Revises: c5deba1826be
Create Date: 2019-05-15 16:25:48.766451
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'ab1167fc8260'
down_revision = 'c5deba1826be'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('last_session_id', postgresql.UUID(as_uuid=True), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'last_session_id')
# ### end Alembic commands ###

View File

@ -2,7 +2,7 @@ import os
import re import re
import pathlib import pathlib
from configparser import ConfigParser from configparser import ConfigParser
from flask import Flask, request, g from flask import Flask, request, g, session
from flask_session import Session from flask_session import Session
import redis import redis
from unipath import Path from unipath import Path
@ -30,6 +30,7 @@ from atst.utils.form_cache import FormCache
from atst.utils.json import CustomJSONEncoder from atst.utils.json import CustomJSONEncoder
from atst.queue import queue from atst.queue import queue
from atst.utils.notification_sender import NotificationSender from atst.utils.notification_sender import NotificationSender
from atst.utils.session_limiter import SessionLimiter
from logging.config import dictConfig from logging.config import dictConfig
from atst.utils.logging import JsonFormatter, RequestContextFilter from atst.utils.logging import JsonFormatter, RequestContextFilter
@ -70,6 +71,7 @@ def make_app(config):
db.init_app(app) db.init_app(app)
csrf.init_app(app) csrf.init_app(app)
Session(app) Session(app)
make_session_limiter(app, session, config)
assets_environment.init_app(app) assets_environment.init_app(app)
make_error_pages(app) make_error_pages(app)
@ -162,6 +164,9 @@ def map_config(config):
"DISABLE_CRL_CHECK": config.getboolean("default", "DISABLE_CRL_CHECK"), "DISABLE_CRL_CHECK": config.getboolean("default", "DISABLE_CRL_CHECK"),
"CRL_FAIL_OPEN": config.getboolean("default", "CRL_FAIL_OPEN"), "CRL_FAIL_OPEN": config.getboolean("default", "CRL_FAIL_OPEN"),
"LOG_JSON": config.getboolean("default", "LOG_JSON"), "LOG_JSON": config.getboolean("default", "LOG_JSON"),
"LIMIT_CONCURRENT_SESSIONS": config.getboolean(
"default", "LIMIT_CONCURRENT_SESSIONS"
),
} }
@ -253,6 +258,10 @@ def make_notification_sender(app):
app.notification_sender = NotificationSender(queue) app.notification_sender = NotificationSender(queue)
def make_session_limiter(app, session, config):
app.session_limiter = SessionLimiter(config, session, app.redis)
def apply_json_logger(): def apply_json_logger():
dictConfig( dictConfig(
{ {

View File

@ -89,6 +89,12 @@ class Users(object):
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
@classmethod
def update_last_session_id(cls, user, session_id):
user.last_session_id = session_id
db.session.add(user)
db.session.commit()
@classmethod @classmethod
def finalize(cls, user): def finalize(cls, user):
user.provisional = False user.provisional = False

View File

@ -58,6 +58,7 @@ class User(
designation = Column(String) designation = Column(String)
date_latest_training = Column(Date) date_latest_training = Column(Date)
last_login = Column(TIMESTAMP(timezone=True), nullable=True) last_login = Column(TIMESTAMP(timezone=True), nullable=True)
last_session_id = Column(UUID(as_uuid=True), nullable=True)
provisional = Column(Boolean) provisional = Column(Boolean)

View File

@ -8,9 +8,9 @@ from flask import (
url_for, url_for,
request, request,
make_response, make_response,
current_app as app,
) )
from flask import current_app as app
from jinja2.exceptions import TemplateNotFound from jinja2.exceptions import TemplateNotFound
import pendulum import pendulum
import os import os
@ -125,6 +125,7 @@ def redirect_after_login_url():
def current_user_setup(user): def current_user_setup(user):
session["user_id"] = user.id session["user_id"] = user.id
session["last_login"] = user.last_login session["last_login"] = user.last_login
app.session_limiter.on_login(user)
Users.update_last_login(user) Users.update_last_login(user)

View File

@ -0,0 +1,19 @@
from atst.domain.users import Users
class SessionLimiter(object):
def __init__(self, config, session, redis):
self.limit_logins = config["LIMIT_CONCURRENT_SESSIONS"]
self.session = session
self.redis = redis
def on_login(self, user):
if not self.limit_logins:
return
session_id = self.session.sid
self._delete_session(user.last_session_id)
Users.update_last_session_id(user, session_id)
def _delete_session(self, session_id):
self.redis.delete("session:{}".format(session_id))

View File

@ -34,3 +34,4 @@ STORAGE_SECRET=''
STORAGE_PROVIDER=LOCAL STORAGE_PROVIDER=LOCAL
STORAGE_CRL_ARCHIVE_NAME = dod_crls.tar.bz STORAGE_CRL_ARCHIVE_NAME = dod_crls.tar.bz
WTF_CSRF_ENABLED = true WTF_CSRF_ENABLED = true
LIMIT_CONCURRENT_SESSIONS = false

View File

@ -1,3 +1,4 @@
[default] [default]
SESSION_COOKIE_SECURE=True SESSION_COOKIE_SECURE=True
SESSION_COOKIE_DOMAIN=atat.codes SESSION_COOKIE_DOMAIN=atat.codes
LIMIT_CONCURRENT_SESSIONS=True

View File

@ -2,6 +2,7 @@ from io import StringIO
import json import json
import logging import logging
from uuid import uuid4 from uuid import uuid4
from unittest.mock import Mock
import pytest import pytest
@ -62,13 +63,16 @@ def test_json_formatter_for_exceptions(logger, log_stream_content):
assert log.get("details") assert log.get("details")
def test_request_context_filter(logger, log_stream_content, request_ctx): def test_request_context_filter(logger, log_stream_content, request_ctx, monkeypatch):
user = UserFactory.create() request_uuid = str(uuid4())
uuid = str(uuid4()) user_uuid = str(uuid4())
request_ctx.g.current_user = user user = Mock(spec=["id"])
request_ctx.request.environ["HTTP_X_REQUEST_ID"] = uuid user.id = user_uuid
monkeypatch.setattr("atst.utils.logging.g", Mock(current_user=user))
request_ctx.request.environ["HTTP_X_REQUEST_ID"] = request_uuid
logger.info("this user is doing something") logger.info("this user is doing something")
log = json.loads(log_stream_content()) log = json.loads(log_stream_content())
assert log["user_id"] == str(user.id) assert log["user_id"] == str(user_uuid)
assert log["request_id"] == uuid assert log["request_id"] == request_uuid

View File

@ -0,0 +1,51 @@
import pytest
from redis import Redis
from unittest.mock import Mock
from uuid import uuid4
from atst.utils.session_limiter import SessionLimiter
from tests.factories import UserFactory
from atst.models.user import User
@pytest.fixture
def mock_redis():
return Mock(spec=Redis)
@pytest.fixture
def mock_session():
mock = Mock(spec=["sid"])
mock.sid = uuid4()
return mock
def test_session_limiter_deletes_users_old_session(mock_redis, mock_session):
last_session_id = uuid4()
current_session_id = uuid4()
mock_session.sid = current_session_id
session_limiter = SessionLimiter(
{"LIMIT_CONCURRENT_SESSIONS": True}, mock_session, mock_redis
)
user = UserFactory.create(last_session_id=last_session_id)
session_limiter.on_login(user)
mock_redis.delete.assert_called_with("session:{}".format(last_session_id))
def test_session_limiter_updates_users_last_sesion_id(mock_redis, mock_session, db):
last_session_id = uuid4()
current_session_id = uuid4()
mock_session.sid = current_session_id
session_limiter = SessionLimiter(
{"LIMIT_CONCURRENT_SESSIONS": True}, mock_session, mock_redis
)
user = UserFactory.create(last_session_id=last_session_id)
session_limiter.on_login(user)
user = db.session.query(User).get(user.id)
assert user.last_session_id == current_session_id