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