mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-29 21:37:15 -05:00
feat: Login with OAuth via OpenID Connect (OIDC) (#3280)
* initial oidc implementation * add dynamic scheme * e2e test setup * add caching * fix * try this * add libldap-2.5 to runtime dependencies (#2849) * New translations en-us.json (Norwegian) (#2851) * New Crowdin updates (#2855) * New translations en-us.json (Italian) * New translations en-us.json (Norwegian) * New translations en-us.json (Portuguese) * fix * remove cache * cache yarn deps * cache docker image * cleanup action * lint * fix tests * remove not needed variables * run code gen * fix tests * add docs * move code into custom scheme * remove unneeded type * fix oidc admin * add more tests * add better spacing on login page * create auth providers * clean up testing stuff * type fixes * add OIDC auth method to postgres enum * add option to bypass login screen and go directly to iDP * remove check so we can fallback to another auth method oauth fails * Add provider name to be shown at the login screen * add new properties to admin about api * fix spec * add a prompt to change auth method when changing password * Create new auth section. Add more info on auth methods * update docs * run ruff * update docs * format * docs gen * formatting * initialize logger in class * mypy type fixes * docs gen * add models to get proper fields in docs and fix serialization * validate id token before using it * only request a mealie token on initial callback * remove unused method * fix unit tests * docs gen * check for valid idToken before getting token * add iss to mealie token * check to see if we already have a mealie token before getting one * fix lock file * update authlib * update lock file * add remember me environment variable * add user group setting to allow only certain groups to log in --------- Co-authored-by: Carter Mintey <cmintey8@gmail.com> Co-authored-by: Carter <35710697+cmintey@users.noreply.github.com>
This commit is contained in:
@@ -30,6 +30,9 @@ class AdminAboutController(BaseAdminController):
|
||||
allow_signup=settings.ALLOW_SIGNUP,
|
||||
build_id=settings.GIT_COMMIT_HASH,
|
||||
recipe_scraper_version=recipe_scraper_version.__version__,
|
||||
enable_oidc=settings.OIDC_AUTH_ENABLED,
|
||||
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
|
||||
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
|
||||
)
|
||||
|
||||
@router.get("/statistics", response_model=AppStatistics)
|
||||
@@ -51,4 +54,5 @@ class AdminAboutController(BaseAdminController):
|
||||
ldap_ready=settings.LDAP_ENABLED,
|
||||
base_url_set=settings.BASE_URL != "http://localhost:8080",
|
||||
is_up_to_date=APP_VERSION == "develop" or APP_VERSION == "nightly" or get_latest_version() == APP_VERSION,
|
||||
oidc_ready=settings.OIDC_READY,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from mealie.core.settings.static import APP_VERSION
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.db.models.users.users import User
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.schema.admin.about import AppInfo, AppStartupInfo, AppTheme
|
||||
from mealie.schema.admin.about import AppInfo, AppStartupInfo, AppTheme, OIDCInfo
|
||||
|
||||
router = APIRouter(prefix="/about")
|
||||
|
||||
@@ -29,6 +29,9 @@ def get_app_info(session: Session = Depends(generate_session)):
|
||||
production=settings.PRODUCTION,
|
||||
allow_signup=settings.ALLOW_SIGNUP,
|
||||
default_group_slug=default_group_slug,
|
||||
enable_oidc=settings.OIDC_READY,
|
||||
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
|
||||
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
|
||||
)
|
||||
|
||||
|
||||
@@ -54,3 +57,12 @@ def get_app_theme(resp: Response):
|
||||
|
||||
resp.headers["Cache-Control"] = "public, max-age=604800"
|
||||
return AppTheme(**settings.theme.model_dump())
|
||||
|
||||
|
||||
@router.get("/oidc", response_model=OIDCInfo)
|
||||
def get_oidc_info(resp: Response):
|
||||
"""Get's the current OIDC configuration needed for the frontend"""
|
||||
settings = get_app_settings()
|
||||
|
||||
resp.headers["Cache-Control"] = "public, max-age=604800"
|
||||
return OIDCInfo(configuration_url=settings.OIDC_CONFIGURATION_URL, client_id=settings.OIDC_CLIENT_ID)
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request, Response, status
|
||||
from fastapi import APIRouter, Depends, Request, Response, status
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core import root_logger, security
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.core.dependencies import get_current_user
|
||||
from mealie.core.security import authenticate_user
|
||||
from mealie.core.security.security import UserLockedOut
|
||||
from mealie.core.exceptions import UserLockedOut
|
||||
from mealie.core.security.security import get_auth_provider
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes._base.routers import UserAPIRouter
|
||||
from mealie.schema.user import PrivateUser
|
||||
from mealie.schema.user.auth import CredentialsRequestForm
|
||||
|
||||
public_router = APIRouter(tags=["Users: Authentication"])
|
||||
user_router = UserAPIRouter(tags=["Users: Authentication"])
|
||||
@@ -22,26 +21,6 @@ logger = root_logger.get_logger("auth")
|
||||
remember_me_duration = timedelta(days=14)
|
||||
|
||||
|
||||
class CustomOAuth2Form(OAuth2PasswordRequestForm):
|
||||
def __init__(
|
||||
self,
|
||||
grant_type: str = Form(None, pattern="password"),
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
remember_me: bool = Form(False),
|
||||
scope: str = Form(""),
|
||||
client_id: str | None = Form(None),
|
||||
client_secret: str | None = Form(None),
|
||||
):
|
||||
self.grant_type = grant_type
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.remember_me = remember_me
|
||||
self.scopes = scope.split()
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
|
||||
|
||||
class MealieAuthToken(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
@@ -52,16 +31,12 @@ class MealieAuthToken(BaseModel):
|
||||
|
||||
|
||||
@public_router.post("/token")
|
||||
def get_token(
|
||||
async def get_token(
|
||||
request: Request,
|
||||
response: Response,
|
||||
data: CustomOAuth2Form = Depends(),
|
||||
data: CredentialsRequestForm = Depends(),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
settings = get_app_settings()
|
||||
|
||||
email = data.username
|
||||
password = data.password
|
||||
if "x-forwarded-for" in request.headers:
|
||||
ip = request.headers["x-forwarded-for"]
|
||||
if "," in ip: # if there are multiple IPs, the first one is canonically the true client
|
||||
@@ -71,28 +46,22 @@ def get_token(
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
try:
|
||||
user = authenticate_user(session, email, password) # type: ignore
|
||||
auth_provider = get_auth_provider(session, request, data)
|
||||
auth = await auth_provider.authenticate()
|
||||
except UserLockedOut as e:
|
||||
logger.error(f"User is locked out from {ip}")
|
||||
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="User is locked out") from e
|
||||
|
||||
if not user:
|
||||
if not auth:
|
||||
logger.error(f"Incorrect username or password from {ip}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
access_token, duration = auth
|
||||
|
||||
duration = timedelta(hours=settings.TOKEN_TIME)
|
||||
if data.remember_me and remember_me_duration > duration:
|
||||
duration = remember_me_duration
|
||||
|
||||
access_token = security.create_access_token(dict(sub=str(user.id)), duration) # type: ignore
|
||||
|
||||
expires_in = duration.total_seconds() if duration else None
|
||||
response.set_cookie(
|
||||
key="mealie.access_token",
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
max_age=duration.seconds if duration else None,
|
||||
key="mealie.access_token", value=access_token, httponly=True, max_age=expires_in, expires=expires_in
|
||||
)
|
||||
|
||||
return MealieAuthToken.respond(access_token)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.core.security import hash_password, verify_password
|
||||
from mealie.core.security import hash_password
|
||||
from mealie.core.security.providers.credentials_provider import CredentialsProvider
|
||||
from mealie.db.models.users.users import AuthMethod
|
||||
from mealie.routes._base import BaseAdminController, BaseUserController, controller
|
||||
from mealie.routes._base.mixins import HttpRepo
|
||||
@@ -70,7 +71,7 @@ class UserController(BaseUserController):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, ErrorResponse.respond(self.t("user.ldap-update-password-unavailable"))
|
||||
)
|
||||
if not verify_password(password_change.current_password, self.user.password):
|
||||
if not CredentialsProvider.verify_password(password_change.current_password, self.user.password):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, ErrorResponse.respond(self.t("user.invalid-current-password"))
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user