Skip to content

Commit

Permalink
Feat: claims verification (#11)
Browse files Browse the repository at this point in the history
  • Loading branch information
thekaveman authored Jan 28, 2025
2 parents 347d140 + 7816ab5 commit 29a60d6
Show file tree
Hide file tree
Showing 39 changed files with 604 additions and 152 deletions.
2 changes: 1 addition & 1 deletion .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ DJANGO_SUPERUSER_PASSWORD=superuser12345!

# Django storage
DJANGO_DB_FILE=django.db
DJANGO_DB_FIXTURES="web/oauth/migrations/sample_fixtures.json"
DJANGO_DB_FIXTURES="web/oauth/migrations/sample_fixtures.json web/vital_records/migrations/sample_fixtures.json"
DJANGO_DB_RESET=true
DJANGO_STORAGE_DIR=.

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*.egg-info
*fixtures.json
!web/oauth/migrations/sample_fixtures.json
!web/vital_records/migrations/sample_fixtures.json
dist/
static/
!web/static
Expand Down
2 changes: 1 addition & 1 deletion bin/reset_db.sh
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ valid_fixtures=$(echo "$DJANGO_DB_FIXTURES" | grep -e fixtures\.json$ || test $?

if [[ -n "$valid_fixtures" ]]; then
# load data fixtures
python manage.py loaddata "$DJANGO_DB_FIXTURES"
python manage.py loaddata $DJANGO_DB_FIXTURES
else
echo "No JSON fixtures to load"
fi
3 changes: 3 additions & 0 deletions web/core/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .userflow import UserFlowAdmin

__all__ = ["UserFlowAdmin"]
12 changes: 12 additions & 0 deletions web/core/admin/userflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import logging

from django.contrib import admin

from web.core import models

logger = logging.getLogger(__name__)


@admin.register(models.UserFlow)
class UserFlowAdmin(admin.ModelAdmin):
list_display = ("label", "scopes", "eligibility_claim", "oauth_config")
12 changes: 12 additions & 0 deletions web/core/context_processors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from .models import UserFlow


def userflows(request):
flows = []
try:
for flow in UserFlow.objects.all():
flows.append({"label": flow.label, "index_url": flow.index_url})
except Exception:
pass

return {"userflows": flows}
86 changes: 86 additions & 0 deletions web/core/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# Generated by Django 5.1.5 on 2025-01-27 23:58

import uuid

import django.db.models.deletion
from django.db import migrations, models


class Migration(migrations.Migration):

initial = True

dependencies = [
("oauth", "0001_initial"),
]

operations = [
migrations.CreateModel(
name="UserFlow",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
(
"label",
models.CharField(
help_text="A human readable label, used as the display text (user-facing)", max_length=50
),
),
(
"system_name",
models.SlugField(help_text="Internal system name for this flow, mapped to the root URL.", unique=True),
),
("urlconf_path", models.CharField(help_text="Django app path to the URLconf for this flow.", max_length=100)),
(
"scopes",
models.CharField(
help_text="A space-separated list of identifiers used to specify what information is being requested",
max_length=200,
),
),
(
"eligibility_claim",
models.CharField(help_text="The claim that is used to verify eligibility", max_length=50),
),
(
"extra_claims",
models.CharField(
blank=True, default="", help_text="A space-separated list of any additional claims", max_length=200
),
),
(
"redirect_failure",
models.CharField(
default="oauth:error",
help_text="A Django route in the form of app:endpoint to redirect to after a successful claims check",
max_length=50,
),
),
(
"redirect_success",
models.CharField(
default="oauth:success",
help_text="A Django route in the form of app:endpoint to redirect to after a successful claims check",
max_length=50,
),
),
(
"scheme_override",
models.CharField(
blank=True,
default="",
help_text="(Optional) the authentication scheme to use. Defaults to that provided by the OAuth config.", # noqa: E501
max_length=50,
verbose_name="Claims scheme",
),
),
(
"oauth_config",
models.ForeignKey(
help_text="The IdG connection details for this flow.",
on_delete=django.db.models.deletion.PROTECT,
to="oauth.ClientConfig",
),
),
],
),
]
Empty file added web/core/migrations/__init__.py
Empty file.
3 changes: 3 additions & 0 deletions web/core/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .userflow import UserFlow

__all__ = ["UserFlow"]
91 changes: 91 additions & 0 deletions web/core/models/userflow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import importlib
from uuid import uuid4

from django.db import models

from web.oauth.models.config import ClientConfig


class UserFlow(models.Model):
"""Represents a user journey through the DDRC app."""

id = models.UUIDField(
primary_key=True,
default=uuid4,
editable=False,
)
label = models.CharField(
help_text="A human readable label, used as the display text (user-facing)",
max_length=50,
)
system_name = models.SlugField(
help_text="Internal system name for this flow, mapped to the root URL.",
unique=True,
)
urlconf_path = models.CharField(
help_text="Django app path to the URLconf for this flow.",
max_length=100,
)
oauth_config = models.ForeignKey(
ClientConfig,
on_delete=models.PROTECT,
help_text="The IdG connection details for this flow.",
)
scopes = models.CharField(
help_text="A space-separated list of identifiers used to specify what information is being requested",
max_length=200,
)
eligibility_claim = models.CharField(
help_text="The claim that is used to verify eligibility",
max_length=50,
)
extra_claims = models.CharField(
blank=True,
default="",
help_text="A space-separated list of any additional claims",
max_length=200,
)
redirect_failure = models.CharField(
default="oauth:error",
help_text="A Django route in the form of app:endpoint to redirect to after a successful claims check",
max_length=50,
)
redirect_success = models.CharField(
default="oauth:success",
help_text="A Django route in the form of app:endpoint to redirect to after a successful claims check",
max_length=50,
)
scheme_override = models.CharField(
blank=True,
default="",
help_text="(Optional) the authentication scheme to use. Defaults to that provided by the OAuth config.",
max_length=50,
verbose_name="Claims scheme",
)

@property
def all_claims(self):
return " ".join((self.eligibility_claim, self.extra_claims))

@property
def index_url(self):
try:
match = [url for url in self.urlpatterns if url.pattern.regex.match("")]
index = match[0]
return f"{self.urlconf.app_name}:{index.name}"
except Exception:
return None

@property
def urlconf(self):
try:
return importlib.import_module(self.urlconf_path)
except Exception:
return None

@property
def urlpatterns(self):
try:
return self.urlconf.urlpatterns
except Exception:
return []
13 changes: 13 additions & 0 deletions web/core/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.http import HttpRequest

from web.core.models import UserFlow
from web.oauth.session import Session as OAuthSession


class Session(OAuthSession):

def __init__(self, request: HttpRequest, reset: bool = False):
self.props["userflow"] = UserFlow
super().__init__(request, reset)
if reset:
self.session["userflow"] = None
8 changes: 5 additions & 3 deletions web/core/templates/core/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,11 @@
<li class="nav-item">
<a class="first-level-link" href="{% url 'core:index' %}">Home</a>
</li>
<li class="nav-item">
<a class="first-level-link" href="{% url 'vital_records:index' %}">Vital records</a>
</li>
{% for flow in userflows %}
<li class="nav-item">
<a class="first-level-link" href="{% url flow.index_url %}">{{ flow.label }}</a>
</li>
{% endfor %}
</ul>
</nav>
</div>
Expand Down
12 changes: 0 additions & 12 deletions web/oauth/admin.py

This file was deleted.

3 changes: 3 additions & 0 deletions web/oauth/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .config import ClientConfigAdmin

__all__ = ["ClientConfigAdmin"]
12 changes: 12 additions & 0 deletions web/oauth/admin/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import logging

from django.contrib import admin

from .. import models

logger = logging.getLogger(__name__)


@admin.register(models.ClientConfig)
class ClientConfigAdmin(admin.ModelAdmin):
list_display = ("client_name", "authority", "scheme")
37 changes: 37 additions & 0 deletions web/oauth/claims.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import logging

logger = logging.getLogger(__name__)


def process(userinfo: dict, expected_claims: list[str]) -> tuple[list[str], dict[str, str]]:
"""Process expected claims from the userinfo dict.
- Boolean claims comes back in userinfo like `{ "claim": "1" | "0" }` or `{ "claim": "true" }`
- Other claims come back in userinfo like `{ "claim": "value" }`
Returns a tuple `(claims: list[str], errors: dict[str, int])`
"""
claims = []
errors = {}

for claim in expected_claims:
claim_value = userinfo.get(claim)
if not claim_value:
logger.warning(f"userinfo did not contain: {claim}")
try:
claim_value = int(claim_value)
except (TypeError, ValueError):
pass
if isinstance(claim_value, int):
if claim_value == 1:
# if userinfo contains our claim and the flag is 1 (true), store the *claim*
claims.append(claim)
elif claim_value >= 10:
errors[claim] = claim_value
elif isinstance(claim_value, str):
if claim_value.lower() == "true":
claims.append(claim)
elif claim_value.lower() != "false":
claims.append(f"{claim}:{claim_value}")

return (claims, errors)
17 changes: 10 additions & 7 deletions web/oauth/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@

from authlib.integrations.django_client import OAuth

from web.oauth.models import OAuthClientConfig
from web.oauth.models import ClientConfig

logger = logging.getLogger(__name__)

oauth = OAuth()


def _client_kwargs(scope=None):
def _client_kwargs(extra_scopes: str = ""):
"""
Generate the OpenID Connect client_kwargs, with optional extra scope(s).
`scope` should be a space-separated list of scopes to add.
`extra_scopes` should be a space-separated list of scopes to add.
"""
scopes = ["openid", scope] if scope else ["openid"]
scopes = []
if "openid" not in extra_scopes:
scopes.append("openid")
scopes.append(extra_scopes)
return {"code_challenge_method": "S256", "scope": " ".join(scopes), "prompt": "login"}


Expand All @@ -41,7 +44,7 @@ def _authorize_params(scheme):
return params


def create_client(oauth_registry: OAuth, config: OAuthClientConfig):
def create_client(oauth_registry: OAuth, config: ClientConfig, scopes: str, scheme: str = ""):
"""
Returns an OAuth client, registering it if needed.
"""
Expand All @@ -55,8 +58,8 @@ def create_client(oauth_registry: OAuth, config: OAuthClientConfig):
config.client_name,
client_id=config.client_id,
server_metadata_url=_server_metadata_url(config.authority),
client_kwargs=_client_kwargs(config.scopes),
authorize_params=_authorize_params(config.scheme),
client_kwargs=_client_kwargs(scopes),
authorize_params=_authorize_params(scheme or config.scheme),
)

return client
Loading

0 comments on commit 29a60d6

Please sign in to comment.