Merge pull request #1 from discretizer/feature/add_auth
Merging with fork master
This commit is contained in:
commit
7d9cc54431
@ -455,3 +455,43 @@ or per service widget (`services.yaml`) with:
|
||||
```
|
||||
|
||||
If either value is set to true, the error message will be hidden.
|
||||
|
||||
## Authentication
|
||||
|
||||
Basic auth integration is implemeted via an `auth` section. An auth provider can be configured using the `provider` section with the given type. Currently the only provider supported is `proxy`, where the users identification and group membership are passed via HTTP Request headers (in plaintext). The expectation is that the application will be accessed only via an authenticating proxy (i.e treafik ).
|
||||
|
||||
The group and user headers are both configurable like so:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
provider:
|
||||
type: proxy
|
||||
groupHeader: "X-group-header"
|
||||
userHeader: "X-user-header"
|
||||
```
|
||||
|
||||
Auth can be configured on the service, bookmark, and widget level using the `allowUsers` and `allowGroups` list.
|
||||
|
||||
```yaml
|
||||
- Example Servie:
|
||||
allowGroups:
|
||||
- Group1
|
||||
- Group2
|
||||
- Group3
|
||||
allowUsers:
|
||||
- User1
|
||||
- User2
|
||||
- User3
|
||||
```
|
||||
|
||||
Auth for groups can be set in the `groups` under `auth`. In general the `groups` tag follows the format of the `layout`
|
||||
section. For example:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
groups:
|
||||
My Service Group:
|
||||
allowGroups: ["Group1", "Group2"]
|
||||
My Other Group:
|
||||
allowGroups: ["Group1"]
|
||||
```
|
||||
|
||||
17
src/pages/api/auth.js
Normal file
17
src/pages/api/auth.js
Normal file
@ -0,0 +1,17 @@
|
||||
import { checkAllowedGroup, readAuthSettings } from "utils/auth/auth-helpers";
|
||||
import { getSettings } from "utils/config/config";
|
||||
|
||||
export default async function handler(req, res) {
|
||||
const { group } = req.query;
|
||||
const { provider, groups } = readAuthSettings(getSettings().auth);
|
||||
|
||||
try {
|
||||
if (checkAllowedGroup(provider.permissions(req), groups, group)) {
|
||||
res.json({ group });
|
||||
} else {
|
||||
res.status(401).json({ message: "Group unathorized" });
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).send("Error authenticating");
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
import { readAuthSettings } from "utils/auth/auth-helpers";
|
||||
import { bookmarksResponse } from "utils/config/api-response";
|
||||
import { getSettings } from "utils/config/config";
|
||||
|
||||
export default async function handler(req, res) {
|
||||
res.send(await bookmarksResponse());
|
||||
const { provider, groups } = readAuthSettings(getSettings().auth);
|
||||
res.send(await bookmarksResponse(provider.permissions(req), groups));
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { readAuthSettings } from "utils/auth/auth-helpers";
|
||||
import { servicesResponse } from "utils/config/api-response";
|
||||
import { getSettings } from "utils/config/config";
|
||||
|
||||
export default async function handler(req, res) {
|
||||
res.send(await servicesResponse());
|
||||
const { provider, groups } = readAuthSettings(getSettings().auth);
|
||||
res.send(await servicesResponse(provider.permissions(req), groups));
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { readAuthSettings } from "utils/auth/auth-helpers";
|
||||
import { widgetsResponse } from "utils/config/api-response";
|
||||
import { getSettings } from "utils/config/config";
|
||||
|
||||
export default async function handler(req, res) {
|
||||
res.send(await widgetsResponse());
|
||||
const { provider } = readAuthSettings(getSettings().auth);
|
||||
res.send(await widgetsResponse(provider.permissions(req)));
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable react/no-array-index-key */
|
||||
import useSWR, { SWRConfig } from "swr";
|
||||
import useSWR, { unstable_serialize as unstableSerialize, SWRConfig } from "swr";
|
||||
import Head from "next/head";
|
||||
import dynamic from "next/dynamic";
|
||||
import classNames from "classnames";
|
||||
@ -9,6 +9,7 @@ import { BiError } from "react-icons/bi";
|
||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||
import { useRouter } from "next/router";
|
||||
|
||||
import NullAuthProvider from "utils/auth/null";
|
||||
import Tab, { slugify } from "components/tab";
|
||||
import FileContent from "components/filecontent";
|
||||
import ServicesGroup from "components/services/group";
|
||||
@ -27,6 +28,7 @@ import ErrorBoundary from "components/errorboundry";
|
||||
import themes from "utils/styles/themes";
|
||||
import QuickLaunch from "components/quicklaunch";
|
||||
import { getStoredProvider, searchProviders } from "components/widgets/search/search";
|
||||
import { fetchWithAuth, readAuthSettings } from "utils/auth/auth-helpers";
|
||||
|
||||
const ThemeToggle = dynamic(() => import("components/toggles/theme"), {
|
||||
ssr: false,
|
||||
@ -42,25 +44,28 @@ const Version = dynamic(() => import("components/version"), {
|
||||
|
||||
const rightAlignedWidgets = ["weatherapi", "openweathermap", "weather", "openmeteo", "search", "datetime"];
|
||||
|
||||
export async function getStaticProps() {
|
||||
export async function getServerSideProps({ req }) {
|
||||
let logger;
|
||||
try {
|
||||
logger = createLogger("index");
|
||||
const { providers, ...settings } = getSettings();
|
||||
const { providers, auth, ...settings } = getSettings();
|
||||
const { provider, groups } = readAuthSettings(auth);
|
||||
|
||||
const services = await servicesResponse();
|
||||
const bookmarks = await bookmarksResponse();
|
||||
const widgets = await widgetsResponse();
|
||||
const services = await servicesResponse(provider.authorize(req), groups);
|
||||
const bookmarks = await bookmarksResponse(provider.authorize(req), groups);
|
||||
const widgets = await widgetsResponse(provider.authorize(req));
|
||||
const authContext = provider.getContext(req);
|
||||
|
||||
return {
|
||||
props: {
|
||||
initialSettings: settings,
|
||||
fallback: {
|
||||
"/api/services": services,
|
||||
"/api/bookmarks": bookmarks,
|
||||
"/api/widgets": widgets,
|
||||
[unstableSerialize(["/api/services", authContext])]: services,
|
||||
[unstableSerialize(["/api/bookmarks", authContext])]: bookmarks,
|
||||
[unstableSerialize(["/api/widgets", authContext])]: widgets,
|
||||
"/api/hash": false,
|
||||
},
|
||||
authContext,
|
||||
...(await serverSideTranslations(settings.language ?? "en")),
|
||||
},
|
||||
};
|
||||
@ -68,22 +73,24 @@ export async function getStaticProps() {
|
||||
if (logger) {
|
||||
logger.error(e);
|
||||
}
|
||||
const authContext = NullAuthProvider.create().getContext(req);
|
||||
return {
|
||||
props: {
|
||||
initialSettings: {},
|
||||
fallback: {
|
||||
"/api/services": [],
|
||||
"/api/bookmarks": [],
|
||||
"/api/widgets": [],
|
||||
[unstableSerialize(["/api/services", authContext])]: [],
|
||||
[unstableSerialize(["/api/bookmarks", authContext])]: [],
|
||||
[unstableSerialize(["/api/widgets", authContext])]: [],
|
||||
"/api/hash": false,
|
||||
},
|
||||
authContext,
|
||||
...(await serverSideTranslations("en")),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function Index({ initialSettings, fallback }) {
|
||||
function Index({ initialSettings, fallback, authContext }) {
|
||||
const windowFocused = useWindowFocus();
|
||||
const [stale, setStale] = useState(false);
|
||||
const { data: errorsData } = useSWR("/api/validate");
|
||||
@ -153,7 +160,7 @@ function Index({ initialSettings, fallback }) {
|
||||
return (
|
||||
<SWRConfig value={{ fallback, fetcher: (resource, init) => fetch(resource, init).then((res) => res.json()) }}>
|
||||
<ErrorBoundary>
|
||||
<Home initialSettings={initialSettings} />
|
||||
<Home initialSettings={initialSettings} authContext={authContext} />
|
||||
</ErrorBoundary>
|
||||
</SWRConfig>
|
||||
);
|
||||
@ -167,7 +174,7 @@ const headerStyles = {
|
||||
boxedWidgets: "m-6 mb-0 sm:m-9 sm:mb-0 sm:mt-1",
|
||||
};
|
||||
|
||||
function Home({ initialSettings }) {
|
||||
function Home({ initialSettings, authContext }) {
|
||||
const { i18n } = useTranslation();
|
||||
const { theme, setTheme } = useContext(ThemeContext);
|
||||
const { color, setColor } = useContext(ColorContext);
|
||||
@ -179,9 +186,9 @@ function Home({ initialSettings }) {
|
||||
setSettings(initialSettings);
|
||||
}, [initialSettings, setSettings]);
|
||||
|
||||
const { data: services } = useSWR("/api/services");
|
||||
const { data: bookmarks } = useSWR("/api/bookmarks");
|
||||
const { data: widgets } = useSWR("/api/widgets");
|
||||
const { data: services } = useSWR(["/api/services", authContext], fetchWithAuth);
|
||||
const { data: bookmarks } = useSWR(["/api/bookmarks", authContext], fetchWithAuth);
|
||||
const { data: widgets } = useSWR(["/api/widgets", authContext], fetchWithAuth);
|
||||
|
||||
const servicesAndBookmarks = [
|
||||
...services.map((sg) => sg.services).flat(),
|
||||
@ -472,7 +479,7 @@ function Home({ initialSettings }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Wrapper({ initialSettings, fallback }) {
|
||||
export default function Wrapper({ initialSettings, fallback, authContext }) {
|
||||
const wrappedStyle = {};
|
||||
let backgroundBlur = false;
|
||||
let backgroundSaturate = false;
|
||||
@ -523,7 +530,7 @@ export default function Wrapper({ initialSettings, fallback }) {
|
||||
backgroundBrightness && `backdrop-brightness-${initialSettings.background.brightness}`,
|
||||
)}
|
||||
>
|
||||
<Index initialSettings={initialSettings} fallback={fallback} />
|
||||
<Index initialSettings={initialSettings} fallback={fallback} authContext={authContext} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
57
src/utils/auth/auth-helpers.js
Normal file
57
src/utils/auth/auth-helpers.js
Normal file
@ -0,0 +1,57 @@
|
||||
import ProxyAuthProvider from "./proxy";
|
||||
import NullAuthProvider from "./null";
|
||||
|
||||
const AuthProviders = {
|
||||
NullAuthProvider,
|
||||
ProxyAuthProvider,
|
||||
};
|
||||
|
||||
function getProviderByKey(key) {
|
||||
return AuthProviders.find((provider) => provider.key === key) ?? NullAuthProvider;
|
||||
}
|
||||
|
||||
function authAllow({ user, groups }, item) {
|
||||
const groupAllow = "allowGroups" in item && groups.some((group) => item.allowGroups.includes(group));
|
||||
const userAllow = "allowUsers" in item && item.allowUsers.includes(user);
|
||||
const allowAll = !("allowGroups" in item) && !("allowUsers" in item);
|
||||
|
||||
return userAllow || groupAllow || allowAll;
|
||||
}
|
||||
|
||||
export function checkAllowedGroup(perms, authGroups, groupName) {
|
||||
const testGroup = authGroups.find((group) => group.name === groupName);
|
||||
return testGroup ? authAllow(perms, testGroup) : true;
|
||||
}
|
||||
|
||||
function filterAllowedItems(perms, authGroups, groups, groupKey) {
|
||||
return groups
|
||||
.filter((group) => checkAllowedGroup(perms, authGroups, group.name))
|
||||
.map((group) => ({
|
||||
name: group.name,
|
||||
[groupKey]: group[groupKey].filter((item) => authAllow(perms, item)),
|
||||
}))
|
||||
.filter((group) => group[groupKey].length);
|
||||
}
|
||||
|
||||
export function readAuthSettings({ provider, groups } = {}) {
|
||||
return {
|
||||
provider: provider ? getProviderByKey(provider.type).create(provider) : NullAuthProvider.create(),
|
||||
groups: groups
|
||||
? groups.map((group) => ({
|
||||
name: Object.keys(group)[0],
|
||||
allowUsers: group[Object.keys(group)[0]].allowUsers,
|
||||
allowGroups: group[Object.keys(group)[0]].allowGroups,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchWithAuth(key, context) {
|
||||
return getProviderByKey(context.provider).fetch([key, context]);
|
||||
}
|
||||
|
||||
export const filterAllowedServices = (perms, authGroups, services) =>
|
||||
filterAllowedItems(perms, authGroups, services, "services");
|
||||
export const filterAllowedBookmarks = (perms, authGroups, bookmarks) =>
|
||||
filterAllowedItems(perms, authGroups, bookmarks, "bookmarks");
|
||||
export const filterAllowedWidgets = (perms, widgets) => widgets.filter((widget) => authAllow(perms, widget.options));
|
||||
23
src/utils/auth/null.js
Normal file
23
src/utils/auth/null.js
Normal file
@ -0,0 +1,23 @@
|
||||
const NullPermissions = { user: null, groups: [] };
|
||||
const NullAuthKey = "none";
|
||||
|
||||
function createNullAuth() {
|
||||
return {
|
||||
authorize: () => NullPermissions,
|
||||
getContext: () => ({
|
||||
provider: NullAuthKey,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchNullAuth([key]) {
|
||||
return fetch(key).then((res) => res.json());
|
||||
}
|
||||
|
||||
const NullAuthProvider = {
|
||||
key: NullAuthKey,
|
||||
create: createNullAuth,
|
||||
fetch: fetchNullAuth,
|
||||
};
|
||||
|
||||
export default NullAuthProvider;
|
||||
33
src/utils/auth/proxy.js
Normal file
33
src/utils/auth/proxy.js
Normal file
@ -0,0 +1,33 @@
|
||||
// 'proxy' auth provider is meant to be used by a reverse proxy that injects permission headers into the origin
|
||||
// request. In this case we are relying on our proxy to authenitcate our users and validate.
|
||||
const ProxyAuthKey = "proxy";
|
||||
|
||||
function getProxyPermissions(userHeader, groupHeader, request) {
|
||||
const user = userHeader ? request.headers.get(userHeader) : null;
|
||||
const groupsString = groupHeader ? request.headers.get(groupHeader) : "";
|
||||
|
||||
return { user, groups: groupsString ? groupsString.split(",").map((v) => v.trimStart()) : [] };
|
||||
}
|
||||
|
||||
function createProxyAuth({ groupHeader, userHeader }) {
|
||||
return {
|
||||
getContext: (request) => ({
|
||||
type: ProxyAuthKey,
|
||||
...(userHeader && { [userHeader]: request.headers.get(userHeader) }),
|
||||
...(groupHeader && { [groupHeader]: request.headers.get(groupHeader) }),
|
||||
}),
|
||||
authorize: (request) => getProxyPermissions(userHeader, groupHeader, request),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchProxyAuth([key, context]) {
|
||||
return fetch(key, { headers: context.headers }).then((res) => res.json());
|
||||
}
|
||||
|
||||
const ProxyAuthProvider = {
|
||||
key: ProxyAuthKey,
|
||||
create: createProxyAuth,
|
||||
fetch: fetchProxyAuth,
|
||||
};
|
||||
|
||||
export default ProxyAuthProvider;
|
||||
@ -12,6 +12,7 @@ import {
|
||||
servicesFromKubernetes,
|
||||
} from "utils/config/service-helpers";
|
||||
import { cleanWidgetGroups, widgetsFromConfig } from "utils/config/widget-helpers";
|
||||
import { filterAllowedBookmarks, filterAllowedServices, filterAllowedWidgets } from "utils/auth/auth-helpers";
|
||||
|
||||
/**
|
||||
* Compares services by weight then by name.
|
||||
@ -24,7 +25,7 @@ function compareServices(service1, service2) {
|
||||
return service1.name.localeCompare(service2.name);
|
||||
}
|
||||
|
||||
export async function bookmarksResponse() {
|
||||
export async function bookmarksResponse(perms, authGroups) {
|
||||
checkAndCopyConfig("bookmarks.yaml");
|
||||
|
||||
const bookmarksYaml = path.join(CONF_DIR, "bookmarks.yaml");
|
||||
@ -45,13 +46,17 @@ export async function bookmarksResponse() {
|
||||
}
|
||||
|
||||
// map easy to write YAML objects into easy to consume JS arrays
|
||||
const bookmarksArray = bookmarks.map((group) => ({
|
||||
const bookmarksArray = filterAllowedBookmarks(
|
||||
perms,
|
||||
authGroups,
|
||||
bookmarks.map((group) => ({
|
||||
name: Object.keys(group)[0],
|
||||
bookmarks: group[Object.keys(group)[0]].map((entries) => ({
|
||||
name: Object.keys(entries)[0],
|
||||
...entries[Object.keys(entries)[0]][0],
|
||||
})),
|
||||
}));
|
||||
})),
|
||||
);
|
||||
|
||||
const sortedGroups = [];
|
||||
const unsortedGroups = [];
|
||||
@ -70,11 +75,11 @@ export async function bookmarksResponse() {
|
||||
return [...sortedGroups.filter((g) => g), ...unsortedGroups];
|
||||
}
|
||||
|
||||
export async function widgetsResponse() {
|
||||
export async function widgetsResponse(perms) {
|
||||
let configuredWidgets;
|
||||
|
||||
try {
|
||||
configuredWidgets = cleanWidgetGroups(await widgetsFromConfig());
|
||||
configuredWidgets = filterAllowedWidgets(perms, await cleanWidgetGroups(await widgetsFromConfig()));
|
||||
} catch (e) {
|
||||
console.error("Failed to load widgets, please check widgets.yaml for errors or remove example entries.");
|
||||
if (e) console.error(e);
|
||||
@ -84,14 +89,14 @@ export async function widgetsResponse() {
|
||||
return configuredWidgets;
|
||||
}
|
||||
|
||||
export async function servicesResponse() {
|
||||
export async function servicesResponse(perms, authGroups) {
|
||||
let discoveredDockerServices;
|
||||
let discoveredKubernetesServices;
|
||||
let configuredServices;
|
||||
let initialSettings;
|
||||
|
||||
try {
|
||||
discoveredDockerServices = cleanServiceGroups(await servicesFromDocker());
|
||||
discoveredDockerServices = filterAllowedServices(perms, authGroups, cleanServiceGroups(await servicesFromDocker()));
|
||||
if (discoveredDockerServices?.length === 0) {
|
||||
console.debug("No containers were found with homepage labels.");
|
||||
}
|
||||
@ -102,7 +107,11 @@ export async function servicesResponse() {
|
||||
}
|
||||
|
||||
try {
|
||||
discoveredKubernetesServices = cleanServiceGroups(await servicesFromKubernetes());
|
||||
discoveredKubernetesServices = filterAllowedServices(
|
||||
perms,
|
||||
authGroups,
|
||||
cleanServiceGroups(await servicesFromKubernetes()),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to discover services, please check kubernetes.yaml for errors or remove example entries.");
|
||||
if (e) console.error(e.toString());
|
||||
@ -110,7 +119,7 @@ export async function servicesResponse() {
|
||||
}
|
||||
|
||||
try {
|
||||
configuredServices = cleanServiceGroups(await servicesFromConfig());
|
||||
configuredServices = filterAllowedServices(perms, authGroups, cleanServiceGroups(await servicesFromConfig()));
|
||||
} catch (e) {
|
||||
console.error("Failed to load services.yaml, please check for errors");
|
||||
if (e) console.error(e.toString());
|
||||
|
||||
Loading…
Reference in New Issue
Block a user