mirror of
https://github.com/hay-kot/mealie.git
synced 2025-07-05 20:42:23 -07:00
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:
parent
4f1abcf4a3
commit
5ed0ec029b
31 changed files with 530 additions and 349 deletions
|
@ -23,7 +23,6 @@ services:
|
|||
POSTGRES_SERVER: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
POSTGRES_DB: mealie
|
||||
|
||||
# =====================================
|
||||
# Email Configuration
|
||||
# SMTP_HOST=
|
||||
|
|
|
@ -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.
|
|
@ -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
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -132,9 +132,6 @@ export interface LongLiveTokenOut {
|
|||
id: number;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
export interface OIDCRequest {
|
||||
id_token: string;
|
||||
}
|
||||
export interface PasswordResetToken {
|
||||
token: string;
|
||||
}
|
||||
|
|
|
@ -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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
15
poetry.lock
generated
|
@ -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"
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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==
|
||||
|
|
127
tests/unit_tests/core/security/providers/test_openid_provider.py
Normal file
127
tests/unit_tests/core/security/providers/test_openid_provider.py
Normal 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
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue