mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-05-23 17:02:55 -04:00
feat: Login with OAuth via OpenID Connect (OIDC) (#3280)
* initial oidc implementation * add dynamic scheme * e2e test setup * add caching * fix * try this * add libldap-2.5 to runtime dependencies (#2849) * New translations en-us.json (Norwegian) (#2851) * New Crowdin updates (#2855) * New translations en-us.json (Italian) * New translations en-us.json (Norwegian) * New translations en-us.json (Portuguese) * fix * remove cache * cache yarn deps * cache docker image * cleanup action * lint * fix tests * remove not needed variables * run code gen * fix tests * add docs * move code into custom scheme * remove unneeded type * fix oidc admin * add more tests * add better spacing on login page * create auth providers * clean up testing stuff * type fixes * add OIDC auth method to postgres enum * add option to bypass login screen and go directly to iDP * remove check so we can fallback to another auth method oauth fails * Add provider name to be shown at the login screen * add new properties to admin about api * fix spec * add a prompt to change auth method when changing password * Create new auth section. Add more info on auth methods * update docs * run ruff * update docs * format * docs gen * formatting * initialize logger in class * mypy type fixes * docs gen * add models to get proper fields in docs and fix serialization * validate id token before using it * only request a mealie token on initial callback * remove unused method * fix unit tests * docs gen * check for valid idToken before getting token * add iss to mealie token * check to see if we already have a mealie token before getting one * fix lock file * update authlib * update lock file * add remember me environment variable * add user group setting to allow only certain groups to log in --------- Co-authored-by: Carter Mintey <cmintey8@gmail.com> Co-authored-by: Carter <35710697+cmintey@users.noreply.github.com>
This commit is contained in:
parent
bea1a592d7
commit
5f6844eceb
46
.github/workflows/e2e.yml
vendored
Normal file
46
.github/workflows/e2e.yml
vendored
Normal file
@ -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
|
@ -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
|
@ -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
|
||||
```
|
@ -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.
|
||||
|
@ -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_PROVIDER_NAME\>" |
|
||||
| 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.
|
||||
|
@ -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
|
||||
|
||||
|
File diff suppressed because one or more lines are too long
@ -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"
|
||||
|
@ -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"),
|
||||
|
@ -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",
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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: "",
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
@ -90,7 +90,7 @@ export default defineComponent({
|
||||
|
||||
const user = ref<UserOut | null>(null);
|
||||
const disabledFields = computed(() => {
|
||||
return user.value?.authMethod === "LDAP" ? ["admin"] : [];
|
||||
return user.value?.authMethod !== "Mealie" ? ["admin"] : [];
|
||||
})
|
||||
|
||||
const userError = ref(false);
|
||||
|
@ -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;
|
||||
});
|
||||
|
@ -70,6 +70,26 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-actions>
|
||||
|
||||
<div v-if="allowOidc" class="d-flex my-4 justify-center align-center" width="80%">
|
||||
<v-divider class="div-width"/>
|
||||
<span
|
||||
class="absolute px-2"
|
||||
:class="{
|
||||
'bg-white': !$vuetify.theme.dark && !isDark,
|
||||
'bg-background': $vuetify.theme.dark || isDark,
|
||||
}"
|
||||
>
|
||||
{{ $t("user.or") }}
|
||||
</span>
|
||||
</div>
|
||||
<v-card-actions v-if="allowOidc" class="justify-center">
|
||||
<div class="max-button">
|
||||
<v-btn color="primary" large rounded class="rounded-xl" block @click.native="oidcAuthenticate">
|
||||
{{ $t("user.login-oidc") }} {{ oidcProviderName }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-actions>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions class="d-flex justify-center flex-column flex-sm-row">
|
||||
@ -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;
|
||||
}
|
||||
</style>
|
||||
|
87
frontend/schemes/DynamicOpenIDConnectScheme.js
Normal file
87
frontend/schemes/DynamicOpenIDConnectScheme.js
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -19,4 +19,4 @@ THEME_DARK_SECONDARY=#973542
|
||||
THEME_DARK_SUCCESS=#43A047
|
||||
THEME_DARK_INFO=#1976D2
|
||||
THEME_DARK_WARNING=#FF6D00
|
||||
THEME_DARK_ERROR=#EF5350
|
||||
THEME_DARK_ERROR=#EF5350
|
||||
|
@ -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)
|
||||
|
@ -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): ...
|
||||
|
@ -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
|
4
mealie/core/security/providers/__init__.py
Normal file
4
mealie/core/security/providers/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from .auth_provider import *
|
||||
from .credentials_provider import *
|
||||
from .ldap_provider import *
|
||||
from .openid_provider import *
|
71
mealie/core/security/providers/auth_provider.py
Normal file
71
mealie/core/security/providers/auth_provider.py
Normal file
@ -0,0 +1,71 @@
|
||||
import abc
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
from jose import jwt
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
ISS = "mealie"
|
||||
remember_me_duration = timedelta(days=14)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class AuthProvider(Generic[T], metaclass=abc.ABCMeta):
|
||||
"""Base Authentication Provider interface"""
|
||||
|
||||
def __init__(self, session: Session, data: T) -> None:
|
||||
self.session = session
|
||||
self.data = data
|
||||
self.user: PrivateUser | None = None
|
||||
self.__has_tried_user = False
|
||||
|
||||
@classmethod
|
||||
def __subclasshook__(cls, __subclass: type) -> bool:
|
||||
return hasattr(__subclass, "authenticate") and callable(__subclass.authenticate)
|
||||
|
||||
def get_access_token(self, user: PrivateUser, remember_me=False) -> tuple[str, timedelta]:
|
||||
settings = get_app_settings()
|
||||
|
||||
duration = timedelta(hours=settings.TOKEN_TIME)
|
||||
if remember_me and remember_me_duration > duration:
|
||||
duration = remember_me_duration
|
||||
|
||||
return AuthProvider.create_access_token({"sub": str(user.id)}, duration)
|
||||
|
||||
@staticmethod
|
||||
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> tuple[str, timedelta]:
|
||||
settings = get_app_settings()
|
||||
|
||||
to_encode = data.copy()
|
||||
expires_delta = expires_delta or timedelta(hours=settings.TOKEN_TIME)
|
||||
|
||||
expire = datetime.now(timezone.utc) + expires_delta
|
||||
|
||||
to_encode["exp"] = expire
|
||||
to_encode["iss"] = ISS
|
||||
return (jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM), expires_delta)
|
||||
|
||||
def try_get_user(self, username: str) -> PrivateUser | None:
|
||||
"""Try to get a user from the database, first trying username, then trying email"""
|
||||
if self.__has_tried_user:
|
||||
return self.user
|
||||
|
||||
db = get_repositories(self.session)
|
||||
|
||||
user = user = db.users.get_one(username, "username", any_case=True)
|
||||
if not user:
|
||||
user = db.users.get_one(username, "email", any_case=True)
|
||||
|
||||
self.user = user
|
||||
return user
|
||||
|
||||
@abc.abstractmethod
|
||||
async def authenticate(self) -> tuple[str, timedelta] | None:
|
||||
"""Attempt to authenticate a user"""
|
||||
raise NotImplementedError
|
57
mealie/core/security/providers/credentials_provider.py
Normal file
57
mealie/core/security/providers/credentials_provider.py
Normal file
@ -0,0 +1,57 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core import root_logger
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.core.exceptions import UserLockedOut
|
||||
from mealie.core.security.hasher import get_hasher
|
||||
from mealie.core.security.providers.auth_provider import AuthProvider
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.schema.user.auth import CredentialsRequest
|
||||
from mealie.services.user_services.user_service import UserService
|
||||
|
||||
|
||||
class CredentialsProvider(AuthProvider[CredentialsRequest]):
|
||||
"""Authentication provider that authenticates a user the database using username/password combination"""
|
||||
|
||||
_logger = root_logger.get_logger("credentials_provider")
|
||||
|
||||
def __init__(self, session: Session, data: CredentialsRequest) -> None:
|
||||
super().__init__(session, data)
|
||||
|
||||
async def authenticate(self) -> tuple[str, timedelta] | None:
|
||||
"""Attempt to authenticate a user given a username and password"""
|
||||
settings = get_app_settings()
|
||||
db = get_repositories(self.session)
|
||||
user = self.try_get_user(self.data.username)
|
||||
|
||||
if not user:
|
||||
# To prevent user enumeration we perform the verify_password computation to ensure
|
||||
# server side time is relatively constant and not vulnerable to timing attacks.
|
||||
CredentialsProvider.verify_password(
|
||||
"abc123cba321", "$2b$12$JdHtJOlkPFwyxdjdygEzPOtYmdQF5/R5tHxw5Tq8pxjubyLqdIX5i"
|
||||
)
|
||||
return None
|
||||
|
||||
if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS or user.is_locked:
|
||||
raise UserLockedOut()
|
||||
|
||||
if not CredentialsProvider.verify_password(self.data.password, user.password):
|
||||
user.login_attemps += 1
|
||||
db.users.update(user.id, user)
|
||||
|
||||
if user.login_attemps >= settings.SECURITY_MAX_LOGIN_ATTEMPTS:
|
||||
user_service = UserService(db)
|
||||
user_service.lock_user(user)
|
||||
|
||||
return None
|
||||
|
||||
user.login_attemps = 0
|
||||
user = db.users.update(user.id, user)
|
||||
return self.get_access_token(user, self.data.remember_me) # type: ignore
|
||||
|
||||
@staticmethod
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Compares a plain string to a hashed password"""
|
||||
return get_hasher().verify(plain_password, hashed_password)
|
178
mealie/core/security/providers/ldap_provider.py
Normal file
178
mealie/core/security/providers/ldap_provider.py
Normal file
@ -0,0 +1,178 @@
|
||||
from datetime import timedelta
|
||||
|
||||
import ldap
|
||||
from ldap.ldapobject import LDAPObject
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core import root_logger
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.core.security.providers.credentials_provider import CredentialsProvider
|
||||
from mealie.db.models.users.users import AuthMethod
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.schema.user.auth import CredentialsRequest
|
||||
from mealie.schema.user.user import PrivateUser
|
||||
|
||||
|
||||
class LDAPProvider(CredentialsProvider):
|
||||
"""Authentication provider that authenticats a user against an LDAP server using username/password combination"""
|
||||
|
||||
_logger = root_logger.get_logger("ldap_provider")
|
||||
|
||||
def __init__(self, session: Session, data: CredentialsRequest) -> None:
|
||||
super().__init__(session, data)
|
||||
self.conn = None
|
||||
|
||||
async def authenticate(self) -> tuple[str, timedelta] | None:
|
||||
"""Attempt to authenticate a user given a username and password"""
|
||||
user = self.try_get_user(self.data.username)
|
||||
if not user or user.password == "LDAP" or user.auth_method == AuthMethod.LDAP:
|
||||
user = self.get_user()
|
||||
if user:
|
||||
return self.get_access_token(user, self.data.remember_me)
|
||||
|
||||
return await super().authenticate()
|
||||
|
||||
def search_user(self, conn: LDAPObject) -> list[tuple[str, dict[str, list[bytes]]]] | None:
|
||||
"""
|
||||
Searches for a user by LDAP_ID_ATTRIBUTE, LDAP_MAIL_ATTRIBUTE, and the provided LDAP_USER_FILTER.
|
||||
If none or multiple users are found, return False
|
||||
"""
|
||||
if not self.data:
|
||||
return None
|
||||
settings = get_app_settings()
|
||||
|
||||
user_filter = ""
|
||||
if settings.LDAP_USER_FILTER:
|
||||
# fill in the template provided by the user to maintain backwards compatibility
|
||||
user_filter = settings.LDAP_USER_FILTER.format(
|
||||
id_attribute=settings.LDAP_ID_ATTRIBUTE,
|
||||
mail_attribute=settings.LDAP_MAIL_ATTRIBUTE,
|
||||
input=self.data.username,
|
||||
)
|
||||
# Don't assume the provided search filter has (|({id_attribute}={input})({mail_attribute}={input}))
|
||||
search_filter = "(&(|({id_attribute}={input})({mail_attribute}={input})){filter})".format(
|
||||
id_attribute=settings.LDAP_ID_ATTRIBUTE,
|
||||
mail_attribute=settings.LDAP_MAIL_ATTRIBUTE,
|
||||
input=self.data.username,
|
||||
filter=user_filter,
|
||||
)
|
||||
|
||||
user_entry: list[tuple[str, dict[str, list[bytes]]]] | None = None
|
||||
try:
|
||||
self._logger.debug(f"[LDAP] Starting search with filter: {search_filter}")
|
||||
user_entry = conn.search_s(
|
||||
settings.LDAP_BASE_DN,
|
||||
ldap.SCOPE_SUBTREE,
|
||||
search_filter,
|
||||
[settings.LDAP_ID_ATTRIBUTE, settings.LDAP_NAME_ATTRIBUTE, settings.LDAP_MAIL_ATTRIBUTE],
|
||||
)
|
||||
except ldap.FILTER_ERROR:
|
||||
self._logger.error("[LDAP] Bad user search filter")
|
||||
|
||||
if not user_entry:
|
||||
conn.unbind_s()
|
||||
self._logger.error("[LDAP] No user was found with the provided user filter")
|
||||
return None
|
||||
|
||||
# we only want the entries that have a dn
|
||||
user_entry = [(dn, attr) for dn, attr in user_entry if dn]
|
||||
|
||||
if len(user_entry) > 1:
|
||||
self._logger.warning("[LDAP] Multiple users found with the provided user filter")
|
||||
self._logger.debug(f"[LDAP] The following entries were returned: {user_entry}")
|
||||
conn.unbind_s()
|
||||
return None
|
||||
|
||||
return user_entry
|
||||
|
||||
def get_user(self) -> PrivateUser | None:
|
||||
"""Given a username and password, tries to authenticate by BINDing to an
|
||||
LDAP server
|
||||
|
||||
If the BIND succeeds, it will either create a new user of that username on
|
||||
the server or return an existing one.
|
||||
Returns False on failure.
|
||||
"""
|
||||
|
||||
settings = get_app_settings()
|
||||
db = get_repositories(self.session)
|
||||
if not self.data:
|
||||
return None
|
||||
data = self.data
|
||||
|
||||
if settings.LDAP_TLS_INSECURE:
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
|
||||
conn = ldap.initialize(settings.LDAP_SERVER_URL)
|
||||
conn.set_option(ldap.OPT_PROTOCOL_VERSION, 3)
|
||||
conn.set_option(ldap.OPT_REFERRALS, 0)
|
||||
|
||||
if settings.LDAP_TLS_CACERTFILE:
|
||||
conn.set_option(ldap.OPT_X_TLS_CACERTFILE, settings.LDAP_TLS_CACERTFILE)
|
||||
conn.set_option(ldap.OPT_X_TLS_NEWCTX, 0)
|
||||
|
||||
if settings.LDAP_ENABLE_STARTTLS:
|
||||
conn.start_tls_s()
|
||||
|
||||
try:
|
||||
conn.simple_bind_s(settings.LDAP_QUERY_BIND, settings.LDAP_QUERY_PASSWORD)
|
||||
except (ldap.INVALID_CREDENTIALS, ldap.NO_SUCH_OBJECT):
|
||||
self._logger.error("[LDAP] Unable to bind to with provided user/password")
|
||||
conn.unbind_s()
|
||||
return None
|
||||
|
||||
user_entry = self.search_user(conn)
|
||||
if not user_entry:
|
||||
return None
|
||||
user_dn, user_attr = user_entry[0]
|
||||
|
||||
# Check the credentials of the user
|
||||
try:
|
||||
self._logger.debug(f"[LDAP] Attempting to bind with '{user_dn}' using the provided password")
|
||||
conn.simple_bind_s(user_dn, data.password)
|
||||
except (ldap.INVALID_CREDENTIALS, ldap.NO_SUCH_OBJECT):
|
||||
self._logger.error("[LDAP] Bind failed")
|
||||
conn.unbind_s()
|
||||
return None
|
||||
|
||||
user = self.try_get_user(data.username)
|
||||
|
||||
if user is None:
|
||||
self._logger.debug("[LDAP] User is not in Mealie. Creating a new account")
|
||||
|
||||
attribute_keys = {
|
||||
settings.LDAP_ID_ATTRIBUTE: "username",
|
||||
settings.LDAP_NAME_ATTRIBUTE: "name",
|
||||
settings.LDAP_MAIL_ATTRIBUTE: "mail",
|
||||
}
|
||||
attributes = {}
|
||||
for attribute_key, attribute_name in attribute_keys.items():
|
||||
if attribute_key not in user_attr or len(user_attr[attribute_key]) == 0:
|
||||
self._logger.error(
|
||||
f"[LDAP] Unable to create user due to missing '{attribute_name}' ('{attribute_key}') attribute"
|
||||
)
|
||||
self._logger.debug(f"[LDAP] User has the following attributes: {user_attr}")
|
||||
conn.unbind_s()
|
||||
return None
|
||||
attributes[attribute_key] = user_attr[attribute_key][0].decode("utf-8")
|
||||
|
||||
user = db.users.create(
|
||||
{
|
||||
"username": attributes[settings.LDAP_ID_ATTRIBUTE],
|
||||
"password": "LDAP",
|
||||
"full_name": attributes[settings.LDAP_NAME_ATTRIBUTE],
|
||||
"email": attributes[settings.LDAP_MAIL_ATTRIBUTE],
|
||||
"admin": False,
|
||||
"auth_method": AuthMethod.LDAP,
|
||||
},
|
||||
)
|
||||
|
||||
if settings.LDAP_ADMIN_FILTER:
|
||||
should_be_admin = len(conn.search_s(user_dn, ldap.SCOPE_BASE, settings.LDAP_ADMIN_FILTER, [])) > 0
|
||||
if user.admin != should_be_admin:
|
||||
self._logger.debug(f"[LDAP] {'Setting' if should_be_admin else 'Removing'} user as admin")
|
||||
user.admin = should_be_admin
|
||||
db.users.update(user.id, user)
|
||||
|
||||
conn.unbind_s()
|
||||
return user
|
127
mealie/core/security/providers/openid_provider.py
Normal file
127
mealie/core/security/providers/openid_provider.py
Normal file
@ -0,0 +1,127 @@
|
||||
from datetime import timedelta
|
||||
from functools import lru_cache
|
||||
|
||||
import requests
|
||||
from authlib.jose import JsonWebKey, JsonWebToken, JWTClaims, KeySet
|
||||
from authlib.jose.errors import ExpiredTokenError
|
||||
from authlib.oidc.core import CodeIDToken
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core import root_logger
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.core.security.providers.auth_provider import AuthProvider
|
||||
from mealie.db.models.users.users import AuthMethod
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.schema.user.auth import OIDCRequest
|
||||
|
||||
|
||||
class OpenIDProvider(AuthProvider[OIDCRequest]):
|
||||
"""Authentication provider that authenticates a user using a token from OIDC ID token"""
|
||||
|
||||
_logger = root_logger.get_logger("openid_provider")
|
||||
|
||||
def __init__(self, session: Session, data: OIDCRequest) -> None:
|
||||
super().__init__(session, data)
|
||||
|
||||
async def authenticate(self) -> tuple[str, timedelta] | None:
|
||||
"""Attempt to authenticate a user given a username and password"""
|
||||
|
||||
claims = self.get_claims()
|
||||
if not claims:
|
||||
return None
|
||||
|
||||
settings = get_app_settings()
|
||||
repos = get_repositories(self.session)
|
||||
|
||||
user = self.try_get_user(claims.get("email"))
|
||||
group_claim = claims.get("groups", [])
|
||||
is_admin = settings.OIDC_ADMIN_GROUP in group_claim if settings.OIDC_ADMIN_GROUP else False
|
||||
is_valid_user = settings.OIDC_USER_GROUP in group_claim if settings.OIDC_USER_GROUP else True
|
||||
|
||||
if not is_valid_user:
|
||||
self._logger.debug(
|
||||
"[OIDC] User does not have the required group. Found: %s - Required: %s",
|
||||
group_claim,
|
||||
settings.OIDC_USER_GROUP,
|
||||
)
|
||||
return None
|
||||
|
||||
if not user:
|
||||
if not settings.OIDC_SIGNUP_ENABLED:
|
||||
self._logger.debug("[OIDC] No user found. Not creating a new user - new user creation is disabled.")
|
||||
return None
|
||||
|
||||
self._logger.debug("[OIDC] No user found. Creating new OIDC user.")
|
||||
|
||||
user = repos.users.create(
|
||||
{
|
||||
"username": claims.get("preferred_username"),
|
||||
"password": "OIDC",
|
||||
"full_name": claims.get("name"),
|
||||
"email": claims.get("email"),
|
||||
"admin": is_admin,
|
||||
"auth_method": AuthMethod.OIDC,
|
||||
}
|
||||
)
|
||||
self.session.commit()
|
||||
return self.get_access_token(user, settings.OIDC_REMEMBER_ME) # type: ignore
|
||||
|
||||
if user:
|
||||
if user.admin != is_admin:
|
||||
self._logger.debug(f"[OIDC] {'Setting' if is_admin else 'Removing'} user as admin")
|
||||
user.admin = is_admin
|
||||
repos.users.update(user.id, user)
|
||||
return self.get_access_token(user, settings.OIDC_REMEMBER_ME)
|
||||
|
||||
self._logger.info("[OIDC] Found user but their AuthMethod does not match OIDC")
|
||||
return None
|
||||
|
||||
def get_claims(self) -> JWTClaims | None:
|
||||
"""Get the claims from the ID token and check if the required claims are present"""
|
||||
required_claims = {"preferred_username", "name", "email"}
|
||||
jwks = OpenIDProvider.get_jwks()
|
||||
if not jwks:
|
||||
return None
|
||||
claims = JsonWebToken(["RS256"]).decode(s=self.data.id_token, key=jwks, claims_cls=CodeIDToken)
|
||||
|
||||
try:
|
||||
claims.validate()
|
||||
except ExpiredTokenError as e:
|
||||
self._logger.debug(f"[OIDC] {e.error}: {e.description}")
|
||||
return None
|
||||
|
||||
if not claims:
|
||||
self._logger.warning("[OIDC] Claims not found")
|
||||
return None
|
||||
if not required_claims.issubset(claims.keys()):
|
||||
self._logger.error(
|
||||
f"[OIDC] Required claims not present. Expected: {required_claims} Actual: {claims.keys()}"
|
||||
)
|
||||
return None
|
||||
return claims
|
||||
|
||||
@lru_cache
|
||||
@staticmethod
|
||||
def get_jwks() -> KeySet | None:
|
||||
"""Get the key set from the open id configuration"""
|
||||
settings = get_app_settings()
|
||||
|
||||
if not (settings.OIDC_READY and settings.OIDC_CONFIGURATION_URL):
|
||||
return None
|
||||
configuration = None
|
||||
with requests.get(settings.OIDC_CONFIGURATION_URL, timeout=5) as config_response:
|
||||
config_response.raise_for_status()
|
||||
configuration = config_response.json()
|
||||
|
||||
if not configuration:
|
||||
OpenIDProvider._logger.warning("[OIDC] Unable to fetch configuration from the OIDC_CONFIGURATION_URL")
|
||||
return None
|
||||
|
||||
jwks_uri = configuration.get("jwks_uri", None)
|
||||
if not jwks_uri:
|
||||
OpenIDProvider._logger.warning("[OIDC] Unable to find the jwks_uri from the OIDC_CONFIGURATION_URL")
|
||||
return None
|
||||
|
||||
with requests.get(jwks_uri, timeout=5) as response:
|
||||
response.raise_for_status()
|
||||
return JsonWebKey.import_key_set(response.json())
|
@ -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)
|
||||
|
@ -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
|
||||
|
||||
|
@ -39,6 +39,7 @@ class LongLiveToken(SqlAlchemyBase, BaseMixins):
|
||||
class AuthMethod(enum.Enum):
|
||||
MEALIE = "Mealie"
|
||||
LDAP = "LDAP"
|
||||
OIDC = "OIDC"
|
||||
|
||||
|
||||
class User(SqlAlchemyBase, BaseMixins):
|
||||
|
@ -30,6 +30,9 @@ class AdminAboutController(BaseAdminController):
|
||||
allow_signup=settings.ALLOW_SIGNUP,
|
||||
build_id=settings.GIT_COMMIT_HASH,
|
||||
recipe_scraper_version=recipe_scraper_version.__version__,
|
||||
enable_oidc=settings.OIDC_AUTH_ENABLED,
|
||||
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
|
||||
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
|
||||
)
|
||||
|
||||
@router.get("/statistics", response_model=AppStatistics)
|
||||
@ -51,4 +54,5 @@ class AdminAboutController(BaseAdminController):
|
||||
ldap_ready=settings.LDAP_ENABLED,
|
||||
base_url_set=settings.BASE_URL != "http://localhost:8080",
|
||||
is_up_to_date=APP_VERSION == "develop" or APP_VERSION == "nightly" or get_latest_version() == APP_VERSION,
|
||||
oidc_ready=settings.OIDC_READY,
|
||||
)
|
||||
|
@ -6,7 +6,7 @@ from mealie.core.settings.static import APP_VERSION
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.db.models.users.users import User
|
||||
from mealie.repos.all_repositories import get_repositories
|
||||
from mealie.schema.admin.about import AppInfo, AppStartupInfo, AppTheme
|
||||
from mealie.schema.admin.about import AppInfo, AppStartupInfo, AppTheme, OIDCInfo
|
||||
|
||||
router = APIRouter(prefix="/about")
|
||||
|
||||
@ -29,6 +29,9 @@ def get_app_info(session: Session = Depends(generate_session)):
|
||||
production=settings.PRODUCTION,
|
||||
allow_signup=settings.ALLOW_SIGNUP,
|
||||
default_group_slug=default_group_slug,
|
||||
enable_oidc=settings.OIDC_READY,
|
||||
oidc_redirect=settings.OIDC_AUTO_REDIRECT,
|
||||
oidc_provider_name=settings.OIDC_PROVIDER_NAME,
|
||||
)
|
||||
|
||||
|
||||
@ -54,3 +57,12 @@ def get_app_theme(resp: Response):
|
||||
|
||||
resp.headers["Cache-Control"] = "public, max-age=604800"
|
||||
return AppTheme(**settings.theme.model_dump())
|
||||
|
||||
|
||||
@router.get("/oidc", response_model=OIDCInfo)
|
||||
def get_oidc_info(resp: Response):
|
||||
"""Get's the current OIDC configuration needed for the frontend"""
|
||||
settings = get_app_settings()
|
||||
|
||||
resp.headers["Cache-Control"] = "public, max-age=604800"
|
||||
return OIDCInfo(configuration_url=settings.OIDC_CONFIGURATION_URL, client_id=settings.OIDC_CLIENT_ID)
|
||||
|
@ -1,19 +1,18 @@
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, Request, Response, status
|
||||
from fastapi import APIRouter, Depends, Request, Response, status
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm.session import Session
|
||||
|
||||
from mealie.core import root_logger, security
|
||||
from mealie.core.config import get_app_settings
|
||||
from mealie.core.dependencies import get_current_user
|
||||
from mealie.core.security import authenticate_user
|
||||
from mealie.core.security.security import UserLockedOut
|
||||
from mealie.core.exceptions import UserLockedOut
|
||||
from mealie.core.security.security import get_auth_provider
|
||||
from mealie.db.db_setup import generate_session
|
||||
from mealie.routes._base.routers import UserAPIRouter
|
||||
from mealie.schema.user import PrivateUser
|
||||
from mealie.schema.user.auth import CredentialsRequestForm
|
||||
|
||||
public_router = APIRouter(tags=["Users: Authentication"])
|
||||
user_router = UserAPIRouter(tags=["Users: Authentication"])
|
||||
@ -22,26 +21,6 @@ logger = root_logger.get_logger("auth")
|
||||
remember_me_duration = timedelta(days=14)
|
||||
|
||||
|
||||
class CustomOAuth2Form(OAuth2PasswordRequestForm):
|
||||
def __init__(
|
||||
self,
|
||||
grant_type: str = Form(None, pattern="password"),
|
||||
username: str = Form(...),
|
||||
password: str = Form(...),
|
||||
remember_me: bool = Form(False),
|
||||
scope: str = Form(""),
|
||||
client_id: str | None = Form(None),
|
||||
client_secret: str | None = Form(None),
|
||||
):
|
||||
self.grant_type = grant_type
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.remember_me = remember_me
|
||||
self.scopes = scope.split()
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
|
||||
|
||||
class MealieAuthToken(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
@ -52,16 +31,12 @@ class MealieAuthToken(BaseModel):
|
||||
|
||||
|
||||
@public_router.post("/token")
|
||||
def get_token(
|
||||
async def get_token(
|
||||
request: Request,
|
||||
response: Response,
|
||||
data: CustomOAuth2Form = Depends(),
|
||||
data: CredentialsRequestForm = Depends(),
|
||||
session: Session = Depends(generate_session),
|
||||
):
|
||||
settings = get_app_settings()
|
||||
|
||||
email = data.username
|
||||
password = data.password
|
||||
if "x-forwarded-for" in request.headers:
|
||||
ip = request.headers["x-forwarded-for"]
|
||||
if "," in ip: # if there are multiple IPs, the first one is canonically the true client
|
||||
@ -71,28 +46,22 @@ def get_token(
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
|
||||
try:
|
||||
user = authenticate_user(session, email, password) # type: ignore
|
||||
auth_provider = get_auth_provider(session, request, data)
|
||||
auth = await auth_provider.authenticate()
|
||||
except UserLockedOut as e:
|
||||
logger.error(f"User is locked out from {ip}")
|
||||
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="User is locked out") from e
|
||||
|
||||
if not user:
|
||||
if not auth:
|
||||
logger.error(f"Incorrect username or password from {ip}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
access_token, duration = auth
|
||||
|
||||
duration = timedelta(hours=settings.TOKEN_TIME)
|
||||
if data.remember_me and remember_me_duration > duration:
|
||||
duration = remember_me_duration
|
||||
|
||||
access_token = security.create_access_token(dict(sub=str(user.id)), duration) # type: ignore
|
||||
|
||||
expires_in = duration.total_seconds() if duration else None
|
||||
response.set_cookie(
|
||||
key="mealie.access_token",
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
max_age=duration.seconds if duration else None,
|
||||
key="mealie.access_token", value=access_token, httponly=True, max_age=expires_in, expires=expires_in
|
||||
)
|
||||
|
||||
return MealieAuthToken.respond(access_token)
|
||||
|
@ -1,7 +1,8 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from pydantic import UUID4
|
||||
|
||||
from mealie.core.security import hash_password, verify_password
|
||||
from mealie.core.security import hash_password
|
||||
from mealie.core.security.providers.credentials_provider import CredentialsProvider
|
||||
from mealie.db.models.users.users import AuthMethod
|
||||
from mealie.routes._base import BaseAdminController, BaseUserController, controller
|
||||
from mealie.routes._base.mixins import HttpRepo
|
||||
@ -70,7 +71,7 @@ class UserController(BaseUserController):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, ErrorResponse.respond(self.t("user.ldap-update-password-unavailable"))
|
||||
)
|
||||
if not verify_password(password_change.current_password, self.user.password):
|
||||
if not CredentialsProvider.verify_password(password_change.current_password, self.user.password):
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, ErrorResponse.respond(self.t("user.invalid-current-password"))
|
||||
)
|
||||
|
@ -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",
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
]
|
||||
|
@ -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",
|
||||
]
|
||||
|
@ -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",
|
||||
]
|
||||
|
@ -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",
|
||||
]
|
||||
|
@ -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",
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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__":
|
||||
|
153
poetry.lock
generated
153
poetry.lock
generated
@ -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"
|
||||
|
@ -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.
|
||||
|
5
tests/e2e/.gitignore
vendored
Normal file
5
tests/e2e/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
52
tests/e2e/docker/docker-compose.yml
Normal file
52
tests/e2e/docker/docker-compose.yml
Normal file
@ -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
|
140
tests/e2e/login.spec.ts
Normal file
140
tests/e2e/login.spec.ts
Normal file
@ -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();
|
||||
});
|
11
tests/e2e/package.json
Normal file
11
tests/e2e/package.json
Normal file
@ -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": {}
|
||||
}
|
77
tests/e2e/playwright.config.ts
Normal file
77
tests/e2e/playwright.config.ts
Normal file
@ -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,
|
||||
// },
|
||||
});
|
41
tests/e2e/yarn.lock
Normal file
41
tests/e2e/yarn.lock
Normal file
@ -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==
|
@ -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
|
||||
|
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user