Adds authentication

This commit is contained in:
Rob Cameron
2020-10-07 14:45:51 -07:00
parent 35456b0a4d
commit 2941c967ad
8 changed files with 189 additions and 9 deletions

View File

@@ -6,9 +6,12 @@ import {
import schemas from 'src/graphql/**/*.{js,ts}' import schemas from 'src/graphql/**/*.{js,ts}'
import services from 'src/services/**/*.{js,ts}' import services from 'src/services/**/*.{js,ts}'
import { getCurrentUser } from 'src/lib/auth'
import { db } from 'src/lib/db' import { db } from 'src/lib/db'
export const handler = createGraphQLHandler({ export const handler = createGraphQLHandler({
getCurrentUser,
schema: makeMergedSchema({ schema: makeMergedSchema({
schemas, schemas,
services: makeServices({ services }), services: makeServices({ services }),

144
api/src/lib/auth.js Normal file
View File

@@ -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.")
}
}

View File

@@ -1,4 +1,5 @@
import { db } from 'src/lib/db' import { db } from 'src/lib/db'
import { requireAuth } from 'src/lib/auth'
export const posts = () => { export const posts = () => {
return db.post.findMany() return db.post.findMany()
@@ -11,12 +12,14 @@ export const post = ({ id }) => {
} }
export const createPost = ({ input }) => { export const createPost = ({ input }) => {
requireAuth()
return db.post.create({ return db.post.create({
data: input, data: input,
}) })
} }
export const updatePost = ({ id, input }) => { export const updatePost = ({ id, input }) => {
requireAuth()
return db.post.update({ return db.post.update({
data: input, data: input,
where: { id }, where: { id },
@@ -24,6 +27,7 @@ export const updatePost = ({ id, input }) => {
} }
export const deletePost = ({ id }) => { export const deletePost = ({ id }) => {
requireAuth()
return db.post.delete({ return db.post.delete({
where: { id }, where: { id },
}) })

View File

@@ -13,9 +13,11 @@
] ]
}, },
"dependencies": { "dependencies": {
"@redwoodjs/auth": "^0.19.2",
"@redwoodjs/forms": "^0.19.2", "@redwoodjs/forms": "^0.19.2",
"@redwoodjs/router": "^0.19.2", "@redwoodjs/router": "^0.19.2",
"@redwoodjs/web": "^0.19.2", "@redwoodjs/web": "^0.19.2",
"netlify-identity-widget": "^1.9.1",
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"react": "^16.13.1", "react": "^16.13.1",
"react-dom": "^16.13.1" "react-dom": "^16.13.1"

View File

@@ -7,19 +7,21 @@
// 'src/pages/HomePage/HomePage.js' -> HomePage // 'src/pages/HomePage/HomePage.js' -> HomePage
// 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage // 'src/pages/Admin/BooksPage/BooksPage.js' -> AdminBooksPage
import { Router, Route } from '@redwoodjs/router' import { Router, Route, Private } from '@redwoodjs/router'
const Routes = () => { const Routes = () => {
return ( return (
<Router> <Router>
<Route path="/contact" page={ContactPage} name="contact" /> <Route path="/contact" page={ContactPage} name="contact" />
<Route path="/blog-post/{id:Int}" page={BlogPostPage} name="blogPost" /> <Route path="/blog-post/{id:Int}" page={BlogPostPage} name="blogPost" />
<Route path="/about" page={AboutPage} name="about" />
<Route path="/" page={HomePage} name="home" />
<Private unauthenticated="home">
<Route path="/admin/posts/new" page={NewPostPage} name="newPost" /> <Route path="/admin/posts/new" page={NewPostPage} name="newPost" />
<Route path="/admin/posts/{id:Int}/edit" page={EditPostPage} name="editPost" /> <Route path="/admin/posts/{id:Int}/edit" page={EditPostPage} name="editPost" />
<Route path="/admin/posts/{id:Int}" page={PostPage} name="post" /> <Route path="/admin/posts/{id:Int}" page={PostPage} name="post" />
<Route path="/admin/posts" page={PostsPage} name="posts" /> <Route path="/admin/posts" page={PostsPage} name="posts" />
<Route path="/about" page={AboutPage} name="about" /> </Private>
<Route path="/" page={HomePage} name="home" />
<Route notfound page={NotFoundPage} /> <Route notfound page={NotFoundPage} />
</Router> </Router>
) )

View File

@@ -1,3 +1,5 @@
import { AuthProvider } from '@redwoodjs/auth'
import netlifyIdentity from 'netlify-identity-widget'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import { RedwoodProvider, FatalErrorBoundary } from '@redwoodjs/web' import { RedwoodProvider, FatalErrorBoundary } from '@redwoodjs/web'
import FatalErrorPage from 'src/pages/FatalErrorPage' import FatalErrorPage from 'src/pages/FatalErrorPage'
@@ -7,11 +9,15 @@ import Routes from 'src/Routes'
import './scaffold.css' import './scaffold.css'
import './index.css' import './index.css'
netlifyIdentity.init()
ReactDOM.render( ReactDOM.render(
<FatalErrorBoundary page={FatalErrorPage}> <FatalErrorBoundary page={FatalErrorPage}>
<AuthProvider client={netlifyIdentity} type="netlify">
<RedwoodProvider> <RedwoodProvider>
<Routes /> <Routes />
</RedwoodProvider> </RedwoodProvider>
</AuthProvider>
</FatalErrorBoundary>, </FatalErrorBoundary>,
document.getElementById('redwood-app') document.getElementById('redwood-app')
) )

View File

@@ -1,6 +1,9 @@
import { Link, routes } from '@redwoodjs/router' import { Link, routes } from '@redwoodjs/router'
import { useAuth } from '@redwoodjs/auth'
const BlogLayout = ({ children }) => { const BlogLayout = ({ children }) => {
const { logIn, logOut, isAuthenticated, currentUser } = useAuth()
return ( return (
<> <>
<header> <header>
@@ -15,6 +18,12 @@ const BlogLayout = ({ children }) => {
<li> <li>
<Link to={routes.contact()}>Contact</Link> <Link to={routes.contact()}>Contact</Link>
</li> </li>
<li>
<a href="#" onClick={isAuthenticated ? logOut : logIn}>
{isAuthenticated ? 'Log Out' : 'Log In'}
</a>
</li>
{isAuthenticated && <li>{currentUser.email}</li>}
</ul> </ul>
</nav> </nav>
</header> </header>

View File

@@ -2205,6 +2205,11 @@
lodash.omitby "^4.6.0" lodash.omitby "^4.6.0"
merge-graphql-schemas "^1.7.6" merge-graphql-schemas "^1.7.6"
"@redwoodjs/auth@^0.19.2":
version "0.19.2"
resolved "https://registry.yarnpkg.com/@redwoodjs/auth/-/auth-0.19.2.tgz#b94b5240c2d88701d9a0b8b015f38e60685f9ac4"
integrity sha512-S8U+g7HuIO14jgrMBoNkfok2sA1MqNC4esJtGvdxZpDCBOSvjCiu/DCAL9vXUpU7W4n1h0m9a915jupxmzZtbQ==
"@redwoodjs/cli@^0.19.2": "@redwoodjs/cli@^0.19.2":
version "0.19.2" version "0.19.2"
resolved "https://registry.yarnpkg.com/@redwoodjs/cli/-/cli-0.19.2.tgz#c9f546d76394a17b786ee5befb433d007639bb4d" resolved "https://registry.yarnpkg.com/@redwoodjs/cli/-/cli-0.19.2.tgz#c9f546d76394a17b786ee5befb433d007639bb4d"
@@ -11050,6 +11055,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" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== 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: new-github-issue-url@^0.2.1:
version "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" resolved "https://registry.yarnpkg.com/new-github-issue-url/-/new-github-issue-url-0.2.1.tgz#e17be1f665a92de465926603e44b9f8685630c1d"