diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000000..db9d10c03491 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,46 @@ +name: E2E Tests +on: + pull_request: + branches: + - mealie-next +jobs: + test: + timeout-minutes: 60 + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./tests/e2e + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 18 + cache: 'yarn' + cache-dependency-path: ./tests/e2e/yarn.lock + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build Image + uses: docker/build-push-action@v5 + with: + file: ./docker/Dockerfile + context: . + push: false + load: true + tags: mealie:e2e + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Deploy E2E Test Environment + run: docker compose up -d + working-directory: ./tests/e2e/docker + - name: Install dependencies + run: npm install -g yarn && yarn + - name: Install Playwright Browsers + run: yarn playwright install --with-deps + - name: Check test environment + run: docker ps + - name: Run Playwright tests + run: yarn playwright test + - name: Destroy Test Environment + if: always() + run: docker compose down --volumes + working-directory: ./tests/e2e/docker diff --git a/alembic/versions/2024-03-10-05.08.32_09aba125b57a_add_oidc_auth_method.py b/alembic/versions/2024-03-10-05.08.32_09aba125b57a_add_oidc_auth_method.py new file mode 100644 index 000000000000..4f8d6018b23f --- /dev/null +++ b/alembic/versions/2024-03-10-05.08.32_09aba125b57a_add_oidc_auth_method.py @@ -0,0 +1,31 @@ +"""add OIDC auth method + +Revision ID: 09aba125b57a +Revises: 2298bb460ffd +Create Date: 2024-03-10 05:08:32.397027 + +""" + +import sqlalchemy as sa + +import mealie.db.migration_types +from alembic import op + +# revision identifiers, used by Alembic. +revision = "09aba125b57a" +down_revision = "2298bb460ffd" +branch_labels = None +depends_on = None + + +def is_postgres(): + return op.get_context().dialect.name == "postgresql" + + +def upgrade(): + if is_postgres(): + op.execute("ALTER TYPE authmethod ADD VALUE 'OIDC'") + + +def downgrade(): + pass diff --git a/docs/docs/documentation/getting-started/usage/ldap.md b/docs/docs/documentation/getting-started/authentication/ldap.md similarity index 100% rename from docs/docs/documentation/getting-started/usage/ldap.md rename to docs/docs/documentation/getting-started/authentication/ldap.md diff --git a/docs/docs/documentation/getting-started/authentication/oidc.md b/docs/docs/documentation/getting-started/authentication/oidc.md new file mode 100644 index 000000000000..ace4a83a0229 --- /dev/null +++ b/docs/docs/documentation/getting-started/authentication/oidc.md @@ -0,0 +1,88 @@ +# OpenID Connect (OIDC) Authentication + +Mealie supports 3rd party authentication via [OpenID Connect (OIDC)](https://openid.net/connect/), an identity layer built on top of OAuth2. OIDC is supported by many identity providers, including: + +- [Authentik](https://goauthentik.io/integrations/sources/oauth/#openid-connect) +- [Authelia](https://www.authelia.com/configuration/identity-providers/open-id-connect/) +- [Keycloak](https://www.keycloak.org/docs/latest/securing_apps/#_oidc) +- [Okta](https://www.okta.com/openid-connect/) + +## Account Linking + +Signing in with OAuth will automatically find your account in Mealie and link to it. If a user does not exist in Mealie, then one will be created (if enabled), but will be unable to log in with any other authentication method. An admin can configure another authentication method for such a user. + +## Provider Setup + +Before you can start using OIDC Authentication, you must first configure a new client application in your identity provider. Your identity provider must support the OAuth **Authorization Code** flow (with PKCE). The steps will vary by provider, but generally, the steps are as follows. + +1. Create a new client application + - The Provider type should be OIDC or OAuth2 + - The Grant type should be `Authorization Code` + - The Application type should be `Web` + - The Client type should be `public` + +2. Configure redirect URI + + The only redirect URI that is needed is `http(s)://DOMAIN:PORT/login` + + The redirect URI should include any URL that Mealie is accessible from. Some examples include + + http://localhost:9091/login + https://mealie.example.com/login + +3. Configure origins + + If your identity provider enforces CORS on any endpoints, you will need to specify your Mealie URL as an Allowed Origin. + +4. Configure allowed scopes + + The scopes required are `openid profile email groups` + +## Mealie Setup + +Take the client id and your discovery URL and update your environment variables to include the required OIDC variables described in [Installation - Backend Configuration](../installation/backend-config.md#openid-connect-oidc) + +## Examples + +### Authelia + +Follow the instructions in [Authelia's documentation](https://www.authelia.com/configuration/identity-providers/open-id-connect/). Below is an example config + +!!! warning + + This is only an example and not meant to be an exhaustive configuration. You should read read through the documentation and adjust your configuration as needed. + +```yaml +identity_providers: + oidc: + access_token_lifespan: 1h + authorize_code_lifespan: 1m + id_token_lifespan: 1h + refresh_token_lifespan: 90m + enable_client_debug_messages: false + enforce_pkce: public_clients_only + cors: + endpoints: + - authorization + - token + - revocation + - introspection + allowed_origins: + - https://mealie.example.com + allowed_origins_from_client_redirect_uris: false + clients: + - id: mealie + description: Mealie + authorization_policy: one_factor + redirect_uris: + - https://mealie.example.com/login + public: true + grant_types: + - authorization_code + scopes: + - openid + - profile + - groups + - email + - offline_access +``` diff --git a/docs/docs/documentation/getting-started/faq.md b/docs/docs/documentation/getting-started/faq.md index 76bb5693837a..7df1f46a5be5 100644 --- a/docs/docs/documentation/getting-started/faq.md +++ b/docs/docs/documentation/getting-started/faq.md @@ -94,6 +94,10 @@ docker exec -it mealie-next bash python /app/mealie/scripts/change_password.py ``` +## I can't log in with external auth. How can I change my authentication method? + +Follow the [steps above](#how-can-i-change-my-password) for changing your password. You will be prompted if you would like to switch your authentication method back to local auth so you can log in again. + ## How do private groups and recipes work? Managing private groups and recipes can be confusing. The following diagram and notes should help explain how they work to determine if a recipe can be shared publicly. diff --git a/docs/docs/documentation/getting-started/installation/backend-config.md b/docs/docs/documentation/getting-started/installation/backend-config.md index 80f678b487f2..911b526fc3cd 100644 --- a/docs/docs/documentation/getting-started/installation/backend-config.md +++ b/docs/docs/documentation/getting-started/installation/backend-config.md @@ -75,6 +75,22 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea | LDAP_NAME_ATTRIBUTE | name | The LDAP attribute that maps to the user's name | | LDAP_MAIL_ATTRIBUTE | mail | The LDAP attribute that maps to the user's email | +### OpenID Connect (OIDC) + +For usage, see [Usage - OpenID Connect](../authentication/oidc.md) + +| Variables | Default | Description | +| --- | :--: | --- | +| OIDC_AUTH_ENABLED | False | Enables authentication via OpenID Connect | +| OIDC_SIGNUP_ENABLED | True | Enables new users to be created when signing in for the first time with OIDC | +| OIDC_CONFIGURATION_URL | None | The URL to the OIDC configuration of your provider. This is usually something like https://auth.example.com/.well-known/openid-configuration | +| OIDC_CLIENT_ID | None | The client id of your configured client in your provider | +| OIDC_USER_GROUP| None | If specified, this group must be present in the user's group claim in order to authenticate | +| OIDC_ADMIN_GROUP | None | If this group is present in the group claims, the user will be set as an admin | +| OIDC_AUTO_REDIRECT | False | If `True`, then the login page will be bypassed an you will be sent directly to your Identity Provider. You can still get to the login page by adding `?direct=1` to the login URL | +| OIDC_PROVIDER_NAME | OAuth | The provider name is shown in SSO login button. "Login with " | +| OIDC_REMEMBER_ME | False | Because redirects bypass the login screen, you cant extend your session by clicking the "Remember Me" checkbox. By setting this value to true, a session will be extended as if "Remember Me" was checked | + ### Themeing Setting the following environmental variables will change the theme of the frontend. Note that the themes are the same for all users. This is a break-change when migration from v0.x.x -> 1.x.x. diff --git a/docs/docs/documentation/getting-started/usage/permissions-and-public-access.md b/docs/docs/documentation/getting-started/usage/permissions-and-public-access.md index f7a3c1043f26..475a0b44886a 100644 --- a/docs/docs/documentation/getting-started/usage/permissions-and-public-access.md +++ b/docs/docs/documentation/getting-started/usage/permissions-and-public-access.md @@ -1,7 +1,7 @@ # Permissions and Public Access Mealie provides various levels of user access and permissions. This includes: -- Authentication and registration ([check out the LDAP guide](./ldap.md) for how to configure access using LDAP) +- Authentication and registration ([LDAP](../authentication/ldap.md) and [OpenID Connect](../authentication/oidc.md) are both supported) - Customizable user permissions - Fine-tuned public access for non-users diff --git a/docs/docs/overrides/api.html b/docs/docs/overrides/api.html index 4e7f1d6d216f..4198afcc6067 100644 --- a/docs/docs/overrides/api.html +++ b/docs/docs/overrides/api.html @@ -14,7 +14,7 @@
diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index fc5badfd6120..c10dcdce8c56 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -73,7 +73,11 @@ nav: - Backend Configuration: "documentation/getting-started/installation/backend-config.md" - Usage: - Backup and Restoring: "documentation/getting-started/usage/backups-and-restoring.md" - - LDAP Authentication: "documentation/getting-started/usage/ldap.md" + - Permissions and Public Access: "documentation/getting-started/usage/permissions-and-public-access.md" + + - Authentication: + - LDAP: "documentation/getting-started/authentication/ldap.md" + - OpenID Connect: "documentation/getting-started/authentication/oidc.md" - Community Guides: - iOS Shortcuts: "documentation/community-guide/ios.md" diff --git a/frontend/composables/use-users/user-form.ts b/frontend/composables/use-users/user-form.ts index 10ac14461376..387f56f49c47 100644 --- a/frontend/composables/use-users/user-form.ts +++ b/frontend/composables/use-users/user-form.ts @@ -38,7 +38,7 @@ export const useUserForm = () => { type: fieldTypes.SELECT, hint: i18n.tc("user.authentication-method-hint"), disableCreate: true, - options: [{ text: "Mealie" }, { text: "LDAP" }], + options: [{ text: "Mealie" }, { text: "LDAP" }, { text: "OIDC" }], }, { section: i18n.tc("user.permissions"), diff --git a/frontend/lang/messages/en-US.json b/frontend/lang/messages/en-US.json index 06e26f925874..aeda8952029e 100644 --- a/frontend/lang/messages/en-US.json +++ b/frontend/lang/messages/en-US.json @@ -728,7 +728,10 @@ "ldap-ready-error-text": "Not all LDAP Values are configured. This can be ignored if you are not using LDAP Authentication.", "ldap-ready-success-text": "Required LDAP variables are all set.", "build": "Build", - "recipe-scraper-version": "Recipe Scraper Version" + "recipe-scraper-version": "Recipe Scraper Version", + "oidc-ready": "OIDC Ready", + "oidc-ready-error-text": "Not all OIDC Values are configured. This can be ignored if you are not using OIDC Authentication.", + "oidc-ready-success-text": "Required OIDC variables are all set." }, "shopping-list": { "all-lists": "All Lists", @@ -836,6 +839,8 @@ "link-id": "Link ID", "link-name": "Link Name", "login": "Login", + "login-oidc": "Login with", + "or": "or", "logout": "Logout", "manage-users": "Manage Users", "new-password": "New Password", diff --git a/frontend/lib/api/types/admin.ts b/frontend/lib/api/types/admin.ts index 0df40dec5f00..7f3e883c6001 100644 --- a/frontend/lib/api/types/admin.ts +++ b/frontend/lib/api/types/admin.ts @@ -10,6 +10,9 @@ export interface AdminAboutInfo { version: string; demoStatus: boolean; allowSignup: boolean; + enableOidc: boolean; + oidcRedirect: boolean; + oidcProviderName: string; versionLatest: string; apiPort: number; apiDocs: boolean; @@ -34,6 +37,9 @@ export interface AppInfo { demoStatus: boolean; allowSignup: boolean; defaultGroupSlug?: string; + enableOidc: boolean; + oidcRedirect: boolean; + oidcProviderName: string; } export interface AppStartupInfo { isFirstLogin: boolean; @@ -72,6 +78,7 @@ export interface BackupOptions { export interface CheckAppConfig { emailReady: boolean; ldapReady: boolean; + oidcReady: boolean; baseUrlSet: boolean; isUpToDate: boolean; } @@ -218,6 +225,10 @@ export interface NotificationImport { status: boolean; exception?: string; } +export interface OIDCInfo { + configurationUrl?: string; + clientId?: string; +} export interface RecipeImport { name: string; status: boolean; diff --git a/frontend/lib/api/types/user.ts b/frontend/lib/api/types/user.ts index 6bba730c5f55..32ec9767f33d 100644 --- a/frontend/lib/api/types/user.ts +++ b/frontend/lib/api/types/user.ts @@ -5,7 +5,7 @@ /* Do not modify it by hand - just update the pydantic models and then re-run the script */ -export type AuthMethod = "Mealie" | "LDAP"; +export type AuthMethod = "Mealie" | "LDAP" | "OIDC"; export interface ChangePassword { currentPassword: string; diff --git a/frontend/nuxt.config.js b/frontend/nuxt.config.js index 9e01cb343bfa..851f2fc567c0 100644 --- a/frontend/nuxt.config.js +++ b/frontend/nuxt.config.js @@ -123,7 +123,7 @@ export default { auth: { redirect: { login: "/login", - logout: "/login", + logout: "/login?direct=1", callback: "/login", home: "/", }, @@ -134,6 +134,7 @@ export default { path: "/", }, }, + rewriteRedirects: false, // Options strategies: { local: { @@ -158,6 +159,14 @@ export default { user: { url: "api/users/self", method: "get" }, }, }, + oidc: { + scheme: "~/schemes/DynamicOpenIDConnectScheme", + resetOnError: true, + clientId: "", + endpoints: { + configuration: "", + } + }, }, }, diff --git a/frontend/pages/admin/manage/users/_id.vue b/frontend/pages/admin/manage/users/_id.vue index 31ad6fc8e917..2c57d54bfab2 100644 --- a/frontend/pages/admin/manage/users/_id.vue +++ b/frontend/pages/admin/manage/users/_id.vue @@ -90,7 +90,7 @@ export default defineComponent({ const user = ref(null); const disabledFields = computed(() => { - return user.value?.authMethod === "LDAP" ? ["admin"] : []; + return user.value?.authMethod !== "Mealie" ? ["admin"] : []; }) const userError = ref(false); diff --git a/frontend/pages/admin/site-settings.vue b/frontend/pages/admin/site-settings.vue index d17ca36751ce..49beba165e02 100644 --- a/frontend/pages/admin/site-settings.vue +++ b/frontend/pages/admin/site-settings.vue @@ -201,6 +201,7 @@ export default defineComponent({ isSiteSecure: true, isUpToDate: false, ldapReady: false, + oidcReady: false, }); function isLocalHostOrHttps() { return window.location.hostname === "localhost" || window.location.protocol === "https:"; @@ -258,6 +259,15 @@ export default defineComponent({ color: appConfig.value.ldapReady ? goodColor : warningColor, icon: appConfig.value.ldapReady ? goodIcon : warningIcon, }, + { + id: "oidc-ready", + text: i18n.t("settings.oidc-ready"), + status: appConfig.value.oidcReady, + errorText: i18n.t("settings.oidc-ready-error-text"), + successText: i18n.t("settings.oidc-ready-success-text"), + color: appConfig.value.oidcReady ? goodColor : warningColor, + icon: appConfig.value.oidcReady ? goodIcon : warningIcon, + }, ]; return data; }); diff --git a/frontend/pages/login.vue b/frontend/pages/login.vue index 2e6bfcec6b24..a595a1a911e8 100644 --- a/frontend/pages/login.vue +++ b/frontend/pages/login.vue @@ -70,6 +70,26 @@ + +
+ + + {{ $t("user.or") }} + +
+ +
+ + {{ $t("user.login-oidc") }} {{ oidcProviderName }} + +
+
@@ -161,6 +181,32 @@ export default defineComponent({ const { passwordIcon, inputType, togglePasswordShow } = usePasswordField(); const allowSignup = computed(() => appInfo.value?.allowSignup || false); + const allowOidc = computed(() => appInfo.value?.enableOidc || false); + const oidcRedirect = computed(() => appInfo.value?.oidcRedirect || false); + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + const oidcProviderName = computed(() => appInfo.value?.oidcProviderName || "OAuth") + + whenever( + () => allowOidc.value && oidcRedirect.value && !isCallback() && !isDirectLogin(), + () => oidcAuthenticate(), + {immediate: true} + ) + + function isCallback() { + return router.currentRoute.query.state; + } + + function isDirectLogin() { + return router.currentRoute.query.direct + } + + async function oidcAuthenticate() { + try { + await $auth.loginWith("oidc") + } catch (error) { + alert.error(i18n.t("events.something-went-wrong") as string); + } + } async function authenticate() { if (form.email.length === 0 || form.password.length === 0) { @@ -199,7 +245,10 @@ export default defineComponent({ form, loggingIn, allowSignup, + allowOidc, authenticate, + oidcAuthenticate, + oidcProviderName, passwordIcon, inputType, togglePasswordShow, @@ -250,4 +299,20 @@ export default defineComponent({ .bg-off-white { background: #f5f8fa; } + +.absolute { + position: absolute; +} + +.div-width { + max-width: 75%; +} + +.bg-background { + background-color: #1e1e1e; +} + +.bg-white { + background-color: #fff; +} diff --git a/frontend/schemes/DynamicOpenIDConnectScheme.js b/frontend/schemes/DynamicOpenIDConnectScheme.js new file mode 100644 index 000000000000..179cd29a1c7a --- /dev/null +++ b/frontend/schemes/DynamicOpenIDConnectScheme.js @@ -0,0 +1,87 @@ +import jwtDecode from "jwt-decode" +import { ConfigurationDocument, OpenIDConnectScheme } from "~auth/runtime" + +/** + * Custom Scheme that dynamically gets the OpenID Connect configuration from the backend. + * This is needed because the SPA frontend does not have access to runtime environment variables. + */ +export default class DynamicOpenIDConnectScheme extends OpenIDConnectScheme { + + async mounted() { + await this.getConfiguration(); + this.options.scope = ["openid", "profile", "email", "groups"] + + this.configurationDocument = new ConfigurationDocument( + this, + this.$auth.$storage + ) + + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return await super.mounted() + } + + async fetchUser() { + if (!this.check().valid) { + return + } + + const { data } = await this.$auth.requestWith(this.name, { + url: "/api/users/self" + }) + + this.$auth.setUser(data) + } + + async _handleCallback() { + const redirect = await super._handleCallback() + await this.updateAccessToken() + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return redirect; + } + + async updateAccessToken() { + if (!this.idToken.sync()) { + return + } + if (this.isValidMealieToken()) { + return + } + + const response = await this.$auth.requestWith(this.name, { + url: "/api/auth/token", + method: "post" + }) + + // Update tokens with mealie token + this.updateTokens(response) + } + + isValidMealieToken() { + if (this.token.status().valid()) { + let iss = null; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + iss = jwtDecode(this.token.get()).iss + } catch (e) { + // pass + } + return iss === "mealie" + } + return false + } + + async getConfiguration() { + const route = "/api/app/about/oidc"; + + try { + const response = await fetch(route); + const data = await response.json(); + this.options.endpoints.configuration = data.configurationUrl; + this.options.clientId = data.clientId; + } catch (error) { + // pass + } + } +} diff --git a/frontend/template.env b/frontend/template.env index d6fa80bbfee5..f8716e6a4ee1 100644 --- a/frontend/template.env +++ b/frontend/template.env @@ -19,4 +19,4 @@ THEME_DARK_SECONDARY=#973542 THEME_DARK_SUCCESS=#43A047 THEME_DARK_INFO=#1976D2 THEME_DARK_WARNING=#FF6D00 -THEME_DARK_ERROR=#EF5350 \ No newline at end of file +THEME_DARK_ERROR=#EF5350 diff --git a/mealie/core/dependencies/dependencies.py b/mealie/core/dependencies/dependencies.py index e92ca5042c58..ba30327f52b5 100644 --- a/mealie/core/dependencies/dependencies.py +++ b/mealie/core/dependencies/dependencies.py @@ -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) diff --git a/mealie/core/exceptions.py b/mealie/core/exceptions.py index 39e179a8b569..874141ed921c 100644 --- a/mealie/core/exceptions.py +++ b/mealie/core/exceptions.py @@ -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): ... diff --git a/mealie/core/security/ldap.py b/mealie/core/security/ldap.py deleted file mode 100644 index 9199f3751173..000000000000 --- a/mealie/core/security/ldap.py +++ /dev/null @@ -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 diff --git a/mealie/core/security/providers/__init__.py b/mealie/core/security/providers/__init__.py new file mode 100644 index 000000000000..cdd755258afb --- /dev/null +++ b/mealie/core/security/providers/__init__.py @@ -0,0 +1,4 @@ +from .auth_provider import * +from .credentials_provider import * +from .ldap_provider import * +from .openid_provider import * diff --git a/mealie/core/security/providers/auth_provider.py b/mealie/core/security/providers/auth_provider.py new file mode 100644 index 000000000000..46b7d583cfd3 --- /dev/null +++ b/mealie/core/security/providers/auth_provider.py @@ -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 diff --git a/mealie/core/security/providers/credentials_provider.py b/mealie/core/security/providers/credentials_provider.py new file mode 100644 index 000000000000..de55357fd1dc --- /dev/null +++ b/mealie/core/security/providers/credentials_provider.py @@ -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) diff --git a/mealie/core/security/providers/ldap_provider.py b/mealie/core/security/providers/ldap_provider.py new file mode 100644 index 000000000000..2b3caeb5d0a0 --- /dev/null +++ b/mealie/core/security/providers/ldap_provider.py @@ -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 diff --git a/mealie/core/security/providers/openid_provider.py b/mealie/core/security/providers/openid_provider.py new file mode 100644 index 000000000000..3416b4ef0e5c --- /dev/null +++ b/mealie/core/security/providers/openid_provider.py @@ -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()) diff --git a/mealie/core/security/security.py b/mealie/core/security/security.py index c04bde22b341..0b937b592a54 100644 --- a/mealie/core/security/security.py +++ b/mealie/core/security/security.py @@ -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) diff --git a/mealie/core/settings/settings.py b/mealie/core/settings/settings.py index 1d35a3614375..52e0ad050ce1 100644 --- a/mealie/core/settings/settings.py +++ b/mealie/core/settings/settings.py @@ -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 diff --git a/mealie/db/models/users/users.py b/mealie/db/models/users/users.py index 83f96ef6050f..65d507d7c33e 100644 --- a/mealie/db/models/users/users.py +++ b/mealie/db/models/users/users.py @@ -39,6 +39,7 @@ class LongLiveToken(SqlAlchemyBase, BaseMixins): class AuthMethod(enum.Enum): MEALIE = "Mealie" LDAP = "LDAP" + OIDC = "OIDC" class User(SqlAlchemyBase, BaseMixins): diff --git a/mealie/routes/admin/admin_about.py b/mealie/routes/admin/admin_about.py index 7df4f99f2f77..f4457063311d 100644 --- a/mealie/routes/admin/admin_about.py +++ b/mealie/routes/admin/admin_about.py @@ -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, ) diff --git a/mealie/routes/app/app_about.py b/mealie/routes/app/app_about.py index 54e11ab8f3ac..8d412bb4ac10 100644 --- a/mealie/routes/app/app_about.py +++ b/mealie/routes/app/app_about.py @@ -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) diff --git a/mealie/routes/auth/auth.py b/mealie/routes/auth/auth.py index d17ff4145790..d78c8b7cd303 100644 --- a/mealie/routes/auth/auth.py +++ b/mealie/routes/auth/auth.py @@ -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) diff --git a/mealie/routes/users/crud.py b/mealie/routes/users/crud.py index 07f41582b978..559fa213490a 100644 --- a/mealie/routes/users/crud.py +++ b/mealie/routes/users/crud.py @@ -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")) ) diff --git a/mealie/schema/admin/__init__.py b/mealie/schema/admin/__init__.py index 50f06197b0cf..b2bf2b3288ca 100644 --- a/mealie/schema/admin/__init__.py +++ b/mealie/schema/admin/__init__.py @@ -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", ] diff --git a/mealie/schema/admin/about.py b/mealie/schema/admin/about.py index 0c624772eed3..0ba91e3af169 100644 --- a/mealie/schema/admin/about.py +++ b/mealie/schema/admin/about.py @@ -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 diff --git a/mealie/schema/group/__init__.py b/mealie/schema/group/__init__.py index 3e7d86ae3e46..13f0ff8bfcce 100644 --- a/mealie/schema/group/__init__.py +++ b/mealie/schema/group/__init__.py @@ -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", ] diff --git a/mealie/schema/meal_plan/__init__.py b/mealie/schema/meal_plan/__init__.py index e46564fdf696..d99fc75c65d6 100644 --- a/mealie/schema/meal_plan/__init__.py +++ b/mealie/schema/meal_plan/__init__.py @@ -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", ] diff --git a/mealie/schema/recipe/__init__.py b/mealie/schema/recipe/__init__.py index fa39db8c5354..b2efd25d58a9 100644 --- a/mealie/schema/recipe/__init__.py +++ b/mealie/schema/recipe/__init__.py @@ -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", ] diff --git a/mealie/schema/response/__init__.py b/mealie/schema/response/__init__.py index c767a8d2258d..bc0e58588761 100644 --- a/mealie/schema/response/__init__.py +++ b/mealie/schema/response/__init__.py @@ -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", ] diff --git a/mealie/schema/user/__init__.py b/mealie/schema/user/__init__.py index 9600df2f1a54..a7a8641cadec 100644 --- a/mealie/schema/user/__init__.py +++ b/mealie/schema/user/__init__.py @@ -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", ] diff --git a/mealie/schema/user/auth.py b/mealie/schema/user/auth.py index 7ecf0dc2cc09..83362d1c2cb6 100644 --- a/mealie/schema/user/auth.py +++ b/mealie/schema/user/auth.py @@ -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 diff --git a/mealie/scripts/change_password.py b/mealie/scripts/change_password.py index 2725d0db4bac..aa55c665e57c 100644 --- a/mealie/scripts/change_password.py +++ b/mealie/scripts/change_password.py @@ -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__": diff --git a/poetry.lock b/poetry.lock index 47b500d08a71..0a1e2037aaa2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "aiofiles" @@ -120,6 +120,20 @@ files = [ [package.dependencies] typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} +[[package]] +name = "authlib" +version = "1.3.0" +description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Authlib-1.3.0-py2.py3-none-any.whl", hash = "sha256:9637e4de1fb498310a56900b3e2043a206b03cb11c05422014b0302cbc814be3"}, + {file = "Authlib-1.3.0.tar.gz", hash = "sha256:959ea62a5b7b5123c5059758296122b57cd2585ae2ed1c0622c21b371ffdae06"}, +] + +[package.dependencies] +cryptography = "*" + [[package]] name = "babel" version = "2.14.0" @@ -252,6 +266,70 @@ files = [ {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, ] +[[package]] +name = "cffi" +version = "1.16.0" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, + {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, + {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, + {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, + {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, + {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, + {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, + {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, + {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, +] + +[package.dependencies] +pycparser = "*" + [[package]] name = "cfgv" version = "3.3.1" @@ -464,6 +542,51 @@ files = [ click = ">=7.1.2" coverage = ">=5.5" +[[package]] +name = "cryptography" +version = "41.0.7" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = ">=3.7" +files = [ + {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:3c78451b78313fa81607fa1b3f1ae0a5ddd8014c38a02d9db0616133987b9cdf"}, + {file = "cryptography-41.0.7-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:928258ba5d6f8ae644e764d0f996d61a8777559f72dfeb2eea7e2fe0ad6e782d"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a1b41bc97f1ad230a41657d9155113c7521953869ae57ac39ac7f1bb471469a"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:841df4caa01008bad253bce2a6f7b47f86dc9f08df4b433c404def869f590a15"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5429ec739a29df2e29e15d082f1d9ad683701f0ec7709ca479b3ff2708dae65a"}, + {file = "cryptography-41.0.7-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:43f2552a2378b44869fe8827aa19e69512e3245a219104438692385b0ee119d1"}, + {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:af03b32695b24d85a75d40e1ba39ffe7db7ffcb099fe507b39fd41a565f1b157"}, + {file = "cryptography-41.0.7-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:49f0805fc0b2ac8d4882dd52f4a3b935b210935d500b6b805f321addc8177406"}, + {file = "cryptography-41.0.7-cp37-abi3-win32.whl", hash = "sha256:f983596065a18a2183e7f79ab3fd4c475205b839e02cbc0efbbf9666c4b3083d"}, + {file = "cryptography-41.0.7-cp37-abi3-win_amd64.whl", hash = "sha256:90452ba79b8788fa380dfb587cca692976ef4e757b194b093d845e8d99f612f2"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:079b85658ea2f59c4f43b70f8119a52414cdb7be34da5d019a77bf96d473b960"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:b640981bf64a3e978a56167594a0e97db71c89a479da8e175d8bb5be5178c003"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e3114da6d7f95d2dee7d3f4eec16dacff819740bbab931aff8648cb13c5ff5e7"}, + {file = "cryptography-41.0.7-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d5ec85080cce7b0513cfd233914eb8b7bbd0633f1d1703aa28d1dd5a72f678ec"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7a698cb1dac82c35fcf8fe3417a3aaba97de16a01ac914b89a0889d364d2f6be"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:37a138589b12069efb424220bf78eac59ca68b95696fc622b6ccc1c0a197204a"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:68a2dec79deebc5d26d617bfdf6e8aab065a4f34934b22d3b5010df3ba36612c"}, + {file = "cryptography-41.0.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:09616eeaef406f99046553b8a40fbf8b1e70795a91885ba4c96a70793de5504a"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:48a0476626da912a44cc078f9893f292f0b3e4c739caf289268168d8f4702a39"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c7f3201ec47d5207841402594f1d7950879ef890c0c495052fa62f58283fde1a"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c5ca78485a255e03c32b513f8c2bc39fedb7f5c5f8535545bdc223a03b24f248"}, + {file = "cryptography-41.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:d6c391c021ab1f7a82da5d8d0b3cee2f4b2c455ec86c8aebbc84837a631ff309"}, + {file = "cryptography-41.0.7.tar.gz", hash = "sha256:13f93ce9bea8016c253b34afc6bd6a75993e5c40672ed5405a9c832f0d4a00bc"}, +] + +[package.dependencies] +cffi = ">=1.12" + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] +nox = ["nox"] +pep8test = ["black", "check-sdist", "mypy", "ruff"] +sdist = ["build"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "dill" version = "0.3.7" @@ -608,7 +731,6 @@ files = [ {file = "greenlet-2.0.2-cp27-cp27m-win32.whl", hash = "sha256:6c3acb79b0bfd4fe733dff8bc62695283b57949ebcca05ae5c129eb606ff2d74"}, {file = "greenlet-2.0.2-cp27-cp27m-win_amd64.whl", hash = "sha256:283737e0da3f08bd637b5ad058507e578dd462db259f7f6e4c5c365ba4ee9343"}, {file = "greenlet-2.0.2-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae"}, - {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c"}, {file = "greenlet-2.0.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:30bcf80dda7f15ac77ba5af2b961bdd9dbc77fd4ac6105cee85b0d0a5fcf74df"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26fbfce90728d82bc9e6c38ea4d038cba20b7faf8a0ca53a9c07b67318d46088"}, {file = "greenlet-2.0.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9190f09060ea4debddd24665d6804b995a9c122ef5917ab26e1566dcc712ceeb"}, @@ -617,7 +739,6 @@ files = [ {file = "greenlet-2.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91"}, {file = "greenlet-2.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:2d4686f195e32d36b4d7cf2d166857dbd0ee9f3d20ae349b6bf8afc8485b3645"}, {file = "greenlet-2.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c4302695ad8027363e96311df24ee28978162cdcdd2006476c43970b384a244c"}, - {file = "greenlet-2.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c48f54ef8e05f04d6eff74b8233f6063cb1ed960243eacc474ee73a2ea8573ca"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a1846f1b999e78e13837c93c778dcfc3365902cfb8d1bdb7dd73ead37059f0d0"}, {file = "greenlet-2.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a06ad5312349fec0ab944664b01d26f8d1f05009566339ac6f63f56589bc1a2"}, @@ -647,7 +768,6 @@ files = [ {file = "greenlet-2.0.2-cp37-cp37m-win32.whl", hash = "sha256:3f6ea9bd35eb450837a3d80e77b517ea5bc56b4647f5502cd28de13675ee12f7"}, {file = "greenlet-2.0.2-cp37-cp37m-win_amd64.whl", hash = "sha256:7492e2b7bd7c9b9916388d9df23fa49d9b88ac0640db0a5b4ecc2b653bf451e3"}, {file = "greenlet-2.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:b864ba53912b6c3ab6bcb2beb19f19edd01a6bfcbdfe1f37ddd1778abfe75a30"}, - {file = "greenlet-2.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:ba2956617f1c42598a308a84c6cf021a90ff3862eddafd20c3333d50f0edb45b"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3a569657468b6f3fb60587e48356fe512c1754ca05a564f11366ac9e306526"}, {file = "greenlet-2.0.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b"}, @@ -656,7 +776,6 @@ files = [ {file = "greenlet-2.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b0ef99cdbe2b682b9ccbb964743a6aca37905fda5e0452e5ee239b1654d37f2a"}, {file = "greenlet-2.0.2-cp38-cp38-win32.whl", hash = "sha256:b80f600eddddce72320dbbc8e3784d16bd3fb7b517e82476d8da921f27d4b249"}, {file = "greenlet-2.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:4d2e11331fc0c02b6e84b0d28ece3a36e0548ee1a1ce9ddde03752d9b79bba40"}, - {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417"}, {file = "greenlet-2.0.2-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:561091a7be172ab497a3527602d467e2b3fbe75f9e783d8b8ce403fa414f71a6"}, {file = "greenlet-2.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:971ce5e14dc5e73715755d0ca2975ac88cfdaefcaab078a284fea6cfabf866df"}, @@ -1699,6 +1818,17 @@ files = [ [package.dependencies] pyasn1 = ">=0.4.6,<0.5.0" +[[package]] +name = "pycparser" +version = "2.21" +description = "C parser in Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"}, + {file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"}, +] + [[package]] name = "pydantic" version = "2.6.3" @@ -2093,7 +2223,6 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, - {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -2101,16 +2230,8 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, - {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, - {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, - {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, - {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, - {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -2127,7 +2248,6 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, - {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -2135,7 +2255,6 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, - {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -3040,4 +3159,4 @@ pgsql = ["psycopg2-binary"] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "fe771c620ec99c7392b8269f206e2d99ae492988002b8814fa44f1dba1bafe28" +content-hash = "d208e7532a75b0bae2dc41ab9e7a7fe6636d2e3d4c0a4c7a14d47e4639a19d2c" diff --git a/pyproject.toml b/pyproject.toml index 8d85a1eb2f9d..88ec625a87b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ beautifulsoup4 = "^4.11.2" isodate = "^0.6.1" text-unidecode = "^1.3" rapidfuzz = "^3.2.0" +authlib = "^1.3.0" html2text = "^2024.0.0" paho-mqtt = "^1.6.1" pydantic-settings = "^2.1.0" @@ -158,6 +159,7 @@ select = [ [tool.ruff.lint.per-file-ignores] "__init__.py" = ["E402", "E501"] +"ldap_provider.py" = ["UP032"] [tool.ruff.lint.mccabe] # Unlike Flake8, default to a complexity level of 10. diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 000000000000..68c5d18f00dc --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/tests/e2e/docker/docker-compose.yml b/tests/e2e/docker/docker-compose.yml new file mode 100644 index 000000000000..ca1bc72eb922 --- /dev/null +++ b/tests/e2e/docker/docker-compose.yml @@ -0,0 +1,52 @@ +version: "3.4" +services: + oidc-mock-server: + container_name: oidc-mock-server + image: ghcr.io/navikt/mock-oauth2-server:2.1.0 + network_mode: host + environment: + LOG_LEVEL: "debug" + SERVER_PORT: 8080 + + ldap: + image: rroemhild/test-openldap + ports: + - 10389:10389 + + mealie: + container_name: mealie + image: mealie:e2e + build: + context: ../../../ + target: production + dockerfile: ./docker/Dockerfile + restart: always + volumes: + - mealie-data:/app/data/ + network_mode: host + environment: + ALLOW_SIGNUP: True + DB_ENGINE: sqlite + + OIDC_AUTH_ENABLED: True + OIDC_SIGNUP_ENABLED: True + OIDC_ADMIN_GROUP: admin + OIDC_CONFIGURATION_URL: http://localhost:8080/default/.well-known/openid-configuration + OIDC_CLIENT_ID: default + + LDAP_AUTH_ENABLED: True + LDAP_SERVER_URL: ldap://localhost:10389 + LDAP_TLS_INSECURE: true + LDAP_ENABLE_STARTTLS: false + LDAP_BASE_DN: "ou=people,dc=planetexpress,dc=com" + LDAP_QUERY_BIND: "cn=admin,dc=planetexpress,dc=com" + LDAP_QUERY_PASSWORD: "GoodNewsEveryone" + LDAP_USER_FILTER: "(&(|({id_attribute}={input})({mail_attribute}={input}))(|(memberOf=cn=ship_crew,ou=people,dc=planetexpress,dc=com)(memberOf=cn=admin_staff,ou=people,dc=planetexpress,dc=com)))" + LDAP_ADMIN_FILTER: "memberOf=cn=admin_staff,ou=people,dc=planetexpress,dc=com" + LDAP_ID_ATTRIBUTE: uid + LDAP_NAME_ATTRIBUTE: cn + LDAP_MAIL_ATTRIBUTE: mail + +volumes: + mealie-data: + driver: local diff --git a/tests/e2e/login.spec.ts b/tests/e2e/login.spec.ts new file mode 100644 index 000000000000..8439e3b97552 --- /dev/null +++ b/tests/e2e/login.spec.ts @@ -0,0 +1,140 @@ +import { test, expect } from '@playwright/test'; + +test('password login', async ({ page }) => { + const username = "changeme@example.com" + const password = "MyPassword" + const name = "Change Me" + + await page.goto('http://localhost:9000/login'); + await page.getByLabel('Email or Username').click(); + await page.getByLabel('Email or Username').fill(username); + await page.locator('div').filter({ hasText: /^Password$/ }).nth(3).click(); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + await expect(page.getByRole('navigation')).toContainText(name); +}); + +test('ldap login', async ({ page }) => { + const username = "bender" + const password = "bender" + const name = "Bender Bending Rodríguez" + + await page.goto('http://localhost:9000/login'); + await page.getByLabel('Email or Username').click(); + await page.getByLabel('Email or Username').fill(username); + await page.locator('div').filter({ hasText: /^Password$/ }).nth(3).click(); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + await expect(page.getByRole('navigation')).toContainText(name); + await expect(page.getByRole('link', { name: 'Settings' })).not.toBeVisible(); +}); + +test('ldap admin login', async ({ page }) => { + const username = "professor" + const password = "professor" + const name = "Hubert J. Farnsworth" + + await page.goto('http://localhost:9000/login'); + await page.getByLabel('Email or Username').click(); + await page.getByLabel('Email or Username').fill(username); + await page.locator('div').filter({ hasText: /^Password$/ }).nth(3).click(); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + await expect(page.getByRole('navigation')).toContainText(name); + await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible(); +}); + +test('oidc initial login', async ({ page }) => { + const username = "testUser" + const name = "Test User" + const claims = { + "sub": username, + "email": `${username}@example.com`, + "preferred_username": username, + "name": name + } + + await page.goto('http://localhost:9000/login'); + await page.getByRole('button', { name: 'Login with OAuth' }).click(); + await page.getByPlaceholder('Enter any user/subject').fill(username); + await page.getByPlaceholder('Optional claims JSON value,').fill(JSON.stringify(claims)); + await page.getByRole('button', { name: 'Sign-in' }).click(); + await expect(page.getByRole('navigation')).toContainText(name); + await expect(page.getByRole('link', { name: 'Settings' })).not.toBeVisible(); +}); + +test('oidc sequential login', async ({ page }) => { + const username = "testUser2" + const name = "Test User 2" + const claims = { + "sub": username, + "email": `${username}@example.com`, + "preferred_username": username, + "name": name + } + + await page.goto('http://localhost:9000/login'); + await page.getByRole('button', { name: 'Login with OAuth' }).click(); + await page.getByPlaceholder('Enter any user/subject').fill(username); + await page.getByPlaceholder('Optional claims JSON value,').fill(JSON.stringify(claims)); + await page.getByRole('button', { name: 'Sign-in' }).click(); + await expect(page.getByRole('navigation')).toContainText(name); + await page.getByRole('button', { name: 'Logout' }).click(); + + await page.goto('http://localhost:9000/login'); + await page.getByRole('button', { name: 'Login with OAuth' }).click(); + await page.getByPlaceholder('Enter any user/subject').fill(username); + await page.getByPlaceholder('Optional claims JSON value,').fill(JSON.stringify(claims)); + await page.getByRole('button', { name: 'Sign-in' }).click(); + await expect(page.getByRole('navigation')).toContainText(name); +}); + +test('settings page verify oidc', async ({ page }) => { + const username = "oidcUser" + const name = "OIDC User" + const claims = { + "sub": username, + "email": `${username}@example.com`, + "preferred_username": username, + "name": name + } + + await page.goto('http://localhost:9000/login'); + await page.getByRole('button', { name: 'Login with OAuth' }).click(); + await page.getByPlaceholder('Enter any user/subject').fill(username); + await page.getByPlaceholder('Optional claims JSON value,').fill(JSON.stringify(claims)); + await page.getByRole('button', { name: 'Sign-in' }).click(); + await expect(page.getByRole('navigation')).toContainText(name); + await page.getByRole('button', { name: 'Logout' }).click(); + + await page.goto('http://localhost:9000/login'); + await page.getByLabel('Email or Username').click(); + await page.getByLabel('Email or Username').fill('changeme@example.com'); + await page.getByLabel('Password').click(); + await page.getByLabel('Password').fill('MyPassword'); + await page.getByRole('button', { name: 'Login', exact: true }).click(); + await page.getByRole('link', { name: 'Settings' }).click(); + await page.getByRole('link', { name: 'Users' }).click(); + await page.getByRole('cell', { name: username, exact: true }).click(); + await expect(page.getByText('Permissions Administrator')).toBeVisible(); +}); + +test('oidc admin user', async ({ page }) => { + const username = "oidcAdmin" + const name = "OIDC Admin" + const claims = { + "sub": username, + "email": `${username}@example.com`, + "preferred_username": username, + "name": name, + "groups": ["admin"] + } + + await page.goto('http://localhost:9000/login'); + await page.getByRole('button', { name: 'Login with OAuth' }).click(); + await page.getByPlaceholder('Enter any user/subject').fill(username); + await page.getByPlaceholder('Optional claims JSON value,').fill(JSON.stringify(claims)); + await page.getByRole('button', { name: 'Sign-in' }).click(); + await expect(page.getByRole('navigation')).toContainText(name); + await expect(page.getByRole('link', { name: 'Settings' })).toBeVisible(); +}); diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 000000000000..b1b4d4749b91 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "e2e", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "devDependencies": { + "@playwright/test": "^1.40.1", + "@types/node": "^20.10.4" + }, + "scripts": {} +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 000000000000..1580891b9719 --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -0,0 +1,77 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './.', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://127.0.0.1:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + + { + name: 'firefox', + use: { ...devices['Desktop Firefox'] }, + }, + + { + name: 'webkit', + use: { ...devices['Desktop Safari'] }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // url: 'http://127.0.0.1:3000', + // reuseExistingServer: !process.env.CI, + // }, +}); diff --git a/tests/e2e/yarn.lock b/tests/e2e/yarn.lock new file mode 100644 index 000000000000..04cfda6278e6 --- /dev/null +++ b/tests/e2e/yarn.lock @@ -0,0 +1,41 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@playwright/test@^1.40.1": + version "1.40.1" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.40.1.tgz#9e66322d97b1d74b9f8718bacab15080f24cde65" + integrity sha512-EaaawMTOeEItCRvfmkI9v6rBkF1svM8wjl/YPRrg2N2Wmp+4qJYkWtJsbew1szfKKDm6fPLy4YAanBhIlf9dWw== + dependencies: + playwright "1.40.1" + +"@types/node@^20.10.4": + version "20.10.4" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.10.4.tgz#b246fd84d55d5b1b71bf51f964bd514409347198" + integrity sha512-D08YG6rr8X90YB56tSIuBaddy/UXAA9RKJoFvrsnogAum/0pmjkgi4+2nx96A330FmioegBWmEYQ+syqCFaveg== + dependencies: + undici-types "~5.26.4" + +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +playwright-core@1.40.1: + version "1.40.1" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.40.1.tgz#442d15e86866a87d90d07af528e0afabe4c75c05" + integrity sha512-+hkOycxPiV534c4HhpfX6yrlawqVUzITRKwHAmYfmsVreltEl6fAZJ3DPfLMOODw0H3s1Itd6MDCWmP1fl/QvQ== + +playwright@1.40.1: + version "1.40.1" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.40.1.tgz#a11bf8dca15be5a194851dbbf3df235b9f53d7ae" + integrity sha512-2eHI7IioIpQ0bS1Ovg/HszsN/XKNwEG1kbzSDDmADpclKc7CyqkHw7Mg2JCz/bbCxg25QUPcjksoMW7JcIFQmw== + dependencies: + playwright-core "1.40.1" + optionalDependencies: + fsevents "2.3.2" + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== diff --git a/tests/unit_tests/test_security.py b/tests/unit_tests/test_security.py index 0b43be2f37fa..c3b1d7f72d52 100644 --- a/tests/unit_tests/test_security.py +++ b/tests/unit_tests/test_security.py @@ -6,8 +6,11 @@ from pytest import MonkeyPatch from mealie.core import security from mealie.core.config import get_app_settings from mealie.core.dependencies import validate_file_token +from mealie.core.security.providers.credentials_provider import CredentialsProvider, CredentialsRequest +from mealie.core.security.providers.ldap_provider import LDAPProvider from mealie.db.db_setup import session_context from mealie.db.models.users.users import AuthMethod +from mealie.schema.user.auth import CredentialsRequestForm from mealie.schema.user.user import PrivateUser from tests.utils import random_string @@ -113,6 +116,11 @@ def test_create_file_token(): assert file_path == validate_file_token(file_token) +def get_provider(session, username: str, password: str): + request_data = CredentialsRequest(username=username, password=password) + return LDAPProvider(session, request_data) + + def test_ldap_user_creation(monkeypatch: MonkeyPatch): user, mail, name, password, query_bind, query_password = setup_env(monkeypatch) @@ -125,7 +133,8 @@ def test_ldap_user_creation(monkeypatch: MonkeyPatch): get_app_settings.cache_clear() with session_context() as session: - result = security.authenticate_user(session, user, password) + provider = get_provider(session, user, password) + result = provider.get_user() assert result assert result.username == user @@ -146,9 +155,10 @@ def test_ldap_user_creation_fail(monkeypatch: MonkeyPatch): get_app_settings.cache_clear() with session_context() as session: - result = security.authenticate_user(session, user, password + "a") + provider = get_provider(session, user, password + "a") + result = provider.get_user() - assert result is False + assert result is None def test_ldap_user_creation_non_admin(monkeypatch: MonkeyPatch): @@ -164,7 +174,8 @@ def test_ldap_user_creation_non_admin(monkeypatch: MonkeyPatch): get_app_settings.cache_clear() with session_context() as session: - result = security.authenticate_user(session, user, password) + provider = get_provider(session, user, password) + result = provider.get_user() assert result assert result.username == user @@ -186,7 +197,8 @@ def test_ldap_user_creation_admin(monkeypatch: MonkeyPatch): get_app_settings.cache_clear() with session_context() as session: - result = security.authenticate_user(session, user, password) + provider = get_provider(session, user, password) + result = provider.get_user() assert result assert result.username == user @@ -198,35 +210,17 @@ def test_ldap_user_creation_admin(monkeypatch: MonkeyPatch): def test_ldap_disabled(monkeypatch: MonkeyPatch): monkeypatch.setenv("LDAP_AUTH_ENABLED", "False") - user = random_string(10) - password = random_string(10) - - class LdapConnMock: - def simple_bind_s(self, dn, bind_pw): - assert False # When LDAP is disabled, this method should not be called - - def search_s(self, dn, scope, filter, attrlist): - pass - - def set_option(self, option, invalue): - pass - - def unbind_s(self): - pass - - def start_tls_s(self): - pass - - def ldap_initialize_mock(url): - assert url == "" - return LdapConnMock() - - monkeypatch.setattr(ldap, "initialize", ldap_initialize_mock) + class Request: + def __init__(self, auth_strategy: str): + self.cookies = {"mealie.auth.strategy": auth_strategy} get_app_settings.cache_clear() with session_context() as session: - security.authenticate_user(session, user, password) + form = CredentialsRequestForm("username", "password", False) + provider = security.get_auth_provider(session, Request("local"), form) + + assert isinstance(provider, CredentialsProvider) def test_user_login_ldap_auth_method(monkeypatch: MonkeyPatch, ldap_user: PrivateUser): @@ -245,7 +239,8 @@ def test_user_login_ldap_auth_method(monkeypatch: MonkeyPatch, ldap_user: Privat get_app_settings.cache_clear() with session_context() as session: - result = security.authenticate_user(session, ldap_user.username, ldap_password) + provider = get_provider(session, ldap_user.username, ldap_password) + result = provider.get_user() assert result assert result.username == ldap_user.username diff --git a/tests/utils/api_routes/__init__.py b/tests/utils/api_routes/__init__.py index fbce3a4f8eb2..c5bdc535520d 100644 --- a/tests/utils/api_routes/__init__.py +++ b/tests/utils/api_routes/__init__.py @@ -43,6 +43,8 @@ admin_users_unlock = "/api/admin/users/unlock" """`/api/admin/users/unlock`""" app_about = "/api/app/about" """`/api/app/about`""" +app_about_oidc = "/api/app/about/oidc" +"""`/api/app/about/oidc`""" app_about_startup_info = "/api/app/about/startup-info" """`/api/app/about/startup-info`""" app_about_theme = "/api/app/about/theme"