wokring oidc setup with authentik

This commit is contained in:
Georges-Antoine Assi
2024-11-26 23:57:15 -05:00
parent 3a91b7ba54
commit bc5c2e45f3
11 changed files with 165 additions and 77 deletions

View File

@@ -137,17 +137,3 @@ cd backend
# path or test file can be passed as argument to test only a subset
poetry run pytest [path/file]
```
## Hydra setup
```sh
docker exec hydra hydra create client \
--endpoint http://127.0.0.1:4445/ \
--format json \
--grant-type authorization_code,refresh_token \
--redirect-uri "http://localhost:3000/api/oauth/openid" \
--scope openid \
--scope profile \
--scope email \
--scope offline
```

View File

@@ -33,7 +33,7 @@ oauth2_password_bearer = OAuth2PasswordBearer(
config = Config(
environ={
"OAUTH_ENABLED": OAUTH_ENABLED,
"OAUTH_ENABLED": str(OAUTH_ENABLED),
"OAUTH_CLIENT_ID": OAUTH_CLIENT_ID,
"OAUTH_CLIENT_SECRET": OAUTH_CLIENT_SECRET,
"OAUTH_REDIRECT_URI": OAUTH_REDIRECT_URI,
@@ -52,6 +52,12 @@ oauth.register(
oauth2_autorization_code_bearer = OAuth2AuthorizationCodeBearer(
authorizationUrl="/auth/openid",
tokenUrl="/token",
auto_error=False,
scopes={
**DEFAULT_SCOPES_MAP,
**WRITE_SCOPES_MAP,
**FULL_SCOPES_MAP,
},
)
@@ -61,7 +67,7 @@ def protected_route(
scopes: list[Scope] | None = None,
**kwargs,
):
def decorator(func: DecoratedCallable):
def decorator(func: DecoratedCallable) -> DecoratedCallable:
fn = requires(scopes or [])(func)
return method(
path,

View File

@@ -6,10 +6,15 @@ from decorators.auth import oauth
from endpoints.forms.identity import OAuth2RequestForm
from endpoints.responses import MessageResponse
from endpoints.responses.oauth import TokenResponse
from exceptions.auth_exceptions import AuthCredentialsException, DisabledException
from exceptions.auth_exceptions import (
AuthCredentialsException,
OAuthDisableException,
OAuthNotConfiguredException,
UserDisabledException,
)
from fastapi import Depends, HTTPException, Request, status
from fastapi.security.http import HTTPBasic
from handler.auth import auth_handler, oauth_handler
from handler.auth import auth_handler, oauth_handler, open_id_handler
from handler.database import db_user_handler
from utils.router import APIRouter
@@ -47,9 +52,15 @@ async def token(form_data: Annotated[OAuth2RequestForm, Depends()]) -> TokenResp
status_code=status.HTTP_400_BAD_REQUEST, detail="Missing refresh token"
)
user, claims = await oauth_handler.get_current_active_user_from_bearer_token(
potential_user = await oauth_handler.get_current_active_user_from_bearer_token(
token
)
if not potential_user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
)
user, claims = potential_user
if claims.get("type") != "refresh":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
@@ -148,7 +159,7 @@ def login(
Raises:
CredentialsException: Invalid credentials
DisabledException: Auth is disabled
UserDisabledException: Auth is disabled
Returns:
MessageResponse: Standard message response
@@ -159,7 +170,7 @@ def login(
raise AuthCredentialsException
if not user.enabled:
raise DisabledException
raise UserDisabledException
request.session.update({"iss": "romm:auth", "sub": user.username})
@@ -177,12 +188,19 @@ async def login_via_openid(request: Request):
Args:
request (Request): Fastapi Request object
Raises:
OAuthDisableException: OAuth is disabled
OAuthNotConfiguredException: OAuth not configured
Returns:
RedirectResponse: Redirect to OAuth2 provider
"""
if not OAUTH_ENABLED:
raise DisabledException
raise OAuthDisableException
if not oauth.openid:
raise OAuthNotConfiguredException
return await oauth.openid.authorize_redirect(request, OAUTH_REDIRECT_URI)
@@ -194,25 +212,42 @@ async def auth_openid(request: Request):
Args:
request (Request): Fastapi Request object
Raises:
OAuthDisableException: OAuth is disabled
OAuthNotConfiguredException: OAuth not configured
AuthCredentialsException: Invalid credentials
UserDisabledException: Auth is disabled
Returns:
RedirectResponse: Redirect to home page
"""
if not OAUTH_ENABLED:
raise OAuthDisableException
if not oauth.openid:
raise OAuthNotConfiguredException
token = await oauth.openid.authorize_access_token(request)
user, claims = await oauth_handler.get_current_active_user_from_bearer_token(token)
potential_user = await open_id_handler.get_current_active_user_from_openid_token(
token
)
if not potential_user:
raise AuthCredentialsException
user, _claims = potential_user
if not user:
raise AuthCredentialsException
if not user.enabled:
raise DisabledException
raise UserDisabledException
request.session.update({"iss": "romm:auth", "sub": user.username})
# Update last login and active times
db_user_handler.update_user(
user.id, {"last_login": datetime.now(), "last_active": datetime.now()}
)
now = datetime.now(timezone.utc)
db_user_handler.update_user(user.id, {"last_login": now, "last_active": now})
return {"msg": "Successfully logged in"}

View File

@@ -35,7 +35,7 @@ def add_user(request: Request, username: str, password: str, role: str) -> UserS
role (str): RomM Role object represented as string
Returns:
UserSchema: Created user info
UserSchema: Newly created user
"""
# If there are admin users already, enforce the USERS_WRITE scope.
@@ -63,7 +63,7 @@ def add_user(request: Request, username: str, password: str, role: str) -> UserS
role=Role[role.upper()],
)
return db_user_handler.add_user(user)
return UserSchema.model_validate(db_user_handler.add_user(user))
@protected_route(router.get, "/users", [Scope.USERS_READ])
@@ -77,7 +77,7 @@ def get_users(request: Request) -> list[UserSchema]:
list[UserSchema]: All users stored in the RomM's database
"""
return db_user_handler.get_users()
return [UserSchema.model_validate(u) for u in db_user_handler.get_users()]
@protected_route(router.get, "/users/me", [Scope.ME_READ])
@@ -109,7 +109,7 @@ def get_user(request: Request, id: int) -> UserSchema:
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
return UserSchema.model_validate(user)
@protected_route(router.put, "/users/{id}", [Scope.USERS_WRITE])
@@ -190,7 +190,7 @@ async def update_user(
if request.user.id == id and creds_updated:
request.session.clear()
return db_user_handler.get_user(id)
return UserSchema.model_validate(db_user_handler.get_user(id))
@protected_route(router.delete, "/users/{id}", [Scope.USERS_WRITE])

View File

@@ -10,7 +10,7 @@ AuthenticationSchemeException = HTTPException(
detail="Invalid authentication scheme",
)
DisabledException = HTTPException(
UserDisabledException = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Disabled user",
)
@@ -20,3 +20,13 @@ OAuthCredentialsException = HTTPException(
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
OAuthDisableException = HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth disabled",
)
OAuthNotConfiguredException = HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="OAuth not configured",
)

View File

@@ -1,4 +1,5 @@
from .base_handler import AuthHandler, OAuthHandler
from .base_handler import AuthHandler, OAuthHandler, OpenIDHandler
auth_handler = AuthHandler()
oauth_handler = OAuthHandler()
open_id_handler = OpenIDHandler()

View File

@@ -1,13 +1,13 @@
import enum
from datetime import datetime, timedelta, timezone
from typing import Final
from typing import Any, Final
from config import ROMM_AUTH_SECRET_KEY
from exceptions.auth_exceptions import OAuthCredentialsException
from fastapi import HTTPException, status
from joserfc import jwt
from joserfc.errors import BadSignatureError
from joserfc.jwk import OctKey
from joserfc.jwk import OctKey, RSAKey
from passlib.context import CryptContext
from starlette.requests import HTTPConnection
@@ -161,3 +161,37 @@ class OAuthHandler:
)
return user, payload.claims
class OpenIDHandler:
def __init__(self) -> None:
pass
async def get_current_active_user_from_openid_token(self, token: Any):
from handler.database import db_user_handler
# http://localhost:9000/application/o/romm/jwks/
rsa_key = RSAKey.import_key(public_key)
id_token = token.get("id_token")
try:
payload = jwt.decode(id_token, rsa_key, algorithms=["RS256"])
except (BadSignatureError, ValueError) as exc:
raise OAuthCredentialsException from exc
# TODO: verify iss claim
username = payload.claims.get("preferred_username")
if username is None:
raise OAuthCredentialsException
user = db_user_handler.get_user_by_username(username)
if user is None:
raise OAuthCredentialsException
if not user.enabled:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user"
)
return user, payload.claims

View File

@@ -15,7 +15,7 @@ from config import (
from endpoints import (
auth,
collections,
config,
configs,
feeds,
firmware,
heartbeat,
@@ -102,7 +102,7 @@ app.include_router(saves.router, prefix="/api")
app.include_router(states.router, prefix="/api")
app.include_router(tasks.router, prefix="/api")
app.include_router(feeds.router, prefix="/api")
app.include_router(config.router, prefix="/api")
app.include_router(configs.router, prefix="/api")
app.include_router(stats.router, prefix="/api")
app.include_router(raw.router, prefix="/api")
app.include_router(screenshots.router, prefix="/api")

View File

@@ -22,51 +22,67 @@ services:
env_file:
- .env
hydra:
image: oryd/hydra:v2.2.0
container_name: hydra
postgresql:
image: docker.io/library/postgres:16-alpine
container_name: postgresql
restart: unless-stopped
ports:
- 4444:4444 # Public port
- 4445:4445 # Admin port
- 5555:5555 # Port for hydra token user
command: serve -c /etc/config/hydra/hydra.yml all --dev
volumes:
- hydra-sqlite:/var/lib/sqlite
- ./hydra/quickstart:/etc/config/hydra
- postgres_db:/var/lib/postgresql/data
environment:
- DSN=sqlite:///var/lib/sqlite/db.sqlite?_fk=true
POSTGRES_PASSWORD: $POSTGRES_PASSWORD
POSTGRES_USER: $POSTGRES_USER
POSTGRES_DB: $POSTGRES_DB
ports:
- 5432:5432
env_file:
- .env
authentik_server:
image: ghcr.io/goauthentik/server:2024.10.4
container_name: authentik_server
restart: unless-stopped
command: server
environment:
AUTHENTIK_REDIS__HOST: valkey
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: $POSTGRES_USER
AUTHENTIK_POSTGRESQL__NAME: $POSTGRES_DB
AUTHENTIK_POSTGRESQL__PASSWORD: $POSTGRES_PASSWORD
AUTHENTIK_SECRET_KEY: $AUTHENTIK_SECRET_KEY
volumes:
- authentik_media:/media
- authentik_templates:/templates
env_file:
- .env
ports:
- 9000:9000
- 9443:9443
depends_on:
- hydra-migrate
networks:
- hydranet
- postgresql
- valkey
hydra-migrate:
image: oryd/hydra:v2.2.0
container_name: hydra-migrate
authentik_worker:
image: ghcr.io/goauthentik/server:2024.10.4
container_name: authentik_worker
restart: unless-stopped
command: worker
environment:
- DSN=sqlite:///var/lib/sqlite/db.sqlite?_fk=true
command: migrate -c /etc/config/hydra/hydra.yml sql -e --yes
AUTHENTIK_REDIS__HOST: valkey
AUTHENTIK_POSTGRESQL__HOST: postgresql
AUTHENTIK_POSTGRESQL__USER: $POSTGRES_USER
AUTHENTIK_POSTGRESQL__NAME: $POSTGRES_DB
AUTHENTIK_POSTGRESQL__PASSWORD: $POSTGRES_PASSWORD
AUTHENTIK_SECRET_KEY: $AUTHENTIK_SECRET_KEY
volumes:
- hydra-sqlite:/var/lib/sqlite
- ./hydra/quickstart:/etc/config/hydra
networks:
- hydranet
hydra-consent:
image: oryd/hydra-login-consent-node:v2.2.0
container_name: hydra-consent
restart: unless-stopped
environment:
- HYDRA_ADMIN_URL=http://hydra:4445
ports:
- 3001:3000
networks:
- hydranet
networks:
hydranet:
- authentik_media:/media
- authentik_templates:/templates
env_file:
- .env
depends_on:
- postgresql
- valkey
volumes:
hydra-sqlite:
postgres_db:
authentik_media:
authentik_templates:

View File

@@ -36,7 +36,7 @@ api.interceptors.response.use(
return response;
},
async (error) => {
if (error.response?.status === 401) {
if (error.response?.status === 403) {
// Clear cookies and redirect to login page
Cookies.remove("romm_session");