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

@@ -10,6 +10,7 @@ from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy.orm.session import Session
from mealie.core import root_logger
from mealie.core.config import get_app_dirs, get_app_settings
from mealie.db.db_setup import generate_session
from mealie.repos.all_repositories import get_repositories
@@ -21,6 +22,13 @@ oauth2_scheme_soft_fail = OAuth2PasswordBearer(tokenUrl="/api/auth/token", auto_
ALGORITHM = "HS256"
app_dirs = get_app_dirs()
settings = get_app_settings()
logger = root_logger.get_logger("dependencies")
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
async def is_logged_in(token: str = Depends(oauth2_scheme_soft_fail), session=Depends(generate_session)) -> bool:
@@ -76,13 +84,10 @@ async def try_get_current_user(
async def get_current_user(
request: Request, token: str | None = Depends(oauth2_scheme_soft_fail), session=Depends(generate_session)
request: Request,
token: str | None = Depends(oauth2_scheme_soft_fail),
session=Depends(generate_session),
) -> PrivateUser:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if token is None and "mealie.access_token" in request.cookies:
# Try extract from cookie
token = request.cookies.get("mealie.access_token", "")
@@ -117,12 +122,6 @@ async def get_current_user(
async def get_integration_id(token: str = Depends(oauth2_scheme)) -> str:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
decoded_token = jwt.decode(token, settings.SECRET, algorithms=[ALGORITHM])
return decoded_token.get("integration_id", DEFAULT_INTEGRATION_ID)

View File

@@ -40,3 +40,6 @@ def mealie_registered_exceptions(t: Translator) -> dict:
NoEntryFound: t.t("exceptions.no-entry-found"),
IntegrityError: t.t("exceptions.integrity-error"),
}
class UserLockedOut(Exception): ...

View File

@@ -1,149 +0,0 @@
import ldap
from ldap.ldapobject import LDAPObject
from mealie.core import root_logger
from mealie.core.config import get_app_settings
from mealie.db.models.users.users import AuthMethod
from mealie.repos.repository_factory import AllRepositories
from mealie.schema.user.user import PrivateUser
logger = root_logger.get_logger("security.ldap")
def search_user(conn: LDAPObject, username: str) -> list[tuple[str, dict[str, list[bytes]]]] | None:
"""
Searches for a user by LDAP_ID_ATTRIBUTE, LDAP_MAIL_ATTRIBUTE, and the provided LDAP_USER_FILTER.
If none or multiple users are found, return False
"""
settings = get_app_settings()
user_filter = ""
if settings.LDAP_USER_FILTER:
# fill in the template provided by the user to maintain backwards compatibility
user_filter = settings.LDAP_USER_FILTER.format(
id_attribute=settings.LDAP_ID_ATTRIBUTE, mail_attribute=settings.LDAP_MAIL_ATTRIBUTE, input=username
)
# Don't assume the provided search filter has (|({id_attribute}={input})({mail_attribute}={input}))
search_filter = (
f"(&(|({settings.LDAP_ID_ATTRIBUTE}={username})({settings.LDAP_MAIL_ATTRIBUTE}={username})){user_filter})"
)
user_entry: list[tuple[str, dict[str, list[bytes]]]] | None = None
try:
logger.debug(f"[LDAP] Starting search with filter: {search_filter}")
user_entry = conn.search_s(
settings.LDAP_BASE_DN,
ldap.SCOPE_SUBTREE,
search_filter,
[settings.LDAP_ID_ATTRIBUTE, settings.LDAP_NAME_ATTRIBUTE, settings.LDAP_MAIL_ATTRIBUTE],
)
except ldap.FILTER_ERROR:
logger.error("[LDAP] Bad user search filter")
if not user_entry:
conn.unbind_s()
logger.error("[LDAP] No user was found with the provided user filter")
return None
# we only want the entries that have a dn
user_entry = [(dn, attr) for dn, attr in user_entry if dn]
if len(user_entry) > 1:
logger.warning("[LDAP] Multiple users found with the provided user filter")
logger.debug(f"[LDAP] The following entries were returned: {user_entry}")
conn.unbind_s()
return None
return user_entry
def get_user(db: AllRepositories, username: str, password: str) -> PrivateUser | bool:
"""Given a username and password, tries to authenticate by BINDing to an
LDAP server
If the BIND succeeds, it will either create a new user of that username on
the server or return an existing one.
Returns False on failure.
"""
settings = get_app_settings()
if settings.LDAP_TLS_INSECURE:
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
conn = ldap.initialize(settings.LDAP_SERVER_URL)
conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
conn.set_option(ldap.OPT_REFERRALS, 0)
if settings.LDAP_TLS_CACERTFILE:
conn.set_option(ldap.OPT_X_TLS_CACERTFILE, settings.LDAP_TLS_CACERTFILE)
conn.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
if settings.LDAP_ENABLE_STARTTLS:
conn.start_tls_s()
try:
conn.simple_bind_s(settings.LDAP_QUERY_BIND, settings.LDAP_QUERY_PASSWORD)
except (ldap.INVALID_CREDENTIALS, ldap.NO_SUCH_OBJECT):
logger.error("[LDAP] Unable to bind to with provided user/password")
conn.unbind_s()
return False
user_entry = search_user(conn, username)
if not user_entry:
return False
user_dn, user_attr = user_entry[0]
# Check the credentials of the user
try:
logger.debug(f"[LDAP] Attempting to bind with '{user_dn}' using the provided password")
conn.simple_bind_s(user_dn, password)
except (ldap.INVALID_CREDENTIALS, ldap.NO_SUCH_OBJECT):
logger.error("[LDAP] Bind failed")
conn.unbind_s()
return False
# Check for existing user
user = db.users.get_one(username, "email", any_case=True)
if not user:
user = db.users.get_one(username, "username", any_case=True)
if user is None:
logger.debug("[LDAP] User is not in Mealie. Creating a new account")
attribute_keys = {
settings.LDAP_ID_ATTRIBUTE: "username",
settings.LDAP_NAME_ATTRIBUTE: "name",
settings.LDAP_MAIL_ATTRIBUTE: "mail",
}
attributes = {}
for attribute_key, attribute_name in attribute_keys.items():
if attribute_key not in user_attr or len(user_attr[attribute_key]) == 0:
logger.error(
f"[LDAP] Unable to create user due to missing '{attribute_name}' ('{attribute_key}') attribute"
)
logger.debug(f"[LDAP] User has the following attributes: {user_attr}")
conn.unbind_s()
return False
attributes[attribute_key] = user_attr[attribute_key][0].decode("utf-8")
user = db.users.create(
{
"username": attributes[settings.LDAP_ID_ATTRIBUTE],
"password": "LDAP",
"full_name": attributes[settings.LDAP_NAME_ATTRIBUTE],
"email": attributes[settings.LDAP_MAIL_ATTRIBUTE],
"admin": False,
"auth_method": AuthMethod.LDAP,
},
)
if settings.LDAP_ADMIN_FILTER:
should_be_admin = len(conn.search_s(user_dn, ldap.SCOPE_BASE, settings.LDAP_ADMIN_FILTER, [])) > 0
if user.admin != should_be_admin:
logger.debug(f"[LDAP] {'Setting' if should_be_admin else 'Removing'} user as admin")
user.admin = should_be_admin
db.users.update(user.id, user)
conn.unbind_s()
return user

View File

@@ -0,0 +1,4 @@
from .auth_provider import *
from .credentials_provider import *
from .ldap_provider import *
from .openid_provider import *

View File

@@ -0,0 +1,71 @@
import abc
from datetime import datetime, timedelta, timezone
from typing import Generic, TypeVar
from jose import jwt
from sqlalchemy.orm.session import Session
from mealie.core.config import get_app_settings
from mealie.repos.all_repositories import get_repositories
from mealie.schema.user.user import PrivateUser
ALGORITHM = "HS256"
ISS = "mealie"
remember_me_duration = timedelta(days=14)
T = TypeVar("T")
class AuthProvider(Generic[T], metaclass=abc.ABCMeta):
"""Base Authentication Provider interface"""
def __init__(self, session: Session, data: T) -> None:
self.session = session
self.data = data
self.user: PrivateUser | None = None
self.__has_tried_user = False
@classmethod
def __subclasshook__(cls, __subclass: type) -> bool:
return hasattr(__subclass, "authenticate") and callable(__subclass.authenticate)
def get_access_token(self, user: PrivateUser, remember_me=False) -> tuple[str, timedelta]:
settings = get_app_settings()
duration = timedelta(hours=settings.TOKEN_TIME)
if remember_me and remember_me_duration > duration:
duration = remember_me_duration
return AuthProvider.create_access_token({"sub": str(user.id)}, duration)
@staticmethod
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> tuple[str, timedelta]:
settings = get_app_settings()
to_encode = data.copy()
expires_delta = expires_delta or timedelta(hours=settings.TOKEN_TIME)
expire = datetime.now(timezone.utc) + expires_delta
to_encode["exp"] = expire
to_encode["iss"] = ISS
return (jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM), expires_delta)
def try_get_user(self, username: str) -> PrivateUser | None:
"""Try to get a user from the database, first trying username, then trying email"""
if self.__has_tried_user:
return self.user
db = get_repositories(self.session)
user = user = db.users.get_one(username, "username", any_case=True)
if not user:
user = db.users.get_one(username, "email", any_case=True)
self.user = user
return user
@abc.abstractmethod
async def authenticate(self) -> tuple[str, timedelta] | None:
"""Attempt to authenticate a user"""
raise NotImplementedError

View File

@@ -0,0 +1,57 @@
from datetime import timedelta
from sqlalchemy.orm.session import Session
from mealie.core import root_logger
from mealie.core.config import get_app_settings
from mealie.core.exceptions import UserLockedOut
from mealie.core.security.hasher import get_hasher
from mealie.core.security.providers.auth_provider import AuthProvider
from mealie.repos.all_repositories import get_repositories
from mealie.schema.user.auth import CredentialsRequest
from mealie.services.user_services.user_service import UserService
class CredentialsProvider(AuthProvider[CredentialsRequest]):
"""Authentication provider that authenticates a user the database using username/password combination"""
_logger = root_logger.get_logger("credentials_provider")
def __init__(self, session: Session, data: CredentialsRequest) -> None:
super().__init__(session, data)
async def authenticate(self) -> tuple[str, timedelta] | None:
"""Attempt to authenticate a user given a username and password"""
settings = get_app_settings()
db = get_repositories(self.session)
user = self.try_get_user(self.data.username)
if not user:
# To prevent user enumeration we perform the verify_password computation to ensure
# server side time is relatively constant and not vulnerable to timing attacks.
CredentialsProvider.verify_password(
"abc123cba321", "$2b$12$JdHtJOlkPFwyxdjdygEzPOtYmdQF5/R5tHxw5Tq8pxjubyLqdIX5i"
)
return None
if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS or user.is_locked:
raise UserLockedOut()
if not CredentialsProvider.verify_password(self.data.password, user.password):
user.login_attemps += 1
db.users.update(user.id, user)
if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS:
user_service = UserService(db)
user_service.lock_user(user)
return None
user.login_attemps = 0
user = db.users.update(user.id, user)
return self.get_access_token(user, self.data.remember_me) # type: ignore
@staticmethod
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Compares a plain string to a hashed password"""
return get_hasher().verify(plain_password, hashed_password)

View File

@@ -0,0 +1,178 @@
from datetime import timedelta
import ldap
from ldap.ldapobject import LDAPObject
from sqlalchemy.orm.session import Session
from mealie.core import root_logger
from mealie.core.config import get_app_settings
from mealie.core.security.providers.credentials_provider import CredentialsProvider
from mealie.db.models.users.users import AuthMethod
from mealie.repos.all_repositories import get_repositories
from mealie.schema.user.auth import CredentialsRequest
from mealie.schema.user.user import PrivateUser
class LDAPProvider(CredentialsProvider):
"""Authentication provider that authenticats a user against an LDAP server using username/password combination"""
_logger = root_logger.get_logger("ldap_provider")
def __init__(self, session: Session, data: CredentialsRequest) -> None:
super().__init__(session, data)
self.conn = None
async def authenticate(self) -> tuple[str, timedelta] | None:
"""Attempt to authenticate a user given a username and password"""
user = self.try_get_user(self.data.username)
if not user or user.password == "LDAP" or user.auth_method == AuthMethod.LDAP:
user = self.get_user()
if user:
return self.get_access_token(user, self.data.remember_me)
return await super().authenticate()
def search_user(self, conn: LDAPObject) -> list[tuple[str, dict[str, list[bytes]]]] | None:
"""
Searches for a user by LDAP_ID_ATTRIBUTE, LDAP_MAIL_ATTRIBUTE, and the provided LDAP_USER_FILTER.
If none or multiple users are found, return False
"""
if not self.data:
return None
settings = get_app_settings()
user_filter = ""
if settings.LDAP_USER_FILTER:
# fill in the template provided by the user to maintain backwards compatibility
user_filter = settings.LDAP_USER_FILTER.format(
id_attribute=settings.LDAP_ID_ATTRIBUTE,
mail_attribute=settings.LDAP_MAIL_ATTRIBUTE,
input=self.data.username,
)
# Don't assume the provided search filter has (|({id_attribute}={input})({mail_attribute}={input}))
search_filter = "(&(|({id_attribute}={input})({mail_attribute}={input})){filter})".format(
id_attribute=settings.LDAP_ID_ATTRIBUTE,
mail_attribute=settings.LDAP_MAIL_ATTRIBUTE,
input=self.data.username,
filter=user_filter,
)
user_entry: list[tuple[str, dict[str, list[bytes]]]] | None = None
try:
self._logger.debug(f"[LDAP] Starting search with filter: {search_filter}")
user_entry = conn.search_s(
settings.LDAP_BASE_DN,
ldap.SCOPE_SUBTREE,
search_filter,
[settings.LDAP_ID_ATTRIBUTE, settings.LDAP_NAME_ATTRIBUTE, settings.LDAP_MAIL_ATTRIBUTE],
)
except ldap.FILTER_ERROR:
self._logger.error("[LDAP] Bad user search filter")
if not user_entry:
conn.unbind_s()
self._logger.error("[LDAP] No user was found with the provided user filter")
return None
# we only want the entries that have a dn
user_entry = [(dn, attr) for dn, attr in user_entry if dn]
if len(user_entry) > 1:
self._logger.warning("[LDAP] Multiple users found with the provided user filter")
self._logger.debug(f"[LDAP] The following entries were returned: {user_entry}")
conn.unbind_s()
return None
return user_entry
def get_user(self) -> PrivateUser | None:
"""Given a username and password, tries to authenticate by BINDing to an
LDAP server
If the BIND succeeds, it will either create a new user of that username on
the server or return an existing one.
Returns False on failure.
"""
settings = get_app_settings()
db = get_repositories(self.session)
if not self.data:
return None
data = self.data
if settings.LDAP_TLS_INSECURE:
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
conn = ldap.initialize(settings.LDAP_SERVER_URL)
conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
conn.set_option(ldap.OPT_REFERRALS, 0)
if settings.LDAP_TLS_CACERTFILE:
conn.set_option(ldap.OPT_X_TLS_CACERTFILE, settings.LDAP_TLS_CACERTFILE)
conn.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
if settings.LDAP_ENABLE_STARTTLS:
conn.start_tls_s()
try:
conn.simple_bind_s(settings.LDAP_QUERY_BIND, settings.LDAP_QUERY_PASSWORD)
except (ldap.INVALID_CREDENTIALS, ldap.NO_SUCH_OBJECT):
self._logger.error("[LDAP] Unable to bind to with provided user/password")
conn.unbind_s()
return None
user_entry = self.search_user(conn)
if not user_entry:
return None
user_dn, user_attr = user_entry[0]
# Check the credentials of the user
try:
self._logger.debug(f"[LDAP] Attempting to bind with '{user_dn}' using the provided password")
conn.simple_bind_s(user_dn, data.password)
except (ldap.INVALID_CREDENTIALS, ldap.NO_SUCH_OBJECT):
self._logger.error("[LDAP] Bind failed")
conn.unbind_s()
return None
user = self.try_get_user(data.username)
if user is None:
self._logger.debug("[LDAP] User is not in Mealie. Creating a new account")
attribute_keys = {
settings.LDAP_ID_ATTRIBUTE: "username",
settings.LDAP_NAME_ATTRIBUTE: "name",
settings.LDAP_MAIL_ATTRIBUTE: "mail",
}
attributes = {}
for attribute_key, attribute_name in attribute_keys.items():
if attribute_key not in user_attr or len(user_attr[attribute_key]) == 0:
self._logger.error(
f"[LDAP] Unable to create user due to missing '{attribute_name}' ('{attribute_key}') attribute"
)
self._logger.debug(f"[LDAP] User has the following attributes: {user_attr}")
conn.unbind_s()
return None
attributes[attribute_key] = user_attr[attribute_key][0].decode("utf-8")
user = db.users.create(
{
"username": attributes[settings.LDAP_ID_ATTRIBUTE],
"password": "LDAP",
"full_name": attributes[settings.LDAP_NAME_ATTRIBUTE],
"email": attributes[settings.LDAP_MAIL_ATTRIBUTE],
"admin": False,
"auth_method": AuthMethod.LDAP,
},
)
if settings.LDAP_ADMIN_FILTER:
should_be_admin = len(conn.search_s(user_dn, ldap.SCOPE_BASE, settings.LDAP_ADMIN_FILTER, [])) > 0
if user.admin != should_be_admin:
self._logger.debug(f"[LDAP] {'Setting' if should_be_admin else 'Removing'} user as admin")
user.admin = should_be_admin
db.users.update(user.id, user)
conn.unbind_s()
return user

View File

@@ -0,0 +1,127 @@
from datetime import timedelta
from functools import lru_cache
import requests
from authlib.jose import JsonWebKey, JsonWebToken, JWTClaims, KeySet
from authlib.jose.errors import ExpiredTokenError
from authlib.oidc.core import CodeIDToken
from sqlalchemy.orm.session import Session
from mealie.core import root_logger
from mealie.core.config import get_app_settings
from mealie.core.security.providers.auth_provider import AuthProvider
from mealie.db.models.users.users import AuthMethod
from mealie.repos.all_repositories import get_repositories
from mealie.schema.user.auth import OIDCRequest
class OpenIDProvider(AuthProvider[OIDCRequest]):
"""Authentication provider that authenticates a user using a token from OIDC ID token"""
_logger = root_logger.get_logger("openid_provider")
def __init__(self, session: Session, data: OIDCRequest) -> None:
super().__init__(session, data)
async def authenticate(self) -> tuple[str, timedelta] | None:
"""Attempt to authenticate a user given a username and password"""
claims = self.get_claims()
if not claims:
return None
settings = get_app_settings()
repos = get_repositories(self.session)
user = self.try_get_user(claims.get("email"))
group_claim = claims.get("groups", [])
is_admin = settings.OIDC_ADMIN_GROUP in group_claim if settings.OIDC_ADMIN_GROUP else False
is_valid_user = settings.OIDC_USER_GROUP in group_claim if settings.OIDC_USER_GROUP else True
if not is_valid_user:
self._logger.debug(
"[OIDC] User does not have the required group. Found: %s - Required: %s",
group_claim,
settings.OIDC_USER_GROUP,
)
return None
if not user:
if not settings.OIDC_SIGNUP_ENABLED:
self._logger.debug("[OIDC] No user found. Not creating a new user - new user creation is disabled.")
return None
self._logger.debug("[OIDC] No user found. Creating new OIDC user.")
user = repos.users.create(
{
"username": claims.get("preferred_username"),
"password": "OIDC",
"full_name": claims.get("name"),
"email": claims.get("email"),
"admin": is_admin,
"auth_method": AuthMethod.OIDC,
}
)
self.session.commit()
return self.get_access_token(user, settings.OIDC_REMEMBER_ME) # type: ignore
if user:
if user.admin != is_admin:
self._logger.debug(f"[OIDC] {'Setting' if is_admin else 'Removing'} user as admin")
user.admin = is_admin
repos.users.update(user.id, user)
return self.get_access_token(user, settings.OIDC_REMEMBER_ME)
self._logger.info("[OIDC] Found user but their AuthMethod does not match OIDC")
return None
def get_claims(self) -> JWTClaims | None:
"""Get the claims from the ID token and check if the required claims are present"""
required_claims = {"preferred_username", "name", "email"}
jwks = OpenIDProvider.get_jwks()
if not jwks:
return None
claims = JsonWebToken(["RS256"]).decode(s=self.data.id_token, key=jwks, claims_cls=CodeIDToken)
try:
claims.validate()
except ExpiredTokenError as e:
self._logger.debug(f"[OIDC] {e.error}: {e.description}")
return None
if not claims:
self._logger.warning("[OIDC] Claims not found")
return None
if not required_claims.issubset(claims.keys()):
self._logger.error(
f"[OIDC] Required claims not present. Expected: {required_claims} Actual: {claims.keys()}"
)
return None
return claims
@lru_cache
@staticmethod
def get_jwks() -> KeySet | None:
"""Get the key set from the open id configuration"""
settings = get_app_settings()
if not (settings.OIDC_READY and settings.OIDC_CONFIGURATION_URL):
return None
configuration = None
with requests.get(settings.OIDC_CONFIGURATION_URL, timeout=5) as config_response:
config_response.raise_for_status()
configuration = config_response.json()
if not configuration:
OpenIDProvider._logger.warning("[OIDC] Unable to fetch configuration from the OIDC_CONFIGURATION_URL")
return None
jwks_uri = configuration.get("jwks_uri", None)
if not jwks_uri:
OpenIDProvider._logger.warning("[OIDC] Unable to find the jwks_uri from the OIDC_CONFIGURATION_URL")
return None
with requests.get(jwks_uri, timeout=5) as response:
response.raise_for_status()
return JsonWebKey.import_key_set(response.json())

View File

@@ -2,23 +2,35 @@ import secrets
from datetime import datetime, timedelta, timezone
from pathlib import Path
from fastapi import Request
from jose import jwt
from sqlalchemy.orm.session import Session
from mealie.core import root_logger
from mealie.core.config import get_app_settings
from mealie.core.security import ldap
from mealie.core.security.hasher import get_hasher
from mealie.db.models.users.users import AuthMethod
from mealie.repos.all_repositories import get_repositories
from mealie.schema.user import PrivateUser
from mealie.services.user_services.user_service import UserService
from mealie.core.security.providers.auth_provider import AuthProvider
from mealie.core.security.providers.credentials_provider import CredentialsProvider
from mealie.core.security.providers.ldap_provider import LDAPProvider
from mealie.core.security.providers.openid_provider import OpenIDProvider
from mealie.schema.user.auth import CredentialsRequest, CredentialsRequestForm, OIDCRequest
ALGORITHM = "HS256"
logger = root_logger.get_logger("security")
class UserLockedOut(Exception): ...
def get_auth_provider(session: Session, request: Request, data: CredentialsRequestForm) -> AuthProvider:
settings = get_app_settings()
if request.cookies.get("mealie.auth.strategy") == "oidc":
return OpenIDProvider(session, OIDCRequest(id_token=request.cookies.get("mealie.auth._id_token.oidc")))
credentials_request = CredentialsRequest(**data.__dict__)
if settings.LDAP_ENABLED:
return LDAPProvider(session, credentials_request)
return CredentialsProvider(session, credentials_request)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
@@ -43,44 +55,6 @@ def create_recipe_slug_token(file_path: str | Path) -> str:
return create_access_token(token_data, expires_delta=timedelta(minutes=30))
def authenticate_user(session, email: str, password: str) -> PrivateUser | bool:
settings = get_app_settings()
db = get_repositories(session)
user = db.users.get_one(email, "email", any_case=True)
if not user:
user = db.users.get_one(email, "username", any_case=True)
if settings.LDAP_AUTH_ENABLED and (not user or user.password == "LDAP" or user.auth_method == AuthMethod.LDAP):
return ldap.get_user(db, email, password)
if not user:
# To prevent user enumeration we perform the verify_password computation to ensure
# server side time is relatively constant and not vulnerable to timing attacks.
verify_password("abc123cba321", "$2b$12$JdHtJOlkPFwyxdjdygEzPOtYmdQF5/R5tHxw5Tq8pxjubyLqdIX5i")
return False
if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS or user.is_locked:
raise UserLockedOut()
elif not verify_password(password, user.password):
user.login_attemps += 1
db.users.update(user.id, user)
if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS:
user_service = UserService(db)
user_service.lock_user(user)
return False
user.login_attemps = 0
return db.users.update(user.id, user)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Compares a plain string to a hashed password"""
return get_hasher().verify(plain_password, hashed_password)
def hash_password(password: str) -> str:
"""Takes in a raw password and hashes it. Used prior to saving a new password to the database."""
return get_hasher().hash(password)

View File

@@ -171,6 +171,26 @@ class AppSettings(BaseSettings):
not_none = None not in required
return self.LDAP_AUTH_ENABLED and not_none
# ===============================================
# OIDC Configuration
OIDC_AUTH_ENABLED: bool = False
OIDC_CLIENT_ID: str | None = None
OIDC_CONFIGURATION_URL: str | None = None
OIDC_SIGNUP_ENABLED: bool = True
OIDC_USER_GROUP: str | None = None
OIDC_ADMIN_GROUP: str | None = None
OIDC_AUTO_REDIRECT: bool = False
OIDC_PROVIDER_NAME: str = "OAuth"
OIDC_REMEMBER_ME: bool = False
@property
def OIDC_READY(self) -> bool:
"""Validates OIDC settings are all set"""
required = {self.OIDC_CLIENT_ID, self.OIDC_CONFIGURATION_URL}
not_none = None not in required
return self.OIDC_AUTH_ENABLED and not_none
# ===============================================
# Testing Config

View File

@@ -39,6 +39,7 @@ class LongLiveToken(SqlAlchemyBase, BaseMixins):
class AuthMethod(enum.Enum):
MEALIE = "Mealie"
LDAP = "LDAP"
OIDC = "OIDC"
class User(SqlAlchemyBase, BaseMixins):

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

View File

@@ -1,5 +1,5 @@
# This file is auto-generated by gen_schema_exports.py
from .about import AdminAboutInfo, AppInfo, AppStartupInfo, AppStatistics, AppTheme, CheckAppConfig
from .about import AdminAboutInfo, AppInfo, AppStartupInfo, AppStatistics, AppTheme, CheckAppConfig, OIDCInfo
from .backup import AllBackups, BackupFile, BackupOptions, CreateBackup, ImportJob
from .email import EmailReady, EmailSuccess, EmailTest
from .maintenance import MaintenanceLogs, MaintenanceStorageDetails, MaintenanceSummary
@@ -22,6 +22,11 @@ __all__ = [
"BackupOptions",
"CreateBackup",
"ImportJob",
"EmailReady",
"EmailSuccess",
"EmailTest",
"CustomPageBase",
"CustomPageOut",
"MaintenanceLogs",
"MaintenanceStorageDetails",
"MaintenanceSummary",
@@ -31,15 +36,7 @@ __all__ = [
"AppStatistics",
"AppTheme",
"CheckAppConfig",
"EmailReady",
"EmailSuccess",
"EmailTest",
"CustomPageBase",
"CustomPageOut",
"ChowdownURL",
"MigrationFile",
"MigrationImport",
"Migrations",
"OIDCInfo",
"CommentImport",
"CustomPageImport",
"GroupImport",
@@ -48,4 +45,8 @@ __all__ = [
"RecipeImport",
"SettingsImport",
"UserImport",
"ChowdownURL",
"MigrationFile",
"MigrationImport",
"Migrations",
]

View File

@@ -15,6 +15,9 @@ class AppInfo(MealieModel):
demo_status: bool
allow_signup: bool
default_group_slug: str | None = None
enable_oidc: bool
oidc_redirect: bool
oidc_provider_name: str
class AppTheme(MealieModel):
@@ -58,5 +61,11 @@ class AdminAboutInfo(AppInfo):
class CheckAppConfig(MealieModel):
email_ready: bool
ldap_ready: bool
oidc_ready: bool
base_url_set: bool
is_up_to_date: bool
class OIDCInfo(MealieModel):
configuration_url: str | None
client_id: str | None

View File

@@ -45,15 +45,12 @@ from .invite_token import CreateInviteToken, EmailInitationResponse, EmailInvita
from .webhook import CreateWebhook, ReadWebhook, SaveWebhook, WebhookPagination, WebhookType
__all__ = [
"CreateGroupPreferences",
"ReadGroupPreferences",
"UpdateGroupPreferences",
"GroupDataExport",
"CreateWebhook",
"ReadWebhook",
"SaveWebhook",
"WebhookPagination",
"WebhookType",
"GroupDataExport",
"GroupEventNotifierCreate",
"GroupEventNotifierOptions",
"GroupEventNotifierOptionsOut",
@@ -63,9 +60,21 @@ __all__ = [
"GroupEventNotifierSave",
"GroupEventNotifierUpdate",
"GroupEventPagination",
"CreateGroupPreferences",
"ReadGroupPreferences",
"UpdateGroupPreferences",
"GroupStatistics",
"GroupStorage",
"GroupAdminUpdate",
"DataMigrationCreate",
"SupportedMigrations",
"SeederConfig",
"SetPermissions",
"CreateInviteToken",
"EmailInitationResponse",
"EmailInvitation",
"ReadInviteToken",
"SaveInviteToken",
"ShoppingListAddRecipeParams",
"ShoppingListCreate",
"ShoppingListItemBase",
@@ -88,13 +97,4 @@ __all__ = [
"ShoppingListSave",
"ShoppingListSummary",
"ShoppingListUpdate",
"GroupAdminUpdate",
"SetPermissions",
"GroupStatistics",
"GroupStorage",
"CreateInviteToken",
"EmailInitationResponse",
"EmailInvitation",
"ReadInviteToken",
"SaveInviteToken",
]

View File

@@ -22,18 +22,6 @@ from .plan_rules import (
from .shopping_list import ListItem, ShoppingListIn, ShoppingListOut
__all__ = [
"CreatePlanEntry",
"CreateRandomEntry",
"PlanEntryPagination",
"PlanEntryType",
"ReadPlanEntry",
"SavePlanEntry",
"UpdatePlanEntry",
"MealDayIn",
"MealDayOut",
"MealIn",
"MealPlanIn",
"MealPlanOut",
"Category",
"PlanRulesCreate",
"PlanRulesDay",
@@ -42,7 +30,19 @@ __all__ = [
"PlanRulesSave",
"PlanRulesType",
"Tag",
"CreatePlanEntry",
"CreateRandomEntry",
"PlanEntryPagination",
"PlanEntryType",
"ReadPlanEntry",
"SavePlanEntry",
"UpdatePlanEntry",
"ListItem",
"ShoppingListIn",
"ShoppingListOut",
"MealDayIn",
"MealDayOut",
"MealIn",
"MealPlanIn",
"MealPlanOut",
]

View File

@@ -88,45 +88,8 @@ from .recipe_tool import RecipeToolCreate, RecipeToolOut, RecipeToolResponse, Re
from .request_helpers import RecipeDuplicate, RecipeSlug, RecipeZipTokenResponse, SlugResponse, UpdateImageResponse
__all__ = [
"RecipeToolCreate",
"RecipeToolOut",
"RecipeToolResponse",
"RecipeToolSave",
"RecipeTimelineEventCreate",
"RecipeTimelineEventIn",
"RecipeTimelineEventOut",
"RecipeTimelineEventPagination",
"RecipeTimelineEventUpdate",
"TimelineEventImage",
"TimelineEventType",
"RecipeAsset",
"Nutrition",
"RecipeSettings",
"RecipeShareToken",
"RecipeShareTokenCreate",
"RecipeShareTokenSave",
"RecipeShareTokenSummary",
"RecipeDuplicate",
"RecipeSlug",
"RecipeZipTokenResponse",
"SlugResponse",
"UpdateImageResponse",
"RecipeNote",
"CategoryBase",
"CategoryIn",
"CategoryOut",
"CategorySave",
"RecipeCategoryResponse",
"RecipeTagResponse",
"TagBase",
"TagIn",
"TagOut",
"TagSave",
"RecipeCommentCreate",
"RecipeCommentOut",
"RecipeCommentPagination",
"RecipeCommentSave",
"RecipeCommentUpdate",
"UserBase",
"AssignCategories",
"AssignSettings",
"AssignTags",
@@ -134,10 +97,12 @@ __all__ = [
"ExportBase",
"ExportRecipes",
"ExportTypes",
"IngredientReferences",
"RecipeStep",
"RecipeImageTypes",
"Nutrition",
"RecipeNote",
"RecipeDuplicate",
"RecipeSlug",
"RecipeZipTokenResponse",
"SlugResponse",
"UpdateImageResponse",
"CreateIngredientFood",
"CreateIngredientFoodAlias",
"CreateIngredientUnit",
@@ -160,6 +125,23 @@ __all__ = [
"SaveIngredientFood",
"SaveIngredientUnit",
"UnitFoodBase",
"ScrapeRecipe",
"ScrapeRecipeTest",
"RecipeImageTypes",
"IngredientReferences",
"RecipeStep",
"RecipeAsset",
"RecipeToolCreate",
"RecipeToolOut",
"RecipeToolResponse",
"RecipeToolSave",
"RecipeTimelineEventCreate",
"RecipeTimelineEventIn",
"RecipeTimelineEventOut",
"RecipeTimelineEventPagination",
"RecipeTimelineEventUpdate",
"TimelineEventImage",
"TimelineEventType",
"CreateRecipe",
"CreateRecipeBulk",
"CreateRecipeByUrlBulk",
@@ -173,6 +155,24 @@ __all__ = [
"RecipeTagPagination",
"RecipeTool",
"RecipeToolPagination",
"ScrapeRecipe",
"ScrapeRecipeTest",
"CategoryBase",
"CategoryIn",
"CategoryOut",
"CategorySave",
"RecipeCategoryResponse",
"RecipeTagResponse",
"TagBase",
"TagIn",
"TagOut",
"TagSave",
"RecipeCommentCreate",
"RecipeCommentOut",
"RecipeCommentPagination",
"RecipeCommentSave",
"RecipeCommentUpdate",
"UserBase",
"RecipeShareToken",
"RecipeShareTokenCreate",
"RecipeShareTokenSave",
"RecipeShareTokenSummary",
]

View File

@@ -6,10 +6,6 @@ from .responses import ErrorResponse, FileTokenResponse, SuccessResponse
from .validation import ValidationResponse
__all__ = [
"ErrorResponse",
"FileTokenResponse",
"SuccessResponse",
"SearchFilter",
"LogicalOperator",
"QueryFilter",
"QueryFilterComponent",
@@ -20,5 +16,9 @@ __all__ = [
"PaginationBase",
"PaginationQuery",
"RecipeSearchQuery",
"ErrorResponse",
"FileTokenResponse",
"SuccessResponse",
"ValidationResponse",
"SearchFilter",
]

View File

@@ -33,6 +33,12 @@ __all__ = [
"Token",
"TokenData",
"UnlockResults",
"ForgotPassword",
"PasswordResetToken",
"PrivatePasswordResetToken",
"ResetPassword",
"SavePasswordResetToken",
"ValidateResetToken",
"ChangePassword",
"CreateToken",
"DeleteTokenResponse",
@@ -49,10 +55,4 @@ __all__ = [
"UserIn",
"UserOut",
"UserPagination",
"ForgotPassword",
"PasswordResetToken",
"PrivatePasswordResetToken",
"ResetPassword",
"SavePasswordResetToken",
"ValidateResetToken",
]

View File

@@ -1,5 +1,6 @@
from typing import Annotated
from fastapi import Form
from pydantic import UUID4, BaseModel, StringConstraints
from mealie.schema._mealie.mealie_model import MealieModel
@@ -17,3 +18,22 @@ class TokenData(BaseModel):
class UnlockResults(MealieModel):
unlocked: int = 0
class CredentialsRequest(BaseModel):
username: str
password: str
remember_me: bool = False
class OIDCRequest(BaseModel):
id_token: str
class CredentialsRequestForm:
"""Class that represents a user's credentials from the login form"""
def __init__(self, username: str = Form(""), password: str = Form(""), remember_me: bool = Form(False)):
self.username = username
self.password = password
self.remember_me = remember_me

View File

@@ -1,8 +1,10 @@
import sys
from getpass import getpass
from mealie.core import root_logger
from mealie.core.security.security import hash_password
from mealie.db.db_setup import session_context
from mealie.db.models.users.users import AuthMethod
from mealie.repos.repository_factory import AllRepositories
@@ -18,22 +20,32 @@ def main():
if not user:
logger.error("no user found")
exit(1)
sys.exit(1)
logger.info(f"changing password for {user.username}")
reset_auth_method = False
if user.auth_method != AuthMethod.MEALIE:
logger.warning("%s is using external authentication.", user.username)
response = input("Would you like to change your authentication method back to local? (y/n): ")
reset_auth_method = response.lower() == "yes" or response.lower() == "y"
logger.info("changing password for %s", user.username)
pw = getpass("Please enter the new password: ")
pw2 = getpass("Please enter the new password again: ")
if pw != pw2:
logger.error("passwords do not match")
sys.exit(1)
hashed_password = hash_password(pw)
repos.users.update_password(user.id, hashed_password)
if reset_auth_method:
user.auth_method = AuthMethod.MEALIE
repos.users.update(user.id, user)
logger.info("password change successful")
input("press enter to exit ")
exit(0)
sys.exit(0)
if __name__ == "__main__":