feat: Add OIDC_CLIENT_SECRET and other changes for v2 (#4254)

Co-authored-by: boc-the-git <3479092+boc-the-git@users.noreply.github.com>
This commit is contained in:
Carter 2024-10-05 16:12:11 -05:00 committed by GitHub
parent 4f1abcf4a3
commit 5ed0ec029b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 530 additions and 349 deletions

View file

@ -23,7 +23,6 @@ services:
POSTGRES_SERVER: postgres
POSTGRES_PORT: 5432
POSTGRES_DB: mealie
# =====================================
# Email Configuration
# SMTP_HOST=

View file

@ -0,0 +1,96 @@
# OpenID Connect (OIDC) Authentication
:octicons-tag-24: v2.0.0
!!! note
Breaking changes to OIDC Authentication were introduced with Mealie v2. Please see the below for [migration steps](#migration-from-mealie-v1x).
Looking instead for the docs for Mealie :octicons-tag-24: v1.x? [Click here](./oidc.md)
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 (IdP), 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 Client type should be `private` (you should have a **Client Secret**)
2. Configure redirect URI
The redirect URI(s) that are needed:
1. `http(s)://DOMAIN:PORT/login`
2. `https(s)://DOMAIN:PORT/login?direct=1`
1. This URI is only required if your IdP supports [RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) such as Keycloak. You may also be able to combine this into the previous URI by using a wildcard: `http(s)://DOMAIN:PORT/login*`
The redirect URI(s) should include any URL that Mealie is accessible from. Some examples include
http://localhost:9091/login
https://mealie.example.com/login
3. Configure allowed scopes
The scopes required are `openid profile email`
If you plan to use the [groups](#groups) to configure access within Mealie, you will need to also add the scope defined by the `OIDC_GROUPS_CLAIM` environment variable. The default claim is `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).
### Groups
There are two (optional) [environment variables](../installation/backend-config.md#openid-connect-oidc) that can control which of the users in your IdP can log in to Mealie and what permissions they will have. Keep in mind that these groups **do not necessarily correspond to groups in Mealie**. The groups claim is configurable via the `OIDC_GROUPS_CLAIM` environment variable. The groups should be **defined in your IdP** and be returned in the configured claim value.
`OIDC_USER_GROUP`: Users must be a part of this group (within your IdP) to be able to log in.
`OIDC_ADMIN_GROUP`: Users that are in this group (within your IdP) will be made an **admin** in Mealie. Users in this group do not also need to be in the `OIDC_USER_GROUP`
## Examples
Example configurations for several Identity Providers have been provided by the Community in the [GitHub Discussions](https://github.com/mealie-recipes/mealie/discussions/categories/oauth-provider-example).
If you don't see your provider and have successfully set it up, please consider [creating your own example](https://github.com/mealie-recipes/mealie/discussions/new?category=oauth-provider-example) so that others can have a smoother setup.
## Migration from Mealie v1.x
**High level changes**
- A Client Secret is now required
- CORS is no longer a requirement since all authentication happens server-side
- A user will be successfully authenticated if they are part of *either* `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. Admins no longer need to be part of both groups
- ID Token signing algorithm is now inferred using the `id_token_signing_alg_values_supported` metadata from the discovery URL
### Changes in your IdP
**Required**
- You must change the Mealie client in your IdP to be **private**. The option is different for every provider, but you need to obtain a **client secret**.
**Optional**
- You may now also remove the `OIDC_USER_GROUP` from your admin users if you so desire. Users within the `OIDC_ADMIN_GROUP` will now be able to successfully authenticate with only that group.
- You may remove any CORS configuration. i.e. configured origins
### Changes in Mealie
**Required**
- After obtaining the **client secret** from your IdP, you must add it to Mealie using the `OIDC_CLIENT_SECRET` environment variable or via [docker secrets](../installation/backend-config.md#docker-secrets). This secret will not be logged on startup.
**Optional**
- Remove `OIDC_SIGNING_ALGORITHM` from your environment. It will no longer have any effect.

View file

@ -82,7 +82,7 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea
:octicons-tag-24: v1.4.0
For usage, see [Usage - OpenID Connect](../authentication/oidc.md)
For usage, see [Usage - OpenID Connect](../authentication/oidc-v2.md)
| Variables | Default | Description |
| ---------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@ -90,12 +90,12 @@ For usage, see [Usage - OpenID Connect](../authentication/oidc.md)
| 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, only users belonging to this group will be able to successfully authenticate, regardless of the `OIDC_ADMIN_GROUP`. For more information see [this page](../authentication/oidc.md#groups) |
| OIDC_ADMIN_GROUP | None | If specified, users belonging to this group will be made an admin. For more information see [this page](../authentication/oidc.md#groups) |
| OIDC_CLIENT_SECRET <br/> :octicons-tag-24: v2.0.0 | None | The client secret of your configured client in your provider|
| OIDC_USER_GROUP | None | If specified, only users belonging to this group will be able to successfully authenticate. For more information see [this page](../authentication/oidc-v2.md#groups) |
| OIDC_ADMIN_GROUP | None | If specified, users belonging to this group will be able to successfully authenticate *and* be made an admin. For more information see [this page](../authentication/oidc-v2.md#groups) |
| 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 |
| OIDC_SIGNING_ALGORITHM | RS256 | The algorithm used to sign the id token (examples: RS256, HS256) |
| OIDC_USER_CLAIM | email | This is the claim which Mealie will use to look up an existing user by (e.g. "email", "preferred_username") |
| OIDC_GROUPS_CLAIM | groups | Optional if not using `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP`. This is the claim Mealie will request from your IdP and will use to compare to `OIDC_USER_GROUP` or `OIDC_ADMIN_GROUP` to allow the user to log in to Mealie or is set as an admin. **Your IdP must be configured to grant this claim** |
| OIDC_TLS_CACERTFILE | None | File path to Certificate Authority used to verify server certificate (e.g. `/path/to/ca.crt`) |

File diff suppressed because one or more lines are too long

View file

@ -81,7 +81,7 @@ nav:
- Authentication:
- LDAP: "documentation/getting-started/authentication/ldap.md"
- OpenID Connect: "documentation/getting-started/authentication/oidc.md"
- OpenID Connect: "documentation/getting-started/authentication/oidc-v2.md"
- Community Guides:
- iOS Shortcuts: "documentation/community-guide/ios.md"

View file

@ -35,7 +35,7 @@
<v-btn v-else icon @click="activateSearch">
<v-icon> {{ $globals.icons.search }}</v-icon>
</v-btn>
<v-btn v-if="loggedIn" :text="$vuetify.breakpoint.smAndUp" :icon="$vuetify.breakpoint.xs" @click="$auth.logout()">
<v-btn v-if="loggedIn" :text="$vuetify.breakpoint.smAndUp" :icon="$vuetify.breakpoint.xs" @click="logout()">
<v-icon :left="$vuetify.breakpoint.smAndUp">{{ $globals.icons.logout }}</v-icon>
{{ $vuetify.breakpoint.smAndUp ? $t("user.logout") : "" }}
</v-btn>
@ -48,7 +48,7 @@
</template>
<script lang="ts">
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, useContext, useRoute } from "@nuxtjs/composition-api";
import { computed, defineComponent, onBeforeUnmount, onMounted, ref, useContext, useRoute, useRouter } from "@nuxtjs/composition-api";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import RecipeDialogSearch from "~/components/Domain/Recipe/RecipeDialogSearch.vue";
@ -64,6 +64,7 @@ export default defineComponent({
const { $auth } = useContext();
const { loggedIn } = useLoggedInState();
const route = useRoute();
const router = useRouter();
const groupSlug = computed(() => route.value.params.groupSlug || $auth.user?.groupSlug || "");
const routerLink = computed(() => groupSlug.value ? `/g/${groupSlug.value}` : "/");
@ -89,11 +90,16 @@ export default defineComponent({
document.removeEventListener("keydown", handleKeyEvent);
});
async function logout() {
await $auth.logout().then(() => router.push("/login?direct=1"))
}
return {
activateSearch,
domSearchDialog,
routerLink,
loggedIn,
logout,
};
},
});

View file

@ -242,11 +242,6 @@ export interface NotificationImport {
status: boolean;
exception?: string | null;
}
export interface OIDCInfo {
configurationUrl: string | null;
clientId: string | null;
groupsClaim: string | null;
}
export interface RecipeImport {
name: string;
status: boolean;

View file

@ -132,9 +132,6 @@ export interface LongLiveTokenOut {
id: number;
createdAt?: string | null;
}
export interface OIDCRequest {
id_token: string;
}
export interface PasswordResetToken {
token: string;
}

View file

@ -123,7 +123,7 @@ export default {
auth: {
redirect: {
login: "/login",
logout: "/login?direct=1",
logout: "/login",
callback: "/login",
home: "/",
},
@ -161,12 +161,24 @@ export default {
},
},
oidc: {
scheme: "~/schemes/DynamicOpenIDConnectScheme",
scheme: "local",
resetOnError: true,
clientId: "",
token: {
property: "access_token",
global: true,
},
user: {
property: "",
autoFetch: true,
},
endpoints: {
configuration: "",
}
login: {
url: "api/auth/oauth/callback",
method: "get",
},
logout: { url: "api/auth/logout", method: "post" },
user: { url: "api/users/self", method: "get" },
},
},
},
},

View file

@ -65,7 +65,7 @@
<v-checkbox v-model="form.remember" class="ml-2 mt-n2" :label="$t('user.remember-me')"></v-checkbox>
<v-card-actions class="justify-center pt-0">
<div class="max-button">
<v-btn :loading="loggingIn" color="primary" type="submit" large rounded class="rounded-xl" block>
<v-btn :loading="loggingIn" :disabled="oidcLoggingIn" color="primary" type="submit" large rounded class="rounded-xl" block>
{{ $t("user.login") }}
</v-btn>
</div>
@ -85,7 +85,7 @@
</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">
<v-btn :loading="oidcLoggingIn" color="primary" large rounded class="rounded-xl" block @click.native="() => oidcAuthenticate()">
{{ $t("user.login-oidc") }} {{ oidcProviderName }}
</v-btn>
</div>
@ -133,7 +133,7 @@
</template>
<script lang="ts">
import { defineComponent, ref, useContext, computed, reactive, useRouter, useAsync } from "@nuxtjs/composition-api";
import { defineComponent, ref, useContext, computed, reactive, useRouter, useAsync, onBeforeMount } from "@nuxtjs/composition-api";
import { useDark, whenever } from "@vueuse/core";
import { useLoggedInState } from "~/composables/use-logged-in-state";
import { useAppInfo } from "~/composables/api";
@ -180,6 +180,7 @@ export default defineComponent({
);
const loggingIn = ref(false);
const oidcLoggingIn = ref(false)
const appInfo = useAppInfo();
@ -196,19 +197,34 @@ export default defineComponent({
{immediate: true}
)
onBeforeMount(async () => {
if (isCallback()) {
await oidcAuthenticate(true)
}
})
function isCallback() {
return router.currentRoute.query.state;
const params = new URLSearchParams(window.location.search)
return params.has("code") || params.has("error")
}
function isDirectLogin() {
return Object.keys(router.currentRoute.query).includes("direct")
const params = new URLSearchParams(window.location.search)
return params.has("direct") && params.get("direct") === "1"
}
async function oidcAuthenticate() {
try {
await $auth.loginWith("oidc")
} catch (error) {
alert.error(i18n.t("events.something-went-wrong") as string);
async function oidcAuthenticate(callback = false) {
if (callback) {
oidcLoggingIn.value = true
try {
await $auth.loginWith("oidc", { params: new URLSearchParams(window.location.search)})
} catch (error) {
await router.replace("/login?direct=1")
alertOnError(error)
}
oidcLoggingIn.value = false
} else {
window.location.replace("/api/auth/oauth") // start the redirect process
}
}
@ -227,6 +243,12 @@ export default defineComponent({
try {
await $auth.loginWith("local", { data: formData });
} catch (error) {
alertOnError(error)
}
loggingIn.value = false;
}
function alertOnError(error: any) {
// TODO Check if error is an AxiosError, but isAxiosError is not working right now
// See https://github.com/nuxt-community/axios-module/issues/550
// Import $axios from useContext()
@ -240,8 +262,6 @@ export default defineComponent({
} else {
alert.error(i18n.t("events.something-went-wrong") as string);
}
}
loggingIn.value = false;
}
return {
@ -253,6 +273,7 @@ export default defineComponent({
authenticate,
oidcAuthenticate,
oidcProviderName,
oidcLoggingIn,
passwordIcon,
inputType,
togglePasswordShow,

View file

@ -1,122 +0,0 @@
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.configurationDocument = new ConfigurationDocument(
this,
this.$auth.$storage
)
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return await super.mounted()
}
// Overrides the check method in the OpenIDConnectScheme
// We don't care if the id token is expired as long as we have a valid Mealie token.
// We only use the id token to verify identity on the initial login, then issue a Mealie token
check(checkStatus = false) {
const response = super.check(checkStatus)
// we can do this because id token is the last thing to be checked so if the id token is expired then it was
// the only thing making the request not valid
if (response.idTokenExpired && !response.valid) {
response.valid = true;
response.idTokenExpired = false;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response;
}
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() {
// sometimes the mealie token is being sent in the request to the IdP on callback which
// causes an error, so we clear it if we have one
if (!this.token.status().valid()) {
this.token.reset();
}
const redirect = await super._handleCallback()
await this.updateAccessToken()
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return redirect;
}
async updateAccessToken() {
if (this.isValidMealieToken()) {
return
}
if (!this.idToken.status().valid()) {
this.idToken.reset();
return
}
try {
const response = await this.$auth.requestWith(this.name, {
url: "/api/auth/token",
method: "post"
})
// Update tokens with mealie token
this.updateTokens(response)
} catch (e) {
if (e.response?.status === 401 || e.response?.status === 500) {
this.$auth.reset()
}
const currentUrl = new URL(window.location.href)
if (currentUrl.pathname === "/login" && currentUrl.searchParams.has("direct")) {
return
}
window.location.replace("/login?direct=1")
}
}
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;
this.options.scope = ["openid", "profile", "email"]
if (data.groupsClaim !== null) {
this.options.scope.push(data.groupsClaim)
}
console.log(this.options.scope)
} catch (error) {
// pass
}
}
}

View file

@ -6,6 +6,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.routing import APIRoute
from starlette.middleware.sessions import SessionMiddleware
from mealie.core.config import get_app_settings
from mealie.core.root_logger import get_logger
@ -66,12 +67,14 @@ async def lifespan_fn(_: FastAPI) -> AsyncGenerator[None, None]:
"LDAP_QUERY_PASSWORD",
"OPENAI_API_KEY",
"SECRET",
"SESSION_SECRET",
"SFTP_PASSWORD",
"SFTP_USERNAME",
"DB_URL", # replace by DB_URL_PUBLIC for logs
"DB_PROVIDER",
"SMTP_USER",
"SMTP_PASSWORD",
"OIDC_CLIENT_SECRET",
},
)
)
@ -91,6 +94,7 @@ app = FastAPI(
)
app.add_middleware(GZipMiddleware, minimum_size=1000)
app.add_middleware(SessionMiddleware, secret_key=settings.SESSION_SECRET)
if not settings.PRODUCTION:
allowed_origins = ["http://localhost:3000"]

View file

@ -49,7 +49,10 @@ class AuthProvider(Generic[T], metaclass=abc.ABCMeta):
to_encode["exp"] = expire
to_encode["iss"] = ISS
return (jwt.encode(to_encode, settings.SECRET, algorithm=ALGORITHM), expires_delta)
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"""
@ -66,6 +69,6 @@ class AuthProvider(Generic[T], metaclass=abc.ABCMeta):
return user
@abc.abstractmethod
async def authenticate(self) -> tuple[str, timedelta] | None:
def authenticate(self) -> tuple[str, timedelta] | None:
"""Attempt to authenticate a user"""
raise NotImplementedError

View file

@ -20,7 +20,7 @@ class CredentialsProvider(AuthProvider[CredentialsRequest]):
def __init__(self, session: Session, data: CredentialsRequest) -> None:
super().__init__(session, data)
async def authenticate(self) -> tuple[str, timedelta] | None:
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, group_id=None, household_id=None)
@ -30,7 +30,8 @@ class CredentialsProvider(AuthProvider[CredentialsRequest]):
# 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"
"abc123cba321",
"$2b$12$JdHtJOlkPFwyxdjdygEzPOtYmdQF5/R5tHxw5Tq8pxjubyLqdIX5i",
)
return None

View file

@ -22,7 +22,7 @@ class LDAPProvider(CredentialsProvider):
super().__init__(session, data)
self.conn = None
async def authenticate(self) -> tuple[str, timedelta] | None:
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:
@ -30,7 +30,7 @@ class LDAPProvider(CredentialsProvider):
if user:
return self.get_access_token(user, self.data.remember_me)
return await super().authenticate()
return super().authenticate()
def search_user(self, conn: LDAPObject) -> list[tuple[str, dict[str, list[bytes]]]] | None:
"""
@ -64,7 +64,11 @@ class LDAPProvider(CredentialsProvider):
settings.LDAP_BASE_DN,
ldap.SCOPE_SUBTREE,
search_filter,
[settings.LDAP_ID_ATTRIBUTE, settings.LDAP_NAME_ATTRIBUTE, settings.LDAP_MAIL_ATTRIBUTE],
[
settings.LDAP_ID_ATTRIBUTE,
settings.LDAP_NAME_ATTRIBUTE,
settings.LDAP_MAIL_ATTRIBUTE,
],
)
except ldap.FILTER_ERROR:
self._logger.error("[LDAP] Bad user search filter")

View file

@ -1,55 +1,58 @@
import time
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, UnsupportedAlgorithmError
from authlib.oidc.core import CodeIDToken
from authlib.oidc.core import UserInfo
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.core.settings.settings import AppSettings
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]):
class OpenIDProvider(AuthProvider[UserInfo]):
"""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:
def __init__(self, session: Session, data: UserInfo) -> None:
super().__init__(session, data)
async def authenticate(self) -> tuple[str, timedelta] | None:
def authenticate(self) -> tuple[str, timedelta] | None:
"""Attempt to authenticate a user given a username and password"""
settings = get_app_settings()
claims = self.get_claims(settings)
claims = self.data
if not claims:
self._logger.error("[OIDC] No claims in the id_token")
return None
if not self.required_claims.issubset(claims.keys()):
self._logger.error(
"[OIDC] Required claims not present. Expected: %s Actual: %s",
self.required_claims,
claims.keys(),
)
return None
repos = get_repositories(self.session, group_id=None, household_id=None)
user = self.try_get_user(claims.get(settings.OIDC_USER_CLAIM))
is_admin = False
if settings.OIDC_USER_GROUP or settings.OIDC_ADMIN_GROUP:
if settings.OIDC_REQUIRES_GROUP_CLAIM:
group_claim = claims.get(settings.OIDC_GROUPS_CLAIM, []) or []
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",
if not (is_valid_user or is_admin):
self._logger.warning(
"[OIDC] Successfully authenticated, but user does not have one of the required group(s). \
Found: %s - Required (one of): %s",
group_claim,
settings.OIDC_USER_GROUP,
[settings.OIDC_USER_GROUP, settings.OIDC_ADMIN_GROUP],
)
return None
user = self.try_get_user(claims.get(settings.OIDC_USER_CLAIM))
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.")
@ -57,22 +60,31 @@ class OpenIDProvider(AuthProvider[OIDCRequest]):
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()
try:
# some IdPs don't provide a username (looking at you Google), so if we don't have the claim,
# we'll create the user with whatever the USER_CLAIM is (default email)
username = claims.get("preferred_username", claims.get(settings.OIDC_USER_CLAIM))
user = repos.users.create(
{
"username": username,
"password": "OIDC",
"full_name": claims.get("name"),
"email": claims.get("email"),
"admin": is_admin,
"auth_method": AuthMethod.OIDC,
}
)
self.session.commit()
except Exception as e:
self._logger.error("[OIDC] Exception while creating user: %s", e)
return None
return self.get_access_token(user, settings.OIDC_REMEMBER_ME) # type: ignore
if user:
if settings.OIDC_ADMIN_GROUP and user.admin != is_admin:
self._logger.debug(f"[OIDC] {'Setting' if is_admin else 'Removing'} user as admin")
self._logger.debug("[OIDC] %s user as admin", "Setting" if is_admin else "Removing")
user.admin = is_admin
repos.users.update(user.id, user)
return self.get_access_token(user, settings.OIDC_REMEMBER_ME)
@ -80,78 +92,11 @@ class OpenIDProvider(AuthProvider[OIDCRequest]):
self._logger.warning("[OIDC] Found user but their AuthMethod does not match OIDC")
return None
def get_claims(self, settings: AppSettings) -> JWTClaims | None:
"""Get the claims from the ID token and check if the required claims are present"""
required_claims = {
"preferred_username",
"name",
"email",
settings.OIDC_USER_CLAIM,
}
jwks = OpenIDProvider.get_jwks(self.get_ttl_hash()) # cache the key set for 30 minutes
if not jwks:
return None
algorithm = settings.OIDC_SIGNING_ALGORITHM
try:
claims = JsonWebToken([algorithm]).decode(s=self.data.id_token, key=jwks, claims_cls=CodeIDToken)
except UnsupportedAlgorithmError:
self._logger.error(
f"[OIDC] Unsupported algorithm '{algorithm}'. Unable to decode id token due to mismatched algorithm."
)
return None
try:
claims.validate()
except ExpiredTokenError as e:
self._logger.error(f"[OIDC] {e.error}: {e.description}")
return None
except Exception as e:
self._logger.error("[OIDC] Exception while validating id_token claims", e)
if not claims:
self._logger.error("[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(ttl_hash=None) -> KeySet | None:
"""Get the key set from the openid configuration"""
del ttl_hash # ttl_hash is used for caching only
@property
def required_claims(self):
settings = get_app_settings()
if not (settings.OIDC_READY and settings.OIDC_CONFIGURATION_URL):
return None
session = requests.Session()
if settings.OIDC_TLS_CACERTFILE:
session.verify = settings.OIDC_TLS_CACERTFILE
config_response = session.get(settings.OIDC_CONFIGURATION_URL, timeout=5)
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")
session.close()
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")
session.close()
return None
response = session.get(jwks_uri, timeout=5)
response.raise_for_status()
session.close()
return JsonWebKey.import_key_set(response.json())
def get_ttl_hash(self, seconds=1800):
return time.time() // seconds
claims = {"name", "email", settings.OIDC_USER_CLAIM}
if settings.OIDC_REQUIRES_GROUP_CLAIM:
claims.add(settings.OIDC_GROUPS_CLAIM)
return claims

View file

@ -3,7 +3,6 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path
import jwt
from fastapi import Request
from sqlalchemy.orm.session import Session
from mealie.core import root_logger
@ -12,20 +11,16 @@ from mealie.core.security.hasher import get_hasher
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
from mealie.schema.user.auth import CredentialsRequest, CredentialsRequestForm
ALGORITHM = "HS256"
logger = root_logger.get_logger("security")
def get_auth_provider(session: Session, request: Request, data: CredentialsRequestForm) -> AuthProvider:
def get_auth_provider(session: Session, 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)

View file

@ -19,11 +19,11 @@ class ScheduleTime(NamedTuple):
minute: int
def determine_secrets(data_dir: Path, production: bool) -> str:
def determine_secrets(data_dir: Path, secret: str, production: bool) -> str:
if not production:
return "shh-secret-test-key"
secrets_file = data_dir.joinpath(".secret")
secrets_file = data_dir.joinpath(secret)
if secrets_file.is_file():
with open(secrets_file) as f:
return f.read()
@ -100,6 +100,7 @@ class AppSettings(AppLoggingSettings):
"""time in hours"""
SECRET: str
SESSION_SECRET: str
GIT_COMMIT_HASH: str = "unknown"
@ -268,6 +269,7 @@ class AppSettings(AppLoggingSettings):
# OIDC Configuration
OIDC_AUTH_ENABLED: bool = False
OIDC_CLIENT_ID: str | None = None
OIDC_CLIENT_SECRET: str | None = None
OIDC_CONFIGURATION_URL: str | None = None
OIDC_SIGNUP_ENABLED: bool = True
OIDC_USER_GROUP: str | None = None
@ -275,23 +277,28 @@ class AppSettings(AppLoggingSettings):
OIDC_AUTO_REDIRECT: bool = False
OIDC_PROVIDER_NAME: str = "OAuth"
OIDC_REMEMBER_ME: bool = False
OIDC_SIGNING_ALGORITHM: str = "RS256"
OIDC_USER_CLAIM: str = "email"
OIDC_GROUPS_CLAIM: str | None = "groups"
OIDC_TLS_CACERTFILE: str | None = None
@property
def OIDC_REQUIRES_GROUP_CLAIM(self) -> bool:
return self.OIDC_USER_GROUP is not None or self.OIDC_ADMIN_GROUP is not None
@property
def OIDC_READY(self) -> bool:
"""Validates OIDC settings are all set"""
required = {
self.OIDC_CLIENT_ID,
self.OIDC_CLIENT_SECRET,
self.OIDC_CONFIGURATION_URL,
self.OIDC_USER_CLAIM,
}
not_none = None not in required
valid_group_claim = True
if (not self.OIDC_USER_GROUP or not self.OIDC_ADMIN_GROUP) and not self.OIDC_GROUPS_CLAIM:
if self.OIDC_REQUIRES_GROUP_CLAIM and self.OIDC_GROUPS_CLAIM is None:
valid_group_claim = False
return self.OIDC_AUTH_ENABLED and not_none and valid_group_claim
@ -353,13 +360,17 @@ def app_settings_constructor(data_dir: Path, production: bool, env_file: Path, e
required dependencies into the AppSettings object and nested child objects. AppSettings should not be substantiated
directly, but rather through this factory function.
"""
secret_settings = {
"SECRET": determine_secrets(data_dir, ".secret", production),
"SESSION_SECRET": determine_secrets(data_dir, ".session_secret", production),
}
app_settings = AppSettings(
_env_file=env_file, # type: ignore
_env_file_encoding=env_encoding, # type: ignore
# `get_secrets_dir` must be called here rather than within `AppSettings`
# to avoid a circular import.
_secrets_dir=get_secrets_dir(), # type: ignore
**{"SECRET": determine_secrets(data_dir, production)},
**secret_settings,
)
app_settings.DB_PROVIDER = db_provider_factory(

View file

@ -6,7 +6,7 @@ from mealie.core.settings.static import APP_VERSION
from mealie.db.db_setup import generate_session
from mealie.db.models.users.users import User
from mealie.repos.all_repositories import get_repositories
from mealie.schema.admin.about import AppInfo, AppStartupInfo, AppTheme, OIDCInfo
from mealie.schema.admin.about import AppInfo, AppStartupInfo, AppTheme
router = APIRouter(prefix="/about")
@ -69,16 +69,3 @@ 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,
groups_claim=settings.OIDC_GROUPS_CLAIM if settings.OIDC_USER_GROUP or settings.OIDC_ADMIN_GROUP else None,
)

View file

@ -1,13 +1,18 @@
from datetime import timedelta
from authlib.integrations.starlette_client import OAuth
from fastapi import APIRouter, Depends, Request, Response, status
from fastapi.exceptions import HTTPException
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from sqlalchemy.orm.session import Session
from starlette.datastructures import URLPath
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.exceptions import UserLockedOut
from mealie.core.security.providers.openid_provider import OpenIDProvider
from mealie.core.security.security import get_auth_provider
from mealie.db.db_setup import generate_session
from mealie.routes._base.routers import UserAPIRouter
@ -20,6 +25,20 @@ logger = root_logger.get_logger("auth")
remember_me_duration = timedelta(days=14)
settings = get_app_settings()
if settings.OIDC_READY:
oauth = OAuth()
groups_claim = settings.OIDC_GROUPS_CLAIM if settings.OIDC_REQUIRES_GROUP_CLAIM else ""
scope = f"openid email profile {groups_claim}"
oauth.register(
"oidc",
client_id=settings.OIDC_CLIENT_ID,
client_secret=settings.OIDC_CLIENT_SECRET,
server_metadata_url=settings.OIDC_CONFIGURATION_URL,
client_kwargs={"scope": scope.rstrip()},
code_challenge_method="S256",
)
class MealieAuthToken(BaseModel):
access_token: str
@ -31,7 +50,7 @@ class MealieAuthToken(BaseModel):
@public_router.post("/token")
async def get_token(
def get_token(
request: Request,
response: Response,
data: CredentialsRequestForm = Depends(),
@ -46,8 +65,8 @@ async def get_token(
ip = request.client.host if request.client else "unknown"
try:
auth_provider = get_auth_provider(session, request, data)
auth = await auth_provider.authenticate()
auth_provider = get_auth_provider(session, data)
auth = 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
@ -61,7 +80,61 @@ async def get_token(
expires_in = duration.total_seconds() if duration else None
response.set_cookie(
key="mealie.access_token", value=access_token, httponly=True, max_age=expires_in, expires=expires_in
key="mealie.access_token",
value=access_token,
httponly=True,
max_age=expires_in,
secure=settings.PRODUCTION,
)
return MealieAuthToken.respond(access_token)
@public_router.get("/oauth")
async def oauth_login(request: Request):
if not oauth:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Could not initialize OAuth client",
)
client = oauth.create_client("oidc")
redirect_url = None
if not settings.PRODUCTION:
# in development, we want to redirect to the frontend
redirect_url = "http://localhost:3000/login"
else:
redirect_url = URLPath("/login").make_absolute_url(request.base_url)
response: RedirectResponse = await client.authorize_redirect(request, redirect_url)
return response
@public_router.get("/oauth/callback")
async def oauth_callback(request: Request, response: Response, session: Session = Depends(generate_session)):
if not oauth:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Could not initialize OAuth client",
)
client = oauth.create_client("oidc")
token = await client.authorize_access_token(request)
auth_provider = OpenIDProvider(session, token["userinfo"])
auth = auth_provider.authenticate()
if not auth:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
)
access_token, duration = auth
expires_in = duration.total_seconds() if duration else None
response.set_cookie(
key="mealie.access_token",
value=access_token,
httponly=True,
max_age=expires_in,
secure=settings.PRODUCTION,
)
return MealieAuthToken.respond(access_token)

View file

@ -1,5 +1,12 @@
# This file is auto-generated by gen_schema_exports.py
from .about import AdminAboutInfo, AppInfo, AppStartupInfo, AppStatistics, AppTheme, CheckAppConfig, OIDCInfo
from .about import (
AdminAboutInfo,
AppInfo,
AppStartupInfo,
AppStatistics,
AppTheme,
CheckAppConfig,
)
from .backup import AllBackups, BackupFile, BackupOptions, CreateBackup, ImportJob
from .debug import DebugResponse
from .email import EmailReady, EmailSuccess, EmailTest
@ -46,7 +53,6 @@ __all__ = [
"AppStatistics",
"AppTheme",
"CheckAppConfig",
"OIDCInfo",
"EmailReady",
"EmailSuccess",
"EmailTest",

View file

@ -72,9 +72,3 @@ class CheckAppConfig(MealieModel):
enable_openai: bool
base_url_set: bool
is_up_to_date: bool
class OIDCInfo(MealieModel):
configuration_url: str | None
client_id: str | None
groups_claim: str | None

View file

@ -1,5 +1,5 @@
# This file is auto-generated by gen_schema_exports.py
from .auth import CredentialsRequest, CredentialsRequestForm, OIDCRequest, Token, TokenData, UnlockResults
from .auth import CredentialsRequest, CredentialsRequestForm, Token, TokenData, UnlockResults
from .registration import CreateUserRegistration
from .user import (
ChangePassword,
@ -37,19 +37,18 @@ from .user_passwords import (
)
__all__ = [
"CreateUserRegistration",
"CredentialsRequest",
"CredentialsRequestForm",
"Token",
"TokenData",
"UnlockResults",
"ForgotPassword",
"PasswordResetToken",
"PrivatePasswordResetToken",
"ResetPassword",
"SavePasswordResetToken",
"ValidateResetToken",
"CredentialsRequest",
"CredentialsRequestForm",
"OIDCRequest",
"Token",
"TokenData",
"UnlockResults",
"CreateUserRegistration",
"ChangePassword",
"CreateToken",
"DeleteTokenResponse",

View file

@ -26,14 +26,15 @@ class CredentialsRequest(BaseModel):
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)):
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

15
poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.1 and should not be changed by hand.
[[package]]
name = "aiofiles"
@ -975,6 +975,17 @@ pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"
plugins = ["setuptools"]
requirements-deprecated-finder = ["pip-api", "pipreqs"]
[[package]]
name = "itsdangerous"
version = "2.2.0"
description = "Safely pass data to untrusted environments and back."
optional = false
python-versions = ">=3.8"
files = [
{file = "itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef"},
{file = "itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173"},
]
[[package]]
name = "jinja2"
version = "3.1.4"
@ -3402,4 +3413,4 @@ pgsql = ["psycopg2-binary"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "bfe38f53327690ca09b1c63a77d6539c4506dad2cdd66dbc881f835e78b418cf"
content-hash = "5a89a55272da78cc8e4ede78f3efef172a1a5aaa7f3cdcd0aa0efade0dd980ba"

View file

@ -49,6 +49,7 @@ pillow-heif = "^0.18.0"
pyjwt = "^2.8.0"
openai = "^1.27.0"
typing-extensions = "^4.12.2"
itsdangerous = "^2.2.0"
[tool.poetry.group.postgres.dependencies]
psycopg2-binary = { version = "^2.9.1" }

View file

@ -2,7 +2,7 @@ version: "3.4"
services:
oidc-mock-server:
container_name: oidc-mock-server
image: ghcr.io/navikt/mock-oauth2-server:2.1.0
image: ghcr.io/navikt/mock-oauth2-server:2.1.9
network_mode: host
environment:
LOG_LEVEL: "debug"
@ -34,6 +34,7 @@ services:
OIDC_ADMIN_GROUP: admin
OIDC_CONFIGURATION_URL: http://localhost:8080/default/.well-known/openid-configuration
OIDC_CLIENT_ID: default
OIDC_CLIENT_SECRET: secret
LDAP_AUTH_ENABLED: True
LDAP_SERVER_URL: ldap://localhost:10389

View file

@ -3,39 +3,39 @@
"@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==
version "1.47.2"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.47.2.tgz#dbe7051336bfc5cc599954214f9111181dbc7475"
integrity sha512-jTXRsoSPONAs8Za9QEQdyjFn+0ZQFjCiIztAIF6bi1HqhBzG9Ma7g1WotyiGqFSBRZjIEqMdT8RUlbk1QVhzCQ==
dependencies:
playwright "1.40.1"
playwright "1.47.2"
"@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==
version "20.16.5"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.16.5.tgz#d43c7f973b32ffdf9aa7bd4f80e1072310fd7a53"
integrity sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==
dependencies:
undici-types "~5.26.4"
undici-types "~6.19.2"
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-core@1.47.2:
version "1.47.2"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.47.2.tgz#7858da9377fa32a08be46ba47d7523dbd9460a4e"
integrity sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==
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==
playwright@1.47.2:
version "1.47.2"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.47.2.tgz#155688aa06491ee21fb3e7555b748b525f86eb20"
integrity sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==
dependencies:
playwright-core "1.40.1"
playwright-core "1.47.2"
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==
undici-types@~6.19.2:
version "6.19.8"
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02"
integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==

View file

@ -0,0 +1,127 @@
from pytest import MonkeyPatch, Session
from mealie.core.config import get_app_settings
from mealie.core.security.providers.openid_provider import OpenIDProvider
from mealie.repos.all_repositories import get_repositories
from tests.utils.fixture_schemas import TestUser
def test_no_claims():
auth_provider = OpenIDProvider(None, None)
assert auth_provider.authenticate() is None
def test_empty_claims():
auth_provider = OpenIDProvider(None, {})
assert auth_provider.authenticate() is None
def test_missing_claims():
data = {"preferred_username": "dude1"}
auth_provider = OpenIDProvider(None, data)
assert auth_provider.authenticate() is None
def test_missing_groups_claim(monkeypatch: MonkeyPatch):
monkeypatch.setenv("OIDC_USER_GROUP", "mealie_user")
get_app_settings.cache_clear()
data = {
"preferred_username": "dude1",
"email": "email@email.com",
"name": "Firstname Lastname",
}
auth_provider = OpenIDProvider(None, data)
assert auth_provider.authenticate() is None
def test_missing_user_group(monkeypatch: MonkeyPatch):
monkeypatch.setenv("OIDC_USER_GROUP", "mealie_user")
get_app_settings.cache_clear()
data = {
"preferred_username": "dude1",
"email": "email@email.com",
"name": "Firstname Lastname",
"groups": ["not_mealie_user"],
}
auth_provider = OpenIDProvider(None, data)
assert auth_provider.authenticate() is None
def test_has_user_group_existing_user(monkeypatch: MonkeyPatch, unique_user: TestUser):
monkeypatch.setenv("OIDC_USER_GROUP", "mealie_user")
get_app_settings.cache_clear()
data = {
"preferred_username": "dude1",
"email": unique_user.email,
"name": "Firstname Lastname",
"groups": ["mealie_user"],
}
auth_provider = OpenIDProvider(unique_user.repos.session, data)
assert auth_provider.authenticate() is not None
def test_has_admin_group_existing_user(monkeypatch: MonkeyPatch, unique_user: TestUser):
monkeypatch.setenv("OIDC_USER_GROUP", "mealie_user")
monkeypatch.setenv("OIDC_ADMIN_GROUP", "mealie_admin")
get_app_settings.cache_clear()
data = {
"preferred_username": "dude1",
"email": unique_user.email,
"name": "Firstname Lastname",
"groups": ["mealie_admin"],
}
auth_provider = OpenIDProvider(unique_user.repos.session, data)
assert auth_provider.authenticate() is not None
def test_has_user_group_new_user(monkeypatch: MonkeyPatch, session: Session):
monkeypatch.setenv("OIDC_USER_GROUP", "mealie_user")
monkeypatch.setenv("OIDC_ADMIN_GROUP", "mealie_admin")
get_app_settings.cache_clear()
data = {
"preferred_username": "dude1",
"email": "dude1@email.com",
"name": "Firstname Lastname",
"groups": ["mealie_user"],
}
auth_provider = OpenIDProvider(session, data)
assert auth_provider.authenticate() is not None
db = get_repositories(session, group_id=None, household_id=None)
user = db.users.get_one("dude1", "username")
assert user is not None
assert not user.admin
def test_has_admin_group_new_user(monkeypatch: MonkeyPatch, session: Session):
monkeypatch.setenv("OIDC_USER_GROUP", "mealie_user")
monkeypatch.setenv("OIDC_ADMIN_GROUP", "mealie_admin")
get_app_settings.cache_clear()
data = {
"preferred_username": "dude2",
"email": "dude2@email.com",
"name": "Firstname Lastname",
"groups": ["mealie_admin"],
}
auth_provider = OpenIDProvider(session, data)
assert auth_provider.authenticate() is not None
db = get_repositories(session, group_id=None, household_id=None)
user = db.users.get_one("dude2", "username")
assert user is not None
assert user.admin

View file

@ -6,7 +6,10 @@ 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.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
@ -102,7 +105,10 @@ def setup_env(monkeypatch: MonkeyPatch):
monkeypatch.setenv("LDAP_BASE_DN", base_dn)
monkeypatch.setenv("LDAP_QUERY_BIND", query_bind)
monkeypatch.setenv("LDAP_QUERY_PASSWORD", query_password)
monkeypatch.setenv("LDAP_USER_FILTER", "(&(objectClass=user)(|({id_attribute}={input})({mail_attribute}={input})))")
monkeypatch.setenv(
"LDAP_USER_FILTER",
"(&(objectClass=user)(|({id_attribute}={input})({mail_attribute}={input})))",
)
return user, mail, name, password, query_bind, query_password
@ -208,15 +214,11 @@ def test_ldap_user_creation_admin(monkeypatch: MonkeyPatch):
def test_ldap_disabled(monkeypatch: MonkeyPatch):
monkeypatch.setenv("LDAP_AUTH_ENABLED", "False")
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:
form = CredentialsRequestForm("username", "password", False)
provider = security.get_auth_provider(session, Request("local"), form)
provider = security.get_auth_provider(session, form)
assert isinstance(provider, CredentialsProvider)
@ -230,7 +232,15 @@ def test_user_login_ldap_auth_method(monkeypatch: MonkeyPatch, ldap_user: Privat
def ldap_initialize_mock(url):
assert url == ""
return LdapConnMock(ldap_user.username, ldap_password, False, query_bind, query_password, ldap_user.email, name)
return LdapConnMock(
ldap_user.username,
ldap_password,
False,
query_bind,
query_password,
ldap_user.email,
name,
)
monkeypatch.setattr(ldap, "initialize", ldap_initialize_mock)

View file

@ -45,6 +45,10 @@ app_about_theme = "/api/app/about/theme"
"""`/api/app/about/theme`"""
auth_logout = "/api/auth/logout"
"""`/api/auth/logout`"""
auth_oauth = "/api/auth/oauth"
"""`/api/auth/oauth`"""
auth_oauth_callback = "/api/auth/oauth/callback"
"""`/api/auth/oauth/callback`"""
auth_refresh = "/api/auth/refresh"
"""`/api/auth/refresh`"""
auth_token = "/api/auth/token"