From 798ae043ac4146c9f212ee800cea22cef6d0689e Mon Sep 17 00:00:00 2001 From: Juan Olvera <1130549+j0lv3r4@users.noreply.github.com> Date: Fri, 14 Dec 2018 16:05:54 -0600 Subject: [PATCH] Example with cookie auth (#5821) Fixes #153 This is my attempt at https://github.com/zeit/next.js/issues/153 Following @rauchg instructions: - it uses an authentication helper across pages which returns a token if there's one - it has session synchronization across tabs - I deployed a passwordless backend on `now.sh` (https://with-cookie-api.now.sh, [src](https://github.com/j0lv3r4/next.js-with-cookies-api)) The backend is included in the repository and you can deploy everything together by running `now` Also, from reviewing other PRs, I made sure to: - use [isomorphic-unfetch](https://www.npmjs.com/package/isomorphic-unfetch). - use [next-cookies](https://www.npmjs.com/package/next-cookies). Here's a little demo: ![GIF](https://i.imgur.com/067Ph56.gif) --- examples/with-cookie-auth/README.md | 47 +++++++++ examples/with-cookie-auth/api/login.js | 21 ++++ examples/with-cookie-auth/api/package.json | 20 ++++ examples/with-cookie-auth/api/profile.js | 29 ++++++ examples/with-cookie-auth/now.json | 12 +++ .../with-cookie-auth/www/components/header.js | 58 +++++++++++ .../with-cookie-auth/www/components/layout.js | 40 ++++++++ examples/with-cookie-auth/www/package.json | 16 +++ examples/with-cookie-auth/www/pages/index.js | 29 ++++++ examples/with-cookie-auth/www/pages/login.js | 97 +++++++++++++++++++ .../with-cookie-auth/www/pages/profile.js | 73 ++++++++++++++ examples/with-cookie-auth/www/utils/auth.js | 84 ++++++++++++++++ 12 files changed, 526 insertions(+) create mode 100644 examples/with-cookie-auth/README.md create mode 100644 examples/with-cookie-auth/api/login.js create mode 100644 examples/with-cookie-auth/api/package.json create mode 100644 examples/with-cookie-auth/api/profile.js create mode 100644 examples/with-cookie-auth/now.json create mode 100644 examples/with-cookie-auth/www/components/header.js create mode 100644 examples/with-cookie-auth/www/components/layout.js create mode 100644 examples/with-cookie-auth/www/package.json create mode 100644 examples/with-cookie-auth/www/pages/index.js create mode 100644 examples/with-cookie-auth/www/pages/login.js create mode 100644 examples/with-cookie-auth/www/pages/profile.js create mode 100644 examples/with-cookie-auth/www/utils/auth.js diff --git a/examples/with-cookie-auth/README.md b/examples/with-cookie-auth/README.md new file mode 100644 index 00000000..c4611715 --- /dev/null +++ b/examples/with-cookie-auth/README.md @@ -0,0 +1,47 @@ +[![Deploy to now](https://deploy.now.sh/static/button.svg)](https://deploy.now.sh/?repo=https://github.com/zeit/next.js/tree/master/examples/with-cookie-auth) +# Example app utilizing cookie-based authentication + +## How to use + +### Using `create-next-app` + +Download [`create-next-app`](https://github.com/segmentio/create-next-app) to bootstrap the example: + +``` +npm i -g create-next-app +create-next-app --example with-cookie-auth with-cookie-auth-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-cookie-auth +cd with-cookie-auth +``` + +Install it and run: + +```bash +npm install +npm run dev +``` + +Deploy it to the cloud with [now](https://zeit.co/now) ([download](https://zeit.co/download)) + +```bash +now +``` + +## The idea behind the example + +In this example, we authenticate users and store a token in a cookie. The example only shows how the user session works, keeping a user logged in between pages. + +This example is backend agnostic and uses [isomorphic-unfetch](https://www.npmjs.com/package/isomorphic-unfetch) to do the API calls on the client and the server. + +The repo includes a minimal passwordless backend built with [Micro](https://www.npmjs.com/package/micro) and it logs the user in with a GitHub username and saves the user id from the API call as token. + +Session is syncronized across tabs. If you logout your session gets logged out on all the windows as well. We use the HOC `withAuthSync` for this. + +The helper function `auth` helps to retrieve the token across pages and redirects the user if not token was found. \ No newline at end of file diff --git a/examples/with-cookie-auth/api/login.js b/examples/with-cookie-auth/api/login.js new file mode 100644 index 00000000..cd435c15 --- /dev/null +++ b/examples/with-cookie-auth/api/login.js @@ -0,0 +1,21 @@ +const { json, send, createError, run } = require('micro') +const fetch = require('isomorphic-unfetch') + +const login = async (req, res) => { + const { username } = await json(req) + const url = `https://api.github.com/users/${username}` + + try { + const response = await fetch(url) + if (response.ok) { + const { id } = await response.json() + send(res, 200, { token: id }) + } else { + send(res, response.status, response.statusText) + } + } catch (error) { + throw createError(error.statusCode, error.statusText) + } +} + +module.exports = (req, res) => run(req, res, login) diff --git a/examples/with-cookie-auth/api/package.json b/examples/with-cookie-auth/api/package.json new file mode 100644 index 00000000..176802b2 --- /dev/null +++ b/examples/with-cookie-auth/api/package.json @@ -0,0 +1,20 @@ +{ + "name": "api", + "version": "1.0.0", + "description": "", + "main": "index.js", + "dependencies": { + "isomorphic-unfetch": "^3.0.0", + "micro": "^9.3.3" + }, + "devDependencies": { + "micro-dev": "^3.0.0" + }, + "scripts": { + "start": "micro", + "dev": "micro-dev" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/examples/with-cookie-auth/api/profile.js b/examples/with-cookie-auth/api/profile.js new file mode 100644 index 00000000..3e57189f --- /dev/null +++ b/examples/with-cookie-auth/api/profile.js @@ -0,0 +1,29 @@ +const { send, createError, run } = require('micro') +const fetch = require('isomorphic-unfetch') + +const profile = async (req, res) => { + if (!('authorization' in req.headers)) { + throw createError(401, 'Authorization header missing') + } + + const auth = await req.headers.authorization + const { token } = JSON.parse(auth) + const url = `https://api.github.com/user/${token}` + + try { + const response = await fetch(url) + + if (response.ok) { + const js = await response.json() + // Need camelcase in the frontend + const data = Object.assign({}, { avatarUrl: js.avatar_url }, js) + send(res, 200, { data }) + } else { + send(res, response.status, response.statusText) + } + } catch (error) { + throw createError(error.statusCode, error.statusText) + } +} + +module.exports = (req, res) => run(req, res, profile) diff --git a/examples/with-cookie-auth/now.json b/examples/with-cookie-auth/now.json new file mode 100644 index 00000000..aedc1fe9 --- /dev/null +++ b/examples/with-cookie-auth/now.json @@ -0,0 +1,12 @@ +{ + "version": 2, + "name": "with-cookie-auth", + "builds": [ + { "src": "www/package.json", "use": "@now/next" }, + { "src": "api/*.js", "use": "@now/node" } + ], + "routes": [ + { "src": "/api/(.*)", "dest": "/api/$1" }, + { "src": "/(.*)", "dest": "/www/$1" } + ] +} diff --git a/examples/with-cookie-auth/www/components/header.js b/examples/with-cookie-auth/www/components/header.js new file mode 100644 index 00000000..7e163e6b --- /dev/null +++ b/examples/with-cookie-auth/www/components/header.js @@ -0,0 +1,58 @@ +import Link from 'next/link' +import { logout } from '../utils/auth' + +const Header = props => ( +
+ + +
+) + +export default Header diff --git a/examples/with-cookie-auth/www/components/layout.js b/examples/with-cookie-auth/www/components/layout.js new file mode 100644 index 00000000..6d7c51b4 --- /dev/null +++ b/examples/with-cookie-auth/www/components/layout.js @@ -0,0 +1,40 @@ +import React from 'react' +import Head from 'next/head' +import Header from './header' + +const Layout = props => ( + + + With Cookies + + +
+ +
+
{props.children}
+
+ +) + +export default Layout diff --git a/examples/with-cookie-auth/www/package.json b/examples/with-cookie-auth/www/package.json new file mode 100644 index 00000000..50b595c2 --- /dev/null +++ b/examples/with-cookie-auth/www/package.json @@ -0,0 +1,16 @@ +{ + "name": "with-cookie-auth", + "scripts": { + "dev": "next", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "isomorphic-unfetch": "^3.0.0", + "js-cookie": "^2.2.0", + "next": "^7.0.2", + "next-cookies": "^1.0.4", + "react": "^16.6.3", + "react-dom": "^16.6.3" + } +} diff --git a/examples/with-cookie-auth/www/pages/index.js b/examples/with-cookie-auth/www/pages/index.js new file mode 100644 index 00000000..e1171847 --- /dev/null +++ b/examples/with-cookie-auth/www/pages/index.js @@ -0,0 +1,29 @@ +import React from 'react' +import Layout from '../components/layout' + +const Home = props => ( + +

Cookie-based authentication example

+ +

Steps to test the functionality:

+ +
    +
  1. Click login and enter your GitHub username.
  2. +
  3. + Click home and click profile again, 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 + the `/login` route. +
  6. +
+ +
+) + +export default Home diff --git a/examples/with-cookie-auth/www/pages/login.js b/examples/with-cookie-auth/www/pages/login.js new file mode 100644 index 00000000..b9edc0e3 --- /dev/null +++ b/examples/with-cookie-auth/www/pages/login.js @@ -0,0 +1,97 @@ +import { Component } from 'react' +import Layout from '../components/layout' +import { login } from '../utils/auth' + +class Login extends Component { + static getInitialProps ({ req }) { + const apiUrl = process.browser + ? `https://${window.location.host}/api/login.js` + : `https://${req.headers.host}/api/login.js` + + return { apiUrl } + } + + constructor (props) { + super(props) + + this.state = { username: '', error: '' } + this.handleChange = this.handleChange.bind(this) + this.handleSubmit = this.handleSubmit.bind(this) + } + + handleChange (event) { + this.setState({ username: event.target.value }) + } + + async handleSubmit (event) { + event.preventDefault() + const username = this.state.username + const url = this.props.apiUrl + login({ username, url }).catch(() => + this.setState({ error: 'Login failed.' }) + ) + } + + render () { + return ( + +
+
+ + + + + + +

+ {this.state.error && `Error: ${this.state.error}`} +

+
+
+ +
+ ) + } +} + +export default Login diff --git a/examples/with-cookie-auth/www/pages/profile.js b/examples/with-cookie-auth/www/pages/profile.js new file mode 100644 index 00000000..95ad4cc3 --- /dev/null +++ b/examples/with-cookie-auth/www/pages/profile.js @@ -0,0 +1,73 @@ +import Router from 'next/router' +import fetch from 'isomorphic-unfetch' +import Layout from '../components/layout' +import auth, { withAuthSync } from '../utils/auth' + +const Profile = withAuthSync(props => { + const { name, login, bio, avatarUrl } = props.data + + return ( + + Avatar +

{name}

+

{login}

+

{bio}

+ + +
+ ) +}) + +Profile.getInitialProps = async ctx => { + const token = auth(ctx) + const apiUrl = process.browser + ? `https://${window.location.host}/api/profile.js` + : `https://${ctx.req.headers.host}/api/profile.js` + + const redirectOnError = () => + process.browser + ? Router.push('/login') + : ctx.res.writeHead(301, { Location: '/login' }) + + try { + const response = await fetch(apiUrl, { + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Authorization: JSON.stringify({ token }) + } + }) + + if (response.ok) { + return await response.json() + } else { + // https://github.com/developit/unfetch#caveats + return redirectOnError() + } + } catch (error) { + // Implementation or Network error + return redirectOnError() + } +} + +export default Profile diff --git a/examples/with-cookie-auth/www/utils/auth.js b/examples/with-cookie-auth/www/utils/auth.js new file mode 100644 index 00000000..38b002ee --- /dev/null +++ b/examples/with-cookie-auth/www/utils/auth.js @@ -0,0 +1,84 @@ +import { Component } from 'react' +import Router from 'next/router' +import nextCookie from 'next-cookies' +import cookie from 'js-cookie' +import fetch from 'isomorphic-unfetch' + +export const login = async ({ username, url }) => { + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username }) + }) + if (response.ok) { + const { token } = await response.json() + cookie.set('token', token, { expires: 1 }) + Router.push('/profile') + } else { + console.log('Login failed.') + // https://github.com/developit/unfetch#caveats + let error = new Error(response.statusText) + error.response = response + return Promise.reject(error) + } + } catch (error) { + console.error( + 'You have an error in your code or there are Network issues.', + error + ) + throw new Error(error) + } +} + +export const logout = () => { + cookie.remove('token') + // to support logging out from all windows + window.localStorage.setItem('logout', Date.now()) + Router.push('/login') +} + +export function withAuthSync (WrappedComponent) { + return class extends Component { + constructor (props) { + super(props) + + this.syncLogout = this.syncLogout.bind(this) + } + componentDidMount () { + window.addEventListener('storage', this.syncLogout) + } + + componentWillUnmount () { + window.removeEventListener('storage', this.syncLogout) + window.localStorage.removeItem('logout') + } + + syncLogout (event) { + if (event.key === 'logout') { + console.log('logged out from storage!') + Router.push('/login') + } + } + + render () { + return + } + } +} + +export default ctx => { + const { token } = nextCookie(ctx) + + if (ctx.req && !token) { + ctx.res.writeHead(302, { Location: '/login' }) + ctx.res.end() + return + } + + if (!token) { + Router.push('/login') + } + + return token +}