From 4e03775d5c9188732d611f7faa1a85bc7578d6b5 Mon Sep 17 00:00:00 2001 From: kirklholub Date: Fri, 29 Dec 2023 15:17:51 +0000 Subject: [PATCH] Added account management feature -- lots of changes required --- .../ssopsb_portal-auth-swagger.json | 65 +++ .../ssopsb_portal-beta-swagger.json | 65 +++ .../ssopsb_portal-prod-swagger.json | 65 +++ AWS/lambda/index.mjs | 8 + AWS/lambda/index.py | 470 ++++++++++++++++ .../system/ssop_account_review.service | 29 + sites/admin.py | 49 +- sites/forms.py | 13 +- .../commands/add_contact_organizations.py | 19 + sites/management/commands/add_none_contact.py | 9 +- sites/management/commands/review_contacts.py | 17 + sites/models.py | 532 ++++++++++++++++-- sites/urls.py | 6 +- sites/views.py | 257 ++++++--- ssop/context_processors.py | 9 + ssop/settings.py | 97 +++- templates/base.html | 15 + templates/did.html | 3 + templates/renewed.html | 12 + 19 files changed, 1575 insertions(+), 165 deletions(-) create mode 100755 AWS/apigateway/ssopsb_portal-auth-swagger.json create mode 100755 AWS/apigateway/ssopsb_portal-beta-swagger.json create mode 100755 AWS/apigateway/ssopsb_portal-prod-swagger.json create mode 100755 AWS/lambda/index.mjs create mode 100755 AWS/lambda/index.py create mode 100644 etc/systemd/system/ssop_account_review.service create mode 100755 sites/management/commands/add_contact_organizations.py create mode 100755 sites/management/commands/review_contacts.py create mode 100755 templates/did.html create mode 100755 templates/renewed.html diff --git a/AWS/apigateway/ssopsb_portal-auth-swagger.json b/AWS/apigateway/ssopsb_portal-auth-swagger.json new file mode 100755 index 0000000..07e2eb7 --- /dev/null +++ b/AWS/apigateway/ssopsb_portal-auth-swagger.json @@ -0,0 +1,65 @@ +{ + "swagger" : "2.0", + "info" : { + "version" : "2023-10-10T22:31:54Z", + "title" : "ssopsb_portal" + }, + "host" : "j8ji8aizsd.execute-api.us-east-1.amazonaws.com", + "basePath" : "/auth", + "schemes" : [ "https" ], + "paths" : { + "/" : { + "get" : { + "operationId" : "auto", + "produces" : [ "text/html" ], + "parameters" : [ { + "name" : "Bearer", + "in" : "header", + "required" : false, + "type" : "string" + } ], + "responses" : { + "200" : { + "description" : "200 response", + "schema" : { + "$ref" : "#/definitions/Empty" + }, + "headers" : { + "Content-Type" : { + "type" : "string" + } + } + } + } + }, + "post" : { + "produces" : [ "text/html", "application/json" ], + "parameters" : [ { + "name" : "Bearer", + "in" : "header", + "required" : false, + "type" : "string" + } ], + "responses" : { + "200" : { + "description" : "200 response", + "schema" : { + "$ref" : "#/definitions/Empty" + }, + "headers" : { + "Content-Type" : { + "type" : "string" + } + } + } + } + } + } + }, + "definitions" : { + "Empty" : { + "type" : "object", + "title" : "Empty Schema" + } + } +} \ No newline at end of file diff --git a/AWS/apigateway/ssopsb_portal-beta-swagger.json b/AWS/apigateway/ssopsb_portal-beta-swagger.json new file mode 100755 index 0000000..961e8fb --- /dev/null +++ b/AWS/apigateway/ssopsb_portal-beta-swagger.json @@ -0,0 +1,65 @@ +{ + "swagger" : "2.0", + "info" : { + "version" : "2023-10-10T22:31:54Z", + "title" : "ssopsb_portal" + }, + "host" : "j8ji8aizsd.execute-api.us-east-1.amazonaws.com", + "basePath" : "/beta", + "schemes" : [ "https" ], + "paths" : { + "/" : { + "get" : { + "operationId" : "auto", + "produces" : [ "text/html" ], + "parameters" : [ { + "name" : "Bearer", + "in" : "header", + "required" : false, + "type" : "string" + } ], + "responses" : { + "200" : { + "description" : "200 response", + "schema" : { + "$ref" : "#/definitions/Empty" + }, + "headers" : { + "Content-Type" : { + "type" : "string" + } + } + } + } + }, + "post" : { + "produces" : [ "text/html", "application/json" ], + "parameters" : [ { + "name" : "Bearer", + "in" : "header", + "required" : false, + "type" : "string" + } ], + "responses" : { + "200" : { + "description" : "200 response", + "schema" : { + "$ref" : "#/definitions/Empty" + }, + "headers" : { + "Content-Type" : { + "type" : "string" + } + } + } + } + } + } + }, + "definitions" : { + "Empty" : { + "type" : "object", + "title" : "Empty Schema" + } + } +} \ No newline at end of file diff --git a/AWS/apigateway/ssopsb_portal-prod-swagger.json b/AWS/apigateway/ssopsb_portal-prod-swagger.json new file mode 100755 index 0000000..60540c4 --- /dev/null +++ b/AWS/apigateway/ssopsb_portal-prod-swagger.json @@ -0,0 +1,65 @@ +{ + "swagger" : "2.0", + "info" : { + "version" : "2023-10-27T21:35:21Z", + "title" : "ssopsb_portal" + }, + "host" : "j8ji8aizsd.execute-api.us-east-1.amazonaws.com", + "basePath" : "/prod", + "schemes" : [ "https" ], + "paths" : { + "/" : { + "get" : { + "operationId" : "auto", + "produces" : [ "text/html" ], + "parameters" : [ { + "name" : "Bearer", + "in" : "header", + "required" : false, + "type" : "string" + } ], + "responses" : { + "200" : { + "description" : "200 response", + "schema" : { + "$ref" : "#/definitions/Empty" + }, + "headers" : { + "Content-Type" : { + "type" : "string" + } + } + } + } + }, + "post" : { + "produces" : [ "application/json" ], + "parameters" : [ { + "name" : "Bearer", + "in" : "header", + "required" : false, + "type" : "string" + } ], + "responses" : { + "200" : { + "description" : "200 response", + "schema" : { + "$ref" : "#/definitions/Empty" + }, + "headers" : { + "Content-Type" : { + "type" : "string" + } + } + } + } + } + } + }, + "definitions" : { + "Empty" : { + "type" : "object", + "title" : "Empty Schema" + } + } +} \ No newline at end of file diff --git a/AWS/lambda/index.mjs b/AWS/lambda/index.mjs new file mode 100755 index 0000000..ff2cba3 --- /dev/null +++ b/AWS/lambda/index.mjs @@ -0,0 +1,8 @@ +export const handler = async (event) => { + // TODO implement + const response = { + statusCode: 200, + body: JSON.stringify('Hello from Lambda!'), + }; + return response; +}; diff --git a/AWS/lambda/index.py b/AWS/lambda/index.py new file mode 100755 index 0000000..efd3125 --- /dev/null +++ b/AWS/lambda/index.py @@ -0,0 +1,470 @@ +""" +Single Sign-On Portal (SSOP) Amazon Web Services (AWS) Integration + +Implements the handler required to processes and access_token from gsl.noaa.gov/ssop or its sandbox version gsl.noaa.gov/ssopsb + +Its design allows an SSOP Project the ability to specify a method to be executed upon succesful authentication. + +Project methods currently implemented: + appstream2(params) -- creates and AppStream2 streaming URL based on input params + +""" + +import ast +import sys +import pprint +import random +import secrets + +from botocore.exceptions import ClientError +import boto3 +appstream = boto3.client('appstream') + +# packages in layer ssop-imports +sys.path.append("/opt") +import requests +import jwt +from cryptography.fernet import Fernet, InvalidToken + +ssm_client = boto3.client('ssm') + + +def userexists(email): + result = False + userinfo = appstream.describe_users(AuthenticationType='USERPOOL') + for user in userinfo['Users']: + if email in user['UserName']: + result = True + break + return result + +def checkusers(params): + email = None + try: + email = params['email'] + except KeyError: + pass + try: + first_name = params['given_name'] + except KeyError: + pass + try: + family_name = params['family_name'] + except KeyError: + pass + + userinfo = appstream.describe_users(AuthenticationType='USERPOOL') + if email: + for user in userinfo['Users']: + try: + if str(email) in str(user['email']): + if 'first_name' in user['FirstName'] or first_name not in user['FirstName'] or family_name not in user['LastName']: + print(" deleting user with first_name: " + str(user['FirstName']) + " and last name: " + str(user['LastName'])) + appstream.delete_user(UserName=user['UserName'], AuthenticationType='USERPOOL') + except KeyError: + pass + + +def createAppStreamUser(params): + email = 'email' + first_name = 'first_name' + family_name = 'family_name' + try: + email = params['email'] + except KeyError: + pass + + if not userexists(email): + try: + first_name = params['given_name'] + except KeyError: + pass + try: + family_name = params['family_name'] + except KeyError: + pass + + try: + appstream.create_user(UserName=email, + MessageAction='SUPPRESS', + FirstName=first_name, + LastName=family_name, + AuthenticationType='USERPOOL') + except ClientError as e: + pass + print("ClientError e: " + str(e)) + except ResourceAlreadyExistsException: + pass + print("User exists error for: " + str(email)) + except Exception as e: + pass + print("Exception e: " + str(e)) + + #except ClientError.InvalidAccountStatusException as e: + # print("e: " + str(e)) + #except ClientError.InvalidParameterCombinationException as e: + # print("e: " + str(e)) + #except ClientError.LimitExceededException as e: + # print("e: " + str(e)) + #except ClientError.OperationNotPermittedException as e: + # print("e: " + str(e) + + +def createas2streamingurl(params): + email = 'email' + + try: + email = params['email'] + except KeyError: + pass + + try: + awsRequestId = params['awsRequestId'] + except KeyError: + awsRequestId = None + + try: + FleetName = params['FleetName'] + except KeyError: + pass + + try: + StackName = params['StackName'] + except KeyError: + pass + + try: + Validity = int(params['Validity']) + except KeyError: + pass + + try: + UserStackAssociations = [] + userinfo = {} + userinfo["StackName"] = StackName + userinfo["UserName"] = email + userinfo["AuthenticationType"] = "USERPOOL" + userinfo["SendEmailNotification"] = False + UserStackAssociations.append(userinfo) + associate_response = appstream.batch_associate_user_stack(UserStackAssociations=UserStackAssociations) + #print("associate_response: " + str(associate_response)) + #print("UserStackAssociations: " + str(UserStackAssociations)) + request = appstream.create_streaming_url(FleetName=FleetName, + StackName=StackName, UserId=email, Validity=Validity, ApplicationId='Desktop', SessionContext=email) + url = request['StreamingURL'] + c2sresponse = { + 'statusCode': 201, + 'body': { + 'Message': url, + 'Reference': awsRequestId + }, + 'headers': { + 'Access-Control-Allow-Origin': 'https://d2jdmiq7araut3.cloudfront.net' + } + } + except Exception as e: + print(" Exception e: " + str(e)) + + return c2sresponse + +def appstream2(params): + """ + SSOP Project method + """ + checkusers(params) + createAppStreamUser(params) + return createas2streamingurl(params) + +def bytes_in_string(b): + if str(b).startswith("b'"): + return str(b)[2:-1] + else: + return b + +def handler(event, context): + """ + processes access_token from gsl.noaa.gov/ssop[sb] + + """ + + try: + qsp = event['queryStringParameters'] + except KeyError: + qsp = None + + access_token = None + if qsp: + try: + access_token = qsp['access_token'] + except KeyError: + access_token = 'keyerror' + + output = "access_token = " + str(access_token) + + + """ + user has been authenticated by login.gov, so we can retrive their attributes via a JWT + """ + + # fetch the decode ID + proxies = {} + didurl = 'https://gsl.noaa.gov/ssop/getdid/' + str(access_token) + '/' + response = requests.get(didurl, proxies=proxies) + FERNET_KEY_ID = response.text + + # Fall back and check sandbox environment if no key id + if len(FERNET_KEY_ID) < int(10): + didurl = 'https://gsl.noaa.gov/ssopsb/getdid/' + str(access_token) + '/' + response = requests.get(didurl, proxies=proxies) + FERNET_KEY_ID = response.text + + # our primary return structure -- useful for debugging + data = {} + #print("event: " + str(event)) + #print("context: " + str(context)) + #data['event'] = str(event) + #data['context'] = str(context) + if access_token is None: + if '?access_token=' in str(event): + (junk, access_token) = str(event).split('?access_token=') + access_token = access_token.replace("\'>", "") + data['access_token'] = str(access_token) + try: + msg = " event.headers = " + str(event['headers']) + except KeyError: + msg = " NO event.headers found" + #data['event.headers'] = msg + + awsRequestId = None + for e in str(context).split(','): + try: + if 'aws_request_id' in str(e): + awsRequestId = str(e).split('=') + awsRequestId = awsRequestId[1] + except KeyError: + pass + data['event.context.aws_request_id'] = awsRequestId + + # the trailing '/' is MANDATORY + extattrsurl = "https://gsl.noaa.gov/ssop/sites/attrsjwt/" + str(access_token) + "/" + #print("extattrsurl: " + str(extattrsurl)) + + # curl headers need str vs {} for requests.get + cheaders = "Authorization: Bearer " + str(access_token) + extcurl = 'curl -v -x -H "' + cheaders + '" ' + extattrsurl + #data['extcurl'] = str(extcurl) + + headers = {} + headers["Authorization"] = "Bearer " + str(access_token) + + jwtresponse = requests.get(extattrsurl, proxies=proxies, headers=headers) + # Fall back to sandbox environment if no JWT returned + if 'JWT' not in str(jwtresponse.text): + extattrsurl = "https://gsl.noaa.gov/ssopsb/sites/attrsjwt/" + str(access_token) + "/" + jwtresponse = requests.get(extattrsurl, proxies=proxies, headers=headers) + data['jwt'] = str(jwtresponse.text) + #print("jwt: " + str(data['jwt'])) + data['extattrsurl'] = str(extattrsurl) + + payload = jwtresponse.text + payload = payload.replace('\n', '', 10) + payload = payload.replace('JWT ', '' ) + payload = payload.replace(' ', '' ) + data['payload'] = payload + + decoded = None + try: + # we trust the JWT since we know where it originated + decoded = jwt.decode(payload, options={"verify_signature": False}) + except jwt.DecodeError as e: + decoded = 'unable to decode from ' + str(extattrsurl) + ' ... e = ' + str(e) + #data['decoded'] = str(decoded) + #print("decoded: " + str(decoded)) + + given_name = None + family_name = None + email = None + full_name = None + app_method = None + FleetName = None + StackName = None + Validity = None + decode_key = None + return_html = None + strmurl = None + if decoded: + dar = None + try: + get_param_response = ssm_client.get_parameter(Name=FERNET_KEY_ID) + except Exception as e: + print("Exception " + str(e) + " fetching parameter " + str(FERNET_KEY_ID)) + + try: + decode_key = get_param_response['Parameter']['Value'] + #print("decode_key: " + str(decode_key)) + dar = Fernet(decode_key) + #print("dar: " + str(dar)) + except Exception as e: + print("Exception " + str(e) + " fetching get_param_response " + str(get_param_response)) + + # This will be all of user attributes in clear text + # dit -- data in transit is a payload within the json web token (jwt.io) + bis = None + try: + bis = bytes_in_string(decoded['dit']) + except Exception as e: + bis = str(e) + #data['bis'] = bis + #print("bis: " + str(bis)) + try: + decodeddar = dar.decrypt(bis).decode() + #print("decodeddar: " + str(decodeddar)) + ale = ast.literal_eval(decodeddar) + except InvalidToken: + ale = {} + ale["InvalidToken"] = "True" + ale = (ale,) + #data['cleardata'] = str(ale) + #print("ale: " + str(ale)) + + # Application parameters + # we could also pass ale to globals()[app_method] instead of dealing with appstream2 specific parameters here, but this works + app_params = {} + for tpl in ale: + #print("tpl: " + str(tpl)) + if not given_name: + try: + given_name = tpl['given_name'] + except KeyError: + pass + if not family_name: + try: + family_name = tpl['family_name'] + except KeyError: + pass + if not email: + try: + email = tpl['email'] + except KeyError: + pass + if not app_method: + try: + app_method = tpl['app_method'] + except KeyError: + pass + if not FleetName: + try: + FleetName = tpl['FleetName'] + app_params['FleetName'] = FleetName + except KeyError: + pass + if not StackName: + try: + StackName = tpl['StackName'] + app_params['StackName'] = StackName + except KeyError: + pass + if not Validity: + try: + Validity = tpl['Validity'] + app_params['Validity'] = Validity + except KeyError: + pass + if not return_html: + try: + return_html = tpl['return_html'] + app_params['return_html'] = return_html + except KeyError: + pass + + full_name = str(given_name) + '_' + str(family_name) + ' (' + str(email) + ')' + if email: + data['full_name'] = full_name + params = {"given_name": given_name, + "family_name": family_name, + "email": email, + "awsRequestId": awsRequestId, + } + for p in app_params.keys(): + params[p] = app_params[p] + #data['params'] = params + + #print("app_method: " + str(app_method)) + #print("full_name: " + str(full_name)) + if app_method: + strmurlresponse = globals()[app_method](params) + data['strmurlresponse'] = strmurlresponse + data['strmurl'] = strmurlresponse['body']['Message'] + #return strmurlresponse + + + try: + return_url = data['strmurl'] + JSON_RESPONSE = False + except KeyError: + JSON_RESPONSE = True + return_url = 'unknown-return_url' + + try: + strmrespheaders = data['strmurlresponse']['headers'] + strmrespbody = data['strmurlresponse']['body'] + except KeyError: + strmrespheaders = {} + strmrespbody = "" + + output = "return_url: " + str(return_url) + "\n" + output + output = output + "\nFERNET_KEY_ID: " + str(FERNET_KEY_ID) + #output = output + "\ndecode_key: " + str(decode_key) + data['output'] = output + + + # JSON response requires double quotes + if JSON_RESPONSE: + #data = str(data).replace('"', '#####', 10000) + #data = data.replace("'", '"', 10000) + #data = data.replace('##A###', '"', 10000) + response = { + "statusCode": 201, + "body": output, + } + # "body": data + else: + safeurls = "https://gsl.noaa.gov https://a--------z.execute-api.region.amazonaws.com https://appstream2.region.aws.amazon.com" + + csp = "" + for src in ["default-src", "script-src", "connect-src", "img-src", "style-src", "base-uri", "form-action"]: + csp = csp + src + " 'self' " + safeurls + "; " + csp = csp + "object-src 'none'; frame-ancestors 'none'; block-all-mixed-content" + + try: + #eventheaders = event['headers'] + eventheaders = {} + eventheaders["Location"] = return_url + eventheaders["Content-Security-Policy"] = csp + #for k in strmrespheaders.keys(): + # eventheaders[k] = strmrespheaders[k] + + #print("eventheaders: " + str(eventheaders)) + except Exception as e: + #print("eventheaders exception e: " + str(e)) + eventheaders = "eventheaders exception" + + response = { + "statusCode": 301, + "headers": eventheaders + } + + # login.gov returns some resources a nonce and the Chrome browser blocks this. A work around is to redirect using an html header. + if return_html is not None: + html = 'redirecting' + html = html + '' + html = html + '

Redirecting

' + response = { + "statusCode": 201, + "body": html, + "headers": {"Content-Type": "text/html; charset=utf-8"} + } + + print("response: " + str(response)) + return response diff --git a/etc/systemd/system/ssop_account_review.service b/etc/systemd/system/ssop_account_review.service new file mode 100644 index 0000000..c6f7b93 --- /dev/null +++ b/etc/systemd/system/ssop_account_review.service @@ -0,0 +1,29 @@ +[Unit] +Description=Single Sign On Portal user account managment service +After=network.target +StartLimitIntervalSec=30 +StartLimitBurst=2 + +[Service] +Type=exec + +# the specific user that our service will run as +User=authroleuser +Group=authrolegroup + +# another option for an even more restricted service is +# DynamicUser=yes +# see http://0pointer.net/blog/dynamic-users-with-systemd.html +WorkingDirectory=/opt/ssop + +ExecStart=/opt/ssop/venv/bin/python3 /opt/ssop/manage.py review_contacts +Restart=on-failure +ExecReload=kill -HUP $MAINPID + +# Redirect stdout/stderr to log file +capture_output = True + +[Install] +WantedBy=multi-user.target + + diff --git a/sites/admin.py b/sites/admin.py index 509f2d0..cb0920e 100755 --- a/sites/admin.py +++ b/sites/admin.py @@ -1,13 +1,17 @@ +import logging from django.contrib import admin -from sites.models import About, Attributes, AuthToken, Organization, OrganizationNode, Project, Connection, Contact, Uniqueuser, AttributeGroup, GraphNode, NodeType, Key, Sysadmin, Room +from sites.models import About, Attributes, AuthToken, Organization, Project, Connection, Contact, Uniqueuser, AttributeGroup, GraphNode, NodeType, Key, Sysadmin, Room, get_or_add_authtoken from sites.forms import ProjectAdminForm, SysadminAdminForm from ssop import settings - + +logger = logging.getLogger('ssop.models') + + def set_dbfield_to_sysad(fieldname, field, db_field, request): if fieldname in str(db_field): now = datetime.datetime.utcnow() - msg = str(now) + ':' + fieldname + ':' + str(db_field) + ":" + get_sysad(request) - logger.info(msg) + #msg = str(now) + ':' + fieldname + ':' + str(db_field) + ":" + get_sysad(request) + #logger.info(msg) sysadmin = Sysadmin.objects.filter(username=request.user) if sysadmin.count() > 0: field.initial = str(sysadmin[0]) @@ -41,26 +45,45 @@ class ConnectionAdmin(admin.ModelAdmin): class ContactAdmin(admin.ModelAdmin): - list_display = ('firstname', 'lastname', 'email') + list_display = ('email', 'firstname', 'lastname', 'organization', 'organizations_list', 'last_connected') list_display_links = list_display - ordering = ('firstname',) + ordering = ('email', 'lastname', 'firstname', ) + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if str(db_field) == 'sites.Contact.organization': + qs = Organization.objects.filter(name=settings.NONE_NAME) + kwargs['initial'] = qs[0] + return super(ContactAdmin, self).formfield_for_foreignkey(db_field, request, **kwargs) -class OrganizationNodeAdmin(admin.ModelAdmin): - #list_display = ('name', 'current_projects', 'contact', 'email', 'graph_node_id') - list_display = ( 'name', 'leaf' ) - list_display_links = list_display + def formfield_for_manytomany(self, db_field, request, **kwargs): + form = super(ContactAdmin, self).formfield_for_manytomany(db_field, request, **kwargs) + if str(db_field) == 'sites.Contact.organizations': + qs = Organization.objects.all().order_by('name') + kwargs['queryset'] = qs + form.initial = {qs[0].id: True} + if str(db_field) == 'sites.Contact.renewal_tokens': + qs = AuthToken.objects.all().order_by('token') + if qs.count() < int(1): + qs = get_or_add_authtoken(settings.NONE_NAME) + kwargs['queryset'] = qs + form.initial = {qs[0].id: True} + return form + +#class OrganizationNodeAdmin(admin.ModelAdmin): +# #list_display = ('name', 'current_projects', 'contact', 'email', 'graph_node_id') +# list_display = ( 'name', 'leaf' ) +# list_display_links = list_display class OrganizationAdmin(admin.ModelAdmin): - list_display = ('name', 'current_projects', 'contacts') + list_display = ('name', 'parent', 'current_projects', 'users') list_display_links = list_display ordering = ('name',) class ProjectAdmin(admin.ModelAdmin): #list_display = ('name', 'organization', 'enabled', 'expiretokens', 'return_to', 'queryparam', 'error_redirect', 'display_order', 'state', 'decrypt_key', 'graph_node_id') - list_display = ('name', 'organization', 'enabled', 'expiretokens', 'queryparam', 'return_to', 'error_redirect', 'contacts_url', 'users', 'app_params', 'decrypt_key', 'state', 'logoimg', 'showlogobin', 'display_order', 'state', 'decrypt_key', 'updated') + list_display = ('name', 'verbose_name', 'organization', 'enabled', 'expiretokens', 'queryparam', 'return_to', 'error_redirect', 'contacts_url', 'owner', 'users', 'app_params', 'decrypt_key', 'state', 'logoimg', 'showlogobin', 'display_order', 'state', 'decrypt_key', 'updated') list_display_links = list_display #readonly_fields = ('state', 'updater') ordering = ('display_order', 'organization', 'name') @@ -127,7 +150,7 @@ class RoomAdmin(admin.ModelAdmin): admin.site.register(Contact, ContactAdmin) admin.site.register(GraphNode, GraphNodeAdmin) admin.site.register(Key) -admin.site.register(OrganizationNode, OrganizationNodeAdmin) +#admin.site.register(OrganizationNode, OrganizationNodeAdmin) admin.site.register(Organization, OrganizationAdmin) admin.site.register(NodeType, NodeTypeAdmin) admin.site.register(Project, ProjectAdmin) diff --git a/sites/forms.py b/sites/forms.py index 18f68f0..2c58946 100755 --- a/sites/forms.py +++ b/sites/forms.py @@ -3,6 +3,16 @@ from sites.models import Contact, Organization, Project, Sysadmin import ssop.settings as settings +class ContactAdminForm(forms.ModelForm): + class Meta: + model = Contact + field_order = ('email', 'firstname', 'lastname', 'last_connection_time', 'organizations', 'organization') + fields = field_order + + def __init__(self, *args, **kwargs): + super(ContactAdminForm, self).__init__(*args, **kwargs) + + class ProjectForm(forms.Form): class Meta: model = Project @@ -22,11 +32,10 @@ def save(self, commit=True): class ProjectAdminForm(forms.ModelForm): - class Meta: model = Project #fields = '__all__' - field_order = ('name', 'organization', 'verbose_name', 'return_to', 'error_redirect', 'enabled', 'display_order', 'decrypt_key', 'logoimg', 'userlist', 'app_params', 'expiretokens', 'graphnode', 'state', 'queryparam', 'querydelimiter', ) + field_order = ('name', 'organization', 'verbose_name', 'return_to', 'error_redirect', 'enabled', 'display_order', 'decrypt_key', 'logoimg', 'owner', 'userlist', 'app_params', 'expiretokens', 'graphnode', 'state', 'queryparam', 'querydelimiter', ) fields = field_order diff --git a/sites/management/commands/add_contact_organizations.py b/sites/management/commands/add_contact_organizations.py new file mode 100755 index 0000000..efdd86b --- /dev/null +++ b/sites/management/commands/add_contact_organizations.py @@ -0,0 +1,19 @@ +""" +Add the NONE email Contact if it does not exits. This placeholder is required to make the GUI happy. +""" +from __future__ import unicode_literals + + +# https://stackoverflow.com/questions/19475955/using-django-models-in-external-python-script +from django.core.management.base import BaseCommand + +from sites.models import Contact + + +class Command(BaseCommand): + help = "add the NONE email Contact if needed" + + def handle(self, *args, **options): + for c in Contact.objects.all(): + print(" saving " + str(c)) + c.save() diff --git a/sites/management/commands/add_none_contact.py b/sites/management/commands/add_none_contact.py index 8bfd2d9..8267f10 100755 --- a/sites/management/commands/add_none_contact.py +++ b/sites/management/commands/add_none_contact.py @@ -7,11 +7,14 @@ # https://stackoverflow.com/questions/19475955/using-django-models-in-external-python-script from django.core.management.base import BaseCommand -from sites.models import add_none_contact - +from sites.models import add_none_contact, get_or_add_organization_by_name, add_none_project, add_none_token +from ssop import settings class Command(BaseCommand): - help = "add the NONE email Contact if needed" + help = "adds the NONE email Contact, Organization, and Project if needed" def handle(self, *args, **options): add_none_contact() + get_or_add_organization_by_name(settings.NONE_NAME) + add_none_project() + add_none_token() diff --git a/sites/management/commands/review_contacts.py b/sites/management/commands/review_contacts.py new file mode 100755 index 0000000..0a2e12a --- /dev/null +++ b/sites/management/commands/review_contacts.py @@ -0,0 +1,17 @@ +""" +Add the NONE email Contact if it does not exits. This placeholder is required to make the GUI happy. +""" +from __future__ import unicode_literals + + +# https://stackoverflow.com/questions/19475955/using-django-models-in-external-python-script +from django.core.management.base import BaseCommand + +from sites.models import review_contacts + + +class Command(BaseCommand): + help = "Reviews last connection time of all Contacts and sends a notice" + + def handle(self, *args, **options): + review_contacts() diff --git a/sites/models.py b/sites/models.py index fa37499..e12eae3 100755 --- a/sites/models.py +++ b/sites/models.py @@ -55,6 +55,17 @@ def runcmdl(cmdl, execute): result = "exception: " + str(e) + ", result = " + str(result) return status, result +def get_or_add_authtoken(token): + qs = AuthToken.objects.filter(token=token) + if qs.count() == int(0): + if 'newtoken' in str(token): + rettoken = AuthToken() + else: + rettoken = AuthToken(token=token) + rettoken.save() + else: + rettoken = qs[0] + return rettoken def get_or_add_sysadmin(user, homeorg, orglist): try: @@ -209,11 +220,26 @@ def add_groups_and_permissions(): msg = str(now) + ":GroupobjectAddedPerms:" + groupname logger.info(msg) +def add_none_token(): + token = get_or_add_authtoken(settings.NONE_NAME) + token.set_expire_time(settings.NONE_NEVER_EXPIRES) + token.set_renewed(settings.NONE_NEVER_EXPIRES) + def add_none_contact(): - contacts = Contact.objects.filter(email=settings.NONE_EMAIL) + contacts = Contact.objects.filter(email=settings.NONE_EMAIL, firstname='none', lastname='none') + if contacts.count() == int(0): + newcontact = Contact.objects.create(email=settings.NONE_EMAIL, firstname='none', lastname='none') + newcontact.save() + contacts = Contact.objects.filter(email=settings.ANYONE_EMAIL, firstname='anyone', lastname='anywhere') if contacts.count() == int(0): - newcontact = Contact.objects.create(email=settings.NONE_EMAIL) + newcontact = Contact.objects.create(email=settings.ANYONE_EMAIL, firstname='anyone', lastname='anywhere') newcontact.save() + +def add_none_project(): + project = Project.objects.filter(name=settings.NONE_NAME) + if project.count() == int(0): + newproject = Project.objects.create(name=settings.NONE_NAME) + newproject.save() def add_contacts_list(userlist): for line in userlist.split('\n'): @@ -226,7 +252,213 @@ def add_contacts_list(userlist): if qs.count() == int(0): newcontact = Contact.objects.create(email=email, firstname=firstname, lastname=lastname) newcontact.save() - + +def check_for_update(filename): + #msg = " check_for_update " + str(filename) + #logger.info(msg) + stats = os.stat(filename) + temp = str(filename).replace("/","_",10) + temp = temp.split('_') + fn = 'unknown' + if len(temp) > int(1): + fn = temp[len(temp)-1] + prevfn = "/usr/share/nginx/html/uploads/stats_" + fn + ".prev" + need_to_update = False + + prevctime = 0.0 + if os.path.exists(prevfn): + fp = open(prevfn, 'r') + prevctime = fp.read() + fp.close() + + if float(stats.st_ctime) > float(prevctime): + need_to_update = True + fp = open(prevfn, 'w') + fp.write(str(stats.st_ctime)) + fp.close() + + return need_to_update + + +def review_contacts(): + utcstart = datetime.datetime.utcnow() + utcstart = utcstart.replace(tzinfo=pytz.UTC) + utcstart = utcstart.replace(microsecond = 0) + msg = "\n\n review_contacts -- utcstart: " + str(utcstart) + logger.info(msg) + msg = " CONTACT_RENEWAL_PERIOD_TIMEDELTA: " + str(settings.CONTACT_RENEWAL_PERIOD_TIMEDELTA) + logger.info(msg) + loopcount = int(1) + while True: + checkname = "/opt/ssop/ssop/settings.py" + if check_for_update(checkname): + msg = " need to update -- exiting" + logger.info(msg) + sys.exit(-1) + #else: + # msg = " " + str(checkname) + " is up to date" + # logger.info(msg) + + utcnow = datetime.datetime.utcnow() + utcnow = utcnow.replace(tzinfo=pytz.UTC) + utcnow = utcnow.replace(microsecond = 0) + #msg = str(loopcount) + " -- " + str(utcnow) + #logger.info(msg) + for token in AuthToken.objects.all(): + if utcnow > token.get_expiration_time(): + if settings.NONE_NAME not in str(token): + msg = " delete token: " + str(token) + for user in Contact.objects.all(): + if str(token) in str(user.get_renewal_tokens()): + msg = msg + " for " + str(user) + break + logger.info(msg) + token.delete() + + none_email = str(settings.NONE_EMAIL) + none_email = none_email.replace('#', '') + for user in Contact.objects.all(): + if none_email in str(user.email): + continue + + if str(user.email) not in str(settings.EMAIL_TEST_USERS): + continue + + none_email = str(settings.NONE_EMAIL) + none_email = none_email.replace('#', '') + for user in Contact.objects.all(): + if none_email in str(user.email): + continue + + if str(user.email) not in str(settings.EMAIL_TEST_USERS): + continue + + utcnow = datetime.datetime.utcnow() + utcnow = utcnow.replace(tzinfo=pytz.UTC) + utcnow = utcnow.replace(microsecond = 0) + msg = " utcnow: " + str(utcnow) + #logger.info(msg) + + last_connected = user.last_connected() + msg = " user: " + str(user) + " last_connected: " + str(last_connected) + + renewal_tokens = user.get_renewal_tokens() + num_tokens = renewal_tokens.count() + + msg = msg + " and has " + str(num_tokens) + " renewal tokens" + #logger.info(msg) + + + if num_tokens <= int(1): + renewal_token = get_or_add_authtoken('newtoken') + renewal_token.save() + expires_dt = utcnow + (2 * settings.CONTACT_RENEWAL_PERIOD_TIMEDELTA) + renewal_token.set_expire_time(expires_dt + datetime.timedelta(seconds=30)) + renewal_token.set_renewed(expires_dt) + user.add_renewal_token(renewal_token) + for dt in settings.CONTACT_RENEWAL_PERIOD_WARNINGS: + accessed = expires_dt - dt + renewal_token = get_or_add_authtoken('newtoken') + renewal_token.save() + renewal_token.set_expire_time(expires_dt) + renewal_token.set_renewed(accessed) + user.add_renewal_token(renewal_token) + msg = " " + str(user) + " queued for removal" + logger.info(msg) + + for rt in user.get_renewal_tokens(): + expires = rt.get_expiration_time() + if utcnow > expires: + try: + if settings.NONE_NAME not in str(rt): + rt.delete() + msg = " ----- DELETED token ----- " + str(rt) + " for " + str(user) + " at " + str(utcnow) + logger.info(msg) + except ValueError as ve: + msg = " ----- value error ve: " + str(ve) + logger.info(msg) + + for rt in user.get_renewal_tokens(): + #msg = " rt: " + str(rt) + #logger.info(msg) + if settings.NONE_NAME in str(rt.token): + continue + + msg = " rt: " + str(rt) + #logger.info(msg) + expires = rt.get_expiration_time() + msg = " renewal_token " + str(rt) + " expires at " + str(expires) + #logger.info(msg) + warned = rt.get_renewal_time() + msg = " renewal_token warned: " + str(warned) + #logger.info(msg) + + body_comment = '' + #print(" utcnow + settings.CRPT: " + str(utcnow + settings.CONTACT_RENEWAL_PERIOD_TIMEDELTA)) + #logger.info(msg) + + if utcnow >= warned: + #msg = " utcnow >= warned:" + #logger.info(msg) + #msg = " warned: " + str(warned) + #logger.info(msg) + #msg = " utcnow: " + str(utcnow) + #logger.info(msg) + expires = expires.replace(microsecond = 0) + expiresstr = expires.strftime('%Y-%m-%d %H:%M:%S') + warned = warned.replace(microsecond = 0) + warnedstr = warned.strftime('%Y-%m-%d %H:%M:%S') + lcdt = last_connected.replace(microsecond = 0) + lcdt = lcdt.strftime('%Y-%m-%d %H:%M:%S') + + removed_in_dtdelta = expires - warned + if ',' in str(removed_in_dtdelta): + removed_in_dtdelta = removed_in_dtdelta.replace(', 0:00:00', '') + removed_in_dtdeltastr = str(removed_in_dtdelta) + + kwargs = {"user": user, "lcdt": lcdt, "body_comment": body_comment} + kwargs["renewal_token"] = rt + kwargs["expires_datetime"] = expiresstr + kwargs["warned_datetime"] = warnedstr + kwargs["removed_in_dtdelta"] = removed_in_dtdeltastr + send_removal_notice_email(kwargs) + #msg = "\n ----- SEND_removal_notice_email ----- rt = " + str(rt) + "\n" + #logger.info(msg) + rt.set_renewed(settings.NONE_NEVER_EXPIRES) + #else: + # msg = " utcnow < warned" + # logger.info(msg) + + #msg = " ------ user.get_renewal_tokens().count(): " + str(user.get_renewal_tokens().count()) + #logger.info(msg) + + if user.get_renewal_tokens().count() == int(2): + try: + msg = " ----- account " + str(user) + " deleted ----- " + logger.info(msg) + user.delete() + except ValueError: + msg = " ----- failed to delete account " + str(user) + " ValueError ----- " + logger.info(msg) + + if int(settings.ACCOUNT_REVIEW_NAPTIME) > int(0): + #msg = " sleeping " + str(settings.ACCOUNT_REVIEW_NAPTIME) + ' seconds.' + #logger.info(msg) + time.sleep(settings.ACCOUNT_REVIEW_NAPTIME) + #msg = " done sleeping" + #logger.info(msg) + else: + msg = " exiting .... " + str(settings.ACCOUNT_REVIEW_NAPTIME) + ' seconds.' + logger.info(msg) + sys.exit(-1) + utcnow = datetime.datetime.utcnow() + utcnow = utcnow.replace(tzinfo=pytz.UTC) + utcnow = utcnow.replace(microsecond = 0) + #msg = " end of loop " + str(loopcount) + " at " + str(utcnow) + #logger.info(msg) + loopcount = int(1) + loopcount + + def hash_to_fingerprint(data): dkeys = [] for k in data.keys(): @@ -265,6 +497,15 @@ def get_or_add_organization_by_name(name): org = qs[0] return org +def add_organization_relationship(parent, child): + cqs = Organization.objects.filter(name=child.name) + if cqs.count() == int(1): + child = cqs[0] + pqs = Organization.objects.filter(name=parent.name) + if pqs.count() == int(0): + child.parent = pqs[0] + child.save() + def get_or_add_decrypt_key(keyname=None): key = None if keyname is not None: @@ -370,7 +611,12 @@ def save(self, *args, **kwargs): class Contact(models.Model): firstname = models.CharField(max_length=150, default='firstname') lastname = models.CharField(max_length=150, default='lastname') - email = models.EmailField(null=True, blank='noemail@default.tld') + email = models.EmailField(unique=True, null=True, blank=settings.NONE_EMAIL, help_text=mark_safe(settings.HELP_CONTACT_EMAIL)) + last_connection_time = models.DateTimeField(null=True, default=settings.DEFAULT_DATETIME) + renewal_tokens = models.ManyToManyField('AuthToken', related_name='contact_renewal_tokens') + organizations = models.ManyToManyField('Organization', related_name='contact_organizations') + organization = models.ForeignKey('Organization', default=1, related_name='contact_organization', + verbose_name='Primary Organization', on_delete=models.CASCADE) def __str__(self): return self.firstname + ' ' + self.lastname + ' (' + str(self.email) + ')' @@ -382,13 +628,43 @@ def save(self, *args, **kwargs): def initstate(self): need_to_update = False - if self.email: + if settings.NONE_EMAIL not in self.email: + email = str(self.email).lower() + namestr, domains = str(self.email).split('@') if 'firstname' in self.firstname: - namestr, domain = str(self.email).split('@') namestr = str(namestr).split('.') self.firstname = namestr[0].capitalize() self.lastname = namestr[len(namestr) - 1].capitalize() + self.last_connection_time = settings.DEFAULT_DATETIME need_to_update = True + + try: + if settings.NONE_NAME in str(self.organization): + domains = str(domains).split('.') + #msg = " domains: " + str(domains) + #logger.info(msg) + homeorg = get_or_add_organization_by_name(domains[0]) + #msg = " homeorg: " + str(homeorg) + #logger.info(msg) + homeorg = get_or_add_organization_by_name(domains[0]) + childorg = homeorg + self.organization = homeorg + self.organizations.add(homeorg) + self.last_connection_time = settings.DEFAULT_DATETIME + for d in domains: + neworg = get_or_add_organization_by_name(d) + #msg = " neworg: " + str(neworg) + #logger.info(msg) + self.organizations.add(neworg) + if neworg == childorg: + continue + add_organization_relationship(neworg, childorg) + #msg = "add_organization_relationship( " + str(neworg) + ", " + str(childorg) + " )" + #logger.info(msg) + childorg = neworg + need_to_update = True + except ValueError: + pass return need_to_update def get_value(self): @@ -398,6 +674,61 @@ def get_value(self): rv['email'] = self.email return str(rv) + def last_connected(self): + return self.last_connection_time + + def set_connected(self): + utcnow = datetime.datetime.utcnow() + utcnow = utcnow.replace(tzinfo=pytz.UTC) + self.last_connection_time = utcnow + self.save() + + def last_renewed(self): + return self.renewal_time + + def get_renewal_tokens(self): + try: + qs = self.renewal_tokens.get_queryset() + except: + qs = AuthToken.objects.filter(token=settings.NONE_NAME) + return qs + + def add_renewal_token(self, token): + self.renewal_tokens.add(token) + self.save() + + def set_renewed(self): + utcnow = datetime.datetime.utcnow() + utcnow = utcnow.replace(tzinfo=pytz.UTC) + self.last_connection_time = utcnow + #msg = " set_renewed --- Contact " + str(self) + " lct = " + str(self.last_connection_time) + #logger.info(msg) + for t in self.get_renewal_tokens(): + if settings.NONE_NAME not in str(t): + t.delete() + msg = " delete token t: " + str(t) + logger.info(msg) + self.save() + #msg = " set_renewed --- Contact self " + str(self) + " last connected = " + str(self.last_connected()) + #logger.info(msg) + + def organizations_list(self): + olist = [] + for o in self.organizations.get_queryset(): + olist.append(str(o)) + if len(olist) > int(1): + olist.sort + return olist + + def projects(self): + projects = [] + for p in Project.objects.all(): + if p.authorized(self): + projects.append(str(p)) + if len(projects) > int(1): + projects.sort() + return projects + class Project(models.Model): name = models.CharField(max_length=150, default='newproject', help_text=mark_safe(settings.HELP_NAME)) @@ -416,6 +747,7 @@ class Project(models.Model): graphnode = models.ForeignKey('sites.GraphNode', null=True, blank=True, on_delete=models.SET_NULL, help_text=mark_safe(settings.HELP_GRAPHNODE)) logoimg = models.ImageField(upload_to = get_upload_path, verbose_name="Logo Image", null=True, blank=True, help_text=mark_safe(settings.HELP_LOGOIMG)) logobin = models.BinaryField(null=True, blank=True) + owner = models.ForeignKey('sites.Contact', null=True, blank=True, on_delete=models.SET_NULL, help_text=mark_safe(settings.HELP_PROJECT_OWNER)) userlist = models.ManyToManyField('sites.Contact', related_name='project_userlist', help_text=mark_safe(settings.HELP_USERLIST)) app_params = models.TextField(max_length=4096, null=True, default='{}', help_text=mark_safe(settings.HELP_APP_PARAMS)) @@ -472,8 +804,8 @@ def initstate(self): if str(imgpath).endswith(ft): filetype = ft break - msg = " filetype is: " + str(filetype) - logger.info(msg) + #msg = " filetype is: " + str(filetype) + #logger.info(msg) if filetype: if os.path.exists(imgpath): os.rename(imgpath, destination_filename) @@ -485,8 +817,8 @@ def initstate(self): imgpath = ' ve: ' + str(ve) except SuspiciousFileOperation as sfo: imgpath = ' sfo: ' + str(sfo) - msg = " imgpath is: " + str(imgpath) - logger.info(msg) + #msg = " imgpath is: " + str(imgpath) + #logger.info(msg) if not settings.DEBUG: if not self.expiretokens: @@ -549,6 +881,15 @@ def get_decode_key(self): pass return key + def get_did(self): + did = None + try: + ale = ast.literal_eval(self.get_app_params()) + did = ale['fernet-key-id'] + except KeyError: + pass + return did + def is_enabled(self): return self.enabled @@ -587,27 +928,30 @@ def users(self): return retstr def authorized(self, uu): - msg = " authorized uu: " + str(uu) - logger.info(msg) - #uu = ast.literal_eval(uustr) - #msg = " uu: " + str(uu) + #msg = " authorized uu: " + str(uu) #logger.info(msg) + auth = False + if settings.DEFAULT_PROJECT_NAME in self.name: + auth = True + if self.name.startswith('demo'): + auth = True + try: email = uu['email'] except KeyError: email = 'noemail' email = str(email).lower() - msg = " email: " + str(email) - logger.info(msg) for a in self.userlist.get_queryset(): - msg = " a: " + str(a) - logger.info(msg) if email in str(a).lower(): auth = True break - msg = " authorized auth: " + str(auth) - logger.info(msg) + if auth: + msg = str(email) + " is authorized" + else: + msg = str(email) + " NOT AUTHORIZED" + #msg = msg + " for " + self.name + #logger.info(msg) return auth def contact_emails(self): @@ -743,13 +1087,30 @@ def expire_token(self): self.expires = mindt.replace(tzinfo=pytz.UTC) self.save() + def get_created_time(self): + return self.created + + def get_expiration_time(self): + return self.expires + + def set_expire_time(self, expires): + self.expires = expires + self.save() + + def get_renewal_time(self): + return self.accessed + + def set_renewed(self, renewed): + self.accessed = renewed + self.save() + class Connection(models.Model): name = models.CharField(max_length=150, default='setme') project = models.ForeignKey(Project, null=True, blank=True, on_delete=models.SET_NULL) attrsgroup = models.ForeignKey('sites.AttributeGroup', null=True, blank=True, on_delete=models.CASCADE, related_name='sites_Connection_attrsgroup') uniqueuser = models.ForeignKey('sites.Uniqueuser', null=True, blank=True, on_delete=models.CASCADE, related_name='sites_Connection_uniqueuser', verbose_name='Unique User') - token = models.ForeignKey(AuthToken, null=True, blank=True, on_delete=models.SET_NULL) + token = models.ForeignKey(AuthToken, null=True, blank=True, on_delete=models.CASCADE) connection_state = models.CharField(max_length=50, null=True, default='setme', help_text=mark_safe(settings.HELP_CONNECTION_STATE)) created = models.DateTimeField(auto_now_add=True) loggedout = models.DateTimeField(default=now) @@ -785,24 +1146,14 @@ def get_connection_state(self): def get_ca(self): ca = [] - msg = " model -- get_ca()" - logger.info(msg) for a in self.attrsgroup.get_attrs(): - msg = " model -- get_ca() -- a: " + str(a) - logger.info(msg) ca.append(a.get_attributes()) return ca def get_request_attributes(self): - msg = " model -- get_request_attributes()" - logger.info(msg) attributes = [] for ca in self.get_ca(): - msg = " ca: " + str(ca) - logger.info(msg) attributes.append(ca) - msg = " attributes: " + str(attributes) - logger.info(msg) return attributes def get_ua(self): @@ -999,29 +1350,12 @@ def connattributes(self): return str(at) -class OrganizationNode(models.Model): - parent = models.ForeignKey('sites.Organization', null=True, blank=True, on_delete=models.CASCADE, related_name='organization_parent') - child = models.ForeignKey('sites.Organization', null=True, blank=True, on_delete=models.CASCADE, related_name='organization_child') - - def __str__(self): - return str(self.parent) + " has " + str(self.child) - - def name(self): - return str(self.parent) - - def leaf(self): - return str(self.child) - - def get_fields(self): - return [(field.name, getattr(self,field.name)) for field in OrganizationNode._meta.fields] - - class Organization(models.Model): name = models.CharField(max_length=50, null=True, default='unknownOrganization') - userlist = models.ManyToManyField('sites.Contact', related_name='organization_userlist') projects = models.ManyToManyField(Project, related_name='organization_projects') updated = models.DateTimeField(auto_now_add=True) graphnode = models.ForeignKey('sites.GraphNode', null=True, blank=True, on_delete=models.SET_NULL) + parent = models.ForeignKey('sites.Organization', null=True, blank=True, on_delete=models.CASCADE, related_name='organization_parent') class Meta: verbose_name = 'Organization' @@ -1075,11 +1409,15 @@ def get_fields(self): retlist.append((k,v)) return retlist - def contacts(self): + def users(self): users = [] - for a in self.userlist.get_queryset(): - users.append(str(a)) - return str(users) + #msg = str(self.projects.get_queryset()) + #logger.info(msg) + for p in self.projects.get_queryset(): + users.append(p.users()) + if len(users) > int(1): + users.sort() + return users def contact_emails(self): users = [] @@ -1358,7 +1696,7 @@ def organizations_id_list(self): @receiver(user_has_authenticated) def post_auth_user_has_authenticated(sender, **kwargs): now = datetime.datetime.utcnow() - msg = str(now) + ":post_auth_user_has_authenticated = " + str(kwargs['user'].username) + msg = str(now) + ":post_auth_user_has_authenticated:" + str(kwargs['user'].username) logger.info(msg) is_user_a_sysad(**kwargs) user_has_authenticated_sendemail(**kwargs) @@ -1476,3 +1814,89 @@ def colors(self): colors = ('white', 'red') return colors + +def send_removal_notice_email(kwargs): + #msg = "\n\n\nsend_removal_notice_email:\n" + #logger.info(msg) + + email = kwargs['user'].email + firstname = kwargs['user'].firstname + last_connected_datetime = str(kwargs['lcdt']) + expires_datetime = str(kwargs['expires_datetime']) + warned_datetime = str(kwargs['warned_datetime']) + removed_in_dtdelta = str(kwargs['removed_in_dtdelta']) + body_comment = str(kwargs['body_comment']) + renewal_token = str(kwargs['renewal_token']) + ymdhms = datetime.datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S') + subject = settings.USER_REMOVAL_NOTICE_SUBJECT + + projects = {} + #msg = " email: " + str(email) + #logger.info(msg) + for p in Project.objects.all(): + if str(p).lower().startswith(settings.NONE_NAME): + continue + if p.authorized({"email": email}): + if not p.name.startswith('demo') and not p.name.startswith('showattr'): + try: + test = projects[str(p)] + except KeyError: + projects[str(p)] = {} + projects[str(p)]['link'] = settings.LDG_BASE + 'ldg/' + p.name + projects[str(p)]['owner'] = str(p.owner) + project_list = [] + project_names = [] + for k in projects.keys(): + project_names.append(str(k)) + if len(project_names) > int(1): + project_names.sort() + info = '\n' + for n in project_names: + info = info + ' ' + str(n) + " at " + str(projects[n]['link']) + ' owned by ' + str(projects[n]['owner']) + '\n' + project_list = info + #removed_in_dtdelta = str(settings.CONTACT_RENEWAL_PERIOD_TIMEDELTA).split('.')[0] + #if ',' in removed_in_dtdelta: + # removed_in_dtdelta = removed_in_dtdelta.replace(', 0:00:00', '') + renewal_link = settings.EXTERNAL_URL + 'sites/renew/' + str(renewal_token) + remove_account_link = settings.EXTERNAL_URL + 'sites/remove/' + str(renewal_token) + + body = settings.USER_REMOVAL_NOTICE_BODY + body = body.replace( 'firstname', firstname ) + body = body.replace( 'ymdhms', ymdhms ) + body = body.replace( 'last_connected_datetime', last_connected_datetime ) + body = body.replace( 'project_list', project_list ) + body = body.replace( 'renewal_link', renewal_link ) + body = body.replace( 'remove_account_link', remove_account_link ) + body = body.replace( 'body_comment', body_comment ) + body = body.replace( 'removed_in_dtdelta', removed_in_dtdelta ) + body = body.replace( 'expires_datetime', expires_datetime ) + body = body.replace( 'warned_datetime', warned_datetime ) + + fromaddr = settings.EMAIL_HOST_USER + toaddr = [email] + try: + SEND_EMAIL = False + if settings.DEBUG: + if str(email) in str(settings.EMAIL_TEST_USERS): + #msg = " send_mail(subject, body, fromaddr, toaddr, fail_silently=False)" + #msg = msg + ' -- toaddr: ' + str(toaddr) + #msg = msg + ' -- fromaddr: ' + str(fromaddr) + #msg = msg + ' -- subject: ' + str(subject) + #msg = msg + ' -- body: ' + str(body) + #logger.info(msg) + SEND_EMAIL = True + + if SEND_EMAIL: + send_mail(subject, body, fromaddr, toaddr, fail_silently=False) + #msg = ' renewal_link: ' + str(renewal_link) + #logger.info(msg) + #msg = ' remove_account_link: ' + str(remove_account_link) + #logger.info(msg) + #else: + # msg = "DEBUG -- NOT running: send_mail(subject, 'BODY', fromaddr, toaddr, fail_silently=False)" + # logger.info(msg) + except Exception as e: + now = datetime.datetime.utcnow() + msg = str(now) + ":User has logged in email failed:" + str(email) + ":send_notice_email:" + str(e) + logger.info(msg) + diff --git a/sites/urls.py b/sites/urls.py index ef05f18..f2e3fc7 100755 --- a/sites/urls.py +++ b/sites/urls.py @@ -17,7 +17,7 @@ from django.contrib import admin from django.urls import include, path -from sites.views import getattrs, index, project_ldg, showattrs, attrsjwt, demoapp_python, demoapp_authorization, oops, connections_by_project, generate_urlsafe_token, generate_fernet_key, pubcert, project_userlist, at2uu, get_cwd +from sites.views import getattrs, index, project_ldg, showattrs, attrsjwt, demoapp_python, demoapp_authorization, oops, connections_by_project, generate_urlsafe_token, generate_fernet_key, pubcert, project_userlist, at2uu, get_cwd, get_did, renew_access, remove_access app_name = 'sites' @@ -38,5 +38,9 @@ path('fernetkey/', generate_fernet_key, name='fernetkey'), path('pubcert/', pubcert), path('getcwd/', get_cwd), + path('getdid//', get_did, name='get_did'), + path('getdid//', get_did, name='get_did'), + path('renew//', renew_access, name='renew_access'), + path('remove//', remove_access, name='remove_access'), ] diff --git a/sites/views.py b/sites/views.py index d9861d4..e0cebf6 100755 --- a/sites/views.py +++ b/sites/views.py @@ -24,7 +24,7 @@ from bs4 import BeautifulSoup from sites.forms import ProjectForm -from sites.models import Attributes, AttributeGroup, AuthToken, GraphNode, NodeType, Connection, Project, Uniqueuser, hash_to_fingerprint, clean_authtokens, runcmdl, Room +from sites.models import Attributes, AttributeGroup, AuthToken, Contact, GraphNode, NodeType, Connection, Project, Uniqueuser, hash_to_fingerprint, clean_authtokens, runcmdl, Room logger = logging.getLogger('ssop.models') @@ -75,15 +75,15 @@ def ldg(request, project_name = None): if project_name is None: project_name = settings.DEFAULT_PROJECT_NAME - msg = " ldg project_name is now: " + str(project_name) - logger.info(msg) + if settings.VERBOSE: + msg = " ldg project_name is now: " + str(project_name) + logger.info(msg) project_state = settings.LOGINDOTGOV_LOGIN_STATE if project_name: qs = Project.objects.filter(name=project_name) if qs.count() == int(1): qs = qs[0] - msg = " ldg project_name: " + str(project_name) project_state = qs.get_connection_state() else: error_msg = "Sorry, project " + str(project_name) + " was not found." @@ -95,12 +95,9 @@ def ldg(request, project_name = None): linktext = p.get_verbose_name() alt = 'Logo for project ' + str(p) attributes.append((str(p), link, linktext, logo, alt, lmr.next())) - msg = " attributes: " + str(attributes) - logger.info(msg) return render(request, 'sites_grid.html', {'attributes': attributes, 'error_msg': error_msg}) if 'ldg' in str(request): - #login = 'https://idp.int.identitysandbox.gov/openid_connect/authorize?' login = settings.LOGINDOTGOV_IDP_SERVER + '/openid_connect/authorize?' login = login + "acr_values=" + settings.LOGINDOTGOV_ACR @@ -111,11 +108,9 @@ def ldg(request, project_name = None): login = login + "response_type=code&" login = login + "scope=" + settings.LOGINDOTGOV_SCOPE + "&" login = login + "state=" + project_state - if settings.VERBOSE: msg = ' ldg login HttpResponseRedirect( ' + str(login) + ' )' logger.info(msg) - return HttpResponseRedirect(login) else: HttpResponseRedirect(settings.LOGINDOTGOV_ERROR_REDIRECT) @@ -203,6 +198,9 @@ def get_authtoken(access_token): return token def ldg_authenticated(request): + msg = " ldg_authenticated request: " + str(request) + logger.info(msg) + attributes = [] #requestattrs = None #userattrs = None @@ -296,11 +294,7 @@ def ldg_authenticated(request): attributes.append(('error_redirect', error_redirect)) attributes.append(('state', state)) app_params = qs.get_app_params() - msg = " app_params: " + str(app_params) - logger.info(msg) aleap = ast.literal_eval(app_params) - msg = " aleap: " + str(aleap) - logger.info(msg) for alk in aleap.keys(): atmp = {} atmp[alk] = aleap[alk] @@ -310,8 +304,6 @@ def ldg_authenticated(request): encrypted_temp = fernet.encrypt(temp) tempattrs = attributesFromDecodedFp(tempfp, encrypted_temp) connattrslist.append(tempattrs) - msg = " connection attrslist append: " + str(tempattrs) - logger.info(msg) if settings.VERBOSE: msg = " state: " + str(state) @@ -336,8 +328,8 @@ def ldg_authenticated(request): if settings.VERBOSE: msg = " assertion: " + str(assertion) logger.info(msg) - msg = " settings.LOGINDOTGOV_PRIVATE_CERT: " + str(settings.LOGINDOTGOV_PRIVATE_CERT) - logger.info(msg) + #msg = " settings.LOGINDOTGOV_PRIVATE_CERT: " + str(settings.LOGINDOTGOV_PRIVATE_CERT) + #logger.info(msg) signedassertion = jwt.encode(assertion, settings.LOGINDOTGOV_PRIVATE_CERT, algorithm="RS256") data = "client_assertion_type=" + settings.LOGINDOTGOV_CLIENT_ASSERTION_TYPE + "&" @@ -355,8 +347,8 @@ def ldg_authenticated(request): msg = " data: " + str(data) logger.info(msg) - curlcmd = 'curl -v -x ' + settings.HTTP_PROXY + ' -d "' + str(data) + '" ' + tokenurl - logger.info(curlcmd) + #curlcmd = 'curl -v -x ' + settings.HTTP_PROXY + ' -d "' + str(data) + '" ' + tokenurl + #logger.info(curlcmd) tokenresponse = requests.post(url=tokenurl, data=data, proxies=proxies) if settings.VERBOSE: @@ -387,7 +379,7 @@ def ldg_authenticated(request): # curl headers need str vs {} for requests.get cheaders = "Authorization: Bearer " + str(accesstoken) curlcmd = 'curl -v -x ' + settings.HTTP_PROXY + ' -H "' + cheaders + '" ' + infourl - logger.info(curlcmd) + #logger.info(curlcmd) attributes.append(('AuthorizationBearer', str(accesstoken))) @@ -406,14 +398,14 @@ def ldg_authenticated(request): for attr in attstr.split(','): attr = attr.replace('{', '') attr = attr.replace('}', '') - if settings.VERBOSE: - msg = " attr = " + str(attr) - logger.info(msg) + #if settings.VERBOSE: + # msg = " attr = " + str(attr) + # logger.info(msg) v = str(attr).split(':') - if settings.VERBOSE: - msg = " v = " + str(v) - logger.info(msg) + #if settings.VERBOSE: + # msg = " v = " + str(v) + # logger.info(msg) try: key = str(v[0]).replace('"', '', 10) @@ -438,20 +430,20 @@ def ldg_authenticated(request): nameattrsgroup = attributeGroupFromAttributes(namegrouptype, uuattrslist) authorized = project.authorized(uu) - msg = " uu " + str(uu) + " is " + str(authorized) - logger.info(msg) + #msg = " uu " + str(uu) + " is " + str(authorized) + #logger.info(msg) - try: - connattrsgroup = attributeGroupFromAttributes(conngrouptype, connattrslist) - except KeyError: - msg = " no connattrsgroup" - logger.info(msg) + if project.authorized(uu): + try: + connattrsgroup = attributeGroupFromAttributes(conngrouptype, connattrslist) + except KeyError: + msg = " no connattrsgroup for " + str(conngrouptype) + " and " + str(connattrslist) + logger.info(msg) - if settings.VERBOSE: - msg = " ldg_authenticated request: " + str(request) - logger.info(msg) + if settings.VERBOSE: + msg = " ldg_authenticated request: " + str(request) + logger.info(msg) - if project.authorized(uu) or settings.DEFAULT_PROJECT_NAME in project.name: uufp = hash_to_fingerprint(uu) uniqueuser = uuFromFp(uufp, nameattrsgroup, connattrsgroup) authtoken = get_authtoken(accesstoken) @@ -459,15 +451,29 @@ def ldg_authenticated(request): connection = Connection(project=project, attrsgroup=connattrsgroup, token=authtoken, connection_state=connection_state, uniqueuser=uniqueuser) connection.save() + qs = Contact.objects.filter(email=uu['email']) + #msg = " Contact qs.count() = " + str(qs.count()) + " for " + str(uu['email']) + #logger.info(msg) + if qs.count() > int(0): + contact = qs[0] + #msg = " contact " + str(contact) + " set_connected()" + #logger.info(msg) + contact.set_connected() + #msg = " contact " + str(contact) + " set_renewed()" + #logger.info(msg) + contact.set_renewed() + # update the connections map #(imageattributes, debugprint) = make_connections_by_project_img() if settings.VERBOSE: msg = " authtoken: " + str(authtoken) logger.info(msg) - msg = " attributes: " + str(attributes) - logger.info(msg) + #msg = " attributes: " + str(attributes) + #logger.info(msg) else: - authtoken = 'no-accesstoken-for-uu:' + authtoken = 'Access not allowed. Please contact the project sponsor.' + attributes.append(('Authorization', authtoken)) + return render(request, 'attrs.html', {'paint_logout': False, 'attributes': attributes}) if RETURN_TO: request.session["Authorization"] = "Bearer " + str(authtoken) @@ -476,23 +482,39 @@ def ldg_authenticated(request): if settings.VERBOSE: msg = " return_to: " + str(return_to) logger.info(msg) - return HttpResponseRedirect(return_to) + + return_domain = return_to.split('/') + try: + return_domain = return_domain[0] + '//' + return_domain[2] + except KeyError: + pass + + csp = "default-src 'none'; " + for src in ["script-src", "connect-src", "img-src", "style-src", "base-uri", "form-action"]: + csp = csp + src + " 'self' " + return_domain + "; " + csp = csp + "object-src 'none'; frame-ancestors 'none'; block-all-mixed-content" + + responseheaders = {} + responseheaders['content-security-policy'] = csp + return HttpResponseRedirect(return_to, headers=responseheaders) else: logouturl = settings.LDG_BASE + 'logout/' + str(connection_state) msg = " logouturl: " + str(logouturl) logger.info(msg) return render(request, 'attrs.html', {'paint_logout': True, 'attributes': attributes, 'logouturl': logouturl}) else: - msg = " project_state " + str(connection_state) + " is not a logindotgov state: " + str(state) - logger.info(msg) + if settings.VERBOSE: + msg = " project_state " + str(connection_state) + " is not a logindotgov state: " + str(state) + logger.info(msg) return HttpResponseRedirect(ERROR_REDIRECT) def logout(request, connection_state = None): - msg = " logout request: " + str(request) - logger.info(msg) - msg = " logout connection_state: " + str(connection_state) - logger.info(msg) + if settings.VERBOSE: + msg = " logout request: " + str(request) + logger.info(msg) + msg = " logout connection_state: " + str(connection_state) + logger.info(msg) if 'logout' in str(request) and connection_state is not None: qs = Connection.objects.filter(connection_state=connection_state) @@ -509,8 +531,9 @@ def logout(request, connection_state = None): logout = logout + "state=" + connection_state - msg = ' logout HttpResponseRedirect( ' + str(logout) + ' )' - logger.info(msg) + if settings.VERBOSE: + msg = ' logout HttpResponseRedirect( ' + str(logout) + ' )' + logger.info(msg) return HttpResponseRedirect(logout) else: return HttpResponseRedirect(settings.LDG_BASE + "oops") @@ -800,10 +823,9 @@ def redact_ua(ua): qs[0].redact_attr() def attrsjwt(request, access_token = None): - msg = " attrsjwt -- request = " + str(request) - logger.info(msg) - msg = " attrsjwt -- access_token = " + str(access_token) - logger.info(msg) + if settings.VERBOSE: + msg = " attrsjwt -- access_token = " + str(access_token) + logger.info(msg) signedattributes = None if access_token: @@ -818,8 +840,9 @@ def attrsjwt(request, access_token = None): # disabled for debugging -- remember it is a one time token fetchedtoken = connection.token.get_token() if str(fetchedtoken) not in str(authtoken): - msg = "fetchedtoken not equal authtoken for " + str(fetchedtoken) + " and " + str(authtoken) - logger.info(msg) + if settings.VERBOSE: + msg = "fetchedtoken not equal authtoken for " + str(fetchedtoken) + " and " + str(authtoken) + logger.info(msg) signedattributes = fetchedtoken fetchedtoken = None else: @@ -827,8 +850,8 @@ def attrsjwt(request, access_token = None): connection.token.expire_token() encode_key = connection.project.get_decode_key() user_attributes = connection.get_user_attributes() - msg = " user_attributes: " + str(user_attributes) - logger.info(msg) + #msg = " user_attributes: " + str(user_attributes) + #logger.info(msg) dar = Fernet(settings.DATA_AT_REST_KEY_ATTRS) alldecrypted = '\n' @@ -844,21 +867,21 @@ def attrsjwt(request, access_token = None): alldecrypted = decrypteddata + ',' + alldecrypted request_attributes = connection.get_request_attributes() - msg = " request_attributes: " + str(request_attributes) - logger.info(msg) + #msg = " request_attributes: " + str(request_attributes) + #logger.info(msg) for ra in request_attributes: #msg = ' request_attribute ra: ' + str(ra) #logger.info(msg) aledar = ast.literal_eval(ra) - msg = ' aledar: ' + str(aledar) - logger.info(msg) + #msg = ' aledar: ' + str(aledar) + #logger.info(msg) dataatrest = bytes_in_string(aledar) #msg = ' dataatrest: ' + str(dataatrest) #logger.info(msg) decrypteddata = dar.decrypt(dataatrest).decode() - msg = " decrypteddata: " + str(decrypteddata) - logger.info(msg) + #msg = " decrypteddata: " + str(decrypteddata) + #logger.info(msg) if decrypteddata.startswith('{') and decrypteddata.endswith('}'): alldecrypted = decrypteddata + ',' + alldecrypted @@ -867,34 +890,49 @@ def attrsjwt(request, access_token = None): if len(alldecrypted) > int(1): alldecrypted = alldecrypted[:-2] - msg = " alldecrypted: " + str(alldecrypted) - logger.info(msg) + #msg = " alldecrypted: " + str(alldecrypted) + #logger.info(msg) dit = Fernet(encode_key) data_in_transit = dit.encrypt(alldecrypted.encode()) - msg = " data_in_transit: " + str(data_in_transit) - logger.info(msg) + #msg = " data_in_transit: " + str(data_in_transit) + #logger.info(msg) attrs = {} attrs['dit'] = str(data_in_transit) - msg = " attrs = " + str(attrs) - logger.info(msg) + #msg = " attrs = " + str(attrs) + #logger.info(msg) signedattributes = jwt.encode(attrs, settings.JWT_PRIVATE_KEY, algorithm="RS256") - msg = " leaving attrsjwt -- access_token = " + str(access_token) - logger.info(msg) - msg = " attrsjwt -- signedattributes = " + str(signedattributes) - logger.info(msg) + #msg = " leaving attrsjwt -- access_token = " + str(access_token) + #logger.info(msg) + #msg = " attrsjwt -- signedattributes = " + str(signedattributes) + #logger.info(msg) else: msg = " cqs.count() is zero for access_token " + str(access_token) logger.info(msg) - msg = " attrsjwt -- return signedattributes " + str(signedattributes) - logger.info(msg) + if settings.VERBOSE: + msg = " attrsjwt -- return signedattributes " + str(signedattributes) + logger.info(msg) return render(request, 'signedattrs.html', {'attributes': signedattributes}) +def get_did(request, access_token = None): + if settings.VERBOSE: + msg = " get_did -- access_token = " + str(access_token) + logger.info(msg) + + fernet_key_id = None + if access_token: + aqs = AuthToken.objects.filter(token=access_token) + authtoken = None + if aqs.count() > int(0): + authtoken = aqs[0] + cqs = Connection.objects.filter(token=authtoken) + if cqs.count() > int(0): + fernet_key_id = cqs[0].project.get_did() + return render(request, 'did.html', {'did': fernet_key_id}) + def at2uu(request, access_token = None): - msg = " at2uu -- request = " + str(request) - logger.info(msg) msg = " at2uu -- access_token = " + str(access_token) logger.info(msg) @@ -1072,16 +1110,16 @@ def demoapp_python(request): template = srcfile.read() data = {} - msg = " demoapp request -- request = " + str(request) - logger.info(msg) + #msg = " demoapp request -- request = " + str(request) + #logger.info(msg) data['request'] = request access_token = None if 'access_token=' in str(request): (junk, access_token) = str(request).split('=') access_token = access_token[:-2] - msg = " demoapp landing -- access_token = " + str(access_token) - logger.info(msg) + #msg = " demoapp landing -- access_token = " + str(access_token) + #logger.info(msg) try: msg = " request.headers = " + str(request.headers) @@ -1232,6 +1270,59 @@ def ldg_auth_error(request): data = "Oopps, something went wrong!" return render(request, 'demoapp.html', {'data': data}) + +def renew_access(request, access_token = None): + data = "renew_access request: " + str(request) + #data = data + "renew_access access_token: " + str(access_token) + if access_token: + authtoken = None + aqs = AuthToken.objects.filter(token=access_token) + if aqs.count() > int(0): + authtoken = aqs[0] + data = data + ' authtoken = ' + str(authtoken) + authtoken.expire_token() + tokenlist = [] + tokenlist.append(authtoken) + cqs = Contact.objects.filter(renewal_tokens__in=tokenlist) + if cqs.count() > int(0): + cqs[0].set_renewed() + data = 'Thanks! You are all set for another ' + str(settings.CONTACT_RENEWAL_PERIOD_DAYS) + ' days!' + else: + #data = data + " cqs.count = " + str(cqs.count()) + data = "Oopps, something went wrong! Token " + str(access_token) + " no longer exists." + else: + #data = data + " aqs.count = " + str(aqs.count()) + data = "Oopps, something went wrong! Token " + str(access_token) + " no longer exists." + return render(request, 'renewed.html', {'data': data}) + +def remove_access(request, access_token = None): + #data = "remove_access request: " + str(request) + #data = data + "remove_access access_token: " + str(access_token) + data = '' + if access_token: + authtoken = None + aqs = AuthToken.objects.filter(token=access_token) + if aqs.count() == int(1): + authtoken = aqs[0] + data = data + ' authtoken = ' + str(authtoken) + authtoken.expire_token() + tokenlist = [] + tokenlist.append(authtoken) + cqs = Contact.objects.filter(renewal_tokens__in=tokenlist) + if cqs.count() > int(0): + msg = ' Account ' + str(cqs[0]) + ' has been deleted on request.' + logger.info(msg) + cqs[0].delete() + data = 'Your account has been deleted as requested.' + else: + data = 'Ooopps! We were unable to delete your account. Please contact ssopadmin.gsl@noaa.gov' + msg = data + ' --- authtoken = ' + str(authtoken) + logger.info(msg) + else: + data = 'Ooopps! Something went wrong. Please contact ssopadmin.gsl@noaa.gov' + return render(request, 'renewed.html', {'data': data}) + + def graphnodeByLabelPnameNodetype(name, pname, nodetype): qs = GraphNode.objects.filter(name=name, projectname=pname, nodetype=nodetype) if qs.count() == int(0): @@ -2629,8 +2720,8 @@ def index(request): # logger.info(msg) username = str(email) - msg = ' username from SAML attributes: ' + str(username) - logger.info(msg) + #msg = ' username from SAML attributes: ' + str(username) + #logger.info(msg) user = None qs = usermodel.objects.filter(email=email) diff --git a/ssop/context_processors.py b/ssop/context_processors.py index a06faad..cf325f3 100755 --- a/ssop/context_processors.py +++ b/ssop/context_processors.py @@ -45,3 +45,12 @@ def cwd_refresh_rate(request): extra_context = {'cwd_refresh_rate': settings.PAGE_REFRESH_RATE} return extra_context +def lapse_in_appropriations(request): + extra_context = {'lapse_in_appropriations': False} + if settings.LAPSE_IN_APPROPRIATIONS: + extra_context['lapse_in_appropriations'] = True + extra_context['lapse_in_appropriations_message_top'] = settings.LAPSE_IN_APPROPRIATIONS_MESSAGE_TOP + extra_context['lapse_in_appropriations_link'] = settings.LAPSE_IN_APPROPRIATIONS_LINK + extra_context['lapse_in_appropriations_message_bottom'] = settings.LAPSE_IN_APPROPRIATIONS_MESSAGE_BOTTOM + return extra_context + diff --git a/ssop/settings.py b/ssop/settings.py index c4214f0..e3ac29e 100755 --- a/ssop/settings.py +++ b/ssop/settings.py @@ -34,9 +34,19 @@ # ... + # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -SSOPSB_VERSION = 'beta -- ' + datetime.datetime.utcnow().strftime('%Y.%m.%d') +SSOPSB_VERSION = 'v1.1.2 -- ' + datetime.datetime.utcnow().strftime('%Y.%m.%d') + +# Enables a 'shutdown' banner For those times when Congress cannot get its act together +LAPSE_IN_APPROPRIATIONS = False +LAPSE_IN_APPROPRIATIONS_LINK = "https://www.commerce.gov/news/blog/2023/10/us-department-commerce-plan-orderly-shutdown-due-lapse-congressional" +LAPSE_IN_APPROPRIATIONS_MESSAGE_TOP = "" +LAPSE_IN_APPROPRIATIONS_MESSAGE_TOP = "TESTING, TESTING, TESTING ---- PLEASE IGNORE ----- " +LAPSE_IN_APPROPRIATIONS_MESSAGE_TOP = LAPSE_IN_APPROPRIATIONS_MESSAGE_TOP + "Parts of the U.S. government are closed. This site will not be updated; however, NOAA websites and social media channels necessary to protect lives and property will be maintained. To learn more, visit commerce.gov." +LAPSE_IN_APPROPRIATIONS_MESSAGE_BOTTOM = "For the latest forecasts and critical weather information, visit weather.gov. *Please note: Some Funding Opportunities offered under the Bipartisan Infrastructure Law and Inflation Reduction Act are open and can be applied for during the partial government shutdown." +LAPSE_IN_APPROPRIATIONS_MESSAGE_BOTTOM = LAPSE_IN_APPROPRIATIONS_MESSAGE_BOTTOM + " ---- END TESTING -----" # https://stackoverflow.com/questions/42077532/django-security-and-settings with open(os.path.join(BASE_DIR, 'secrets.json')) as secrets_file: @@ -60,6 +70,12 @@ def get_secret(key): DEBUG = True DEBUG_SAML_DEBUG = False VERBOSE = False +DEBUG_VERBOSE = False + + + + + # Organization structure @@ -70,8 +86,6 @@ def get_secret(key): "4": {"name": "GSL-ITS", "parent": "GSL", "contact": "Scott Nahman", "email": "scott.nahman@noaa.gov"}, "5": {"name": "GSL-WIDS", "parent": "GSL", "contact": "Dan Nietfeld", "email": "dan.nietfeld@noaa.gov"}, "6": {"name": "GSL-WIDS-WIZARD", "parent": "GSL-WIDS", "contact": "Jebb Stewart", "email": "jebb.q.stewart@@noaa.gov"}, - "7": {"name": "GSL-ASCEND", "parent": "GSL", "contact": "Curtis Alexander", "email": "curtis.alexander@noaa.gov"}, - "8": {"name": "PMEL", "parent": "OAR", "contact": "Eugene Berger", "email": "eugene.berger@noaa.gov"} } # SSO @@ -115,17 +129,18 @@ def get_secret(key): } - SSOP_SYSADS = {} #SSOP_SYSADS = { # 'holubdev': {'type': 'localdev', 'email': 'kirk.l.holub@gmail.com', 'homeorg': 'noaa', 'divisions': ['oar', 'gsl', 'gsl-its']}, #} + LOCAL_PASSWORD_MINIMUM_LENGTH = 40 NONE_NAME = "#none" +NONE_NEVER_EXPIRES = '9999-12-31T23:59:59+00:00' +ANYONE_EMAIL = "#anynone.anywhere@anydomain.tld" NONE_EMAIL = "#none.none@none.tld" - # Expire the session after an hour SESSION_COOKIE_AGE = 3600 LOGOUT_EXPIRY = 2 @@ -141,6 +156,7 @@ def get_secret(key): EMAIL_HOST_USER = 'noreply.gsl@noaa.gov' EMAIL_USE_TLS = True SSOP_ADMIN_EMAIL = "ssopadmin.gsl@noaa.gov" +EMAIL_TEST_USERS = ['kirk.l.holub@gmail.com', 'kirk.l.holub@noaa.gov'] USER_HAS_AUTHENTICATED_SUBJECT = 'SSOPSB Login' @@ -149,6 +165,50 @@ def get_secret(key): body = body + '\n\nYou can also direct message @Kirk Holub on https://oar-gsl.slack.com' USER_HAS_AUTHENTICATED_BODY = body +# to help manage Contacts +# D H M S +#CONTACT_RENEWAL_PERIOD = 60 * 24 * 60 * 60 +#CONTACT_RENEWAL_PERIOD = 60 * 1440 * 60 + +# Policy per Security Officer +CONTACT_RENEWAL_PERIOD_DAYS = 60 +# days to minutes for debugging +#CONTACT_RENEWAL_PERIOD_DAYS = 6 + +# days to minutes for debugging +CONTACT_RENEWAL_PERIOD_SECONDS = CONTACT_RENEWAL_PERIOD_DAYS * 24 * 60 * 60 +#CONTACT_RENEWAL_PERIOD_SECONDS = CONTACT_RENEWAL_PERIOD_DAYS * 3600 +# days to minutes for debugging +#CONTACT_RENEWAL_PERIOD_SECONDS = CONTACT_RENEWAL_PERIOD_DAYS * 1 * 1 * 60 +CONTACT_RENEWAL_PERIOD_TIMEDELTA = datetime.timedelta(seconds=CONTACT_RENEWAL_PERIOD_SECONDS) + + +CONTACT_RENEWAL_PERIOD_WARNINGS = [] +# Send an warning email on these days before an account is deleted +CONTACT_RENEWAL_PERIOD_WARNING_DAYS = [1, 14] +for d in CONTACT_RENEWAL_PERIOD_WARNING_DAYS: + CONTACT_RENEWAL_PERIOD_WARNINGS.append(datetime.timedelta(days=d)) + # days to minutes for debugging + #s = d * 60 + #CONTACT_RENEWAL_PERIOD_WARNINGS.append(datetime.timedelta(seconds=s)) + +if len(CONTACT_RENEWAL_PERIOD_WARNINGS) > int(1): + CONTACT_RENEWAL_PERIOD_WARNINGS.sort() + +USER_REMOVAL_NOTICE_SUBJECT = 'Single Sign-On Portal Sandbox access expiration notice' +body = 'Hello firstname,\n' +body = body + '\nYour authorized Single Sign-on Portal Sandbox (SSOPSB) projects are: project_list\n' +body = body + 'If you require continued access to any of these projects, then you must login to a project before expires_datetime UTC.\n' +body = body + 'Or, you may use this link to acknowledge your need for continued access: renewal_link.\n' +body = body + '\nYour SSOPSB access will be removed in removed_in_dtdelta at expires_datetime UTC if you take no action.\n' +#body = body + ' Last connected at: last_connected_datetime\n' +#body = body + ' Previous warning sent at: warned_datetime\n' +body = body + '\nYour access can be removed immediately by visiting: remove_account_link\n' +body = body + '\nPlease contact the project owner if you have any questions.\n' +#body = body + 'body_comment' +USER_REMOVAL_NOTICE_BODY = body + + # https://stackoverflow.com/questions/8023126/how-can-i-test-https-connections-with-django-as-easily-as-i-can-non-https-conne1826 SESSION_EXPIRE_AT_BROWSER_CLOSE = True @@ -305,6 +365,7 @@ def get_secret(key): 'ssop.context_processors.deploy_env', 'ssop.context_processors.server_url', 'ssop.context_processors.cwd_refresh_rate', + 'ssop.context_processors.lapse_in_appropriations', ], }, }, @@ -318,12 +379,14 @@ def get_secret(key): DEPLOY_ENV_COLOR = '#ff6666' # light red DEPLOY_ENV_TEXT_COLOR = 'gold' SERVER_FQDN = 'gsl-webstage8.gsd.esrl.noaa.gov' + EXTERNAL_URL = 'https://gsl.noaa.gov/ssopsb/' SERVER_IP = '137.75.133.86' elif SSOP_DEPLOY_ENV == "Integration": DEPLOY_ENV_COLOR = '#99ff99' # light green DEPLOY_ENV_TEXT_COLOR = 'black' SERVER_FQDN = 'gsl-webssop.gsd.esrl.noaa.gov' + EXTERNAL_URL = 'https://gsl.noaa.gov/ssop/' SERVER_IP = '137.75.133.109' elif SSOP_DEPLOY_ENV == "Production": @@ -362,6 +425,9 @@ def get_secret(key): #DBPWD = MIGRATIONPWD + + + # https://www.laurencegellert.com/2019/03/making-djangos-database-connection-more-secure-for-migrations/ DATABASES = { 'default': { @@ -427,6 +493,11 @@ def get_secret(key): USE_I18N = True USE_L10N = True USE_TZ = True +DEFAULT_DATETIME_STR = '0001-01-01T00:00:00+00:00' +DEFAULT_DATETIME = datetime.datetime.fromisoformat(DEFAULT_DATETIME_STR) + + + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.2/howto/static-files/ @@ -484,6 +555,8 @@ def get_secret(key): # JWT expiration time in seconds -- will be added to current UTC JWTEXP = 300 +# Length of a production key -- to aid in deployments +PRODKEYLEN = 60 # Length of a production key -- to aid in deployments PRODKEYLEN = 60 @@ -520,17 +593,23 @@ def get_secret(key): HELP_ENABLED = "If True the project's tile will be available on the main screen." HELP_EXPIRETOKENS = "If True this project's authorization tokens will be fetched (and expired) upon use, as it will be in production. If False (the default state), tokens will never expire which is convenitent for development work." HELP_ORGANIZATION = "Organization responsible for this project." -HELP_USERLIST = "A list of authorized users. Use cmd-click to select multiple users. Click + to add a new Contact." +HELP_USERLIST = "A list of authorized users. Use command-click to select multiple users. Click + to add a new Contact." HELP_GRAPHNODE = "Used for connection graphing. Automatically generated when needed." HELP_LOGOIMG = "An image used for the Project's tile on the main page. Types are limited to: " + LOGO_FILETYPESTR + ". The image will be resized using a square aspect ratio." HELP_APP_PARAMS = "Optional field for application use. Defaults is an empty dictionary." -# +HELP_PROJECT_OWNER = "The Federal sponsor for this Project." +HELP_CONTACT_EMAIL = "If email is the only field set, then the system will attempt to set firstname and lastname by splitting email address on the '@' and '.' characters when the SAVE button is clicked." + + + + + CWD_PREV = "uploads/ncocwd.txt" CWD_URL = "https://www.nco.ncep.noaa.gov/status/cwd/" PAGE_REFRESH_RATE = "60" # NCO likely only refreshes on synoptic times 0, 6, 12, 18 UTC. However, just in case, pull every hour CWD_FETCH_INTERVAL = 3600 -# -UPDATED = 1540 +# in seconds +ACCOUNT_REVIEW_NAPTIME = 60 diff --git a/templates/base.html b/templates/base.html index f97eadb..8981936 100755 --- a/templates/base.html +++ b/templates/base.html @@ -1,4 +1,5 @@ {% load i18n static %} + {% get_current_language as LANGUAGE_CODE %}{% get_current_language_bidi as LANGUAGE_BIDI %} @@ -100,6 +101,19 @@ + + {% if lapse_in_appropriations %} + {% block nomoney %} +
+

+ {{lapse_in_appropriations_message_top}} + [ {{lapse_in_appropriations_link}} ] + {{lapse_in_appropriations_message_bottom}} +

+
+ {% endblock %} + {% endif %} +