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

Custom authentication support #15

Open
KCFindstr opened this issue Apr 23, 2021 · 5 comments
Open

Custom authentication support #15

KCFindstr opened this issue Apr 23, 2021 · 5 comments

Comments

@KCFindstr
Copy link

I tried to use the adminbro nestjs module with oauth authentication. The auth option does not work for me because the user should be redirected to an identity server to log in, instead showing the login page. Is there a way I can provide my own authentication router? I noticed that it seems possible with the original adminbro library (SoftwareBrothers/adminjs#546) but I'm not sure how to use it with nestjs module.

@loagencydev
Copy link

@KCFindstr were you able to solve this one?
are there updates regarding this feature?

@KCFindstr
Copy link
Author

@KCFindstr were you able to solve this one?

are there updates regarding this feature?

I tried to get around this by creating my own adminjs loader:

  • I redirect adminjs login route to the identity server.
  • In the OAuth callback route, I create a session if the user is admin.
  • Write a loader with a middleware that checks all requests to adminjs routes and reject if session is invalid.
import { Injectable } from '@nestjs/common';
import { AbstractHttpAdapter } from '@nestjs/core';
import { AdminModuleOptions, AbstractLoader } from '@adminjs/nestjs';
import AdminJS from 'adminjs';
import adminJsExpressjs from '@adminjs/express';

// This file is modified from https://github.com/SoftwareBrothers/adminjs-nestjs/blob/fcd978e8db80b69766d3736b231e89be0f800d86/src/loaders/express.loader.ts
@Injectable()
export class AdminLoader implements AbstractLoader {
  public register(
    admin: AdminJS,
    httpAdapter: AbstractHttpAdapter,
    options: AdminModuleOptions,
  ) {
    const app = httpAdapter.getInstance();

    const router = adminJsExpressjs.buildRouter(
      admin,
      undefined,
      options.formidableOptions,
    );

    // This named function is there on purpose.
    // It names layer in main router with the name of the function, which helps localize
    // admin layer in reorderRoutes() step.
    app.use(options.adminJsOptions.rootPath, function admin(req, res, next) {
      const session: any = req.session;
      if (!session.adminUser) {
        return res.redirect(options.adminJsOptions.loginPath);
      }
      return router(req, res, next);
    });
    this.reorderRoutes(app);
  }

  private reorderRoutes(app) {
    let jsonParser;
    let urlencodedParser;
    let admin;

    // Nestjs uses bodyParser under the hood which is in conflict with adminjs setup.
    // Due to adminjs-expressjs usage of formidable we have to move body parser in layer tree after adminjs init.
    // Notice! This is not documented feature of express, so this may change in the future. We have to keep an eye on it.
    if (app && app._router && app._router.stack) {
      const jsonParserIndex = app._router.stack.findIndex(
        (layer: { name: string }) => layer.name === 'jsonParser',
      );
      if (jsonParserIndex >= 0) {
        jsonParser = app._router.stack.splice(jsonParserIndex, 1);
      }

      const urlencodedParserIndex = app._router.stack.findIndex(
        (layer: { name: string }) => layer.name === 'urlencodedParser',
      );
      if (urlencodedParserIndex >= 0) {
        urlencodedParser = app._router.stack.splice(urlencodedParserIndex, 1);
      }

      const adminIndex = app._router.stack.findIndex(
        (layer: { name: string }) => layer.name === 'admin',
      );
      if (adminIndex >= 0) {
        admin = app._router.stack.splice(adminIndex, 1);
      }

      // if adminjs-nestjs didn't reorder the middleware
      // the body parser would have come after corsMiddleware
      const corsIndex = app._router.stack.findIndex(
        (layer: { name: string }) => layer.name === 'corsMiddleware',
      );

      // in other case if there is no corsIndex we go after expressInit, because right after that
      // there are nest endpoints.
      const expressInitIndex = app._router.stack.findIndex(
        (layer: { name: string }) => layer.name === 'expressInit',
      );

      const initIndex = (corsIndex >= 0 ? corsIndex : expressInitIndex) + 1;

      app._router.stack.splice(
        initIndex,
        0,
        ...admin,
        ...jsonParser,
        ...urlencodedParser,
      );
    }
  }
}

I wrote this a while ago so I'm not sure if it still works with current version of adminjs, and I have no idea if there's official custom authentication support, either.

@AienTech
Copy link

great @KCFindstr! thanks for the solution, I actually had to do the same. I basically overwrote the routing logic of AdminJS and used passport to integrate with the oauth I wanted.

for anyone who wants to do the same later:

  1. remember that everything begins with router.use(admin.options.rootPath, Auth.buildAuthenticatedRouter(admin));, so all you have to do is to create a function which does almost the same, and pass the admin instance to it
  2. the buildAuthenticatedRouter has the following signature:
function buildAuthenticatedRouter(admin: AdminJS, predefinedRouter?: express.Router | null, formidableOptions?: FormidableOptions): Router

here's how mine looks like atm:

export const buildAuthenticatedRouter = (
	admin: AdminJS,
	predefinedRouter?: express.Router | null,
	formidableOptions?: FormidableOptions,
): Router => {
	const router = predefinedRouter || express.Router();

	router.use((req, _, next) => {
		if ((req as any)._body) {
			next(new OldBodyParserUsedError());
		}
		next();
	});

	router.use(formidableMiddleware(formidableOptions));

	withProtectedAdminRoutesHandler(router, admin);
	withLogin(router, admin); // <-- this function is what we need
	withLogout(router, admin);

	return buildRouter(admin, router, formidableOptions);
};
  1. now you can easily create the withLogin function you want and replace it with the one above. remember that withLogin will set up the routes that can only be accessed if the user/session is authenticated.
export const withLogin = (router: Router, admin: AdminJS): void => {
	const { rootPath } = admin.options;
	const loginPath = getLoginPath(admin);

	const callbackPath = `${config.admin.path}/${loginPath}/callback`;
	const authPath = `${config.admin.path}/${loginPath}/auth`;

	passport.use(
		new OAuth2Strategy(
			{
				// ...configs that you need
			},
			function (
				accessToken: string,
				refreshToken: string,
				profile: any,
				cb: (...args: any[]) => any,
			) {
				// you probably want to check some stuff here.

				const decoded: any = jwt.decode(accessToken);

				const userSession: CurrentAdmin = {
					title: decoded["name"],
					email: decoded["email"],
					id: decoded["sid"],
					avatarUrl:
						decoded["profile"] ||
						`https://ui-avatars.com/api/?name=${(decoded["name"] as string).replace(
							" ",
							"+",
						)}`,
				};

				return cb(null, userSession);
			},
		),
	);

	// this route will only render the login page you have. take note that this must be overriden,
	// as most probably you don't want to directly get the user's username/pass.
	router.get(loginPath, async (_, res) => {
		const login = await renderLogin(admin, {
			action: authPath,
		});

		res.send(login);
	});

	router.get(path.join(loginPath, "auth"), passport.authenticate("oauth2"));
	router.get(
		path.join(loginPath, "callback"),
		passport.authenticate("oauth2", { failureRedirect: `${config.admin.path}/login` }),
		(req, res, next) => {
			(req.session as any).adminUser = (req.session as any).passport.user;
			req.session.save((err) => {
				if (err) {
					next(err);
				}

				if ((req.session as any).redirectTo) {
					res.redirect(302, (req.session as any).redirectTo);
				} else {
					res.redirect(302, rootPath);
				}
			});
		},
	);
};
  1. now since I wanted to redirect the user from the login page to my auth provider, where they can give their user/pass, I also had to rewrite the login page. the key for this is to create a renderLogin function, and replace it with the one that is used in the GET route:
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { combineStyles } from "@adminjs/design-system";
import i18n from "i18next";
import React from "react";
import { renderToString } from "react-dom/server";
import { I18nextProvider } from "react-i18next";
import { Provider } from "react-redux";
import { Store } from "redux";
import { ServerStyleSheet, StyleSheetManager, ThemeProvider } from "styled-components";
import AdminJS, {
	createStore,
	getAssets,
	getBranding,
	getFaviconFromBranding,
	initializeAssets,
	initializeBranding,
	initializeLocale,
	ReduxState,
	ViewHelpers,
} from "adminjs";
import LoginComponent from "./login-component";

type LoginTemplateAttributes = {
	/**
	 * action which should be called when user clicks submit button
	 */
	action: string;
	/**
	 * Error message to present in the form
	 */
	errorMessage?: string;
};

const html = async (admin: AdminJS, { action, errorMessage }: LoginTemplateAttributes): Promise<string> => {
	const h = new ViewHelpers({ options: admin.options });

	const store: Store<ReduxState> = createStore();

	const branding = await getBranding(admin);
	const assets = await getAssets(admin);
	const faviconTag = getFaviconFromBranding(branding);

	const scripts = ((assets && assets.scripts) || []).map((s) => `<script src="${s}"></script>`);
	const styles = ((assets && assets.styles) || []).map(
		(l) => `<link rel="stylesheet" type="text/css" href="${l}">`,
	);

	store.dispatch(initializeBranding(branding));
	store.dispatch(initializeAssets(assets));
	store.dispatch(initializeLocale(admin.locale));

	const theme = combineStyles((branding && branding.theme) || {});
	const { locale } = store.getState();
	i18n.init({
		resources: {
			[locale.language]: {
				translation: locale.translations,
			},
		},
		lng: locale.language,
		interpolation: { escapeValue: false },
	});

	const sheet = new ServerStyleSheet();

	const loginComponent = renderToString(
		<StyleSheetManager sheet={sheet.instance}>
			<Provider store={store}>
				<I18nextProvider i18n={i18n}>
					<ThemeProvider theme={theme}>
						<LoginComponent action={action} message={errorMessage} />
					</ThemeProvider>
				</I18nextProvider>
			</Provider>
		</StyleSheetManager>,
	);

	sheet.collectStyles(<LoginComponent action={action} message={errorMessage} />);
	const style = sheet.getStyleTags();
	sheet.seal();

	return `
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
      <title>${branding.companyName}</title>
      ${style}
      ${faviconTag}
      <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,700" type="text/css">
      ${styles.join("\n")}

      <script src="${h.assetPath("global.bundle.js", assets)}"></script>
      <script src="${h.assetPath("design-system.bundle.js", assets)}"></script>
    </head>
    <body>
      <div id="app">${loginComponent}</div>
      ${scripts.join("\n")}
    </body>
    </html>
  `;
};

export default html;
  1. Now the last step (I promise) is to create a LoginComponent react component, which does what ever you want it to:
import React from "react";
import styled, { createGlobalStyle } from "styled-components";

import { useSelector } from "react-redux";
import { Box, H5, Button, Text, MessageBox } from "@adminjs/design-system";
import { ReduxState, useTranslation } from "adminjs";

const GlobalStyle = createGlobalStyle`
  html, body, #app {
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 0;
  }
`;

const Wrapper = styled(Box)`
	align-items: center;
	justify-content: center;
	flex-direction: column;
	height: 100%;
	text-align: center;
`;

const StyledLogo = styled.img`
	max-width: 200px;
	margin: 1em 0;
`;

export type LoginProps = {
	message: string | undefined;
	action: string;
};

export const Login: React.FC<LoginProps> = (props) => {
	const { action, message } = props;
	const { translateMessage } = useTranslation();
	const branding = useSelector((state: ReduxState) => state.branding);

	return (
		<React.Fragment>
			<GlobalStyle />
			<Wrapper flex variant="grey">
				<Box bg="white" height="440px" flex boxShadow="login" width={[1, 2 / 3, "auto"]}>
					<Box
						as="form"
						action={action}
						method="GET"
						p="x3"
						flexGrow={1}
						width={["100%", "100%", "480px"]}
						style={{
							alignSelf: "center",
						}}>
						<H5 marginBottom="xxl">
							{branding.logo ? (
								<StyledLogo
									src={branding.logo}
									alt={branding.companyName}
								/>
							) : (
								branding.companyName
							)}
						</H5>
						{message && (
							<MessageBox
								my="lg"
								message={
									message.split(" ").length > 1
										? message
										: translateMessage(message)
								}
								variant="danger"
							/>
						)}
						<Text mt="xl" textAlign="center">
							<Button variant="primary">Login with LoID</Button>
						</Text>
					</Box>
				</Box>
			</Wrapper>
		</React.Fragment>
	);
};

export default Login;

Now my users can perfectly login using any authentication method I want :)

hope this help y'all

@amygooch
Copy link

amygooch commented Jul 5, 2022

This looks like exactly what I need, have you put files on GitHub? Also want to make sure I make the right attribution and checking on usage rights.

@KCFindstr
Copy link
Author

This looks like exactly what I need, have you put files on GitHub? Also want to make sure I make the right attribution and checking on usage rights.

I can't say for @AienTech but feel free to use my code - it's just adminjs's source code with very few modifications.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants