Merge pull request #1304 from dod-ccpo/170609987-session-logging

Log details about user login and logout.
This commit is contained in:
dandds 2020-01-10 11:44:57 -05:00 committed by GitHub
commit a7da0e9104
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 76 additions and 45 deletions

View File

@ -0,0 +1,28 @@
"""remove users.provisional
Revision ID: 5d7198d34b91
Revises: 02ac8bdcf16f
Create Date: 2020-01-09 08:42:34.512191
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5d7198d34b91' # pragma: allowlist secret
down_revision = '02ac8bdcf16f' # pragma: allowlist secret
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('users', 'provisional')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('provisional', sa.BOOLEAN(), autoincrement=False, nullable=True))
# ### end Alembic commands ###

View File

@ -1,4 +1,4 @@
from flask import g, redirect, url_for, session, request from flask import g, redirect, url_for, session, request, current_app as app
from atst.domain.users import Users from atst.domain.users import Users
@ -59,8 +59,10 @@ def get_last_login():
def logout(): def logout():
if session.get("user_id"): # pragma: no branch if session.get("user_id"): # pragma: no branch
dod_id = g.current_user.dod_id
del session["user_id"] del session["user_id"]
del session["last_login"] del session["last_login"]
app.logger.info(f"user with EDIPI {dod_id} has logged out")
def _unprotected_route(request): def _unprotected_route(request):

View File

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

View File

@ -1,4 +1,4 @@
from sqlalchemy import String, ForeignKey, Column, Date, Boolean, Table, TIMESTAMP from sqlalchemy import String, ForeignKey, Column, Date, Table, TIMESTAMP
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.event import listen from sqlalchemy.event import listen
@ -67,8 +67,6 @@ class User(
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) last_session_id = Column(UUID(as_uuid=True), nullable=True)
provisional = Column(Boolean)
cloud_id = Column(String) cloud_id = Column(String)
REQUIRED_FIELDS = [ REQUIRED_FIELDS = [

View File

@ -19,6 +19,7 @@ from werkzeug.exceptions import NotFound
from atst.domain.users import Users from atst.domain.users import Users
from atst.domain.authnid import AuthenticationContext from atst.domain.authnid import AuthenticationContext
from atst.domain.auth import logout as _logout from atst.domain.auth import logout as _logout
from atst.domain.exceptions import UnauthenticatedError
from atst.utils.flash import formatted_flash as flash from atst.utils.flash import formatted_flash as flash
@ -64,11 +65,15 @@ def catch_all(path):
raise NotFound() raise NotFound()
def _client_s_dn():
return request.environ.get("HTTP_X_SSL_CLIENT_S_DN")
def _make_authentication_context(): def _make_authentication_context():
return AuthenticationContext( return AuthenticationContext(
crl_cache=app.crl_cache, crl_cache=app.crl_cache,
auth_status=request.environ.get("HTTP_X_SSL_CLIENT_VERIFY"), auth_status=request.environ.get("HTTP_X_SSL_CLIENT_VERIFY"),
sdn=request.environ.get("HTTP_X_SSL_CLIENT_S_DN"), sdn=_client_s_dn(),
cert=request.environ.get("HTTP_X_SSL_CLIENT_CERT"), cert=request.environ.get("HTTP_X_SSL_CLIENT_CERT"),
) )
@ -89,19 +94,24 @@ 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) app.session_limiter.on_login(user)
app.logger.info(f"authentication succeeded for user with EDIPI {user.dod_id}")
Users.update_last_login(user) Users.update_last_login(user)
@bp.route("/login-redirect") @bp.route("/login-redirect")
def login_redirect(): def login_redirect():
auth_context = _make_authentication_context() try:
auth_context.authenticate() auth_context = _make_authentication_context()
user = auth_context.get_user() auth_context.authenticate()
if user.provisional: user = auth_context.get_user()
Users.finalize(user) current_user_setup(user)
except UnauthenticatedError as err:
app.logger.info(
f"authentication failed for subject distinguished name {_client_s_dn()}"
)
raise err
current_user_setup(user)
return redirect(redirect_after_login_url()) return redirect(redirect_after_login_url())

View File

@ -2,16 +2,22 @@ import datetime
import json import json
import logging import logging
from flask import g, request, has_request_context from flask import g, request, has_request_context, session
class RequestContextFilter(logging.Filter): class RequestContextFilter(logging.Filter):
def filter(self, record): def filter(self, record):
if has_request_context(): if has_request_context():
if getattr(g, "current_user", None): if getattr(g, "current_user", None):
record.user_id = str(g.current_user.id)
record.dod_edipi = g.current_user.dod_id record.dod_edipi = g.current_user.dod_id
user_id = session.get("user_id")
if user_id:
record.user_id = str(user_id)
record.logged_in = True
else:
record.logged_in = False
if request.environ.get("HTTP_X_REQUEST_ID"): if request.environ.get("HTTP_X_REQUEST_ID"):
record.request_id = request.environ.get("HTTP_X_REQUEST_ID") record.request_id = request.environ.get("HTTP_X_REQUEST_ID")
@ -30,6 +36,7 @@ class JsonFormatter(logging.Formatter):
("request_id", lambda r: r.__dict__.get("request_id")), ("request_id", lambda r: r.__dict__.get("request_id")),
("user_id", lambda r: r.__dict__.get("user_id")), ("user_id", lambda r: r.__dict__.get("user_id")),
("dod_edipi", lambda r: r.__dict__.get("dod_edipi")), ("dod_edipi", lambda r: r.__dict__.get("dod_edipi")),
("logged_in", lambda r: r.__dict__.get("logged_in")),
("severity", lambda r: r.levelname), ("severity", lambda r: r.levelname),
("tags", lambda r: r.__dict__.get("tags")), ("tags", lambda r: r.__dict__.get("tags")),
("audit_event", lambda r: r.__dict__.get("audit_event")), ("audit_event", lambda r: r.__dict__.get("audit_event")),
@ -44,7 +51,7 @@ class JsonFormatter(logging.Formatter):
for field, func in self._DEFAULT_RECORD_FIELDS: for field, func in self._DEFAULT_RECORD_FIELDS:
result = func(record) result = func(record)
if result: if result is not None:
message_dict[field] = result message_dict[field] = result
if record.args: if record.args:

View File

@ -15,7 +15,7 @@ from atst.domain.authnid.crl import CRLCache
from .factories import UserFactory from .factories import UserFactory
from .mocks import DOD_SDN_INFO, DOD_SDN, FIXTURE_EMAIL_ADDRESS from .mocks import DOD_SDN_INFO, DOD_SDN, FIXTURE_EMAIL_ADDRESS
from .utils import make_crl_list from .utils import make_crl_list, FakeLogger
MOCK_USER = {"id": "438567dd-25fa-4d83-a8cc-8aa8366cb24a"} MOCK_USER = {"id": "438567dd-25fa-4d83-a8cc-8aa8366cb24a"}
@ -36,7 +36,7 @@ def _login(client, verify="SUCCESS", sdn=DOD_SDN, cert="", **url_query_args):
) )
def test_successful_login_redirect_non_ccpo(client, monkeypatch): def test_successful_login_redirect(client, monkeypatch, mock_logger):
monkeypatch.setattr( monkeypatch.setattr(
"atst.domain.authnid.AuthenticationContext.authenticate", lambda *args: True "atst.domain.authnid.AuthenticationContext.authenticate", lambda *args: True
) )
@ -51,30 +51,19 @@ def test_successful_login_redirect_non_ccpo(client, monkeypatch):
assert "home" in resp.headers["Location"] assert "home" in resp.headers["Location"]
assert session["user_id"] assert session["user_id"]
login_msg = mock_logger.messages[-1]
def test_successful_login_redirect_ccpo(client, monkeypatch): assert "succeeded" in login_msg
monkeypatch.setattr(
"atst.domain.authnid.AuthenticationContext.authenticate", lambda *args: True
)
role = PermissionSets.get(PermissionSets.VIEW_AUDIT_LOG)
monkeypatch.setattr(
"atst.domain.authnid.AuthenticationContext.get_user",
lambda *args: UserFactory.create(),
)
resp = _login(client)
assert resp.status_code == 302
assert "home" in resp.headers["Location"]
assert session["user_id"]
def test_unsuccessful_login_redirect(client, monkeypatch): def test_unsuccessful_login_redirect(client, monkeypatch, mock_logger):
resp = client.get(url_for("atst.login_redirect")) resp = client.get(url_for("atst.login_redirect"))
assert resp.status_code == 401 assert resp.status_code == 401
assert "user_id" not in session assert "user_id" not in session
login_msg = mock_logger.messages[0]
assert "failed" in login_msg
# checks that all of the routes in the app are protected by auth # checks that all of the routes in the app are protected by auth
def is_unprotected(rule): def is_unprotected(rule):
@ -221,13 +210,13 @@ def test_creates_new_user_without_email_on_login(
assert user.email == None assert user.email == None
def test_logout(app, client, monkeypatch): def test_logout(app, client, monkeypatch, mock_logger):
user = UserFactory.create()
monkeypatch.setattr( monkeypatch.setattr(
"atst.domain.authnid.AuthenticationContext.authenticate", lambda s: True "atst.domain.authnid.AuthenticationContext.authenticate", lambda s: True
) )
monkeypatch.setattr( monkeypatch.setattr(
"atst.domain.authnid.AuthenticationContext.get_user", "atst.domain.authnid.AuthenticationContext.get_user", lambda s: user,
lambda s: UserFactory.create(),
) )
# create a real session # create a real session
resp = _login(client) resp = _login(client)
@ -240,6 +229,10 @@ def test_logout(app, client, monkeypatch):
assert resp_failure.status_code == 302 assert resp_failure.status_code == 302
destination = urlparse(resp_failure.headers["Location"]).path destination = urlparse(resp_failure.headers["Location"]).path
assert destination == url_for("atst.root") assert destination == url_for("atst.root")
# verify that logout is noted in the logs
logout_msg = mock_logger.messages[-1]
assert user.dod_id in logout_msg
assert "logged out" in logout_msg
def test_logging_out_creates_a_flash_message(app, client, monkeypatch): def test_logging_out_creates_a_flash_message(app, client, monkeypatch):

View File

@ -72,9 +72,11 @@ def test_request_context_filter(logger, log_stream_content, request_ctx, monkeyp
user.dod_id = "5678901234" user.dod_id = "5678901234"
monkeypatch.setattr("atst.utils.logging.g", Mock(current_user=user)) monkeypatch.setattr("atst.utils.logging.g", Mock(current_user=user))
monkeypatch.setattr("atst.utils.logging.session", {"user_id": user_uuid})
request_ctx.request.environ["HTTP_X_REQUEST_ID"] = request_uuid 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_uuid) assert log["user_id"] == str(user_uuid)
assert log["dod_edipi"] == str(user.dod_id) assert log["dod_edipi"] == str(user.dod_id)
assert log["request_id"] == request_uuid assert log["request_id"] == request_uuid
assert log["logged_in"] == True