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:
Hayden
2024-03-10 13:51:36 -05:00
committed by GitHub
parent bea1a592d7
commit 5f6844eceb
53 changed files with 1533 additions and 400 deletions

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"))
)