mirror of
https://github.com/terribleplan/next.js.git
synced 2024-01-19 02:48:18 +00:00
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 - <strike>I deployed a passwordless backend on `now.sh` (https://with-cookie-api.now.sh, [src](https://github.com/j0lv3r4/next.js-with-cookies-api))</strike> 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)
This commit is contained in:
parent
b4e877c8a8
commit
798ae043ac
47
examples/with-cookie-auth/README.md
Normal file
47
examples/with-cookie-auth/README.md
Normal file
|
@ -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.
|
21
examples/with-cookie-auth/api/login.js
Normal file
21
examples/with-cookie-auth/api/login.js
Normal file
|
@ -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)
|
20
examples/with-cookie-auth/api/package.json
Normal file
20
examples/with-cookie-auth/api/package.json
Normal file
|
@ -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"
|
||||||
|
}
|
29
examples/with-cookie-auth/api/profile.js
Normal file
29
examples/with-cookie-auth/api/profile.js
Normal file
|
@ -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)
|
12
examples/with-cookie-auth/now.json
Normal file
12
examples/with-cookie-auth/now.json
Normal file
|
@ -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" }
|
||||||
|
]
|
||||||
|
}
|
58
examples/with-cookie-auth/www/components/header.js
Normal file
58
examples/with-cookie-auth/www/components/header.js
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { logout } from '../utils/auth'
|
||||||
|
|
||||||
|
const Header = props => (
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<Link href='/'>
|
||||||
|
<a>Home</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href='/login'>
|
||||||
|
<a>Login</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href='/profile'>
|
||||||
|
<a>Profile</a>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button onClick={logout}>Logout</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<style jsx>{`
|
||||||
|
ul {
|
||||||
|
display: flex;
|
||||||
|
list-style: none;
|
||||||
|
margin-left: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
li:first-child {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 0.2rem;
|
||||||
|
color: #fff;
|
||||||
|
background-color: #333;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Header
|
40
examples/with-cookie-auth/www/components/layout.js
Normal file
40
examples/with-cookie-auth/www/components/layout.js
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Head from 'next/head'
|
||||||
|
import Header from './header'
|
||||||
|
|
||||||
|
const Layout = props => (
|
||||||
|
<React.Fragment>
|
||||||
|
<Head>
|
||||||
|
<title>With Cookies</title>
|
||||||
|
</Head>
|
||||||
|
<style jsx global>{`
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||||
|
'Helvetica Neue', Arial, Noto Sans, sans-serif, 'Apple Color Emoji',
|
||||||
|
'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 65rem;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
padding-left: 1rem;
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<Header />
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div className='container'>{props.children}</div>
|
||||||
|
</main>
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Layout
|
16
examples/with-cookie-auth/www/package.json
Normal file
16
examples/with-cookie-auth/www/package.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
29
examples/with-cookie-auth/www/pages/index.js
Normal file
29
examples/with-cookie-auth/www/pages/index.js
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
import React from 'react'
|
||||||
|
import Layout from '../components/layout'
|
||||||
|
|
||||||
|
const Home = props => (
|
||||||
|
<Layout>
|
||||||
|
<h1>Cookie-based authentication example</h1>
|
||||||
|
|
||||||
|
<p>Steps to test the functionality:</p>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li>Click login and enter your GitHub username.</li>
|
||||||
|
<li>
|
||||||
|
Click home and click profile again, notice how your session is being
|
||||||
|
used through a token stored in a cookie.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Click logout and try to go to profile again. You'll get redirected to
|
||||||
|
the `/login` route.
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<style jsx>{`
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
|
||||||
|
export default Home
|
97
examples/with-cookie-auth/www/pages/login.js
Normal file
97
examples/with-cookie-auth/www/pages/login.js
Normal file
|
@ -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 (
|
||||||
|
<Layout>
|
||||||
|
<div className='login'>
|
||||||
|
<form onSubmit={this.handleSubmit}>
|
||||||
|
<label htmlFor='username'>GitHub username</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
id='username'
|
||||||
|
name='username'
|
||||||
|
value={this.state.username}
|
||||||
|
onChange={this.handleChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type='submit'>Login</button>
|
||||||
|
|
||||||
|
<p className={`error ${this.state.error && 'show'}`}>
|
||||||
|
{this.state.error && `Error: ${this.state.error}`}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<style jsx>{`
|
||||||
|
.login {
|
||||||
|
max-width: 340px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
padding: 8px;
|
||||||
|
margin: 0.3rem 0 1rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
display: none;
|
||||||
|
color: brown;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Login
|
73
examples/with-cookie-auth/www/pages/profile.js
Normal file
73
examples/with-cookie-auth/www/pages/profile.js
Normal file
|
@ -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 (
|
||||||
|
<Layout>
|
||||||
|
<img src={avatarUrl} alt='Avatar' />
|
||||||
|
<h1>{name}</h1>
|
||||||
|
<p className='lead'>{login}</p>
|
||||||
|
<p>{bio}</p>
|
||||||
|
|
||||||
|
<style jsx>{`
|
||||||
|
img {
|
||||||
|
max-width: 200px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: #6a737d;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
84
examples/with-cookie-auth/www/utils/auth.js
Normal file
84
examples/with-cookie-auth/www/utils/auth.js
Normal file
|
@ -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 <WrappedComponent {...this.props} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
Loading…
Reference in a new issue