Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP: Add generic SSO authentication support #1622

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,9 @@ Changing the webworker settings may cause unforeseen memory leak issues with Mea
| LDAP_SERVER_URL | None | LDAP server URL (e.g. ldap://ldap.example.com) |
| LDAP_BIND_TEMPLATE | None | Templated DN for users, `{}` will be replaced with the username (e.g. `cn={},dc=example,dc=com`) |
| LDAP_ADMIN_FILTER | None | Optional LDAP filter, which tells Mealie the LDAP user is an admin (e.g. `(memberOf=cn=admins,dc=example,dc=com)`) |

### SSO

| Variables | Default | Description |
| ----------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| SSO_TRUSTED_HEADER_USER | None | Authenticate via an external SSO server with this HTTP header being trusted from a reverse proxy. Must be identical to the frontend setting of the same name. |
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,11 @@ Setting the following environmental variables will change the theme of the front
| THEME_DARK_INFO | #1976D2 | Dark Theme Config Variable |
| THEME_DARK_WARNING | #FF6D00 | Dark Theme Config Variable |
| THEME_DARK_ERROR | #EF5350 | Dark Theme Config Variable |

### SSO

| Variables | Default | Description |
| ----------------------- | :-----: | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| SSO_TRUSTED_HEADER_USER | None | Authenticate via an external SSO server with this HTTP header being trusted from a reverse proxy. Must be identical to the backend setting of the same name. |
| SSO_LOGIN_URL | None | URL to redirect to when a login is required. Must be an absolute URL. |
| SSO_LOGOUT_URL | None | URL to redirect to after the frontend logs the user out (logout page of the IdP) |
6 changes: 5 additions & 1 deletion frontend/nuxt.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ export default {
},
// Options
strategies: {
local: {
maybeSSO: {
scheme: './schemes/maybeSSO',
resetOnError: true,
token: {
property: "access_token",
Expand Down Expand Up @@ -235,6 +236,9 @@ export default {
publicRuntimeConfig: {
GLOBAL_MIDDLEWARE: process.env.GLOBAL_MIDDLEWARE || null,
SUB_PATH: process.env.SUB_PATH || "",
SSO_TRUSTED_HEADER_USER: process.env.SSO_TRUSTED_HEADER_USER || null,
SSO_LOGIN_URL: process.env.SSO_LOGIN_URL || null,
SSO_LOGOUT_URL: process.env.SSO_LOGOUT_URL || null,
axios: {
browserBaseURL: process.env.SUB_PATH || "",
},
Expand Down
19 changes: 17 additions & 2 deletions frontend/pages/login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@
</template>

<script lang="ts">
import { defineComponent, ref, useContext, computed, reactive } from "@nuxtjs/composition-api";
import { defineComponent, onMounted, ref, useContext, computed, reactive } from "@nuxtjs/composition-api";
import { useDark } from "@vueuse/core";
import { useAppInfo } from "~/composables/api";
import { usePasswordField } from "~/composables/use-passwords";
Expand Down Expand Up @@ -151,7 +151,7 @@ export default defineComponent({
formData.append("remember_me", String(form.remember));

try {
await $auth.loginWith("local", { data: formData });
await $auth.loginWith("maybeSSO", { data: formData });
} catch (error) {
// TODO Check if error is an AxiosError, but isAxiosError is not working right now
// See https://github.com/nuxt-community/axios-module/issues/550
Expand All @@ -170,6 +170,12 @@ export default defineComponent({
loggingIn.value = false;
}

onMounted(() => {
if ($auth.loggedIn) {
$auth.redirect("home");
}
});

return {
isDark,
form,
Expand All @@ -183,6 +189,15 @@ export default defineComponent({
};
},

asyncData(ctx) {
// Redirect to external login if SSO is enabled, but user is not logged in
// with the SSO provider
if (ctx.$config.SSO_TRUSTED_HEADER_USER !== null && ctx.$auth.ctx.store.state.ssoUser === null && ctx.$config.SSO_LOGIN_URL !== null) {
const loginUrl: string = ctx.$config.SSO_LOGIN_URL;
ctx.redirect(loginUrl);
}
},

head() {
return {
title: this.$t("user.login") as string,
Expand Down
21 changes: 21 additions & 0 deletions frontend/schemes/maybeSSO.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { HTTPResponse } from "@nuxtjs/auth-next";
import { LocalScheme } from "~auth/runtime"

export default class MaybeSSO extends LocalScheme {
async mounted(): Promise<HTTPResponse | void> {
// nuxt-auth can't be configured from the environment directly, so we
// overwrite redirect.logout here
if (this.$auth.ctx.$config.SSO_LOGOUT_URL !== null) {
this.$auth.options.redirect.logout = this.$auth.ctx.$config.SSO_LOGOUT_URL;
}

if (!this.$auth.loggedIn && this.$auth.ctx.store.state.ssoUser !== null) {
const formData = new FormData();
formData.append("username", "sso");
formData.append("password", "sso");
await this.$auth.loginWith("maybeSSO", { data: formData });
}

return await super.mounted();
}
}
24 changes: 24 additions & 0 deletions frontend/store/index.js
Original file line number Diff line number Diff line change
@@ -1 +1,25 @@
export const store = {};

export const state = () => ({
ssoUser: null
})

export const mutations = {
set_sso_user(state, user) {
state.ssoUser = user
}
}

export const actions = {
async nuxtServerInit({ commit }, { req, $config }) {
let trustedHeader = $config.SSO_TRUSTED_HEADER_USER;
if (trustedHeader !== null) {
// req.headers has lower-cased keys
trustedHeader = trustedHeader.toLowerCase();

if (req.headers && req.headers[trustedHeader]) {
await commit("set_sso_user", req.headers[trustedHeader]);
}
}
}
}
4 changes: 4 additions & 0 deletions frontend/types/ts-shim.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ declare module "*.vue" {
import Vue from "vue";
export default Vue;
}

declare module "~auth/runtime" {
export { LocalScheme, SchemeCheck } from "@nuxtjs/auth-next"
}
29 changes: 27 additions & 2 deletions mealie/core/security/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,35 @@ def user_from_ldap(db: AllRepositories, username: str, password: str) -> Private
return user


def authenticate_user(session, email: str, password: str) -> PrivateUser | bool:
settings = get_app_settings()
def user_from_sso(db: AllRepositories, username: str):
"""Given a username, returns a user

It will either create a new user of that username or return an existing one. No checks are done, the SSO provider is responsible for authentication.
"""
user = db.users.get_one(username, "username", any_case=True)
if not user:
user = db.users.create(
{
"username": username,
"password": "SSO",
# Fill the next two values with something unique and vaguely
# relevant
"full_name": username,
"email": username,
"admin": False,
},
)

return user


def authenticate_user(session, email: str, password: str, request=None) -> PrivateUser | bool:
settings = get_app_settings()
db = get_repositories(session)

if settings.SSO_TRUSTED_HEADER_USER and settings.SSO_TRUSTED_HEADER_USER in request.headers:
return user_from_sso(db, request.headers[settings.SSO_TRUSTED_HEADER_USER])

user = db.users.get_one(email, "email", any_case=True)

if not user:
Expand Down
5 changes: 5 additions & 0 deletions mealie/core/settings/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,11 @@ def LDAP_ENABLED(self) -> bool:
not_none = None not in required
return self.LDAP_AUTH_ENABLED and not_none

# ===============================================
# SSO Configuration

SSO_TRUSTED_HEADER_USER: NoneStr = None

# ===============================================
# Testing Config

Expand Down
7 changes: 3 additions & 4 deletions mealie/routes/auth/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from datetime import timedelta
from typing import Optional

from fastapi import APIRouter, Depends, Form, status
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.exceptions import HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from pydantic import BaseModel
Expand Down Expand Up @@ -49,13 +49,12 @@ def respond(cls, token: str, token_type: str = "bearer") -> dict:


@public_router.post("/token")
def get_token(data: CustomOAuth2Form = Depends(), session: Session = Depends(generate_session)):

def get_token(request: Request, data: CustomOAuth2Form = Depends(), session: Session = Depends(generate_session)):
email = data.username
password = data.password

try:
user = authenticate_user(session, email, password) # type: ignore
user = authenticate_user(session, email, password, request) # type: ignore
except UserLockedOut as e:
raise HTTPException(status_code=status.HTTP_423_LOCKED, detail="User is locked out") from e

Expand Down
5 changes: 5 additions & 0 deletions template.env
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@ LDAP_AUTH_ENABLED=False
LDAP_SERVER_URL=None
LDAP_BIND_TEMPLATE=None
LDAP_ADMIN_FILTER=None

# Configuration for authentication via an external SSO service
#SSO_TRUSTED_HEADER_USER="REMOTE_USER"
#SSO_LOGIN_URL=""
#SSO_LOGOUT_URL=""