Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
How to create a login experience from the admin with API Platform 2.6

Creating a login experience from the Admin with API Platform 2.6

You can use whatever authentication mode you want, but for the sake of the demonstration let's use JWT.

I'll grab a freshly downloaded api-platform distribution.

from the php container :

	composer require jwt-auth
	apk add openssl
	mkdir -p config/jwt
	jwt_passphrase=${JWT_PASSPHRASE:-$(grep ''^JWT_PASSPHRASE='' .env | cut -f 2 -d ''='')}/srv/api # echo "$jwt_passphrase" | openssl genpkey -out config/jwt/private.pem -pass stdin -aes256 -algorithm rsa -pkeyopt rsa_keygen_bits:4096
	echo "$jwt_passphrase" | openssl pkey -in config/jwt/private.pem -passin stdin -out config/jwt/public.pem -pubout

if you are using ACL :

	setfacl -R -m u:www-data:rX -m u:"$(whoami)":rwX config/jwt
	setfacl -dR -m u:www-data:rX -m u:"$(whoami)":rwX config/jwt

else

	chown -R www-data:www-data config/jwt

Changing the security.yml file

security:
    encoders:
        # this internal class is used by Symfony to represent in-memory users
        Symfony\Component\Security\Core\User\User: 'auto'

    providers:
        backend_users:
            memory:
                users:
                    john_admin: { password: '$argon2...stuff...eF74c', roles: [ 'ROLE_ADMIN' ] }
                    jane_admin: { password: '$argon2...stuff...eF74c', roles: [ 'ROLE_ADMIN', 'ROLE_SUPER_ADMIN' ] }
                    
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false

        main:
            stateless: true
            anonymous: true
            provider: backend_users
            json_login:
                check_path: /authentication_token
                username_path: username
                password_path: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
            guard:
                authenticators:
                    - lexik_jwt_authentication.jwt_token_authenticator

    access_control:
        - { path: ^/docs, roles: IS_AUTHENTICATED_ANONYMOUSLY } # Allows accessing the Swagger UI
        - { path: ^/authentication_token, roles: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/, roles: IS_AUTHENTICATED_FULLY }

do not forget to change the passwords :)

changing the route.yml file

authentication_token:
    path: /authentication_token
    methods: ['POST']

updating the api-platform.yml file with the swagger key

api_platform:
    swagger:
        api_keys:
            apiKey:
                name: Authorization
                type: header

Checking authentication works.

curl https://localhost/greetings  -H 'Content-Type: application/json' -k
{"code":401,"message":"JWT Token not found"}

seems fair enough.

curl -X POST https://localhost/authentication_token  -H 'Content-Type: application/json'  -d '{ "username": "jane_admin", "password": "admin"}'  -k 
{"token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE2MTM1NjE0MjIsImV4cCI6MTYxMzU2NTAyMiwicm9sZXMiOlsiUk9MRV9BRE1JTiIsIlJPTEVfU1VQRVJfQURNSU4iXSwidXNlcm5hbWUiOiJqYW5lX2FkbWluIn0.jQJ5FRVi8manQ6ocaaO0OtVQ2pfXTR3_pxC84j-p0xFLN9VXN4tC2EsFYcIRfYGT-YE0SKCipwaNhV4MLsz28NWC0fAtcnjoU326qIrg5M6yR4D7hSkKnijIY3JzWmzC_FBNBnHoroXbyIqxTPYhNPNAEaiXEiO9wlkQNLAMPIfb-wKTFqONgptvsSYWZhYK_cuoeZFB2GW5cywOIGCwVvEz1CS6ZGrz2Ut0QLr2OYhLIvXLVJi-g9uofgMTOkaEniDLZYatvTxrzLDx34Rien7uxaIQAQmnSx2arSDzEbqo0JfVQaYZJaAbPkqCiBv-YnDNVeWmiI3kFCbpKOv6y5g0DNABuUtlx8yNnaQwCWOHdF-2SLDCgYEm2cQJHe-1xqfVVWe-1cZ899DxSl0sU41dC8lCXaewEifrPcPA_n82waiHf-Ap9gwjAyMrbHLlGJqdSkZq4hf-9LwMgBxXugC-ediqBTtHeFnEo3qXa7A2kZ_1aDJyd00NuI3gt-fe5kg0z5c0WACd9psll1hsw0r8cPGXn_q8s3bYHImHtCSwTZWb4VJiWzOyDLJF9ZU3FPATISkzFUthGu7aqcXh-1lT-0wVsdRawcyRgxg6nDEG5gVL6NAPZ6pQYyRvGFEJG92uiEjetuK46B0RuBBffM3jp5fMDhfdyAedwYRmDRI"}

I've got a token now. Try again :

curl https://localhost/greetings  -H 'Content-Type: application/json' -H 'Authorization: bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpYXQiOjE2MTM1NjE0MjIsImV4cCI6MTYxMzU2NTAyMiwicm9sZXMiOlsiUk9MRV9BRE1JTiIsIlJPTEVfU1VQRVJfQURNSU4iXSwidXNlcm5hbWUiOiJqYW5lX2FkbWluIn0.jQJ5FRVi8manQ6ocaaO0OtVQ2pfXTR3_pxC84j-p0xFLN9VXN4tC2EsFYcIRfYGT-YE0SKCipwaNhV4MLsz28NWC0fAtcnjoU326qIrg5M6yR4D7hSkKnijIY3JzWmzC_FBNBnHoroXbyIqxTPYhNPNAEaiXEiO9wlkQNLAMPIfb-wKTFqONgptvsSYWZhYK_cuoeZFB2GW5cywOIGCwVvEz1CS6ZGrz2Ut0QLr2OYhLIvXLVJi-g9uofgMTOkaEniDLZYatvTxrzLDx34Rien7uxaIQAQmnSx2arSDzEbqo0JfVQaYZJaAbPkqCiBv-YnDNVeWmiI3kFCbpKOv6y5g0DNABuUtlx8yNnaQwCWOHdF-2SLDCgYEm2cQJHe-1xqfVVWe-1cZ899DxSl0sU41dC8lCXaewEifrPcPA_n82waiHf-Ap9gwjAyMrbHLlGJqdSkZq4hf-9LwMgBxXugC-ediqBTtHeFnEo3qXa7A2kZ_1aDJyd00NuI3gt-fe5kg0z5c0WACd9psll1hsw0r8cPGXn_q8s3bYHImHtCSwTZWb4VJiWzOyDLJF9ZU3FPATISkzFUthGu7aqcXh-1lT-0wVsdRawcyRgxg6nDEG5gVL6NAPZ6pQYyRvGFEJG92uiEjetuK46B0RuBBffM3jp5fMDhfdyAedwYRmDRI' -k
{"@context":"\/contexts\/Greeting","@id":"\/greetings","@type":"hydra:Collection","hydra:member":[],"hydra:totalItems":0}

Great I've got something. now to the admin part.

I update the pwa/pages/admin/index.tsx

import Head from "next/head";
import { Redirect, Route } from "react-router-dom";
import { HydraAdmin, hydraDataProvider as baseHydraDataProvider, fetchHydra as baseFetchHydra, useIntrospection } from "@api-platform/admin";
import parseHydraDocumentation from "@api-platform/api-doc-parser/lib/hydra/parseHydraDocumentation";
import authProvider from "./authProvider";
import Login from "./layout/Login";

const API_ENTRYPOINT =
  process.env.REACT_APP_API_ENTRYPOINT || "https://localhost";

const AdminLoader = () => {
  if (typeof window !== "undefined") {
    const { HydraAdmin } = require("@api-platform/admin");

      const getHeaders = () => localStorage.getItem("token") ? {
          Authorization: `Bearer ${localStorage.getItem("token")}`,
      } : {};

      const fetchHydra = (url, options = {}) =>
          baseFetchHydra(url, {
              ...options,
              headers: getHeaders,
          });

      const RedirectToLogin = () => {
          const introspect = useIntrospection();

          if (localStorage.getItem("token")) {
              introspect();
              return <></>;
          }
          return <Redirect to="/login" />;
      };

      const apiDocumentationParser = async (entrypoint) => {
          try {
              const { api } = await parseHydraDocumentation(entrypoint, { headers: getHeaders });
              return { api };
          } catch (result) {
              if (result.status === 401) {
                  // Prevent infinite loop if the token is expired
                  localStorage.removeItem("token");

                  return {
                      api: result.api,
                      customRoutes: [
                          <Route path="/" component={RedirectToLogin} />
                      ],
                  };
              }

              throw result;
          }
      };

    const dataProvider = baseHydraDataProvider(API_ENTRYPOINT, fetchHydra, apiDocumentationParser);

    return <HydraAdmin
      dataProvider={ dataProvider }
      authProvider={ authProvider }
      entrypoint={ API_ENTRYPOINT }
      loginPage={Login}
    />;
  }

  return <></>;
};

const Admin = () => (
  <>
    <Head>
      <title>API Platform Admin</title>
    </Head>

    <AdminLoader />
  </>
);
export default Admin;

I create the pwa/pages/admin/authProvider.js

import jwtDecode from "jwt-decode";

const API_ENTRYPOINT =
  process.env.REACT_APP_API_ENTRYPOINT || "https://localhost";

export default {
  login: ({ username, password }) => {
    const request = new Request(
      `${API_ENTRYPOINT}/authentication_token`,
      {
        method: "POST",
        body: JSON.stringify({ username: username, password }),
        headers: new Headers({ "Content-Type": "application/json" }),
      }
    );
    return fetch(request)
      .then((response) => {
        if (response.status < 200 || response.status >= 300) {
          throw new Error(response.statusText);
        }
        return response.json();
      })
      .then(({ token }) => {
        localStorage.setItem("token", token);
      });
  },
  logout: () => {
    localStorage.removeItem("token");
    return Promise.resolve();
  },
  checkAuth: () => {
    try {
      if (
        !localStorage.getItem("token") ||
        new Date().getTime() / 1000 >
        jwtDecode(localStorage.getItem("token"))?.exp
      ) {
        return Promise.reject();
      }
      return Promise.resolve();
    } catch (e) {
      // override possible jwtDecode error
      return Promise.reject();
    }
  },
  checkError: (err) => {
    if ([401, 403].includes(err?.status || err?.response?.status)) {
      localStorage.removeItem("token");
      return Promise.reject();
    }
    return Promise.resolve();
  },
  getPermissions: () => Promise.resolve(),
};

the jwt decoder is not part of the distribution. I need to execute yarn add jwt-decoder.

I create a nice layout for the login (not at all, I just copied the demo lol) into pwa/pages/admin/layout/Login.js

import PropTypes from "prop-types";
import { Field, Form } from "react-final-form";

import Button from "@material-ui/core/Button";
import CardActions from "@material-ui/core/CardActions";
import CircularProgress from "@material-ui/core/CircularProgress";
import TextField from "@material-ui/core/TextField";
import { makeStyles } from "@material-ui/core/styles";

import { Login as BaseLogin } from "react-admin";
import { useTranslate, useLogin, useNotify, useSafeSetState } from "ra-core";

const useStyles = makeStyles((theme) => ({
  hint: {
    marginTop: "1em",
    display: "flex",
    justifyContent: "center",
    color: theme.palette.grey[500],
  },
  form: {
    padding: "0 1em 1em 1em",
  },
  input: {
    marginTop: "1em",
  },
  button: {
    width: "100%",
  },
  icon: {
    marginRight: theme.spacing(1),
  },
}));

const Input = ({ meta: { touched, error }, input: inputProps, ...props }) => (
  <TextField
    error={!!(touched && error)}
    helperText={touched && error}
    {...inputProps}
    {...props}
    fullWidth
  />
);

const Login = (props) => {
  const { redirectTo } = props;
  const [loading, setLoading] = useSafeSetState(false);
  const login = useLogin();
  const translate = useTranslate();
  const notify = useNotify();
  const classes = useStyles(props);

  const validate = (values) => {
    const errors = { username: undefined, password: undefined };

    if (!values.username) {
      errors.username = translate("ra.validation.required");
    }
    if (!values.password) {
      errors.password = translate("ra.validation.required");
    }
    return errors;
  };

  const submit = (values) => {
    setLoading(true);
    login(values, redirectTo)
      .then(() => {
        setLoading(false);
      })
      .catch((error) => {
        setLoading(false);
        notify(
          typeof error === "string"
            ? error
            : typeof error === "undefined" || !error.message
            ? "ra.auth.sign_in_error"
            : error.message,
          "warning"
        );
      });
  };

  return (
    <BaseLogin {...props}>
      <Form
        onSubmit={submit}
        validate={validate}
        initialValues={{ username: "admin@example.com", password: "admin" }}
        render={({ handleSubmit }) => (
          <form onSubmit={handleSubmit} noValidate>
            <div className={classes.hint}>Hint: admin@example.com / admin</div>
            <div className={classes.form}>
              <div className={classes.input}>
                <Field
                  autoFocus
                  id="username"
                  name="username"
                  component={Input}
                  label={translate("ra.auth.username")}
                  disabled={loading}
                />
              </div>
              <div className={classes.input}>
                <Field
                  id="password"
                  name="password"
                  component={Input}
                  label={translate("ra.auth.password")}
                  type="password"
                  disabled={loading}
                  autoComplete="current-password"
                />
              </div>
            </div>
            <CardActions>
              <Button
                variant="contained"
                type="submit"
                color="primary"
                disabled={loading}
                className={classes.button}
              >
                {loading && (
                  <CircularProgress
                    className={classes.icon}
                    size={18}
                    thickness={2}
                  />
                )}
                {translate("ra.auth.sign_in")}
              </Button>
            </CardActions>
          </form>
        )}
      />
    </BaseLogin>
  );
};

Login.propTypes = {
  authProvider: PropTypes.func,
  previousRoute: PropTypes.string,
};

export default Login;

I reload my admin https://localhost/admin#/
and I am redirected to https://localhost/admin#/login
nice and smooth, now let use jane_admin / admin as credentials
Bingo I am redirected to https://localhost/admin#/greetings
And I can create a greeting :)

Conclusion, the documentation is a bit behind the reality.
But not that much. There are one or two things that did change, but nothing you should be afraid of :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment