diff --git a/.travis.yml b/.travis.yml
index 94b83396..5a0734c2 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,14 +1,17 @@
sudo: required
-language: python
-python: "3.6"
+language: minimal
services:
- docker
git:
submodules: false
env:
global:
- - TESTER_IMAGE_NAME=atst-tester
- PROD_IMAGE_NAME=atst-prod
+ - TESTER_IMAGE1_NAME=atst-tester-nocrls
+ - TESTER_IMAGE2_NAME=atst-tester
+cache:
+ directories:
+ - crl
before_install:
# Use sed to replace the SSH URL with the public URL
@@ -17,16 +20,22 @@ before_install:
- git submodule update --init --recursive
before_script:
+ - rm -rf ./crl/*
- docker run -d --name postgres96 postgres:9.6-alpine
- docker run -d --name redis redis:4.0.10-alpine
- docker run --link postgres96:postgres96 --link redis:redis waisbrot/wait
- export postgres_ip="$(docker inspect -f "{{ .NetworkSettings.IPAddress }}" postgres96)"
- export redis_ip="$(docker inspect -f "{{ .NetworkSettings.IPAddress }}" redis)"
- docker login -u $ATAT_DOCKER_REGISTRY_USERNAME -p $ATAT_DOCKER_REGISTRY_PASSWORD $ATAT_DOCKER_REGISTRY_URL
- - docker build --tag "${TESTER_IMAGE_NAME}" --add-host "postgreshost:${postgres_ip}" --add-host "redishost:${redis_ip}" . -f deploy/docker/tester/Dockerfile
+ - docker build --tag "${TESTER_IMAGE1_NAME}" --add-host "postgreshost:${postgres_ip}" --add-host "redishost:${redis_ip}" . -f deploy/docker/tester/Dockerfile
script:
- - docker run --add-host "postgreshost:${postgres_ip}" --add-host "redishost:${redis_ip}" "${TESTER_IMAGE_NAME}"
+ - docker run -d --entrypoint='/bin/sh' -t --name current-atst-tester "${TESTER_IMAGE1_NAME}"
+ - docker container exec -t current-atst-tester script/sync-crls
+ - docker commit current-atst-tester "${TESTER_IMAGE2_NAME}"
+ - docker cp current-atst-tester:/opt/atat/atst/crl/. ./crl/
+ - docker container stop current-atst-tester
+ - docker run --add-host "postgreshost:${postgres_ip}" --add-host "redishost:${redis_ip}" "${TESTER_IMAGE2_NAME}"
before_deploy:
- docker build --tag "${PROD_IMAGE_NAME}" . -f deploy/docker/prod/Dockerfile
diff --git a/atst/app.py b/atst/app.py
index d095a8bf..06f3c475 100644
--- a/atst/app.py
+++ b/atst/app.py
@@ -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)
diff --git a/atst/domain/authnid/__init__.py b/atst/domain/authnid/__init__.py
index e69de29b..80d645b8 100644
--- a/atst/domain/authnid/__init__.py
+++ b/atst/domain/authnid/__init__.py
@@ -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
diff --git a/atst/domain/authnid/crl/validator.py b/atst/domain/authnid/crl/__init__.py
similarity index 94%
rename from atst/domain/authnid/crl/validator.py
rename to atst/domain/authnid/crl/__init__.py
index 409a8bf7..68358e37 100644
--- a/atst/domain/authnid/crl/validator.py
+++ b/atst/domain/authnid/crl/__init__.py
@@ -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
)
diff --git a/atst/domain/authnid/crl/util.py b/atst/domain/authnid/crl/util.py
index 13e8106d..7e9948e1 100644
--- a/atst/domain/authnid/crl/util.py
+++ b/atst/domain/authnid/crl/util.py
@@ -56,7 +56,6 @@ def refresh_crls(out_dir, logger=None):
if __name__ == "__main__":
import sys
- import datetime
import logging
logging.basicConfig(
diff --git a/atst/domain/authnid/utils.py b/atst/domain/authnid/utils.py
index 763820ce..58f04ce4 100644
--- a/atst/domain/authnid/utils.py
+++ b/atst/domain/authnid/utils.py
@@ -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))
diff --git a/atst/domain/date.py b/atst/domain/date.py
new file mode 100644
index 00000000..4a131671
--- /dev/null
+++ b/atst/domain/date.py
@@ -0,0 +1,12 @@
+import pendulum
+
+
+def parse_date(data):
+ date_formats = ["YYYY-MM-DD", "MM/DD/YYYY"]
+ for _format in date_formats:
+ try:
+ return pendulum.from_format(data, _format).date()
+ except (ValueError, pendulum.parsing.exceptions.ParserError):
+ pass
+
+ raise ValueError("Unable to parse string {}".format(data))
diff --git a/atst/domain/requests.py b/atst/domain/requests.py
index 3986a849..23290085 100644
--- a/atst/domain/requests.py
+++ b/atst/domain/requests.py
@@ -150,3 +150,7 @@ class Requests(object):
@classmethod
def is_pending_financial_verification(cls, request):
return request.status == RequestStatus.PENDING_FINANCIAL_VERIFICATION
+
+ @classmethod
+ def is_pending_ccpo_approval(cls, request):
+ return request.status == RequestStatus.PENDING_CCPO_APPROVAL
diff --git a/atst/forms/fields.py b/atst/forms/fields.py
index 00e53529..2c06154a 100644
--- a/atst/forms/fields.py
+++ b/atst/forms/fields.py
@@ -1,20 +1,14 @@
from wtforms.fields.html5 import DateField
from wtforms.fields import Field
from wtforms.widgets import TextArea
-import pendulum
+
+from atst.domain.date import parse_date
class DateField(DateField):
def _value(self):
if self.data:
- date_formats = ["YYYY-MM-DD", "MM/DD/YYYY"]
- for _format in date_formats:
- try:
- return pendulum.from_format(self.data, _format).date()
- except (ValueError, pendulum.parsing.exceptions.ParserError):
- pass
-
- raise ValueError("Unable to parse string {}".format(self.data))
+ return parse_date(self.data)
else:
return None
diff --git a/atst/forms/financial.py b/atst/forms/financial.py
index 674eace4..994e84bf 100644
--- a/atst/forms/financial.py
+++ b/atst/forms/financial.py
@@ -1,7 +1,6 @@
import re
from wtforms.fields.html5 import EmailField
from wtforms.fields import StringField, SelectField
-from wtforms.form import Form
from wtforms.validators import Required, Email
from atst.domain.exceptions import NotFoundError
@@ -41,7 +40,7 @@ def suggest_pe_id(pe_id):
def validate_pe_id(field, existing_request):
try:
- pe_number = PENumbers.get(field.data)
+ PENumbers.get(field.data)
except NotFoundError:
suggestion = suggest_pe_id(field.data)
error_str = (
diff --git a/atst/forms/org.py b/atst/forms/org.py
index b8220228..f45c8a15 100644
--- a/atst/forms/org.py
+++ b/atst/forms/org.py
@@ -12,9 +12,11 @@ class OrgForm(ValidatedForm):
lname_request = StringField("Last Name", validators=[Required(), Alphabet()])
- email_request = EmailField("Email Address", validators=[Required(), Email()])
+ email_request = EmailField("E-mail Address", validators=[Required(), Email()])
- phone_number = TelField("Phone Number", validators=[Required(), PhoneNumber()])
+ phone_number = TelField("Phone Number",
+ description='Enter a 10-digit phone number',
+ validators=[Required(), PhoneNumber()])
service_branch = StringField(
"Service Branch or Agency",
diff --git a/atst/forms/poc.py b/atst/forms/poc.py
index 66b77064..c595097b 100644
--- a/atst/forms/poc.py
+++ b/atst/forms/poc.py
@@ -2,7 +2,7 @@ from wtforms.fields import StringField
from wtforms.fields.html5 import EmailField
from wtforms.validators import Required, Email, Length
from .forms import ValidatedForm
-from .validators import IsNumber, Alphabet
+from .validators import IsNumber
class POCForm(ValidatedForm):
diff --git a/atst/forms/request.py b/atst/forms/request.py
index c6b7c8c5..1c4c7420 100644
--- a/atst/forms/request.py
+++ b/atst/forms/request.py
@@ -1,10 +1,7 @@
from wtforms.fields.html5 import IntegerField
-from wtforms.fields import RadioField, StringField, TextAreaField, SelectField
-from wtforms.validators import NumberRange, InputRequired
+from wtforms.fields import RadioField, TextAreaField, SelectField
from .fields import DateField
from .forms import ValidatedForm
-from .validators import DateRange
-import pendulum
class RequestForm(ValidatedForm):
diff --git a/atst/forms/validators.py b/atst/forms/validators.py
index 3937dabb..241a5401 100644
--- a/atst/forms/validators.py
+++ b/atst/forms/validators.py
@@ -2,18 +2,19 @@ import re
from wtforms.validators import ValidationError
import pendulum
+from atst.domain.date import parse_date
+
def DateRange(lower_bound=None, upper_bound=None, message=None):
def _date_range(form, field):
now = pendulum.now().date()
+ date = parse_date(field.data)
if lower_bound is not None:
- date = pendulum.parse(field.data).date()
if (now - lower_bound) > date:
raise ValidationError(message)
if upper_bound is not None:
- date = pendulum.parse(field.data).date()
if (now + upper_bound) < date:
raise ValidationError(message)
diff --git a/atst/routes/__init__.py b/atst/routes/__init__.py
index 965b4b37..f8c4199c 100644
--- a/atst/routes/__init__.py
+++ b/atst/routes/__init__.py
@@ -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
diff --git a/atst/routes/requests/index.py b/atst/routes/requests/index.py
index 5e9d26ca..5b7984ee 100644
--- a/atst/routes/requests/index.py
+++ b/atst/routes/requests/index.py
@@ -9,8 +9,10 @@ def map_request(request):
time_created = pendulum.instance(request.time_created)
is_new = time_created.add(days=1) > pendulum.now()
app_count = request.body.get("details_of_use", {}).get("num_software_systems", 0)
- update_url = url_for('requests.requests_form_update', screen=1, request_id=request.id)
- verify_url = url_for('requests.financial_verification', request_id=request.id)
+ update_url = url_for(
+ "requests.requests_form_update", screen=1, request_id=request.id
+ )
+ verify_url = url_for("requests.financial_verification", request_id=request.id)
return {
"order_id": request.id,
@@ -19,7 +21,9 @@ def map_request(request):
"app_count": app_count,
"date": time_created.format("M/DD/YYYY"),
"full_name": request.creator.full_name,
- "edit_link": verify_url if Requests.is_pending_financial_verification(request) else update_url
+ "edit_link": verify_url if Requests.is_pending_financial_verification(
+ request
+ ) else update_url,
}
@@ -33,4 +37,12 @@ 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)
+ pending_ccpo = any(Requests.is_pending_ccpo_approval(r) for r in requests)
+
+ return render_template(
+ "requests.html",
+ requests=mapped_requests,
+ pending_financial_verification=pending_fv,
+ pending_ccpo_approval=pending_ccpo,
+ )
diff --git a/atst/routes/requests/jedi_request_flow.py b/atst/routes/requests/jedi_request_flow.py
index 1a3a7163..6623a909 100644
--- a/atst/routes/requests/jedi_request_flow.py
+++ b/atst/routes/requests/jedi_request_flow.py
@@ -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
+ elif 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)
diff --git a/atst/routes/requests/requests_form.py b/atst/routes/requests/requests_form.py
index d384abdf..c027890c 100644
--- a/atst/routes/requests/requests_form.py
+++ b/atst/routes/requests/requests_form.py
@@ -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/
+ We will review and respond to your request in 72 hours. You’ll be notified via email or phone.
+
+ While your request is being reviewed, your next step is to create a Task Order associated with JEDI Cloud. Please contact a Contracting Officer (KO), Contracting Officer Representative (COR), or a Financial Manager to help with this step.
+
+ Learn more about the JEDI Task Order and the Financial Verification process.
+
+ We will review and respond to your request in 72 hours. You’ll be notified via email or phone.
+
+ Your request is being reviewed because:
+
+ Request submitted. Approval pending.
+
+
+
+
+
+ While your request is being reviewed, your next step is to create a Task Order associated with JEDI Cloud. Please contact a Contracting Officer (KO), Contracting Officer Representative (COR), or a Financial Manager to help with this step. +
+ ++ Once the Task Order has been created, you will be asked to provide details about the task order in the Financial Verification step. +
+ ++ Learn more about the JEDI Task Order and the Financial Verification process. +
diff --git a/templates/fragments/pending_financial_verification.html b/templates/fragments/pending_financial_verification.html new file mode 100644 index 00000000..858b3e26 --- /dev/null +++ b/templates/fragments/pending_financial_verification.html @@ -0,0 +1,12 @@ ++ The next step is to create a Task Order associated with JEDI Cloud. + Please contact a Contracting Officer (KO), Contracting Officer + Representative (COR), or a Financial Manager to help with this step. +
++ Once the Task Order has been created, you will be asked to provide + details about the task order in the Financial Verification step. +
++ Learn more about the JEDI Task Order and the Financial Verification process. +
diff --git a/templates/requests.html b/templates/requests.html index 25520ac2..566da288 100644 --- a/templates/requests.html +++ b/templates/requests.html @@ -4,37 +4,27 @@ {% from "components/modal.html" import Modal %} {% from "components/empty_state.html" import EmptyState %} -{% block modal %} - {% if g.modalOpen %} - {% call Modal() %} -- Your next step is to create a Task Order (T.O.) associated with - JEDI Cloud. Please consult a Contracting Officer (KO) or - Contracting Officer Representative (COR) to help with this step. -
- -- Once the Task Order (T.O.) has been created, we will need the following - details to create your account. These details will help keep your cloud - usage in sync with your budget. -
- - {{ Alert("You'll need these details: ", - message="Task Order Number
Contracting Officer: Name, E-mail and Office
" - ) }} - - -Your next step is to create a Task Order (T.O.) associated with JEDI Cloud. Please consult a Contracting Officer (KO) or Contracting Officer Representative (COR) to help with this step.
" - ) }} + {% if pending_financial_verification %} + + {{ Alert('Pending Financial Verification', fragment="fragments/pending_financial_verification.html") }} + + {% endif %} + + {% if pending_ccpo_approval %} + + {{ Alert('Request submitted. Approval pending.', fragment="fragments/pending_ccpo_approval_alert.html") }} + + {% endif %} + +