Skip to content

Commit

Permalink
LGA-3224 Add in Authorisation to the API (#65)
Browse files Browse the repository at this point in the history
* Add in Authorisation to the API
* Add command to list and update user scopes
* Add documentation for scopes
  • Loading branch information
said-moj authored Dec 2, 2024
1 parent dbc5d71 commit 1659e02
Show file tree
Hide file tree
Showing 12 changed files with 304 additions and 29 deletions.
40 changes: 34 additions & 6 deletions app/auth/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
from passlib.hash import argon2
import jwt
from jwt.exceptions import InvalidTokenError
from fastapi.security import OAuth2PasswordBearer
from fastapi import HTTPException, Depends, status
from app.models.users import User, TokenData, Token
from fastapi.security import OAuth2PasswordBearer, SecurityScopes
from fastapi import HTTPException, Depends, status, Security
from app.models.users import User, TokenData
from app.config import Config
from app.db import get_session
from sqlmodel import Session
Expand All @@ -16,7 +16,6 @@
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
SECRET_KEY = Config.SECRET_KEY

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")


Expand Down Expand Up @@ -61,12 +60,15 @@ def authenticate_user(session, username: str, password: str) -> str | User | boo
return user


def create_access_token(data: dict, expires_delta: timedelta | None = None) -> Token:
def create_access_token(
data: dict, scopes: list | None = None, expires_delta: timedelta | None = None
) -> str:
"""
Creates the JWT access token with an expiry time.
Args:
data: A dictionary containing the username.
scopes: A list of scopes assigned to the user.
expires_delta: A timedelta of the expiry time of the token.
Returns:
Expand All @@ -77,6 +79,8 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> T
the expiry field to ensure it can still be read as a standalone object.
"""
to_encode = data.copy()
scopes = scopes or []
to_encode.update({"scopes": scopes})
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
Expand All @@ -88,15 +92,22 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> T
return encoded_jwt


def token_decode(token: str) -> dict:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])


async def get_current_user(
security_scopes: SecurityScopes,
token: Annotated[str, Depends(oauth2_scheme)],
session: Annotated[Session, Depends(get_session)],
):
"""
Checks the current user token to return a user.
Args:
security_scopes: Security scopes user should have access to.
token: Uses the oauth2 scheme to get the current JWT.
session: Uses the session object to get the current user.
Returns:
user: Returns the current user object by verifying against the JWT.
Expand All @@ -105,11 +116,19 @@ async def get_current_user(
HTTP_Exception: If authentication fails, a HTTP 401 Unauthorised error is
raised with a message indicating that the credentials could not be validated.
"""

credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)

scopes_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions",
headers={"WWW-Authenticate": f'Bearer scope="{security_scopes.scope_str}"'},
)

try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
Expand All @@ -120,15 +139,24 @@ async def get_current_user(
user = session.get(User, token_data.username)
if user is None:
raise credentials_exception

if security_scopes.scopes and not user.scopes:
raise scopes_exception

for scope in security_scopes.scopes:
if scope not in user.scopes:
raise scopes_exception

return user


async def get_current_active_user(
current_user: Annotated[User, Depends(get_current_user)],
current_user: Annotated[User, Security(get_current_user, scopes=[])],
):
if current_user.disabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User Disabled",
)

return current_user
31 changes: 31 additions & 0 deletions app/db/migrations/versions/d4223a91f1de_user_scopes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""user scopes
Revision ID: d4223a91f1de
Revises: c4b9d0057513
Create Date: 2024-11-21 11:33:22.255372
"""

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = "d4223a91f1de"
down_revision: Union[str, None] = "c4b9d0057513"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column("users", sa.Column("scopes", sa.JSON(), nullable=True))
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users", "scopes")
# ### end Alembic commands ###
21 changes: 20 additions & 1 deletion app/models/users.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,22 @@
from sqlmodel import Field, SQLModel
from sqlmodel import Field, SQLModel, JSON
from enum import Enum
from typing import List


class UserScopes(str, Enum):
READ = "read"
CREATE = "create"
UPDATE = "update"
DELETE = "delete"

@classmethod
def as_list(cls):
# Iterate over the values only
return [member.value for member in cls]

@classmethod
def as_dict(cls) -> dict:
return {member.name: member.value for member in cls}


class Token(SQLModel):
Expand Down Expand Up @@ -27,3 +45,4 @@ class User(SQLModel, table=True):
email: str | None = None
full_name: str | None = None
disabled: bool = Field(default=False)
scopes: List[UserScopes] = Field(sa_type=JSON, default=[], nullable=True)
40 changes: 30 additions & 10 deletions app/routers/case_information.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import structlog
from typing import Sequence

from fastapi import APIRouter, HTTPException, Depends

from uuid import UUID
from fastapi import APIRouter, HTTPException, Security, Depends
from sqlmodel import Session, select
from app.models.cases import (
CaseRequest,
Case,
CaseResponse,
CaseUpdateRequest,
)
from sqlmodel import Session, select
from app.db import get_session
from app.auth.security import get_current_active_user
from uuid import UUID
import structlog
from app.models.users import UserScopes


logger = structlog.getLogger(__name__)

Expand All @@ -25,21 +25,36 @@
)


@router.get("/{case_id}", tags=["cases"], response_model=CaseResponse)
@router.get(
"/{case_id}",
tags=["cases"],
response_model=CaseResponse,
dependencies=[Security(get_current_active_user, scopes=[UserScopes.READ])],
)
async def read_case(case_id: UUID, session: Session = Depends(get_session)) -> Case:
case: Case | None = session.get(Case, case_id)
if not case:
raise HTTPException(status_code=404, detail="Case not found")
return case


@router.get("/", tags=["cases"])
@router.get(
"/",
tags=["cases"],
dependencies=[Security(get_current_active_user, scopes=[UserScopes.READ])],
)
async def read_all_cases(session: Session = Depends(get_session)) -> Sequence[Case]:
cases = session.exec(select(Case)).all()
return cases


@router.post("/", tags=["cases"], response_model=CaseResponse, status_code=201)
@router.post(
"/",
tags=["cases"],
response_model=CaseResponse,
status_code=201,
dependencies=[Security(get_current_active_user, scopes=[UserScopes.CREATE])],
)
def create_case(
request: CaseRequest,
session: Session = Depends(get_session),
Expand All @@ -50,7 +65,12 @@ def create_case(
return case


@router.put("/{case_id}", tags=["cases"], response_model=CaseResponse)
@router.put(
"/{case_id}",
tags=["cases"],
response_model=CaseResponse,
dependencies=[Security(get_current_active_user, scopes=[UserScopes.UPDATE])],
)
def update_case(
case_id: UUID, request: CaseUpdateRequest, session: Session = Depends(get_session)
):
Expand Down
4 changes: 3 additions & 1 deletion app/routers/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ async def login_for_access_token(
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
data={"sub": user.username},
expires_delta=access_token_expires,
scopes=user.scopes,
)
return Token(access_token=str(access_token), token_type="bearer")
15 changes: 12 additions & 3 deletions bin/add_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))

from app.db import get_session
from app.models.users import User
from app.models.users import User, UserScopes
from app.auth.security import get_password_hash
import logging

Expand All @@ -28,6 +28,7 @@ def add_users(users_list_dict: list[dict]):
username = user_info.get("username")
password = user_info.get("password")
disabled = user_info.get("disabled")
scopes = user_info.get("scopes", [])

if not username or not password:
logging.warning(
Expand All @@ -43,15 +44,23 @@ def add_users(users_list_dict: list[dict]):

password = get_password_hash(password)
new_user = User(
username=username, hashed_password=password, disabled=disabled
username=username,
hashed_password=password,
disabled=disabled,
scopes=scopes,
)
session.add(new_user)

session.commit()


users_to_add = [
{"username": "cla_admin", "password": "cla_admin", "disabled": False},
{
"username": "cla_admin",
"password": "cla_admin",
"disabled": False,
"scopes": UserScopes.as_list(),
},
{"username": "janedoe", "password": "password", "disabled": True},
]

Expand Down
10 changes: 9 additions & 1 deletion docs/source/documentation/case.html.md.erb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ title: Case model
```
POST /cases
```
### Scope
create

A case can be created using the following request schema

Expand Down Expand Up @@ -118,18 +120,24 @@ You will receive the following response schema:
```
GET /cases/
```
### Scope
read

### Gets all case information for a given case id

```
GET /cases/{case_id}
```
### Scope
read

### Modify a case

```
PATCH /cases/{case_id}
PUT /cases/{case_id}
```
### Scope
update

A case can be modified by providing a new case with the following schema.

Expand Down
24 changes: 24 additions & 0 deletions docs/source/documentation/scopes.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
title: Scopes
---

## Scopes
There are four scopes which each correspond to a http method:
- create
- read
- update
- delete

Scopes are only assignable by the CLA team and you need to request which scopes you api requires as part of your account creation process

### Adding/updating user scopes
The following overwrites the current user scopes with the ones given
`python manage.py user-scopes-add <username> --scope=create --scope=read --scope=update`

### Listing user scopes
To get a list of current scopes assign to a user do
`python manage.py user-scopes-list <username>`

### List routes with scopes
To get a list of all the routes which includes their scopes do
`python manage.py routes-list`
Loading

0 comments on commit 1659e02

Please sign in to comment.