Merge branch 'master' into ui/input-field-frontend-validation

This commit is contained in:
Patrick Smith
2018-08-12 12:16:46 -04:00
26 changed files with 549 additions and 96 deletions

View File

@@ -16,7 +16,7 @@ from atst.routes.workspaces import bp as workspace_routes
from atst.routes.requests import requests_bp
from atst.routes.dev import bp as dev_routes
from atst.routes.errors import make_error_pages
from atst.domain.authnid.crl.validator import Validator
from atst.domain.authnid.crl import Validator
from atst.domain.auth import apply_authentication
@@ -68,7 +68,7 @@ def make_flask_callbacks(app):
)
g.dev = os.getenv("FLASK_ENV", "dev") == "dev"
g.matchesPath = lambda href: re.match("^" + href, request.path)
g.modalOpen = request.args.get("modal", False)
g.modal = request.args.get("modal", None)
g.current_user = {
"id": "cce17030-4109-4719-b958-ed109dbb87c8",
"first_name": "Amanda",
@@ -142,8 +142,6 @@ def make_crl_validator(app):
for filename in pathlib.Path(app.config["CRL_DIRECTORY"]).glob("*"):
crl_locations.append(filename.absolute())
app.crl_validator = Validator(
roots=[app.config["CA_CHAIN"]], crl_locations=crl_locations
roots=[app.config["CA_CHAIN"]], crl_locations=crl_locations, logger=app.logger
)
for e in app.crl_validator.errors:
app.logger.error(e)

View File

@@ -0,0 +1,62 @@
from atst.domain.exceptions import UnauthenticatedError, NotFoundError
from atst.domain.users import Users
from .utils import parse_sdn, email_from_certificate
class AuthenticationContext():
def __init__(self, crl_validator, auth_status, sdn, cert):
if None in locals().values():
raise UnauthenticatedError(
"Missing required authentication context components"
)
self.crl_validator = crl_validator
self.auth_status = auth_status
self.sdn = sdn
self.cert = cert.encode()
self._parsed_sdn = None
def authenticate(self):
if not self.auth_status == "SUCCESS":
raise UnauthenticatedError("SSL/TLS client authentication failed")
elif not self._crl_check():
raise UnauthenticatedError("Client certificate failed CRL check")
return True
def get_user(self):
try:
return Users.get_by_dod_id(self.parsed_sdn["dod_id"])
except NotFoundError:
email = self._get_user_email()
return Users.create(**{"email": email, **self.parsed_sdn})
def _get_user_email(self):
try:
return email_from_certificate(self.cert)
# this just means it is not an email certificate; we might choose to
# log in that case
except ValueError:
return None
def _crl_check(self):
if self.cert:
result = self.crl_validator.validate(self.cert)
return result
else:
return False
@property
def parsed_sdn(self):
if not self._parsed_sdn:
try:
self._parsed_sdn = parse_sdn(self.sdn)
except ValueError as exc:
raise UnauthenticatedError(str(exc))
return self._parsed_sdn

View File

@@ -20,11 +20,11 @@ class Validator:
re.DOTALL,
)
def __init__(self, crl_locations=[], roots=[], base_store=crypto.X509Store):
self.errors = []
def __init__(self, crl_locations=[], roots=[], base_store=crypto.X509Store, logger=None):
self.crl_locations = crl_locations
self.roots = roots
self.base_store = base_store
self.logger = logger
self._reset()
def _reset(self):
@@ -34,12 +34,16 @@ class Validator:
self._add_roots(self.roots)
self.store.set_flags(crypto.X509StoreFlags.CRL_CHECK)
def log_error(self, message):
if self.logger:
self.logger.error(message)
def _add_crls(self, locations):
for filename in locations:
try:
self._add_crl(filename)
except crypto.Error as err:
self.errors.append(
self.log_error(
"CRL could not be parsed. Filename: {}, Error: {}, args: {}".format(
filename, type(err), err.args
)
@@ -116,7 +120,7 @@ class Validator:
return True
except crypto.X509StoreContextError as err:
self.errors.append(
self.log_error(
"Certificate revoked or errored. Error: {}. Args: {}".format(
type(err), err.args
)

View File

@@ -56,7 +56,6 @@ def refresh_crls(out_dir, logger=None):
if __name__ == "__main__":
import sys
import datetime
import logging
logging.basicConfig(

View File

@@ -1,7 +1,9 @@
import re
import cryptography.x509 as x509
from cryptography.hazmat.backends import default_backend
# TODO: our sample SDN does not have an email address
def parse_sdn(sdn):
try:
parts = sdn.split(",")
@@ -9,5 +11,21 @@ def parse_sdn(sdn):
cn = cn_string.split("=")[-1]
info = cn.split(".")
return {"last_name": info[0], "first_name": info[1], "dod_id": info[-1]}
except (IndexError, AttributeError):
raise ValueError("'{}' is not a valid SDN".format(sdn))
def email_from_certificate(cert_file):
cert = x509.load_pem_x509_certificate(cert_file, default_backend())
try:
ext = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
email = ext.value.get_values_for_type(x509.RFC822Name)
if email:
return email[0]
else:
raise ValueError("No email available for certificate with serial {}".format(cert.serial_number))
except x509.extensions.ExtensionNotFound:
raise ValueError("No subjectAltName available for certificate with serial {}".format(cert.serial_number))

View File

@@ -4,8 +4,8 @@ import pendulum
from atst.domain.requests import Requests
from atst.domain.users import Users
from atst.domain.authnid.utils import parse_sdn
from atst.domain.exceptions import UnauthenticatedError
from atst.domain.authnid import AuthenticationContext
bp = Blueprint("atst", __name__)
@@ -30,28 +30,29 @@ def catch_all(path):
return render_template("{}.html".format(path))
# TODO: this should be partly consolidated into a domain function that takes
# all the necessary UWSGI environment values as args and either returns a user
# or raises the UnauthenticatedError
def _make_authentication_context():
return AuthenticationContext(
crl_validator=app.crl_validator,
auth_status=request.environ.get("HTTP_X_SSL_CLIENT_VERIFY"),
sdn=request.environ.get("HTTP_X_SSL_CLIENT_S_DN"),
cert=request.environ.get("HTTP_X_SSL_CLIENT_CERT")
)
@bp.route('/login-redirect')
def login_redirect():
if request.environ.get('HTTP_X_SSL_CLIENT_VERIFY') == 'SUCCESS' and _is_valid_certificate(request):
sdn = request.environ.get('HTTP_X_SSL_CLIENT_S_DN')
sdn_parts = parse_sdn(sdn)
user = Users.get_or_create_by_dod_id(**sdn_parts)
session["user_id"] = user.id
auth_context = _make_authentication_context()
auth_context.authenticate()
user = auth_context.get_user()
session["user_id"] = user.id
return redirect(url_for("atst.home"))
else:
raise UnauthenticatedError()
return redirect(url_for("atst.home"))
def _is_valid_certificate(request):
cert = request.environ.get('HTTP_X_SSL_CLIENT_CERT')
if cert:
result = app.crl_validator.validate(cert.encode())
if not result:
app.logger.info(app.crl_validator.errors[-1])
return result
else:
return False

View File

@@ -33,4 +33,6 @@ def requests_index():
mapped_requests = [map_request(r) for r in requests]
return render_template("requests.html", requests=mapped_requests)
pending_fv = any(Requests.is_pending_financial_verification(r) for r in requests)
return render_template("requests.html", requests=mapped_requests, pending_financial_verification=pending_fv)

View File

@@ -11,13 +11,15 @@ class JEDIRequestFlow(object):
def __init__(
self,
current_step,
current_user=None,
request=None,
post_data=None,
request_id=None,
current_user=None,
existing_request=None,
):
self.current_step = current_step
self.current_user = current_user
self.request = request
self.post_data = post_data
@@ -26,16 +28,13 @@ class JEDIRequestFlow(object):
self.request_id = request_id
self.form = self._form()
self.current_user = current_user
self.existing_request = existing_request
def _form(self):
if self.is_post:
return self.form_class()(self.post_data)
elif self.request:
return self.form_class()(data=self.current_step_data)
else:
return self.form_class()()
return self.form_class()(data=self.current_step_data)
def validate(self):
return self.form.validate()
@@ -59,6 +58,16 @@ class JEDIRequestFlow(object):
def form_class(self):
return self.current_screen["form"]
# maps user data to fields in OrgForm; this should be moved into the
# request initialization process when we have a request schema, or we just
# shouldn't record this data on the request
def map_user_data(self, user):
return {
"fname_request": user.first_name,
"lname_request": user.last_name,
"email_request": user.email
}
@property
def current_step_data(self):
data = {}
@@ -69,8 +78,13 @@ class JEDIRequestFlow(object):
if self.request:
if self.form_section == "review_submit":
data = self.request.body
if self.form_section == "information_about_you":
form_data = self.request.body.get(self.form_section, {})
data = { **self.map_user_data(self.request.creator), **form_data }
else:
data = self.request.body.get(self.form_section, {})
elif self.form_section == "information_about_you":
data = self.map_user_data(self.current_user)
return defaultdict(lambda: defaultdict(lambda: "Input required"), data)

View File

@@ -4,12 +4,13 @@ from . import requests_bp
from atst.domain.requests import Requests
from atst.routes.requests.jedi_request_flow import JEDIRequestFlow
from atst.models.permissions import Permissions
from atst.models.request_status_event import RequestStatus
from atst.domain.exceptions import UnauthorizedError
@requests_bp.route("/requests/new/<int:screen>", methods=["GET"])
def requests_form_new(screen):
jedi_flow = JEDIRequestFlow(screen, request=None)
jedi_flow = JEDIRequestFlow(screen, request=None, current_user=g.current_user)
return render_template(
"requests/screen-%d.html" % int(screen),
@@ -31,7 +32,7 @@ def requests_form_update(screen=1, request_id=None):
_check_can_view_request(request_id)
request = Requests.get(request_id) if request_id is not None else None
jedi_flow = JEDIRequestFlow(screen, request, request_id=request_id)
jedi_flow = JEDIRequestFlow(screen, request=request, request_id=request_id, current_user=g.current_user)
return render_template(
"requests/screen-%d.html" % int(screen),
@@ -99,8 +100,8 @@ def requests_submit(request_id=None):
request = Requests.get(request_id)
Requests.submit(request)
if request.status == "approved":
return redirect("/requests?modal=True")
if request.status == RequestStatus.PENDING_FINANCIAL_VERIFICATION:
return redirect("/requests?modal=pendingFinancialVerification")
else:
return redirect("/requests")