mirror of
https://github.com/rommapp/romm.git
synced 2026-06-28 06:46:00 +00:00
wokring oidc setup with authentik
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user