Merge pull request #834 from dod-ccpo/limit-concurrent-logins
Prevent multiple active sessions
This commit is contained in:
commit
6a504fdf89
28
alembic/versions/ab1167fc8260_add_user_last_session_id.py
Normal file
28
alembic/versions/ab1167fc8260_add_user_last_session_id.py
Normal 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 ###
|
11
atst/app.py
11
atst/app.py
@ -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(
|
||||||
{
|
{
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
19
atst/utils/session_limiter.py
Normal file
19
atst/utils/session_limiter.py
Normal 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))
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
51
tests/utils/test_session_limiter.py
Normal file
51
tests/utils/test_session_limiter.py
Normal 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
|
Loading…
x
Reference in New Issue
Block a user