From 53795161289faef2483e984f40741c701924e9ee Mon Sep 17 00:00:00 2001 From: Kurt Hutten Date: Tue, 13 Oct 2020 07:45:53 +1100 Subject: [PATCH] add authentication --- api/src/functions/graphql.js | 3 + api/src/lib/auth.js | 144 ++++++++++++++++++++++++++++++++ api/src/services/posts/posts.js | 4 + web/package.json | 2 + web/src/Routes.js | 12 +-- web/src/index.js | 12 ++- yarn.lock | 10 +++ 7 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 api/src/lib/auth.js diff --git a/api/src/functions/graphql.js b/api/src/functions/graphql.js index 2d3f47b..57ba262 100644 --- a/api/src/functions/graphql.js +++ b/api/src/functions/graphql.js @@ -6,9 +6,12 @@ import { import schemas from 'src/graphql/**/*.{js,ts}' import services from 'src/services/**/*.{js,ts}' + +import { getCurrentUser } from 'src/lib/auth' import { db } from 'src/lib/db' export const handler = createGraphQLHandler({ + getCurrentUser, schema: makeMergedSchema({ schemas, services: makeServices({ services }), diff --git a/api/src/lib/auth.js b/api/src/lib/auth.js new file mode 100644 index 0000000..402f218 --- /dev/null +++ b/api/src/lib/auth.js @@ -0,0 +1,144 @@ +// Define what you want `currentUser` to return throughout your app. For example, +// to return a real user from your database, you could do something like: +// +// export const getCurrentUser = async ({ email }) => { +// return await db.user.findOne({ where: { email } }) +// } +// +// If you want to enforce role-based access ... +// +// You'll need to set the currentUser's roles attributes to the +// collection of roles as defined by your app. +// +// This allows requireAuth() on the api side and hasRole() in the useAuth() hook on the web side +// to check if the user is assigned a given role or not. +// +// How you set the currentUser's roles depends on your auth provider and its implementation. +// +// For example, your decoded JWT may store `roles` in it namespaced `app_metadata`: +// +// { +// 'https://example.com/app_metadata': { authorization: { roles: ['admin'] } }, +// 'https://example.com/user_metadata': {}, +// iss: 'https://app.us.auth0.com/', +// sub: 'email|1234', +// aud: [ +// 'https://example.com', +// 'https://app.us.auth0.com/userinfo' +// ], +// iat: 1596481520, +// exp: 1596567920, +// azp: '1l0w6JXXXXL880T', +// scope: 'openid profile email' +// } +// +// The parseJWT utility will extract the roles from decoded token. +// +// The app_medata claim may or may not be namespaced based on the auth provider. +// Note: Auth0 requires namespacing custom JWT claims +// +// Some providers, such as with Auth0, will set roles an authorization +// attribute in app_metadata (namespaced or not): +// +// 'app_metadata': { authorization: { roles: ['publisher'] } } +// 'https://example.com/app_metadata': { authorization: { roles: ['publisher'] } } +// +// Other providers may include roles simply within app_metadata: +// +// 'app_metadata': { roles: ['author'] } +// 'https://example.com/app_metadata': { roles: ['author'] } +// +// And yet other may define roles as a custom claim at the root of the decoded token: +// +// roles: ['admin'] +// +// The function `getCurrentUser` should return the user information +// together with a collection of roles to check for role assignment: + +import { AuthenticationError, ForbiddenError, parseJWT } from '@redwoodjs/api' + +/** + * Use requireAuth in your services to check that a user is logged in, + * whether or not they are assigned a role, and optionally raise an + * error if they're not. + * + * @param {string=, string[]=} role - An optional role + * + * @example - No role-based access control. + * + * export const getCurrentUser = async (decoded) => { + * return await db.user.findOne({ where: { decoded.email } }) + * } + * + * @example - User info is conatined in the decoded token and roles extracted + * + * export const getCurrentUser = async (decoded, { _token, _type }) => { + * return { ...decoded, roles: parseJWT({ decoded }).roles } + * } + * + * @example - User record query by email with namespaced app_metadata roles + * + * export const getCurrentUser = async (decoded) => { + * const currentUser = await db.user.findOne({ where: { email: decoded.email } }) + * + * return { + * ...currentUser, + * roles: parseJWT({ decoded: decoded, namespace: NAMESPACE }).roles, + * } + * } + * + * @example - User record query by an identity with app_metadata roles + * + * const getCurrentUser = async (decoded) => { + * const currentUser = await db.user.findOne({ where: { userIdentity: decoded.sub } }) + * return { + * ...currentUser, + * roles: parseJWT({ decoded: decoded }).roles, + * } + * } + */ +export const getCurrentUser = async (decoded, { _token, _type }) => { + return { ...decoded, roles: parseJWT({ decoded }).roles } +} + +/** + * Use requireAuth in your services to check that a user is logged in, + * whether or not they are assigned a role, and optionally raise an + * error if they're not. + * + * @param {string=} roles - An optional role or list of roles + * @param {string[]=} roles - An optional list of roles + + * @example + * + * // checks if currentUser is authenticated + * requireAuth() + * + * @example + * + * // checks if currentUser is authenticated and assigned one of the given roles + * requireAuth({ role: 'admin' }) + * requireAuth({ role: ['editor', 'author'] }) + * requireAuth({ role: ['publisher'] }) + */ +export const requireAuth = ({ role } = {}) => { + if (!context.currentUser) { + throw new AuthenticationError("You don't have permission to do that.") + } + + if ( + typeof role !== 'undefined' && + typeof role === 'string' && + !context.currentUser.roles?.includes(role) + ) { + throw new ForbiddenError("You don't have access to do that.") + } + + if ( + typeof role !== 'undefined' && + Array.isArray(role) && + !context.currentUser.roles?.some((r) => role.includes(r)) + ) { + throw new ForbiddenError("You don't have access to do that.") + } +} diff --git a/api/src/services/posts/posts.js b/api/src/services/posts/posts.js index be53d32..8912a71 100644 --- a/api/src/services/posts/posts.js +++ b/api/src/services/posts/posts.js @@ -1,4 +1,5 @@ import { db } from 'src/lib/db' +import { requireAuth } from 'src/lib/auth' export const posts = () => { return db.post.findMany() @@ -11,12 +12,14 @@ export const post = ({ id }) => { } export const createPost = ({ input }) => { + requireAuth() return db.post.create({ data: input, }) } export const updatePost = ({ id, input }) => { + requireAuth() return db.post.update({ data: input, where: { id }, @@ -24,6 +27,7 @@ export const updatePost = ({ id, input }) => { } export const deletePost = ({ id }) => { + requireAuth() return db.post.delete({ where: { id }, }) diff --git a/web/package.json b/web/package.json index b6b12fa..2a9bdad 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ ] }, "dependencies": { + "@redwoodjs/auth": "^0.19.3", "@redwoodjs/forms": "^0.19.2", "@redwoodjs/router": "^0.19.2", "@redwoodjs/web": "^0.19.2", @@ -21,6 +22,7 @@ "jquery": "^3.5.1", "monaco-editor": "^0.20.0", "monaco-editor-webpack-plugin": "^1.9.1", + "netlify-identity-widget": "^1.9.1", "opencascade.js": "^0.1.15", "prop-types": "^15.7.2", "react": "^16.13.1", diff --git a/web/src/Routes.js b/web/src/Routes.js index 0959e4f..9fa6d36 100644 --- a/web/src/Routes.js +++ b/web/src/Routes.js @@ -7,7 +7,7 @@ // 'src/pages/HomePage/HomePage.js' -> HomePage // 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage -import { Router, Route } from '@redwoodjs/router' +import { Router, Route, Private } from '@redwoodjs/router' const Routes = () => { return ( @@ -18,10 +18,12 @@ const Routes = () => { - - - - + + + + + + diff --git a/web/src/index.js b/web/src/index.js index 66c7d22..6e5dc52 100644 --- a/web/src/index.js +++ b/web/src/index.js @@ -1,3 +1,5 @@ +import { AuthProvider } from '@redwoodjs/auth' +import netlifyIdentity from 'netlify-identity-widget' import ReactDOM from 'react-dom' import { RedwoodProvider, FatalErrorBoundary } from '@redwoodjs/web' import FatalErrorPage from 'src/pages/FatalErrorPage' @@ -11,11 +13,15 @@ import './cascade/css/main.css' import 'monaco-editor/min/vs/editor/editor.main.css' import './index.css' +netlifyIdentity.init() + ReactDOM.render( - - - + + + + + , document.getElementById('redwood-app') ) diff --git a/yarn.lock b/yarn.lock index c408422..ac6952d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2205,6 +2205,11 @@ lodash.omitby "^4.6.0" merge-graphql-schemas "^1.7.6" +"@redwoodjs/auth@^0.19.3": + version "0.19.3" + resolved "https://registry.yarnpkg.com/@redwoodjs/auth/-/auth-0.19.3.tgz#8a4f136d46e92ec58d85751c403a49647f653901" + integrity sha512-3yD2gyDZ9MuoAAWKI8BbvSy19LVZErystmW72jV+xk4bzwIuD6z1VtYdET5sTMK8t0RFaplHH1b1OD/He/BfuA== + "@redwoodjs/cli@^0.19.2": version "0.19.2" resolved "https://registry.yarnpkg.com/@redwoodjs/cli/-/cli-0.19.2.tgz#c9f546d76394a17b786ee5befb433d007639bb4d" @@ -11079,6 +11084,11 @@ neo-async@^2.5.0, neo-async@^2.6.1: resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== +netlify-identity-widget@^1.9.1: + version "1.9.1" + resolved "https://registry.yarnpkg.com/netlify-identity-widget/-/netlify-identity-widget-1.9.1.tgz#9e716c4b92b9f0cc041074eb86fc962f35295b46" + integrity sha512-9oIWjwUSdRk3SkREcZNjZaVuDDx9T/wSIXZNQsQeY4qoXic/FiXVEGgu2RU3IuA4OI3L2652xY1o+PpS03Ugaw== + new-github-issue-url@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/new-github-issue-url/-/new-github-issue-url-0.2.1.tgz#e17be1f665a92de465926603e44b9f8685630c1d"