diff --git a/apps/accounts/migrations/0006_alter_userprofile_mobile_phone_number.py b/apps/accounts/migrations/0006_alter_userprofile_mobile_phone_number.py new file mode 100644 index 000000000..599ad98ce --- /dev/null +++ b/apps/accounts/migrations/0006_alter_userprofile_mobile_phone_number.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.2 on 2026-04-20 18:07 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0005_alter_userprofile_user_type'), + ] + + operations = [ + migrations.AlterField( + model_name='userprofile', + name='mobile_phone_number', + field=models.CharField(blank=True, help_text='US numbers only.', max_length=16, validators=[django.core.validators.RegexValidator(message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed.", regex='^\\+?\\d{9,15}$')]), + ), + ] diff --git a/apps/accounts/models.py b/apps/accounts/models.py index f03a7850e..96992775c 100644 --- a/apps/accounts/models.py +++ b/apps/accounts/models.py @@ -1,4 +1,5 @@ from apps.constants import USER_CHOICES, USER_TYPE_DEV +from apps.validators import phone_regex from apps.accounts.constants import ( AAL_CHOICES, ADDITION, @@ -100,7 +101,8 @@ class UserProfile(models.Model): ) mobile_phone_number = models.CharField( - max_length=12, + validators=[phone_regex], + max_length=16, blank=True, help_text=_('US numbers only.'), ) diff --git a/apps/dot_ext/constants.py b/apps/dot_ext/constants.py index e6aebd4e7..3af020b14 100644 --- a/apps/dot_ext/constants.py +++ b/apps/dot_ext/constants.py @@ -1,5 +1,3 @@ -import re - from apps.constants import LAUNCH_SCOPE, OPENID_SCOPE # REGEX of paths that should be updated with auth flow info in hhs_oauth_server.request_logging.py @@ -49,11 +47,6 @@ TOKEN_ENDPOINT_V2_KEY = 'token-v2' TOKEN_ENDPOINT_V3_KEY = 'token-v3' -URL_REGEX = re.compile( - r'^(https:\/\/)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.' - r'[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$' -) - """ Test Cases for demographic scopes related testing Dictionaries included: diff --git a/apps/dot_ext/forms.py b/apps/dot_ext/forms.py index 6e6fbb731..0fb48e996 100644 --- a/apps/dot_ext/forms.py +++ b/apps/dot_ext/forms.py @@ -3,6 +3,7 @@ from django import forms from django.conf import settings +from django.core.validators import URLValidator from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from oauth2_provider.forms import AllowForm as DotAllowForm @@ -12,7 +13,7 @@ from apps.dot_ext.constants import PRINTABLE_SPECIAL_ASCII from apps.dot_ext.scopes import CapabilitiesScopes from apps.dot_ext.models import Application, InternalApplicationLabels -from apps.dot_ext.validators import validate_logo_image, validate_notags, validate_url +from apps.dot_ext.validators import validate_logo_image, validate_notags from django.contrib.auth.models import Group, User from django.forms.widgets import URLInput @@ -50,22 +51,22 @@ class CustomRegisterApplicationForm(forms.ModelForm): policy_uri = forms.CharField( required=False, - max_length=512, - validators=[validate_url], + max_length=2048, + validators=[URLValidator()], widget=URLInput, ) tos_uri = forms.CharField( required=False, - max_length=512, - validators=[validate_url], + max_length=2048, + validators=[URLValidator()], widget=URLInput, ) website_uri = forms.CharField( required=False, - max_length=512, - validators=[validate_url], + max_length=2048, + validators=[URLValidator()], widget=URLInput, ) @@ -219,22 +220,22 @@ class CreateNewApplicationForm(forms.ModelForm): policy_uri = forms.CharField( required=False, - max_length=512, - validators=[validate_url], + max_length=2048, + validators=[URLValidator()], widget=URLInput, ) tos_uri = forms.CharField( required=False, - max_length=512, - validators=[validate_url], + max_length=2048, + validators=[URLValidator()], widget=URLInput, ) website_uri = forms.CharField( required=False, - max_length=512, - validators=[validate_url], + max_length=2048, + validators=[URLValidator()], widget=URLInput, ) diff --git a/apps/dot_ext/migrations/0012_alter_application_client_uri_and_more.py b/apps/dot_ext/migrations/0012_alter_application_client_uri_and_more.py new file mode 100644 index 000000000..fc5857774 --- /dev/null +++ b/apps/dot_ext/migrations/0012_alter_application_client_uri_and_more.py @@ -0,0 +1,99 @@ +# Generated by Django 6.0.2 on 2026-04-20 18:07 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dot_ext', '0011_remove_application_allow_client_credentials_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='client_uri', + field=models.TextField(blank=True, default='', help_text='This is typically a home/download website for the application. For example, https://www.example.org or http://www.example.org .', max_length=512, null=True, validators=[django.core.validators.URLValidator()], verbose_name='Client URI'), + ), + migrations.AlterField( + model_name='application', + name='contacts', + field=models.TextField(blank=True, default='', help_text='This is typically an email', verbose_name="Client's Contacts"), + ), + migrations.AlterField( + model_name='application', + name='jwks_uri', + field=models.TextField(blank=True, default=None, max_length=512, null=True, validators=[django.core.validators.URLValidator()], verbose_name='JSON Web Key Set URI'), + ), + migrations.AlterField( + model_name='application', + name='logo_uri', + field=models.TextField(blank=True, default='', max_length=512, validators=[django.core.validators.URLValidator()], verbose_name='Logo URI'), + ), + migrations.AlterField( + model_name='application', + name='op_policy_uri', + field=models.TextField(blank=True, default='', max_length=512, validators=[django.core.validators.URLValidator()]), + ), + migrations.AlterField( + model_name='application', + name='op_tos_uri', + field=models.TextField(blank=True, default='https://bluebutton.cms.gov/terms', max_length=512, validators=[django.core.validators.URLValidator()]), + ), + migrations.AlterField( + model_name='application', + name='policy_uri', + field=models.TextField(blank=True, default='', help_text='This can be a model privacy notice or other policy document.', max_length=512, validators=[django.core.validators.URLValidator()], verbose_name="Client's Policy URI"), + ), + migrations.AlterField( + model_name='application', + name='software_id', + field=models.TextField(blank=True, default='', help_text='A unique identifier for an application defined by its creator.', max_length=128), + ), + migrations.AlterField( + model_name='application', + name='support_email', + field=models.TextField(blank=True, null=True), + ), + migrations.AlterField( + model_name='application', + name='support_phone_number', + field=models.CharField(blank=True, max_length=16, null=True, validators=[django.core.validators.RegexValidator(message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed.", regex='^\\+?\\d{9,15}$')]), + ), + migrations.AlterField( + model_name='application', + name='tos_uri', + field=models.TextField(blank=True, default='', max_length=512, validators=[django.core.validators.URLValidator()], verbose_name="Client's Terms of Service URI"), + ), + migrations.AlterField( + model_name='application', + name='website_uri', + field=models.TextField(blank=True, default='', help_text='This is typically a home/download website for the application. For example, https://www.example.org or http://www.example.org .', max_length=512, null=True, validators=[django.core.validators.URLValidator()], verbose_name='Website URI'), + ), + migrations.AlterField( + model_name='applicationlabel', + name='name', + field=models.TextField(max_length=255, unique=True), + ), + migrations.AlterField( + model_name='authflowuuid', + name='state', + field=models.TextField(db_index=True, max_length=64, null=True, unique=True), + ), + migrations.AlterField( + model_name='internalapplicationlabels', + name='description', + field=models.TextField(blank=True, default=''), + ), + migrations.AlterField( + model_name='internalapplicationlabels', + name='label', + field=models.TextField(default='', max_length=255, unique=True), + ), + migrations.AlterField( + model_name='internalapplicationlabels', + name='slug', + field=models.TextField(default='', max_length=1024, unique=True), + ), + ] diff --git a/apps/dot_ext/migrations/0013_alter_application_client_uri_and_more.py b/apps/dot_ext/migrations/0013_alter_application_client_uri_and_more.py new file mode 100644 index 000000000..f48286fa4 --- /dev/null +++ b/apps/dot_ext/migrations/0013_alter_application_client_uri_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 6.0.2 on 2026-04-21 19:17 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dot_ext', '0012_alter_application_client_uri_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='client_uri', + field=models.TextField(blank=True, default='', help_text='This is typically a home/download website for the application. For example, https://www.example.org or http://www.example.org .', max_length=2048, null=True, validators=[django.core.validators.URLValidator()], verbose_name='Client URI'), + ), + migrations.AlterField( + model_name='application', + name='jwks_uri', + field=models.TextField(blank=True, default=None, max_length=2048, null=True, validators=[django.core.validators.URLValidator()], verbose_name='JSON Web Key Set URI'), + ), + migrations.AlterField( + model_name='application', + name='logo_uri', + field=models.TextField(blank=True, default='', max_length=2048, validators=[django.core.validators.URLValidator()], verbose_name='Logo URI'), + ), + migrations.AlterField( + model_name='application', + name='policy_uri', + field=models.TextField(blank=True, default='', help_text='This can be a model privacy notice or other policy document.', max_length=2048, validators=[django.core.validators.URLValidator()], verbose_name="Client's Policy URI"), + ), + migrations.AlterField( + model_name='application', + name='tos_uri', + field=models.TextField(blank=True, default='', max_length=2048, validators=[django.core.validators.URLValidator()], verbose_name="Client's Terms of Service URI"), + ), + migrations.AlterField( + model_name='application', + name='website_uri', + field=models.TextField(blank=True, default='', help_text='This is typically a home/download website for the application. For example, https://www.example.org or http://www.example.org .', max_length=2048, null=True, validators=[django.core.validators.URLValidator()], verbose_name='Website URI'), + ), + ] diff --git a/apps/dot_ext/migrations/0014_alter_application_op_policy_uri_and_more.py b/apps/dot_ext/migrations/0014_alter_application_op_policy_uri_and_more.py new file mode 100644 index 000000000..cc179907a --- /dev/null +++ b/apps/dot_ext/migrations/0014_alter_application_op_policy_uri_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.2 on 2026-04-21 19:27 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dot_ext', '0013_alter_application_client_uri_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='op_policy_uri', + field=models.TextField(blank=True, default='', max_length=2048, validators=[django.core.validators.URLValidator()]), + ), + migrations.AlterField( + model_name='application', + name='op_tos_uri', + field=models.TextField(blank=True, default='https://bluebutton.cms.gov/terms', max_length=2048, validators=[django.core.validators.URLValidator()]), + ), + ] diff --git a/apps/dot_ext/migrations/0015_alter_application_client_secret_plain.py b/apps/dot_ext/migrations/0015_alter_application_client_secret_plain.py new file mode 100644 index 000000000..1f5415d76 --- /dev/null +++ b/apps/dot_ext/migrations/0015_alter_application_client_secret_plain.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-04-21 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dot_ext', '0014_alter_application_op_policy_uri_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='client_secret_plain', + field=models.CharField(blank=True, default='', max_length=128), + ), + ] diff --git a/apps/dot_ext/migrations/0016_alter_authflowuuid_client_id.py b/apps/dot_ext/migrations/0016_alter_authflowuuid_client_id.py new file mode 100644 index 000000000..0685ebacb --- /dev/null +++ b/apps/dot_ext/migrations/0016_alter_authflowuuid_client_id.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-04-21 22:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dot_ext', '0015_alter_application_client_secret_plain'), + ] + + operations = [ + migrations.AlterField( + model_name='authflowuuid', + name='client_id', + field=models.CharField(max_length=40, null=True), + ), + ] diff --git a/apps/dot_ext/migrations/0017_alter_authflowuuid_auth_pkce_method_and_more.py b/apps/dot_ext/migrations/0017_alter_authflowuuid_auth_pkce_method_and_more.py new file mode 100644 index 000000000..25c7b04ea --- /dev/null +++ b/apps/dot_ext/migrations/0017_alter_authflowuuid_auth_pkce_method_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.2 on 2026-04-22 11:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dot_ext', '0016_alter_authflowuuid_client_id'), + ] + + operations = [ + migrations.AlterField( + model_name='authflowuuid', + name='auth_pkce_method', + field=models.CharField(choices=[('S256', 'S256'), ('plain', 'plain')], max_length=16, null=True), + ), + migrations.AlterField( + model_name='authflowuuidcopy', + name='auth_pkce_method', + field=models.CharField(choices=[('S256', 'S256'), ('plain', 'plain')], max_length=16, null=True), + ), + migrations.AlterField( + model_name='authflowuuidcopy', + name='client_id', + field=models.CharField(max_length=40, null=True), + ), + migrations.AlterField( + model_name='authflowuuidcopy', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='authflowuuidcopy', + name='state', + field=models.TextField(db_index=True, max_length=64, null=True, unique=True), + ), + ] diff --git a/apps/dot_ext/models.py b/apps/dot_ext/models.py index bec6f10d9..c26eac531 100644 --- a/apps/dot_ext/models.py +++ b/apps/dot_ext/models.py @@ -8,8 +8,8 @@ from dateutil.relativedelta import relativedelta from django.conf import settings from django.contrib.auth import get_user_model +from django.core.validators import URLValidator from django.core.files.storage import default_storage -from django.core.validators import RegexValidator from django.db import models from django.db.models import Q from django.db.models.signals import ( @@ -27,6 +27,7 @@ from oauth2_provider.settings import oauth2_settings from waffle import switch_is_active +from apps.validators import phone_regex import apps.logging.request_logger as logging from apps.capabilities.models import ProtectedCapability from apps.dot_ext.constants import ( @@ -43,9 +44,9 @@ class InternalApplicationLabels(models.Model): - label = models.CharField(max_length=255, default='', unique=True) - slug = models.CharField(max_length=1024, default='', unique=True) - description = models.TextField(max_length=10240, blank=True, default='') + label = models.TextField(max_length=255, default='', unique=True) + slug = models.TextField(max_length=1024, default='', unique=True) + description = models.TextField(blank=True, default='') def __str__(self): return self.label @@ -70,30 +71,37 @@ class Application(AbstractApplication): agree = models.BooleanField(default=False) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) - op_tos_uri = models.CharField(default=settings.TOS_URI, blank=True, max_length=512) - op_policy_uri = models.CharField(default='', blank=True, max_length=512) + op_tos_uri = models.TextField(default=settings.TOS_URI, blank=True, validators=[URLValidator()], max_length=2048) + op_policy_uri = models.TextField(default='', blank=True, max_length=2048, validators=[URLValidator()]) # oauth2_provider upgraded and there is a breaking change on Application.client_secret field # see migration file 0005_alter_application_client_secret.py # field added to save client_secret in plain text before Application.save() # where the client_secret is hashed ireversible - client_secret_plain = models.CharField(default='', blank=True, max_length=255) + # + # In ouath2_provider, the default length of the generated secret is 128, + # but the max_length of AbstractApplication.client_secret is 255. Going with + # 128 here because that is the secret's actual length and the length expected + # by code and tests. + client_secret_plain = models.CharField(default='', blank=True, max_length=128) # client_uri is depreciated but will continued to be referenced until it can be removed safely - client_uri = models.URLField( + client_uri = models.TextField( default='', blank=True, null=True, - max_length=512, + validators=[URLValidator()], + max_length=2048, verbose_name='Client URI', help_text='This is typically a home/download website for the application. ' 'For example, https://www.example.org or http://www.example.org .', ) - website_uri = models.URLField( + website_uri = models.TextField( default='', blank=True, null=True, - max_length=512, + validators=[URLValidator()], + max_length=2048, verbose_name='Website URI', help_text='This is typically a home/download website for the application. ' 'For example, https://www.example.org or http://www.example.org .', @@ -109,24 +117,28 @@ class Application(AbstractApplication): redirect_uris = models.TextField(help_text=help_text, blank=True) - logo_uri = models.CharField(default='', blank=True, max_length=512, verbose_name='Logo URI') + logo_uri = models.TextField( + default='', blank=True, max_length=2048, verbose_name='Logo URI', validators=[URLValidator()] + ) - tos_uri = models.CharField( + tos_uri = models.TextField( default='', blank=True, - max_length=512, + validators=[URLValidator()], + max_length=2048, verbose_name="Client's Terms of Service URI", ) - policy_uri = models.CharField( + policy_uri = models.TextField( default='', blank=True, - max_length=512, + validators=[URLValidator()], + max_length=2048, verbose_name="Client's Policy URI", help_text='This can be a model privacy notice or other policy document.', ) - software_id = models.CharField( + software_id = models.TextField( default='', blank=True, max_length=128, @@ -136,20 +148,13 @@ class Application(AbstractApplication): contacts = models.TextField( default='', blank=True, - max_length=512, verbose_name="Client's Contacts", help_text='This is typically an email', ) - support_email = models.EmailField(blank=True, null=True) - - # FROM https://stackoverflow.com/questions/19130942/whats-the-best-way-to-store-phone-number-in-django-models - phone_regex = RegexValidator( - regex=r'^\+?1?\d{9,15}$', - message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed.", - ) + support_email = models.TextField(blank=True, null=True) - support_phone_number = models.CharField(validators=[phone_regex], max_length=17, blank=True, null=True) + support_phone_number = models.CharField(validators=[phone_regex], max_length=16, blank=True, null=True) description = models.TextField( default='', @@ -204,13 +209,14 @@ class Application(AbstractApplication): ) # New fields for CMS Aligned Networks epic - jwks_uri = models.URLField( + jwks_uri = models.TextField( default=None, blank=True, null=True, + validators=[URLValidator()], # Went with 512 as that is the established pattern we have for URLFields # and to allow for extremely long URLs. 200 is default - max_length=512, + max_length=2048, verbose_name='JSON Web Key Set URI', ) @@ -384,7 +390,7 @@ class Meta: class ApplicationLabel(models.Model): - name = models.CharField(max_length=255, unique=True) + name = models.TextField(max_length=255, unique=True) slug = models.SlugField(db_index=True, unique=True) description = models.TextField() applications = models.ManyToManyField(Application, blank=True) @@ -460,6 +466,7 @@ class ArchivedToken(models.Model): db_constraint=False, related_name='%(app_label)s_%(class)s', ) + # The length of the AccessToken's created by oauth2_provider is 255 token = models.CharField( max_length=255, unique=True, @@ -486,7 +493,10 @@ class ExpiresIn(models.Model): issued to the user. """ + # hex digest from sha-256, which is always 64 characters long + # https://csrc.nist.gov/pubs/fips/180-4/upd1/final key = models.CharField(max_length=64, unique=True) + expires_in = models.IntegerField() objects = ExpiresInManager() @@ -505,7 +515,11 @@ def archive_token(sender, instance=None, **kwargs): ) -class AuthFlowUuid(models.Model): +# Make an abstract class that both versions inherit from, because just using normal +# inheritance would have Django create links in the database between them, which is +# not what we want. +# See https://docs.djangoproject.com/en/6.0/topics/db/models/#model-inheritance +class AbstractAuthFlowUuid(models.Model): """ An instance used to persist the beneficiary authorization flow auth_uuid across the auth flow when there are breaks in the @@ -523,10 +537,21 @@ class AuthFlowUuid(models.Model): """ auth_uuid = models.UUIDField(primary_key=True, unique=True) - state = models.CharField(max_length=64, null=True, unique=True, db_index=True) + state = models.TextField(max_length=64, null=True, unique=True, db_index=True) + + # matches what is in django rest framework + # https://github.com/django-oauth/django-oauth-toolkit/blob/d422eeab79052c04a91d124f938ddc22c841c38c/oauth2_provider/models.py#L333 code = models.CharField(max_length=255, null=True, unique=True, db_index=True) # code comes from oauthlib - client_id = models.CharField(max_length=100, null=True) - auth_pkce_method = models.CharField(max_length=16, null=True) + + # In ouath2_provider, the length of the generated client_id is 40, + # but the max_length of AbstractApplication.client_id is 100. Going with + # 40 here because that is the ID's actual length. + client_id = models.CharField(max_length=40, null=True) + + # code challenge method, either "S256" or "plain" by spec + # https://datatracker.ietf.org/doc/html/rfc7636#section-4.3 + auth_pkce_method = models.CharField(max_length=16, null=True, choices={'S256': 'S256', 'plain': 'plain'}) + created = models.DateTimeField(auto_now_add=True, null=True) auth_crosswalk_action = models.CharField(max_length=1, null=True) auth_share_demographic_scopes = models.BooleanField(null=True) @@ -534,35 +559,16 @@ class AuthFlowUuid(models.Model): def __str__(self): return str(self.auth_uuid) + class Meta: + abstract = True -class AuthFlowUuidCopy(models.Model): - """ - An instance used to persist the beneficiary authorization flow - auth_uuid across the auth flow when there are breaks in the - session and for logging the resulting access token that is granted. - Fields: +class AuthFlowUuid(AbstractAuthFlowUuid): + pass - auth_uuid - The beneficiary authorization flow tracing UUID - state - The state noance used in the Medicare.gov login and callback - code - The authorization code generated by the authorization server - client_id - The application client id - auth_pkce_method - PKCE method used - auth_crosswalk_action - Action taken with regard to the crosswalk model (retreived/created) - auth_share_demographic_scopes - Bene demographic sharing choice from consent page/form - """ - auth_uuid = models.UUIDField(primary_key=True, unique=True) - state = models.CharField(max_length=64, null=True, unique=True, db_index=True) - code = models.CharField(max_length=255, null=True, unique=True, db_index=True) # code comes from oauthlib - client_id = models.CharField(max_length=100, null=True) - auth_pkce_method = models.CharField(max_length=16, null=True) - created = models.DateTimeField(null=True) - auth_crosswalk_action = models.CharField(max_length=1, null=True) - auth_share_demographic_scopes = models.BooleanField(null=True) - - def __str__(self): - return str(self.auth_uuid) +class AuthFlowUuidCopy(AbstractAuthFlowUuid): + pass def get_application_counts(): diff --git a/apps/dot_ext/tests/test_validators.py b/apps/dot_ext/tests/test_validators.py deleted file mode 100644 index 60a14a483..000000000 --- a/apps/dot_ext/tests/test_validators.py +++ /dev/null @@ -1,41 +0,0 @@ -from django.test import TestCase -from django.core.exceptions import ValidationError -from apps.dot_ext.validators import validate_url - - -class ValidateURLTests(TestCase): - def test_valid_urls(self): - valid_urls = [ - "https://example.com", - "https://foo.bar/baz", - "https://sub.domain.co.uk/path?query=1", - ] - for url in valid_urls: - try: - validate_url(url) - except ValidationError: - self.fail(f"validate_url() raised ValidationError unexpectedly for {url}") - - def test_invalid_urls(self): - invalid_urls = [ - "not-a-url", - "example", - "http://", - "://example.com", - "javascript:alert(document.cookie)", - "javascript:alert(document.domain)", - "https://localhost", - "http://localhost", - " ", - ] - for url in invalid_urls: - with self.assertRaises(ValidationError, msg=f"Expected failure for {url}"): - validate_url(url) - - def test_empty_value_allowed(self): - """Empty values should pass without raising error""" - try: - validate_url("") - validate_url(None) - except ValidationError: - self.fail("validate_url() raised ValidationError for empty value") diff --git a/apps/dot_ext/validators.py b/apps/dot_ext/validators.py index 3bae30382..948b35000 100644 --- a/apps/dot_ext/validators.py +++ b/apps/dot_ext/validators.py @@ -7,7 +7,6 @@ from django.utils.html import strip_tags from django.utils.translation import gettext_lazy as _ from os import path as ospath -from apps.dot_ext.constants import URL_REGEX class RedirectURIValidator(URIValidator): @@ -34,15 +33,6 @@ def validate_uris(value): v(uri) -def validate_url(value: str): - """Validate that the value is a syntactically valid URL.""" - if not value: - return - value = value.strip() - if not URL_REGEX.match(value): - raise ValidationError('Enter a valid URL') - - # Validate that there are no HTML tags def validate_notags(value): if value != strip_tags(value): diff --git a/apps/fhir/bluebutton/migrations/0012_alter_archivedcrosswalk_fhir_id_v2_and_more.py b/apps/fhir/bluebutton/migrations/0012_alter_archivedcrosswalk_fhir_id_v2_and_more.py new file mode 100644 index 000000000..d7d807c1f --- /dev/null +++ b/apps/fhir/bluebutton/migrations/0012_alter_archivedcrosswalk_fhir_id_v2_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 6.0.2 on 2026-04-20 18:07 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('bluebutton', '0011_alter_crosswalk__user_id_hash_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='archivedcrosswalk', + name='fhir_id_v2', + field=models.CharField(db_column='fhir_id_v2', db_index=True, max_length=64, null=True), + ), + migrations.AlterField( + model_name='archivedcrosswalk', + name='fhir_id_v3', + field=models.CharField(db_column='fhir_id_v3', db_index=True, max_length=64, null=True), + ), + migrations.AlterField( + model_name='crosswalk', + name='fhir_id_v2', + field=models.CharField(db_column='fhir_id_v2', db_index=True, max_length=64, null=True, validators=[django.core.validators.MinLengthValidator(1)]), + ), + migrations.AlterField( + model_name='crosswalk', + name='fhir_id_v3', + field=models.CharField(db_column='fhir_id_v3', db_index=True, max_length=64, null=True, validators=[django.core.validators.MinLengthValidator(1)]), + ), + ] diff --git a/apps/fhir/bluebutton/models.py b/apps/fhir/bluebutton/models.py index 63c95739b..b5fbd8928 100644 --- a/apps/fhir/bluebutton/models.py +++ b/apps/fhir/bluebutton/models.py @@ -90,16 +90,18 @@ class Crosswalk(models.Model): settings.AUTH_USER_MODEL, on_delete=CASCADE, ) + # https://www.hl7.org/fhir/R4/datatypes.html#id fhir_id_v2 = models.CharField( - max_length=80, + max_length=64, null=True, unique=False, db_column='fhir_id_v2', db_index=True, validators=[MinLengthValidator(1)], ) + # https://www.hl7.org/fhir/R4/datatypes.html#id fhir_id_v3 = models.CharField( - max_length=80, + max_length=64, null=True, unique=False, db_column='fhir_id_v3', @@ -131,6 +133,7 @@ class Crosswalk(models.Model): db_column='user_mbi_hash', db_index=True, ) + # spec: https://www.cms.gov/training-education/partner-outreach-resources/new-medicare-card/medical-beneficiary-identifiers-mbis _user_mbi = models.CharField( max_length=11, verbose_name='Unhashed MBI', @@ -213,7 +216,7 @@ class ArchivedCrosswalk(models.Model): This is performed via code in the '__get_and_update_user()' function in apps/mymedicare_cb/models.py Attributes: - user: auth_user.id + username: auth_user.username fhir_id_v2: v1/v2 BFD fhir patient id fhir_id_v3: v3 BFD fhir patient id user_id_type: value is to be set to the type of lookup used MBI or HICN, TODO remove during BB2-3143 @@ -226,6 +229,7 @@ class ArchivedCrosswalk(models.Model): create (crosswalk): static method to create an ArchivedCrosswalk from a Crosswalk instance """ + # The max_length of django's AbstractUser.username field is 150 username = models.CharField( max_length=150, null=False, @@ -234,15 +238,17 @@ class ArchivedCrosswalk(models.Model): db_column='username', db_index=True, ) + # https://www.hl7.org/fhir/R4/datatypes.html#id fhir_id_v2 = models.CharField( - max_length=80, + max_length=64, null=True, unique=False, db_column='fhir_id_v2', db_index=True, ) + # https://www.hl7.org/fhir/R4/datatypes.html#id fhir_id_v3 = models.CharField( - max_length=80, + max_length=64, null=True, unique=False, db_column='fhir_id_v3', @@ -272,6 +278,7 @@ class ArchivedCrosswalk(models.Model): db_column='user_mbi_hash', db_index=True, ) + # spec: https://www.cms.gov/training-education/partner-outreach-resources/new-medicare-card/medical-beneficiary-identifiers-mbis _user_mbi = models.CharField( max_length=11, verbose_name='Unhashed MBI', diff --git a/apps/mymedicare_cb/migrations/0004_alter_anonuserstate_next_uri_and_more.py b/apps/mymedicare_cb/migrations/0004_alter_anonuserstate_next_uri_and_more.py new file mode 100644 index 000000000..9e781c87d --- /dev/null +++ b/apps/mymedicare_cb/migrations/0004_alter_anonuserstate_next_uri_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 6.0.2 on 2026-04-20 18:07 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mymedicare_cb', '0003_alter_anonuserstate_next_uri'), + ] + + operations = [ + migrations.AlterField( + model_name='anonuserstate', + name='next_uri', + field=models.TextField(default='', max_length=512, validators=[django.core.validators.URLValidator()]), + ), + migrations.AlterField( + model_name='anonuserstate', + name='state', + field=models.TextField(db_index=True, default='', max_length=64), + ), + ] diff --git a/apps/mymedicare_cb/migrations/0005_alter_anonuserstate_next_uri.py b/apps/mymedicare_cb/migrations/0005_alter_anonuserstate_next_uri.py new file mode 100644 index 000000000..c371dd04a --- /dev/null +++ b/apps/mymedicare_cb/migrations/0005_alter_anonuserstate_next_uri.py @@ -0,0 +1,19 @@ +# Generated by Django 6.0.2 on 2026-04-21 19:17 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mymedicare_cb', '0004_alter_anonuserstate_next_uri_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='anonuserstate', + name='next_uri', + field=models.TextField(default='', max_length=2048, validators=[django.core.validators.URLValidator()]), + ), + ] diff --git a/apps/mymedicare_cb/models.py b/apps/mymedicare_cb/models.py index da84308db..7950d837f 100644 --- a/apps/mymedicare_cb/models.py +++ b/apps/mymedicare_cb/models.py @@ -1,3 +1,4 @@ +from django.core.validators import URLValidator import apps.logging.request_logger as logging from django.contrib.auth.models import User, Group @@ -440,8 +441,8 @@ def _validate_asserts(logger, log_dict, asserts): class AnonUserState(models.Model): - state = models.CharField(default='', max_length=64, db_index=True) - next_uri = models.TextField(default='') + state = models.TextField(default='', max_length=64, db_index=True) + next_uri = models.TextField(default='', validators=[URLValidator()], max_length=2048) def __str__(self): return '%s %s' % (self.state, self.next_uri) diff --git a/apps/pkce/migrations/0003_alter_codechallenge_challenge_and_more.py b/apps/pkce/migrations/0003_alter_codechallenge_challenge_and_more.py new file mode 100644 index 000000000..b7b8c41ea --- /dev/null +++ b/apps/pkce/migrations/0003_alter_codechallenge_challenge_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.2 on 2026-04-20 18:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pkce', '0001_squashed_0002_auto_20210513_1812'), + ] + + operations = [ + migrations.AlterField( + model_name='codechallenge', + name='challenge', + field=models.CharField(default=None, max_length=128), + ), + migrations.AlterField( + model_name='codechallenge', + name='challenge_method', + field=models.CharField(choices=[('S256', 'S256'), ('plain', 'plain')], default='S256', max_length=5), + ), + ] diff --git a/apps/pkce/models.py b/apps/pkce/models.py index 7e762a3a2..fd3a87ab6 100644 --- a/apps/pkce/models.py +++ b/apps/pkce/models.py @@ -10,5 +10,11 @@ class CodeChallenge(models.Model): on_delete=models.CASCADE, primary_key=True, ) - challenge = models.CharField(max_length=255, default=None) - challenge_method = models.CharField(max_length=255, default="S256") + # up to 128 characters in plain transformation method, and + # shorter if S256 + # https://datatracker.ietf.org/doc/html/rfc7636#section-4.2 + challenge = models.CharField(max_length=128, default=None) + + # either "S256" or "plain" by spec + # https://datatracker.ietf.org/doc/html/rfc7636#section-4.3 + challenge_method = models.CharField(max_length=5, default='S256', choices={'S256': 'S256', 'plain': 'plain'}) diff --git a/apps/validators.py b/apps/validators.py new file mode 100644 index 000000000..9c074234f --- /dev/null +++ b/apps/validators.py @@ -0,0 +1,10 @@ +from django.core.validators import RegexValidator + + +# FROM https://stackoverflow.com/questions/19130942/whats-the-best-way-to-store-phone-number-in-django-models +# removed the `1?` because E.164 phone numbers can be up to 15 digits long, +# which *includes* the country code +phone_regex = RegexValidator( + regex=r'^\+?\d{9,15}$', + message="Phone number must be entered in the format: '+999999999'. Up to 15 digits allowed.", +)