Skip to content
This repository has been archived by the owner on Jan 5, 2024. It is now read-only.

Commit

Permalink
Merge pull request #157 from mmoomocow/DCS-059
Browse files Browse the repository at this point in the history
DCS-058 - Oauth with Microsoft
  • Loading branch information
mmoomocow committed Jun 8, 2023
2 parents 37dae56 + 8e75c2e commit 43403f9
Show file tree
Hide file tree
Showing 14 changed files with 394 additions and 218 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/django.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
strategy:
max-parallel: 4
matrix:
python-version: [3.8, 3.9, 3.10.5, 3.11.2]
python-version: [3.10.5, 3.11.2]

steps:
- uses: actions/checkout@v3
Expand Down
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ A digital commendation system being developed for use at Bayfield High School

## Install Instructions

This project uses Python version 3.9.9 which is a prerequisite to install.
This project requires Python 3.10 or higher!

### Cloning the repo

Expand Down Expand Up @@ -111,6 +111,22 @@ Create a `.env` file in the root directory of the project and add the following

- Example: `password`

- MY_HOST - The hostname of the site. Used for redirect callbacks

- Example: `localhost:8000`

- MICROSOFT_AUTH_TENANT_DOMAIN - The domain of the Microsoft tenant

- Example: `example.com`

- MICROSOFT_AUTH_CLIENT_ID - The client ID of the Microsoft application

- Example: `12345678-1234-1234-1234-123456789012`

- MICROSOFT_AUTH_CLIENT_SECRET - The client secret of the Microsoft application

- Example: `1234567890123456789012345678901234567890`

### Migrate the database

```cmd
Expand Down
16 changes: 16 additions & 0 deletions commendationSite/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"django.contrib.messages",
"django.contrib.staticfiles",
"django.contrib.admindocs",
"django.contrib.sites",
]

MIDDLEWARE = [
Expand Down Expand Up @@ -118,6 +119,12 @@
# Max age of database connections
CONN_MAX_AGE = int(os.environ.get("CONN_MAX_AGE", 0))

# Authentication backends
AUTHENTICATION_BACKENDS = [
"users.backends.MicrosoftAuthBackend",
"django.contrib.auth.backends.ModelBackend",
]


# Password validation
# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators
Expand Down Expand Up @@ -194,3 +201,12 @@
MANAGERS = []
for manager in os.environ.get("MANAGERS", "").split(","):
MANAGERS.append(manager.split(":"))

# Site ID
SITE_ID = 1

# Login URL
LOGIN_REDIRECT_URL = "/"
LOGIN_URL = "/login/"
LOGOUT_REDIRECT_URL = "/"
LOGOUT_URL = "/logout/"
4 changes: 3 additions & 1 deletion home/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
class TestHomePages(TestCase):
def test_index_teacher(self):
teacher = testHelper.createTeacher(self, is_management=False)
self.client.force_login(teacher)
self.client.force_login(
teacher, backend="django.contrib.auth.backends.ModelBackend"
)
response = self.client.get("/")
self.assertRedirects(response, "/commendations/award/")

Expand Down
1 change: 1 addition & 0 deletions home/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ def index(request) -> render:
if request.user.is_authenticated: # skipcq: PTC-W0048
if request.user.is_teacher: # skipcq: PTC-W0048
return redirect("/commendations/award/")
return redirect("/about/")
return redirect("/login/")


Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ python-dotenv==1.0.0
mysqlclient==2.1.1
pymysql==1.0.3
tomli==2.0.1
docutils==0.20
docutils==0.20
msal==1.22.0
224 changes: 224 additions & 0 deletions users/backends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import os
from typing import Any

import msal
import requests
from django.contrib.auth import login as django_login
from django.contrib.auth.backends import BaseBackend
from django.contrib.auth.base_user import AbstractBaseUser
from django.http.request import HttpRequest
from dotenv import load_dotenv

from .models import User

load_dotenv()

# Create auth backend with django.contrib.auth.backends.ModelBackend as parent class

MY_HOST = os.getenv("MY_HOST")

APP_ID = os.getenv("MICROSOFT_AUTH_CLIENT_ID")
APP_SECRET = os.getenv("MICROSOFT_AUTH_CLIENT_SECRET")
TENANT_DOMAIN = os.getenv("MICROSOFT_AUTH_TENANT_DOMAIN")

REDIRECT = f"{MY_HOST}/users/callback/"
SCOPES = ["https://graph.microsoft.com/user.read"]
AUTHORITY = "https://login.microsoftonline.com/organizations"
LOGOUTURL = f"{MY_HOST}/users/logout/"

GRAPH_ENDPOINT = "https://graph.microsoft.com/v1.0"


class MicrosoftAuthBackend(BaseBackend):
"""MicrosoftAuthBackend A custom authentication backend for Microsoft authentication.
Args:
BaseBackend (_type_): Django's base authentication backend to extend.
Returns:
MicrosoftAuthBackend: The authentication backend.
"""

def __init__(self) -> None:
self.ms_client = msal.ConfidentialClientApplication(
APP_ID, authority=AUTHORITY, client_credential=APP_SECRET
)
super().__init__()

SESSION_KEY = "microsoft_auth"
AUTH = "Microsoft"

def setup(self, request: HttpRequest) -> None:
"""setup Set up the backend by creating a flow and storing it in the session.
Args:
request (HttpRequest): The request object.
"""
if request.session.get(self.SESSION_KEY):
request.session[self.SESSION_KEY] = {}
flow = self.ms_client.initiate_auth_code_flow(
SCOPES, redirect_uri=REDIRECT, domain_hint=TENANT_DOMAIN
)

self._store_to_session(request, "flow", flow)

def get_auth_uri(self, request: HttpRequest) -> str:
"""get_auth_uri Get the auth URI from the session.
Args:
request (HttpRequest): The request object.
Returns:
str: The auth URI.
"""
self.setup(request)
return self._get_from_session(request, "flow")["auth_uri"]

def authenticate(
self,
request: HttpRequest,
**kwargs: Any,
) -> AbstractBaseUser | None:
"""authenticate Authenticate the user by getting the token from the auth code flow.
Args:
request (HttpRequest): The request object.
Returns:
AbstractBaseUser | None: The user object if authenticated, None otherwise.
"""
# Dont authenticate if there is no request or no code in the request
if not request or not request.GET.get("code"):
return None

flow = request.session.get(self.SESSION_KEY, {}).get("flow")
if not flow:
return None

try:
token = self.ms_client.acquire_token_by_auth_code_flow(flow, request.GET)
except ValueError:
return None

if not token.get("access_token"):
return None

ms_user = requests.get(
f"{GRAPH_ENDPOINT}/me",
headers={"Authorization": f"Bearer {token['access_token']}"},
).json()

# Check that the email is the tenant domain
if ms_user["mail"].split("@")[1] != TENANT_DOMAIN:
return None

user = self._get_create_user(ms_user)
if self.user_can_authenticate(user):
return user

return None

def login(self, request: HttpRequest, user: User) -> None:
"""login Log the user in.
Args:
request (HttpRequest): The request object.
user (User): The user object.
"""
django_login(request, user)

def logout(self, request: HttpRequest) -> None:
"""logout Log the user out.
Args:
request (HttpRequest): The request object.
"""
self._delete_from_session(request, "flow")

# OTHER HELPER METHODS

def get_user(self, user_id: int) -> AbstractBaseUser | None:
"""get_user Get the user object.
Args:
user_id (int): The user id.
Returns:
AbstractBaseUser | None: The user object if found, None otherwise.
"""
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return None

def user_can_authenticate(self, user: User) -> bool:
"""user_can_authenticate Check if the user can authenticate.
Args:
user (User): The user object.
Returns:
bool: True if the user can authenticate, False otherwise.
"""
if user is None:
return False
is_active = getattr(user, "is_active", None)
return is_active or is_active is None

def _get_create_user(self, ms_user: dict[str, Any]) -> User:
"""_get_create_user Get or create the user.
Args:
ms_user (dict[str, Any]): The user object from Microsoft Graph.
Returns:
User: The found/created user object.
"""
try:
user = User.objects.get(email=ms_user["mail"])
return user
except User.DoesNotExist:
username = ms_user["mail"].split("@")[0]
return User.objects.create(
username=f"{username}",
email=ms_user["mail"],
first_name=ms_user["givenName"],
last_name=ms_user["surname"],
)

def _store_to_session(self, request: HttpRequest, key: str, value: Any) -> None:
"""_store_to_session Store a value in the session using the SESSION_KEY.
Args:
request (HttpRequest): The request object.
key (str): The key to store the value under.
value (Any): The value to store.
"""
if self.SESSION_KEY not in request.session:
request.session[self.SESSION_KEY] = {}
request.session[self.SESSION_KEY][key] = value

def _get_from_session(self, request: HttpRequest, key: str) -> Any:
"""_get_from_session Get a value from the session using the SESSION_KEY.
Args:
request (HttpRequest): The request object.
key (str): The key to get the value from.
Returns:
Any: The value from the session.
"""
if self.SESSION_KEY not in request.session:
return None
return request.session[self.SESSION_KEY].get(key)

def _delete_from_session(self, request: HttpRequest, key: str) -> None:
"""_delete_from_session Delete a value from the session using the SESSION_KEY.
Args:
request (HttpRequest): The request object.
key (str): The key to delete the value from.
"""
if self.SESSION_KEY not in request.session:
return
del request.session[self.SESSION_KEY][key]
1 change: 0 additions & 1 deletion users/static/users/css/login.min.css

This file was deleted.

34 changes: 32 additions & 2 deletions users/static/users/css/login.css → users/static/users/login.css
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ form {
max-width: 500px;
padding: 1rem;
background-color: var(--color-grey-light);
border-radius: 0.5rem;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.2);
}

.formField {
Expand Down Expand Up @@ -38,4 +36,36 @@ form #submit {
color: red;
font-weight: bold;
margin-bottom: 1rem;
}

#ms_button {
display: inline-block;
width: 100%;
background-color: var(--color-gold);
color: var(--color-grey-dark);
font-weight: bold;
text-align: center;
cursor: pointer;
padding: 0.5em;
border: 1px solid var(--color-grey-dark);
line-height: 1.42857;
border-radius: 0.25rem;
}

#ms_icon {
height: 1em;
width: 1em;
top: .125em;
position: relative;
}

.login-split {
display: grid;
grid-template-columns: 1fr 1fr;
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.2);
border-radius: 0.5rem;
}

.microsoft {
padding: 1rem;
}
1 change: 1 addition & 0 deletions users/static/users/login.min.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 43403f9

Please sign in to comment.