SoftwareBrothers / adminjs-nestjs

NestJS module to import admin the Nest way
MIT License
160 stars 35 forks source link

Custom authentication support #15

Open KCFindstr opened 3 years ago

KCFindstr commented 3 years ago

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 (https://github.com/SoftwareBrothers/admin-bro/issues/546) but I'm not sure how to use it with nestjs module.

loagencydev commented 2 years ago

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

KCFindstr commented 2 years ago

@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:

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 commented 2 years ago

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);
    };
  3. 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);
                }
            });
        },
    );
    };
  4. 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 = { /**

const html = async (admin: AdminJS, { action, errorMessage }: LoginTemplateAttributes): Promise => { 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;

5. Now the last step (I promise) is to create a `LoginComponent ` react component, which does what ever you want it to:
```typescript
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 commented 2 years ago

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 commented 2 years ago

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.