1
0
Fork 0
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:
Juan Olvera 2018-12-14 16:05:54 -06:00 committed by Tim Neutkens
parent b4e877c8a8
commit 798ae043ac
12 changed files with 526 additions and 0 deletions

View 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.

View 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)

View 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"
}

View 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)

View 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" }
]
}

View 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

View 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

View 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"
}
}

View 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

View 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

View 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

View 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
}