Skip to content

Commit

Permalink
8 sol navigation (#11)
Browse files Browse the repository at this point in the history
Implemented forum router link so user can access forum page via the navigation bar
  • Loading branch information
wastilla authored Mar 26, 2023
1 parent c42dc98 commit c2a2927
Show file tree
Hide file tree
Showing 12 changed files with 331 additions and 13 deletions.
67 changes: 65 additions & 2 deletions backend/api/authentication.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,63 @@
"""User authentication over HTTP via Headers and/or HTTP JWT Bearer Tokens.
This module provides a `registered_user` dependency injection function for other routes
to use to both ensure a user is authenticated and resolve to the logged in User's model.
Further, this module provides the routes and logic for backend authentication.
The router is mounted at `/auth` and provides the following endpoints:
/auth
Redirects to the authentication server to authenticate the user.
/auth/as/{uid}/{pid}
Redirects to the authentication server to authenticate the user as
another user. This route is only available in development mode.
/auth/verify
Verifies the validity of a JWT token and returns the decoded token. This is
the end-point a development/staging server requests of the production server
to verify the legitimacy of delegated authentication.
The implementation of auth routes are nuanced due to UNC Cloud Apps' Single Sign-On (SSO)
proxy service. In production, there is a proxy sitting in front of the app that
integrates with UNC's SSO/Shibboleth service for authentication. For more information
on this proxy service, see the [official CloudApps documentation](https://help.unc.edu/sp?id=kb_article_view&sysparm_article=KB0011256)
In production, the proxy intercepts all routes prefixed with `/auth`. Two paths follow:
1. If the user is not logged in, the proxy redirects the user to the authentication server.
2. If the user is logged in, the proxy sets the `uid` and `pid` headers to the user's
Onyen and PID, respectively, and forwards the request to our app.
Once the request is forwarded to our app server, the `uid` and `pid` headers are used to
generate a JWT token and persist it in the client's local storage via a the _set_client_token
function. The frontend client code then uses this token, via JwtToken imported in AppModule,
to set the HTTP Authorization header on all subsequent API requests thanks to the @auth0/angular-jwt
library's [HTTP Interceptor](https://www.npmjs.com/package/@auth0/angular-jwt).
In development, the proxy is not present. Instead, there are two options for authentication:
1. If an unauthenticated user visits /auth in development, or staging, they are redirected
to the production `csxl.unc.edu/auth` route with an additional query parameter `origin`.
A. The production server authentication works as usual, but if the `origin` parameter is
detected alongside the SSO headers, the user will be redirected back to the `origin`
server with a JWT `token` query parameter. This token is signed by the production server.
B. Back on the development/staging server, we need to verify that the token given to the route
was actually signed by the production server. If we did not do this, a malicious user could
simply generate a token and pass it to the development server to gain access. Thus, an HTTP
request from the development/stage server is made to the production server's `/auth/verify` route
to verify the token's validity. If the token is valid, the development/staging server then
issues a new `token` to the client that is signed by the development/staging server.
This token is then used for all subsequent requests.
2. If an unauthenticated user visits /auth/as/{uid}/{pid} in development, they are authenticated
as the user with the given `uid` and `pid`, which are their ONYEN and PID, respectively.
This route is only available in development mode.
Finally, the `authenticated_pid` function ensures a user is authenticated with PID and Onyen,
but does not require that the user be registered in the database. This is only really useful
for routes used in the process of registering a user.
"""

import jwt
import requests
from datetime import datetime, timedelta
Expand Down Expand Up @@ -29,6 +89,7 @@ def registered_user(
user_service: UserService = Depends(),
token: HTTPAuthorizationCredentials | None = Depends(HTTPBearer())
) -> User:
"""Returns the authenticated user or raises a 401 HTTPException if the user is not authenticated."""
if token:
try:
auth_info = jwt.decode(
Expand All @@ -44,6 +105,7 @@ def registered_user(
def authenticated_pid(
token: HTTPAuthorizationCredentials | None = Depends(HTTPBearer())
) -> tuple[int, str]:
"""Returns the authenticated user's PID and Onyen or raises a 401 HTTPException if the user is not authenticated."""
if token:
try:
auth_info = jwt.decode(
Expand All @@ -54,7 +116,6 @@ def authenticated_pid(
raise HTTPException(status_code=401, detail='Unauthorized')



@api.get('/verify')
def auth_verify(token: str, continue_to: str = '/'):
return jwt.decode(token, _JWT_SECRET, algorithms=[_JST_ALGORITHM], options={'verify_signature': True})
Expand All @@ -70,13 +131,15 @@ def bearer_token_bootstrap(
origin: str | None = None,
token: str | None = None,
):
"""Handles authentication in both production and development. See the module docstring for more details."""
if request.url.path.startswith('/auth/as/'):
# Authenticate as another user in development using special route.
if getenv('MODE') == 'development':
testing_authentication = True
else:
onyen = request.headers['uid']
raise HTTPException(status_code=400, detail=f'Tsk, tsk. That is a naughty request {onyen}.')
raise HTTPException(
status_code=400, detail=f'Tsk, tsk. That is a naughty request {onyen}.')

if HOST == AUTH_SERVER_HOST or ('testing_authentication' in locals() and testing_authentication):
# Production App Request
Expand Down
26 changes: 25 additions & 1 deletion backend/api/profile.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,29 @@
"""Profile API
This API is used to retrieve and update a user's profile."""

from fastapi import APIRouter, Depends
from .authentication import authenticated_pid
from ..services import UserService
from ..models import User, NewUser, ProfileForm

__authors__ = ['Kris Jordan']
__copyright__ = 'Copyright 2023'
__license__ = 'MIT'

api = APIRouter(prefix="/api/profile")

PID = 0
ONYEN = 1


@api.get("", response_model=User | NewUser, tags=['profile'])
def read_profile(pid_onyen: tuple[int, str] = Depends(authenticated_pid), user_svc: UserService = Depends()):
"""Retrieve a user's profile. If the user does not exist, return a NewUser.
To handle new users, we rely only on the authenticated_pid dependency rather than
registered_user.
"""
pid, onyen = pid_onyen
user = user_svc.get(pid)
if user:
Expand All @@ -24,6 +38,15 @@ def update_profile(
pid_onyen: tuple[int, str] = Depends(authenticated_pid),
user_svc: UserService = Depends()
):
"""Update a user's profile. If the user does not exist, create a new user.
Since the user is authenticated, we can trust the pid and onyen. However,
since the user may not be registered, we depend on authenticated_pid rather
than registered_user.
ProfileForm is used here, rather than User, for similar registration-specific
purposes. Importantly, ProfileForm doesn't contain an ID field.
"""
pid, onyen = pid_onyen
user = user_svc.get(pid)
if user is None:
Expand All @@ -35,11 +58,12 @@ def update_profile(
email=profile.email,
pronouns=profile.pronouns,
)
user = user_svc.create(user, user)
else:
user.first_name = profile.first_name
user.last_name = profile.last_name
user.email = profile.email
user.pronouns = profile.pronouns
user.onyen = onyen
user_svc.save(user)
user = user_svc.update(user, user)
return user
88 changes: 80 additions & 8 deletions backend/services/user.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
"""User Service.
The User Service provides access to the User model and its associated database operations.
"""

from fastapi import Depends
from sqlalchemy import select, or_, func
from sqlalchemy.orm import Session
Expand All @@ -6,17 +11,30 @@
from ..entities import UserEntity
from .permission import PermissionService

__authors__ = ['Kris Jordan']
__copyright__ = 'Copyright 2023'
__license__ = 'MIT'


class UserService:

_session: Session
_permission: PermissionService

def __init__(self, session: Session = Depends(db_session), permission: PermissionService = Depends()):
"""Initialize the User Service."""
self._session = session
self._permission = permission

def get(self, pid: int) -> User | None:
"""Get a User by PID.
Args:
pid: The PID of the user.
Returns:
User | None: The user or None if not found.
"""
query = select(UserEntity).where(UserEntity.pid == pid)
user_entity: UserEntity = self._session.scalar(query)
if user_entity is None:
Expand All @@ -26,7 +44,16 @@ def get(self, pid: int) -> User | None:
model.permissions = self._permission.get_permissions(model)
return model

def search(self, subject: User, query: str) -> list[User]:
def search(self, _subject: User, query: str) -> list[User]:
"""Search for users by their name, onyen, email.
Args:
subject: The user performing the action.
query: The search query.
Returns:
list[User]: The list of users matching the query.
"""
statement = select(UserEntity)
criteria = or_(
UserEntity.first_name.ilike(f'%{query}%'),
Expand All @@ -39,6 +66,19 @@ def search(self, subject: User, query: str) -> list[User]:
return [entity.to_model() for entity in entities]

def list(self, subject: User, pagination_params: PaginationParams) -> Paginated[User]:
"""List Users.
The subject must have the 'user.list' permission on the 'user/' resource.
Args:
subject: The user performing the action.
pagination_params: The pagination parameters.
Returns:
Paginated[User]: The paginated list of users.
Raises:
PermissionError: If the subject does not have the required permission."""
self._permission.enforce(subject, 'user.list', 'user/')

statement = select(UserEntity)
Expand Down Expand Up @@ -67,12 +107,44 @@ def list(self, subject: User, pagination_params: PaginationParams) -> Paginated[

return Paginated(items=[entity.to_model() for entity in entities], length=length, params=pagination_params)

def save(self, user: User) -> User | None:
if user.id:
entity = self._session.get(UserEntity, user.id)
entity.update(user)
else:
entity = UserEntity.from_model(user)
self._session.add(entity)
def create(self, subject: User, user: User) -> User:
"""Create a User.
If the subject is not the user, the subject must have the `user.create` permission.
Args:
subject: The user performing the action.
user: The user to create.
Returns:
The created User.
Raises:
PermissionError: If the subject does not have permission to create the user."""
if subject != user:
self._permission.enforce(subject, 'user.create', 'user/')
entity = UserEntity.from_model(user)
self._session.add(entity)
self._session.commit()
return entity.to_model()

def update(self, subject: User, user: User) -> User:
"""Update a User.
If the subject is not the user, the subject must have the `user.update` permission.
Args:
subject: The user performing the action.
user: The user to update.
Returns:
The updated User.
Raises:
PermissionError: If the subject does not have permission to update the user."""
if subject != user:
self._permission.enforce(subject, 'user.update', f'user/{user.id}')
entity = self._session.get(UserEntity, user.id)
entity.update(user)
self._session.commit()
return entity.to_model()
Loading

0 comments on commit c2a2927

Please sign in to comment.