diff --git a/examples/with-passport/README.md b/examples/with-passport/README.md new file mode 100644 index 0000000000000..db78f88011dfd --- /dev/null +++ b/examples/with-passport/README.md @@ -0,0 +1,48 @@ +# Passport.js Example + +This example show how to use [Passport.js](http://www.passportjs.org) with Next.js. The example features cookie based authentication with username and password. + +The example shows how to do a login, signup and logout; and to get the user info using a hook with [SWR](https://swr.now.sh). + +A DB is not included. You can use any db you want and add it [here](/lib/user.js). + +The login cookie is httpOnly, meaning it can only be accessed by the API, and it's encrypted using [@hapi/iron](https://hapi.dev/family/iron) for more security. + +## Deploy your own + +Deploy the example using [ZEIT Now](https://zeit.co/now): + +[![Deploy with ZEIT Now](https://zeit.co/button)](https://zeit.co/new/project?template=https://github.com/zeit/next.js/tree/canary/examples/with-passport) + +## How to use + +### Using `create-next-app` + +Execute [`create-next-app`](https://github.com/zeit/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +npm init next-app --example with-passport with-passport-app +# or +yarn create next-app --example with-passport with-passport-app +``` + +### Download manually + +Download the example [or clone the repo](https://github.com/zeit/next.js): + +```bash +curl https://codeload.github.com/zeit/next.js/tar.gz/canary | tar -xz --strip=2 next.js-canary/examples/with-passport +cd with-passport +``` + +Install it and run: + +```bash +npm install +npm run dev +# or +yarn +yarn dev +``` + +Deploy it to the cloud with [ZEIT Now](https://zeit.co/import?filter=next.js&utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/deployment)). diff --git a/examples/with-passport/components/form.js b/examples/with-passport/components/form.js new file mode 100644 index 0000000000000..2b5ba4b9f04e9 --- /dev/null +++ b/examples/with-passport/components/form.js @@ -0,0 +1,82 @@ +import Link from 'next/link' + +const Form = ({ isLogin, errorMessage, onSubmit }) => ( +
+ + + {!isLogin && ( + + )} + +
+ {isLogin ? ( + <> + + I don't have an account + + + + ) : ( + <> + + I already have an account + + + + )} +
+ + {errorMessage &&

{errorMessage}

} + + +
+) + +export default Form diff --git a/examples/with-passport/components/header.js b/examples/with-passport/components/header.js new file mode 100644 index 0000000000000..0b605d656739a --- /dev/null +++ b/examples/with-passport/components/header.js @@ -0,0 +1,67 @@ +import Link from 'next/link' +import { useUser } from '../lib/hooks' + +const Header = () => { + const user = useUser() + + return ( +
+ + +
+ ) +} + +export default Header diff --git a/examples/with-passport/components/layout.js b/examples/with-passport/components/layout.js new file mode 100644 index 0000000000000..174351ec9b608 --- /dev/null +++ b/examples/with-passport/components/layout.js @@ -0,0 +1,38 @@ +import Head from 'next/head' +import Header from './header' + +const Layout = props => ( + <> + + With Cookies + + +
+ +
+
{props.children}
+
+ + + +) + +export default Layout diff --git a/examples/with-passport/lib/auth-cookies.js b/examples/with-passport/lib/auth-cookies.js new file mode 100644 index 0000000000000..1d215f3a66423 --- /dev/null +++ b/examples/with-passport/lib/auth-cookies.js @@ -0,0 +1,40 @@ +import { serialize, parse } from 'cookie' + +const TOKEN_NAME = 'token' +const MAX_AGE = 60 * 60 * 8 // 8 hours + +export function setTokenCookie(res, token) { + const cookie = serialize(TOKEN_NAME, token, { + maxAge: MAX_AGE, + expires: new Date(Date.now() + MAX_AGE * 1000), + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + path: '/', + sameSite: 'lax', + }) + + res.setHeader('Set-Cookie', cookie) +} + +export function removeTokenCookie(res) { + const cookie = serialize(TOKEN_NAME, '', { + maxAge: -1, + path: '/', + }) + + res.setHeader('Set-Cookie', cookie) +} + +export function parseCookies(req) { + // For API Routes we don't need to parse the cookies. + if (req.cookies) return req.cookies + + // For pages we do need to parse the cookies. + const cookie = req.headers?.cookie + return parse(cookie || '') +} + +export function getTokenCookie(req) { + const cookies = parseCookies(req) + return cookies[TOKEN_NAME] +} diff --git a/examples/with-passport/lib/hooks.js b/examples/with-passport/lib/hooks.js new file mode 100644 index 0000000000000..645364466d3c0 --- /dev/null +++ b/examples/with-passport/lib/hooks.js @@ -0,0 +1,31 @@ +import { useEffect } from 'react' +import Router from 'next/router' +import useSWR from 'swr' + +const fetcher = url => + fetch(url) + .then(r => r.json()) + .then(data => { + return { user: data?.user || null } + }) + +export function useUser({ redirectTo, redirectIfFound } = {}) { + const { data, error } = useSWR('/api/user', fetcher) + const user = data?.user + const finished = Boolean(data) + const hasUser = Boolean(user) + + useEffect(() => { + if (!redirectTo || !finished) return + if ( + // If redirectTo is set, redirect if the user was not found. + (redirectTo && !redirectIfFound && !hasUser) || + // If redirectIfFound is also set, redirect if the user was found + (redirectIfFound && hasUser) + ) { + Router.push(redirectTo) + } + }, [redirectTo, redirectIfFound, finished, hasUser]) + + return error ? null : user +} diff --git a/examples/with-passport/lib/iron.js b/examples/with-passport/lib/iron.js new file mode 100644 index 0000000000000..977c4b110dd99 --- /dev/null +++ b/examples/with-passport/lib/iron.js @@ -0,0 +1,14 @@ +import Iron from '@hapi/iron' +import { getTokenCookie } from './auth-cookies' + +// Use an environment variable here instead of a hardcoded value for production +const TOKEN_SECRET = 'this-is-a-secret-value-with-at-least-32-characters' + +export function encryptSession(session) { + return Iron.seal(session, TOKEN_SECRET, Iron.defaults) +} + +export async function getSession(req) { + const token = getTokenCookie(req) + return token && Iron.unseal(token, TOKEN_SECRET, Iron.defaults) +} diff --git a/examples/with-passport/lib/password-local.js b/examples/with-passport/lib/password-local.js new file mode 100644 index 0000000000000..1fc07d7d44781 --- /dev/null +++ b/examples/with-passport/lib/password-local.js @@ -0,0 +1,16 @@ +import Local from 'passport-local' +import { findUser } from './user' + +export const localStrategy = new Local.Strategy(function( + username, + password, + done +) { + findUser({ username, password }) + .then(user => { + done(null, user) + }) + .catch(error => { + done(error) + }) +}) diff --git a/examples/with-passport/lib/user.js b/examples/with-passport/lib/user.js new file mode 100644 index 0000000000000..80276dabd035e --- /dev/null +++ b/examples/with-passport/lib/user.js @@ -0,0 +1,27 @@ +// import crypto from 'crypto' + +/** + * User methods. The example doesn't contain a DB, but for real applications you must use a + * db here, such as MongoDB, Fauna, SQL, etc. + */ + +export async function createUser({ username, password }) { + // Here you should create the user and save the salt and hashed password (some dbs may have + // authentication methods that will do it for you so you don't have to worry about it): + // + // const salt = crypto.randomBytes(16).toString('hex') + // const hash = crypto.pbkdf2Sync(password, salt, 1000, 64, 'sha512').toString('hex') + // const user = await DB.createUser({ username, salt, hash }) + + return { username, createdAt: Date.now() } +} + +export async function findUser({ username, password }) { + // Here you should lookup for the user in your DB and compare the password: + // + // const user = await DB.findUser(...) + // const hash = crypto.pbkdf2Sync(password, user.salt, 1000, 64, 'sha512').toString('hex') + // const passwordsMatch = user.hash === hash + + return { username, createdAt: Date.now() } +} diff --git a/examples/with-passport/package.json b/examples/with-passport/package.json new file mode 100644 index 0000000000000..5c15d1ca55a64 --- /dev/null +++ b/examples/with-passport/package.json @@ -0,0 +1,20 @@ +{ + "name": "with-passport", + "scripts": { + "dev": "next", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "@hapi/iron": "6.0.0", + "cookie": "0.4.0", + "express": "4.17.1", + "next": "latest", + "passport": "0.4.1", + "passport-local": "1.0.0", + "react": "latest", + "react-dom": "latest", + "swr": "0.1.16" + }, + "license": "ISC" +} diff --git a/examples/with-passport/pages/api/login.js b/examples/with-passport/pages/api/login.js new file mode 100644 index 0000000000000..62fb8be02f204 --- /dev/null +++ b/examples/with-passport/pages/api/login.js @@ -0,0 +1,41 @@ +import express from 'express' +import passport from 'passport' +import { localStrategy } from '../../lib/password-local' +import { encryptSession } from '../../lib/iron' +import { setTokenCookie } from '../../lib/auth-cookies' + +const app = express() +const authenticate = (method, req, res) => + new Promise((resolve, reject) => { + passport.authenticate(method, { session: false }, (error, token) => { + if (error) { + reject(error) + } else { + resolve(token) + } + })(req, res) + }) + +app.disable('x-powered-by') + +app.use(passport.initialize()) + +passport.use(localStrategy) + +app.post('/api/login', async (req, res) => { + try { + const user = await authenticate('local', req, res) + // session is the payload to save in the token, it may contain basic info about the user + const session = { ...user } + // The token is a string with the encrypted session + const token = await encryptSession(session) + + setTokenCookie(res, token) + res.status(200).send({ done: true }) + } catch (error) { + console.error(error) + res.status(401).send(error.message) + } +}) + +export default app diff --git a/examples/with-passport/pages/api/logout.js b/examples/with-passport/pages/api/logout.js new file mode 100644 index 0000000000000..1fe3096cdc014 --- /dev/null +++ b/examples/with-passport/pages/api/logout.js @@ -0,0 +1,7 @@ +import { removeTokenCookie } from '../../lib/auth-cookies' + +export default async function logout(req, res) { + removeTokenCookie(res) + res.writeHead(302, { Location: '/' }) + res.end() +} diff --git a/examples/with-passport/pages/api/signup.js b/examples/with-passport/pages/api/signup.js new file mode 100644 index 0000000000000..7972d47838a74 --- /dev/null +++ b/examples/with-passport/pages/api/signup.js @@ -0,0 +1,11 @@ +import { createUser } from '../../lib/user' + +export default async function signup(req, res) { + try { + await createUser(req.body) + res.status(200).send({ done: true }) + } catch (error) { + console.error(error) + res.status(500).end(error.message) + } +} diff --git a/examples/with-passport/pages/api/user.js b/examples/with-passport/pages/api/user.js new file mode 100644 index 0000000000000..dad216c76e9a3 --- /dev/null +++ b/examples/with-passport/pages/api/user.js @@ -0,0 +1,9 @@ +import { getSession } from '../../lib/iron' + +export default async function user(req, res) { + const session = await getSession(req) + // After getting the session you may want to fetch for the user instead + // of sending the session's payload directly, this example doesn't have a DB + // so it won't matter in this case + res.status(200).json({ user: session || null }) +} diff --git a/examples/with-passport/pages/index.js b/examples/with-passport/pages/index.js new file mode 100644 index 0000000000000..0870d849e3d8c --- /dev/null +++ b/examples/with-passport/pages/index.js @@ -0,0 +1,36 @@ +import { useUser } from '../lib/hooks' +import Layout from '../components/layout' + +const Home = () => { + const user = useUser() + + return ( + +

Passport.js Example

+ +

Steps to test the example:

+ +
    +
  1. Click Login and enter an username and password.
  2. +
  3. + You'll be redirected to Home. Click on Profile, notice how your + session is being used through a token stored in a cookie. +
  4. +
  5. + Click Logout and try to go to Profile again. You'll get redirected to + Login. +
  6. +
+ + {user &&

Currently logged in as: {JSON.stringify(user)}

} + + +
+ ) +} + +export default Home diff --git a/examples/with-passport/pages/login.js b/examples/with-passport/pages/login.js new file mode 100644 index 0000000000000..64ccd37f43558 --- /dev/null +++ b/examples/with-passport/pages/login.js @@ -0,0 +1,57 @@ +import { useState } from 'react' +import Router from 'next/router' +import { useUser } from '../lib/hooks' +import Layout from '../components/layout' +import Form from '../components/form' + +const Login = () => { + useUser({ redirectTo: '/', redirectIfFound: true }) + + const [errorMsg, setErrorMsg] = useState('') + + async function handleSubmit(e) { + event.preventDefault() + + if (errorMsg) setErrorMsg('') + + const body = { + username: e.currentTarget.username.value, + password: e.currentTarget.password.value, + } + + try { + const res = await fetch('/api/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (res.status === 200) { + Router.push('/') + } else { + throw new Error(await res.text()) + } + } catch (error) { + console.error('An unexpected error happened occurred:', error) + setErrorMsg(error.message) + } + } + + return ( + +
+
+
+ +
+ ) +} + +export default Login diff --git a/examples/with-passport/pages/profile.js b/examples/with-passport/pages/profile.js new file mode 100644 index 0000000000000..e864f7480f04b --- /dev/null +++ b/examples/with-passport/pages/profile.js @@ -0,0 +1,15 @@ +import { useUser } from '../lib/hooks' +import Layout from '../components/layout' + +const Profile = () => { + const user = useUser({ redirectTo: '/login' }) + + return ( + +

Profile

+ {user &&

Your session: {JSON.stringify(user)}

} +
+ ) +} + +export default Profile diff --git a/examples/with-passport/pages/signup.js b/examples/with-passport/pages/signup.js new file mode 100644 index 0000000000000..a0f31880a8ff6 --- /dev/null +++ b/examples/with-passport/pages/signup.js @@ -0,0 +1,62 @@ +import { useState } from 'react' +import Router from 'next/router' +import { useUser } from '../lib/hooks' +import Layout from '../components/layout' +import Form from '../components/form' + +const Signup = () => { + useUser({ redirectTo: '/', redirectIfFound: true }) + + const [errorMsg, setErrorMsg] = useState('') + + async function handleSubmit(e) { + event.preventDefault() + + if (errorMsg) setErrorMsg('') + + const body = { + username: e.currentTarget.username.value, + password: e.currentTarget.password.value, + } + + if (body.password !== e.currentTarget.rpassword.value) { + setErrorMsg(`The passwords don't match`) + return + } + + try { + const res = await fetch('/api/signup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + if (res.status === 200) { + Router.push('/login') + } else { + throw new Error(await res.text()) + } + } catch (error) { + console.error('An unexpected error happened occurred:', error) + setErrorMsg(error.message) + } + } + + return ( + +
+ +
+ +
+ ) +} + +export default Signup