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 not be afraid of :)