Skip to content

Commit

Permalink
Authentication (#32)
Browse files Browse the repository at this point in the history
* heel simpel inloggen en data ophalen

er staan nog heel veel waarden hardcoded, en code is totaal niet clean

* improve fetch

* add frontend_url to config

* env file initialized + README updated

* linter prettier

* logout fixed + temporary '/'

* composition api only + logout page

* improve fetch profile

* update local SSL certificate for frontend

* remove config.yml

* format code

* refactor backend to use JWT instead of cookies

* update backend readme for explanation authentication

* update requirements.txt

* fix jwt -> pyjwt

* update frontend to use JWT and some basic styling

* format frontend

* remove counter store

* update readme about logging in

* cleanup backend auth

* cleanup frontend auth

* run formatter

* Update backend/src/auth/dependencies.py

Co-authored-by: Xander Bil <[email protected]>

* apply requested changes

* run formatter

* update backend README

* Update frontend/.env.local.example

Co-authored-by: Xander Bil <[email protected]>

* reject config when values are missing

* fix env value in config

* color fixes

* linter

---------

Co-authored-by: Marieke <[email protected]>
Co-authored-by: Xander Bil <[email protected]>
  • Loading branch information
3 people authored Mar 10, 2024
1 parent d026975 commit d34b73d
Show file tree
Hide file tree
Showing 44 changed files with 856 additions and 408 deletions.
2 changes: 1 addition & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
__pycache__
*venv*
local-cert/

*.db
config.yml
.env
Expand Down
53 changes: 27 additions & 26 deletions backend/README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,26 @@
## Run the backend api
# Backend API

### Setup
## Running the API

#### In this directy execute the following command to create a python environment:
### Setup

```sh
# Create a python virtual environment
python -m venv venv
```

#### Activate the environment:

```sh
# Activate the environment
source venv/bin/activate
```

#### Install the dependencies:

```sh
# Install dependencies
pip install -r requirements.txt
```

#### Create a config.yml file with following content
#### Create a `.env` file with following content

```yml
api_url: https://localhost:8080
cas_server_url: https://login.ugent.be
database_uri: "database connection string: postgresql://..., see discord..."
FRONTEND_URL="https://localhost:8080"
CAS_SERVER_URL="https://login.ugent.be"
DATABASE_URI="database connection string: postgresql://..., see discord..."
SECRET_KEY="<secret key to sign JWT tokens>" # e.g. generate with `openssl rand -hex 32`
ALGORITHM="HS256" # algorithm used to sign JWT tokens
```

### Usage
Expand All @@ -38,21 +33,27 @@ source venv/bin/activate

#### Run the web server:

> Note: For local development, an SSL-certificate is needed to interact with the
> CAS-server of UGent. Install [mkcert](https://github.com/FiloSottile/mkcert)
> and run
> ```sh
> mkdir local-cert
> mkcert -key-file local-cert/localhost-key.pem -cert-file local-cert/localhost.pem localhost
> ```
```sh
./run.sh
```

It will start a local development server on port `8080`
This will start a local development server on port `5173`

## The API

## Login

Authentication happens with the use of CAS. The client can ask where it can find
the CAS server with the `/api/authority` endpoint. A ticket then can be obtained
via `<authority>?service=<redirect_url>`. The CAS server will redirect to
`<redirect_url>?ticket=<ticket>` after authentication. Once the client is
authenticated, further authorization happens with [JWT](https://jwt.io/). To
obtain this token, a `POST` request has to be made to `/api/token/`, with the
CAS ticket `<ticket>` and the `<redirect_url>`. The redirect url is needed to
verify the ticket. If the ticket is valid, a webtoken will be returned. To
authorize each request, add the token in the `Authorization` header.

## Developing

#### To format the python code in place to conform to PEP 8 style:

Expand Down
9 changes: 5 additions & 4 deletions backend/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,37 +4,38 @@ attrs==23.1.0
autopep8==2.0.4
cattrs==23.1.2
certifi==2024.2.2
cffi==1.16.0
charset-normalizer==3.3.2
click==8.1.7
cryptography==42.0.5
exceptiongroup==1.1.2
fastapi==0.109.2
greenlet==3.0.3
h11==0.14.0
httptools==0.6.1
httpx==0.27.0
idna==3.6
itsdangerous==2.1.2
lsprotocol==2023.0.0a2
lxml==5.1.0
nodeenv==1.8.0
psycopg2-binary==2.9.9
pycodestyle==2.11.1
pycparser==2.21
pydantic==2.6.1
pydantic_core==2.16.2
pygls==1.0.1
PyJWT==2.8.0
pyright==1.1.352
python-cas==1.6.0
python-dotenv==1.0.1
PyYAML==6.0.1
requests==2.31.0
ruff-lsp==0.0.38
setuptools==69.1.1
six==1.16.0
sniffio==1.3.0
SQLAlchemy==2.0.27
starlette==0.36.3
typeguard==2.13.3
typing_extensions>=4.7.1
typing_extensions==4.10.0
urllib3==2.2.1
uvicorn==0.27.1
watchfiles==0.21.0
Expand Down
4 changes: 1 addition & 3 deletions backend/run.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
#!/usr/bin/env bash

uvicorn src.main:app --reload --port 8080 \
--ssl-keyfile "local-cert/localhost-key.pem" \
--ssl-certfile "local-cert/localhost.pem"
uvicorn src.main:app --reload --port 5173
36 changes: 36 additions & 0 deletions backend/src/auth/dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import jwt
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from src.config import CONFIG

from .exceptions import UnAuthenticated


def verify_jwt_token(
credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()),
) -> str:
"""
Verify the JWT token and return the user_id
Args:
credentials (HTTPAuthorizationCredentials): The credentials from the request
Returns:
str: The user_id
Raises:
UnAuthenticated: If the token is invalid or expired
"""
try:
payload = jwt.decode(
credentials.credentials,
CONFIG.secret_key,
algorithms=[CONFIG.algorithm],
verify_signature=True,
options={"require": ["exp", "sub"]},
)
user_id = payload["sub"]
return user_id
except (jwt.ExpiredSignatureError, jwt.MissingRequiredClaimError):
# Token is expired or no expiration time is set
raise UnAuthenticated
except jwt.InvalidTokenError:
# Token is invalid
raise UnAuthenticated
13 changes: 13 additions & 0 deletions backend/src/auth/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from fastapi import HTTPException, status


class NotAuthorized(HTTPException):
def __init__(self, detail: str = "Not authorized"):
"""Raised when user is not privileged enough to do this action"""
super().__init__(status_code=status.HTTP_403_FORBIDDEN, detail=detail)


class UnAuthenticated(HTTPException):
def __init__(self, detail: str = "Login required"):
"""Raised when user not logged in"""
super().__init__(status_code=status.HTTP_401_UNAUTHORIZED, detail=detail)
73 changes: 73 additions & 0 deletions backend/src/auth/router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import src.user.service as user_service
from cas import CASClient
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from src import config
from src.auth.schemas import Authority, Token, TokenRequest
from src.dependencies import get_db
from src.user.schemas import UserCreate

from .exceptions import UnAuthenticated
from .utils import create_jwt_token

router = APIRouter(
prefix="/api", tags=["auth"], responses={404: {"description": "Not Found"}}
)

cas_client = CASClient(
version=2,
server_url=f"{config.CONFIG.cas_server_url}",
)


@router.get("/authority")
def authority() -> Authority:
"""
Get CAS authority
"""
return Authority(method="cas", authority=config.CONFIG.cas_server_url)


@router.post("/token")
async def token(
request: Request,
token_request: TokenRequest,
db: Session = Depends(get_db),
) -> Token:
"""
Get JWT token from CAS ticket
"""
# No ticket provided
if not token_request.ticket:
raise UnAuthenticated(detail="No ticket provided")

cas_client.service_url = f"{request.headers.get('origin')}{token_request.returnUrl}"
user, attributes, _ = cas_client.verify_ticket(token_request.ticket)

# Invalid ticket
if not user or not attributes:
raise UnAuthenticated(detail="Invalid CAS ticket")
# Create user if not exists
if not await user_service.get_by_id(db, attributes["uid"]):
await user_service.create_user(
db,
UserCreate(
given_name=attributes["givenname"],
uid=attributes["uid"],
mail=attributes["mail"],
),
)

# Create JWT token
jwt_token = create_jwt_token(attributes["uid"])
return jwt_token


@router.get("/logout")
async def logout() -> RedirectResponse:
"""
Logout from CAS
"""
cas_logout_url = cas_client.get_logout_url()
return RedirectResponse(cas_logout_url)
16 changes: 16 additions & 0 deletions backend/src/auth/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from pydantic import BaseModel


class Token(BaseModel):
token: str
token_type: str


class TokenRequest(BaseModel):
ticket: str
returnUrl: str


class Authority(BaseModel):
method: str
authority: str
18 changes: 18 additions & 0 deletions backend/src/auth/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import jwt
from src import config
from src.auth.schemas import Token
from datetime import datetime, timedelta, timezone


def create_jwt_token(user_id: str) -> Token:
now = datetime.now(timezone.utc)
payload = {
"sub": user_id,
"iat": now,
# TODO: don't hardcode this
"exp": now + timedelta(weeks=1),
}
token = jwt.encode(
payload, config.CONFIG.secret_key, algorithm=config.CONFIG.algorithm
)
return Token(token=token, token_type="bearer")
28 changes: 22 additions & 6 deletions backend/src/config.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,36 @@
from __future__ import annotations
from dotenv import load_dotenv
import os
from dataclasses import dataclass

from dotenv import load_dotenv

load_dotenv()


@dataclass
class Config:
api_url: str
frontend_url: str
cas_server_url: str
database_uri: str
secret_key: str
algorithm: str


env = {
"frontend_url": os.getenv("FRONTEND_URL", ""),
"cas_server_url": os.getenv("CAS_SERVER_URL", ""),
"database_uri": os.getenv("DATABASE_URI", ""),
"secret_key": os.getenv("SECRET_KEY", ""),
"algorithm": os.getenv("ALGORITHM", ""),
}

for key, value in env.items():
if value == "":
raise ValueError(f"Environment variable {key} is not set")

CONFIG = Config(
os.getenv("API_URL", "https://localhost:8000"),
os.getenv("CAS_SERVER", "https://login.ugent.be"),
os.getenv("DATABASE_URI", "CONNECtOON_STRING")
frontend_url=env["frontend_url"],
cas_server_url=env["cas_server_url"],
database_uri=env["database_uri"],
secret_key=env["secret_key"],
algorithm=env["algorithm"],
)
13 changes: 13 additions & 0 deletions backend/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,26 @@
from starlette.middleware.sessions import SessionMiddleware
from src.subject.router import router as subject_router
from src.user.router import router as user_router
from src.auth.router import router as auth_router
from fastapi.middleware.cors import CORSMiddleware
from src import config

app = FastAPI()

app.add_middleware(SessionMiddleware, secret_key="!secret")

origins = [config.CONFIG.frontend_url]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)

app.include_router(subject_router)
app.include_router(user_router)
app.include_router(auth_router)


@app.get("/api")
Expand Down
2 changes: 1 addition & 1 deletion backend/src/subject/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from sqlalchemy.orm import Session
from src.dependencies import get_db
from src.user.dependencies import get_authenticated_user
from src.user.exceptions import NotAuthorized
from src.auth.exceptions import NotAuthorized
from src.user.schemas import User

from . import service
Expand Down
Loading

0 comments on commit d34b73d

Please sign in to comment.