Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add first draft for using keycloack as idp and delegating auth to it #33

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
- name: Build Kucukdev backend image
run: docker build ./backend --tag kucukdev_api_backend:latest
- name: Start backend
run: docker run --env SECRET_KEY=SOMESECRETKEY --env DB_NAME=stg --env DB_URL=stg --network host -d kucukdev_api_backend && sleep 5
run: docker run --env AUTH_SECRET_KEY=SOMESECRETKEY --env DB_NAME=stg --env DB_URL=stg --network host -d kucukdev_api_backend && sleep 5
- name: Download openapi.json
run: wget -O openapi.json localhost:8000/openapi.json
- uses: stefanzweifel/git-auto-commit-action@v4
Expand Down
11 changes: 9 additions & 2 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
class CommonSettings(BaseSettings):
APP_NAME: str = "Kucukdev"
DEBUG_MODE: bool = False
SECRET_KEY: Optional[str]


class ServerSettings(BaseSettings):
Expand All @@ -22,7 +21,15 @@ class AdminSettings(BaseSettings):
ADMIN_USERNAME: Optional[str]
ADMIN_PASSWORD: Optional[str]

class AuthSettings(BaseSettings):
AUTH_API_MANAGE_USERS: bool = False # determines if API accepts login or sign-up requests
AUTH_JWT_ALGORITHM: str = "RS256"
AUTH_JWK_URL: str = "http://auth_server:8080/realms/kucukdev/protocol/openid-connect/certs"
AUTH_AUDIENCE: str = "account"
AUTH_SECRET_KEY: Optional[str] = ""
AUTH_ACCESS_TOKEN_EXPIRE_MINUTES: int = 30

class Settings(CommonSettings, ServerSettings, DatabaseSettings, AdminSettings):

class Settings(CommonSettings, ServerSettings, DatabaseSettings, AdminSettings, AuthSettings):
pass

93 changes: 67 additions & 26 deletions backend/app/dependencies.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import json
import secrets
from datetime import datetime, timedelta
from typing import Optional

import requests
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.encoders import jsonable_encoder
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
Expand All @@ -17,8 +19,22 @@

router = APIRouter()

ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

class LazyURLBasedJWK:
def __init__(self, URL, key=None):
self.URL = URL
self.key = key

def get_key(self):
if self.key is None:
self.key = json.loads(requests.get(self.URL).text)
return self.key


if settings.AUTH_JWT_ALGORITHM == "RS256":
JWK_SET = LazyURLBasedJWK(settings.AUTH_JWK_URL) if settings.AUTH_JWK_URL else None
else:
JWK_SET = LazyURLBasedJWK(None, settings.AUTH_SECRET_KEY)

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
Expand All @@ -45,9 +61,9 @@ async def control_secret_key(request: Request):
{}, {"$setOnInsert": {"secret_key": secret_key}}, upsert=True
)
if res.upserted_id is not None:
settings.SECRET_KEY = secret_key
settings.AUTH_SECRET_KEY = secret_key
else:
settings.SECRET_KEY = (await request["key"].find_one())["secret_key"]
settings.AUTH_SECRET_KEY = (await request["key"].find_one())["secret_key"]


class Token(BaseModel):
Expand Down Expand Up @@ -75,26 +91,18 @@ async def login_for_access_token(
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token_expires = timedelta(minutes=settings.AUTH_ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user["email"]}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}


def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)


def get_password_hash(password):
return pwd_context.hash(password)


async def authenticate_user(request: Request, username: str, password: str):
user = await request.app.mongodb["users"].find_one({"email": username})
if not user:
return False
if not verify_password(password, user["password"]):
if not pwd_context.verify(password, user["password"]):
return False
return user

Expand All @@ -104,27 +112,60 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
expire = datetime.utcnow() + timedelta(
minutes=settings.AUTH_ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode.update({"exp": expire, "aud": "account"})
encoded_jwt = jwt.encode(
to_encode, settings.AUTH_SECRET_KEY, algorithm=settings.AUTH_JWT_ALGORITHM
)
return encoded_jwt


async def get_current_user(request: Request, token: str = Depends(oauth2_scheme)):
async def get_current_user(
request: Request,
token: str = Depends(oauth2_scheme), # jwt token
):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
email: str = payload.get("sub")
if email is None:
payload = jwt.decode(
token,
key=JWK_SET.get_key(),
algorithms=[settings.AUTH_JWT_ALGORITHM],
audience=settings.AUTH_AUDIENCE,
)
sub: str = payload.get("sub")
if sub is None:
raise credentials_exception
token_data = TokenData(email=email)
except JWTError:
raise credentials_exception
user = await request.app.mongodb["users"].find_one({"email": token_data.email})
if user is None:
except JWTError as e:
raise credentials_exception

if settings.AUTH_API_MANAGE_USERS:
# when API is managing the users, not finding the user means that user got deleted after authenticated
user = await request.app.mongodb["users"].find_one({"email": sub})
if user is None:
raise credentials_exception
else:
# when API is not managing the users, not finding the user means that it's users first login
email = payload.get("email")
user = await request.app.mongodb["users"].find_one({"email": email})

if user is None:
user = UserModel(email=email, password="nothere")
user = jsonable_encoder(user)
user.update({
"email": email,
"semesters": [],
"userGroup": "default",
"curSemesterID": "null",
"curUniversityID": "null",
"entranceYear": 0,
})

await request.app.mongodb["users"].insert_one(user)

return user
6 changes: 4 additions & 2 deletions backend/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,16 +50,18 @@ async def startup_db_client():
app.mongodb = app.mongodb_client[settings.DB_NAME]
if settings.ADMIN_USERNAME is not None and settings.ADMIN_PASSWORD is not None:
await create_admin_user(request=app.mongodb)
if settings.SECRET_KEY is None:
if settings.AUTH_SECRET_KEY is None:
await control_secret_key(request=app.mongodb)


@app.on_event("shutdown")
async def shutdown_db_client():
app.mongodb_client.close()

if settings.AUTH_API_MANAGE_USERS:
# API can choose to accept login/signup requests
app.include_router(token_router, tags=["token"], prefix="/token")

app.include_router(token_router, tags=["token"], prefix="/token")
app.include_router(user_router, tags=["users"], prefix="/users")
app.include_router(semester_router, tags=["semesters"], prefix="/users")
app.include_router(lesson_router, tags=["lessons"], prefix="/users")
Expand Down
Loading