Adding support for credentials based sign in

* Resolves #18 by providing an easy way to define a custom credentials based sign in end point and use it with NextAuth.

The NextAuth client explicitly supports this option and an new example in example/pages/credentials.js shows how to use it (it’s super easy to use and and you can pass any fields you like to it).

Note that this does not explicitly allow a localStrategy to be defined but provides the same ability to define a custom auth hook - allowing custom localStrategies would probably be a footgun and likely generate support requests (as it’s more complicated to implement) so I’m inclined to keep it simple for everyone.

* Resolves #20 by passing the req to email sign in method (useful for things like language and hostname detection).

* If you do not pass a sendSignInEmail() or signIn() functions (or set them to null) then the routes for these will not be created, so that they are easy to disable.
This commit is contained in:
Iain Collins
2018-02-18 00:09:15 +01:00
parent cc872701eb
commit bf3c5fb273
12 changed files with 364 additions and 120 deletions

View File

@@ -18,11 +18,13 @@ When using Server Side Rendering and passed `req` object from **getInitialProps(
When using Client Side Rendering it will use localStorage (if avalible) to check for cached session data and if not found or expired it call the `/auth/session` end point.
### NextAuthClient.signin({ email })
### NextAuthClient.signin(string or object)
Client side only method. Request an email sign in token.
Client side only method.
Makes POST request to `/auth/signin`.
If passed a string treats it as an email address, generates an email sign in token and makes POST request to `/auth/email/signin`.
If passed an object treats it as a form to be handled by a custom signIn() function and makes a POST request to `/auth/signin`.
### NextAuthClient.signout()

View File

@@ -197,15 +197,27 @@ It is where the **next-auth.functions.js** and **next-auth.providers.js** files
Methods for user management and sending email are defined in **next-auth.functions.js**
The example configuration provided is for Mongo DB. By defining the behaviour in these functions you can use NextAuth with any database, including a relational database that uses SQL.
#### Required
* find({id,email,emailToken,provider})
* insert(user, oAuthProfile)
* update(user, oAuthProfile)
* remove(id)
* serialize(user)
* deserialize(id)
* sendSigninEmail({email, url})
The example configuration provided is for Mongo DB. By defining the behaviour in these functions you can use NextAuth with any database, including a relational database that uses SQL.
#### Optional
* sendSigninEmail({email, url, req})
* signIn({form, req})
The `sendSigninEmail()` method is used to send an email for email token based sign in (one time use passwords). Omit it or set it to null to disable email based sign in.
The `signIn()` method is used to handle authenticating with custom credentials (e.g. username and password, 2FA token, etc). Omit it or leave it undefined unless you need it.
You can use any combination of authentication methods (email, credentials, oAuth providers).
### next-auth.providers.js

File diff suppressed because one or more lines are too long

View File

@@ -33,6 +33,8 @@ This example includes the following pages:
* pages/auth/check-email.js
* pages/auth/callback.js
The file `pages/auth/credentials.js` provides an additional example of how to use a custom authentication handler defined in `next-auth.functions.js`.
## Configuration
It also includes the following configuration files:

View File

@@ -57,16 +57,16 @@ const nodemailerDirectTransport = require('nodemailer-direct-transport')
let nodemailerTransport = nodemailerDirectTransport()
if (process.env.EMAIL_SERVER && process.env.EMAIL_USERNAME && process.env.EMAIL_PASSWORD) {
nodemailerTransport = nodemailerSmtpTransport({
host: process.env.EMAIL_SERVER,
port: process.env.EMAIL_PORT || 25,
secure: true,
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD
}
})
host: process.env.EMAIL_SERVER,
port: process.env.EMAIL_PORT || 25,
secure: true,
auth: {
user: process.env.EMAIL_USERNAME,
pass: process.env.EMAIL_PASSWORD
}
})
}
module.exports = () => {
return new Promise((resolve, reject) => {
if (process.env.MONGO_URI) {
@@ -166,8 +166,8 @@ module.exports = () => {
// Handle responses from deserialize()
return Promise.resolve(user.id)
} else if (user._id) {
// Handle responses from find(), insert(), update()
return Promise.resolve(user._id)
// Handle responses from find(), insert(), update()
return Promise.resolve(user._id)
} else {
return Promise.reject(new Error("Unable to serialise user"))
}
@@ -193,11 +193,14 @@ module.exports = () => {
})
})
},
// Define method for sending links for signing in over email.
sendSignInEmail: ({
email = null,
url = null
} = {}) => {
// Email Sign In
//
// Accounts are created automatically, as when signing in via oAuth.
// Users are sent one-time use sign in tokens in links. This avoids
// storing user supplied passwords anywhere, preventing password re-use.
//
// To disable this option, do not set sendSignInEmail (or set it to null).
sendSignInEmail: ({email, url, req}) => {
nodemailer
.createTransport(nodemailerTransport)
.sendMail({
@@ -213,8 +216,40 @@ module.exports = () => {
})
if (process.env.NODE_ENV === 'development') {
console.log('Generated sign in link ' + url + ' for ' + email)
}
}
},
// Credentials Sign In
//
// If you use this you will need to define your own way to validate
// credentials. Unlike with oAuth or Email Sign In, accounts are not
// created automatically so you will need to provide a way to create them.
//
// This feature is intended for strategies like Two Factor Authentication.
//
// To disable this option, do not set signin (or set it to null).
/*
signIn: ({form, req}) => {
return new Promise((resolve, reject) => {
// Should validate credentials (e.g. hash password, compare 2FA token
// etc) and return a valid user object from a database.
return usersCollection.findOne({
email: form.email
}, (err, user) => {
if (err) return reject(err)
if (!user) return resolve(null)
// Check credentials - e.g. compare bcrypt password hashes
if (form.password == "test1234") {
// If valid, return user object - e.g. { id, name, email }
return resolve(user)
} else {
// If invalid, return null
return resolve(null)
}
})
})
}
*/
})
})
}

View File

@@ -1,6 +1,6 @@
{
"name": "next-auth-examples",
"version": "1.7.3",
"version": "1.8.0",
"description": "An example project for next-auth",
"repository": "https://github.com/iaincollins/next-auth.git",
"main": "",
@@ -18,7 +18,7 @@
"mongodb": "^3.0.1",
"nedb": "^1.8.0",
"next": "^5.0.0",
"next-auth": "^1.7.3",
"next-auth": "^1.8.0",
"nodemailer": "^4.4.2",
"nodemailer-direct-transport": "^3.3.2",
"nodemailer-smtp-transport": "^2.7.4",

View File

@@ -0,0 +1,117 @@
import React from 'react'
import Head from 'next/head'
import Router from 'next/router'
import Link from 'next/link'
import { NextAuth } from 'next-auth/client'
export default class extends React.Component {
static async getInitialProps({req}) {
return {
session: await NextAuth.init({req}),
linkedAccounts: await NextAuth.linked({req}),
providers: await NextAuth.providers({req})
}
}
constructor(props) {
super(props)
this.state = {
email: '',
password: '',
session: this.props.session
}
this.handleEmailChange = this.handleEmailChange.bind(this)
this.handlePasswordChange = this.handlePasswordChange.bind(this)
this.handleSignInSubmit = this.handleSignInSubmit.bind(this)
}
async componentDidMount() {
if (this.props.session.user) {
Router.push(`/auth/`)
}
}
handleEmailChange(event) {
this.setState({
email: event.target.value
})
}
handlePasswordChange(event) {
this.setState({
password: event.target.value
})
}
handleSignInSubmit(event) {
event.preventDefault()
// An object passed NextAuth.signin will be passed to your signin() function
NextAuth.signin({
email: this.state.email,
password: this.state.password
})
.then(authenticated => {
Router.push(`/auth/callback`)
})
.catch(() => {
alert("Authentication failed.")
})
}
render() {
if (this.props.session.user) {
return null
} else {
return (
<div className="container">
<Head>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<script src="https://cdn.polyfill.io/v2/polyfill.min.js"/>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossOrigin="anonymous"/>
</Head>
<div className="text-center">
<h1 className="display-4 mt-3 mb-3">NextAuth Example</h1>
</div>
<div className="row">
<div className="col-sm-6 mr-auto ml-auto">
<p>
If you need password based sign in, two factor authentication
or some other credentials based sign in method, you can define
a signin() function in <strong>next-auth.functions.js</strong>.
You can pass in any properties you need (e.g. username and password,
a token, etc) to NextAuth.signin().
</p>
<div className="card mt-3 mb-3">
<h4 className="card-header">Sign In</h4>
<div className="card-body pb-0">
<p className="text-italic text-muted text-center">
Tip: Create an account first then try the password "test1234".
</p>
<form id="signin" method="post" action="/auth/signin" onSubmit={this.handleSignInSubmit}>
<input name="_csrf" type="hidden" value={this.state.session.csrfToken}/>
<p>
<label htmlFor="email">Email address</label><br/>
<input name="email" type="text" placeholder="j.smith@example.com" id="email" className="form-control" value={this.state.email} onChange={this.handleEmailChange}/>
</p>
<p>
<label htmlFor="password">Password</label><br/>
<input name="password" type="password" placeholder="" id="email" className="form-control" value={this.state.password} onChange={this.handlePasswordChange}/>
</p>
<p className="text-right">
<button id="submitButton" type="submit" className="btn btn-outline-primary">Sign in</button>
</p>
</form>
</div>
</div>
</div>
</div>
<p className="text-center">
<Link href="/auth"><a>Back</a></Link>
</p>
</div>
)
}
}
}

View File

@@ -39,7 +39,7 @@ export default class extends React.Component {
.then(() => {
Router.push(`/auth/check-email?email=${this.state.email}`)
})
.catch(err => {
.catch(() => {
Router.push(`/auth/error?action=signin&type=email&email=${this.state.email}`)
})
}
@@ -55,7 +55,7 @@ export default class extends React.Component {
</Head>
<div className="text-center">
<h1 className="display-4 mt-3">NextAuth Example</h1>
<p className="lead mt-3 mb-1">You are signed in as <span className="font-weight-bold">{this.props.session.user.name || this.props.session.user.email}</span>.</p>
<p className="lead mt-3 mb-1">You are signed in as <span className="font-weight-bold">{this.props.session.user.email}</span>.</p>
</div>
<div className="row">
<div className="col-sm-5 mr-auto ml-auto">
@@ -101,6 +101,9 @@ export default class extends React.Component {
</div>
</div>
</div>
<p className="text-center small">
<Link href="/auth/credentials"><a>Sign in with credentials</a></Link>
</p>
<p className="text-center">
<Link href="/"><a>Home</a></Link>
</p>

201
index.js
View File

@@ -63,11 +63,17 @@ module.exports = (nextApp, {
remove: (id) => { Promise.resolve(id) },
serialize: (user) => { Promise.resolve(id) },
deserialize: (id) => { Promise.resolve(user) },
sendSignInEmail: ({
sendSignInEmail: null, /* ({
email = null,
url = null,
req = null
} = {}) => { Promise.resolve(true) }
*/
signIn: null /* ({
email = null,
password = null
} = {}) => { Promise.resolve(user) }
*/
}
} = {}) => {
@@ -88,7 +94,7 @@ module.exports = (nextApp, {
})
}
/**
/*
* Set up body parsing, express sessions and add CSRF tokens.
*
* You can set bodyParser to false and pass an Express instance if you want
@@ -119,7 +125,7 @@ module.exports = (nextApp, {
expressApp.set('trust proxy', 1)
}
/**
/*
* With sessions configured we need to configure Passport and trigger
* passport.initialize() before we add any other routes.
*/
@@ -130,7 +136,7 @@ module.exports = (nextApp, {
functions: functions
})
/**
/*
* Add route to get CSRF token via AJAX
*/
expressApp.get(`${pathPrefix}/csrf`, (req, res) => {
@@ -139,7 +145,7 @@ module.exports = (nextApp, {
})
})
/**
/*
* Return session info
*/
expressApp.get(`${pathPrefix}/session`, (req, res) => {
@@ -162,7 +168,7 @@ module.exports = (nextApp, {
return res.json(session)
})
/**
/*
* Return list of which accounts are already linked.
*
* We define this both as a server side function and a RESTful endpoint so
@@ -222,7 +228,7 @@ module.exports = (nextApp, {
})
})
/**
/*
* Return list of configured oAuth Providers
*
* We define this both as a server side function and a RESTful endpoint so
@@ -257,80 +263,133 @@ module.exports = (nextApp, {
return res.json(configuredProviders)
})
})
/*
* Enable /auth/signin routes if signIn() function is passed
*/
if (functions.signIn) {
expressApp.post(`${pathPrefix}/signin`, (req, res) => {
// Passes all supplied credentials to the signIn function
functions.signIn({
form: req.body,
req: req
})
.then(user => {
if (user) {
// If signIn() returns a user, sign in as them
req.logIn(user, (err) => {
if (err) return res.redirect(`${pathPrefix}/error?action=signin&type=credentials`)
if (req.xhr) {
// If AJAX request (from client with JS), return JSON response
return res.json({success: true})
} else {
// If normal form POST (from client without JS) return redirect
return res.redirect(`${pathPrefix}/callback?action=signin&service=credentials`)
}
})
} else {
// If no user object is returned, bounce back to the sign in page
return res.redirect(`${pathPrefix}`)
}
})
.catch(err => {
return res.redirect(`${pathPrefix}/error?action=signin&type=credentials`)
})
})
}
/**
* Generate a one time use sign in link and email it to the user
/*
* Enable /auth/email/signin routes if sendSignInEmail() function is passed
*/
expressApp.post(`${pathPrefix}/email/signin`, (req, res) => {
const email = req.body.email || null
if (functions.sendSignInEmail) {
/*
* Generate a one time use sign in link and email it to the user
*/
expressApp.post(`${pathPrefix}/email/signin`, (req, res) => {
const email = req.body.email || null
if (!email || email.trim() === '') {
res.redirect(`${pathPrefix}`)
}
if (!email || email.trim() === '') {
res.redirect(`${pathPrefix}`)
}
const token = uuid()
const url = (serverUrl || `${req.protocol}://${req.headers.host}`) + `${pathPrefix}/email/signin/${token}`
const token = uuid()
const url = (serverUrl || `${req.protocol}://${req.headers.host}`) + `${pathPrefix}/email/signin/${token}`
// Create verification token save it to database
functions.find({ email: email })
.then(user => {
if (user) {
// If a user with that email address exists already, update token.
user.emailToken = token
return functions.update(user)
} else {
// If the user does not exist, create a new account with the token.
return functions.insert({
email: email,
emailToken: token
// Create verification token save it to database
functions.find({ email: email })
.then(user => {
if (user) {
// If a user with that email address exists already, update token.
user.emailToken = token
return functions.update(user)
} else {
// If the user does not exist, create a new account with the token.
return functions.insert({
email: email,
emailToken: token
})
}
})
.then(user => {
functions.sendSignInEmail({
email: user.email,
url: url,
req: req
})
}
})
.then(user => {
functions.sendSignInEmail({
email: user.email,
url: url
if (req.xhr) {
// If AJAX request (from client with JS), return JSON response
return res.json({success: true})
} else {
// If normal form POST (from client without JS) return redirect
return res.redirect(`${pathPrefix}/check-email?email=${email}`)
}
})
return res.redirect(`${pathPrefix}/check-email?email=${email}`)
})
.catch(err => {
return res.redirect(`${pathPrefix}/error?action=signin&type=email&email=${email}`)
})
})
/**
* Verify token in callback URL for email sign in
*/
expressApp.get(`${pathPrefix}/email/signin/:token`, (req, res) => {
if (!req.params.token) {
return res.redirect(`${pathPrefix}/error?action=signin&type=token-missing`)
}
functions.find({ emailToken: req.params.token })
.then(user => {
if (user) {
// Delete current token so it cannot be used again
delete user.emailToken
// Mark email as verified now we know they have access to it
user.emailVerified = true
return functions.update(user)
} else {
return Promise.reject(new Error("Token not valid"))
}
})
.then(user => {
// If the user object is valid, sign the user in
req.logIn(user, (err) => {
if (err) throw err
return res.redirect(`${pathPrefix}/callback?action=signin&service=email`)
.catch(err => {
return res.redirect(`${pathPrefix}/error?action=signin&type=email&email=${email}`)
})
})
.catch(err => {
return res.redirect(`${pathPrefix}/error?action=signin&type=token-invalid`)
})
})
/**
/*
* Verify token in callback URL for email sign in
*/
expressApp.get(`${pathPrefix}/email/signin/:token`, (req, res) => {
if (!req.params.token) {
return res.redirect(`${pathPrefix}/error?action=signin&type=token-missing`)
}
functions.find({ emailToken: req.params.token })
.then(user => {
if (user) {
// Delete current token so it cannot be used again
delete user.emailToken
// Mark email as verified now we know they have access to it
user.emailVerified = true
return functions.update(user)
} else {
return Promise.reject(new Error("Token not valid"))
}
})
.then(user => {
// If the user object is valid, sign the user in
req.logIn(user, (err) => {
if (err) return res.redirect(`${pathPrefix}/error?action=signin&type=token-invalid`)
if (req.xhr) {
// If AJAX request (from client with JS), return JSON response
return res.json({success: true})
} else {
// If normal form POST (from client without JS) return redirect
return res.redirect(`${pathPrefix}/callback?action=signin&service=email`)
}
})
})
.catch(err => {
return res.redirect(`${pathPrefix}/error?action=signin&type=token-invalid`)
})
})
}
/*
* Sign a user out
*/
expressApp.post(`${pathPrefix}/signout`, (req, res) => {

View File

@@ -1,6 +1,6 @@
{
"name": "next-auth",
"version": "1.7.3",
"version": "1.8.0",
"description": "An authentication library for Next.js",
"repository": "https://github.com/iaincollins/next-auth.git",
"main": "index.js",

View File

@@ -151,43 +151,57 @@ export default class {
.then(data => data)
.catch(() => Error('Unable to get oAuth providers'))
}
static async signin(email) {
// Sign in to the server
// Load current session info from cache
let session = await this.init()
// Make sure we have the latest CSRF Token in our session
session.csrfToken = await this.csrfToken()
/*
* Sign in
*
* Will post a form to /auth/signin auth route if an object is passed.
* If the details are valid a session will be created and you should redirect
* to your callback page so the session is loaded in the client.
*
* If just a string containing an email address is specififed will generate a
* a one-time use sign in link and send it via email; you should redirect to a
* page telling the user to check their inbox for an email with the link.
*/
static async signin(params) {
// Params can be just string (an email address) or an object (form fields)
const formData = (typeof params === 'string') ? { email: params } : params
const formData = {
_csrf: session.csrfToken,
email,
}
// Use either the email token generation route or the custom form auth route
const route = (typeof params === 'string') ? '/auth/email/signin' : '/auth/signin'
// Add latest CSRF Token to request
formData._csrf = await this.csrfToken()
// Encoded form parser for sending data in the body
const encodedForm = Object.keys(formData).map((key) => {
return encodeURIComponent(key) + '=' + encodeURIComponent(formData[key])
}).join('&')
return fetch('/auth/email/signin', {
return fetch(route, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
'Content-Type': 'application/x-www-form-urlencoded',
'X-Requested-With': 'XMLHttpRequest' // So Express can detect AJAX post
},
body: encodedForm,
credentials: 'same-origin'
})
.then(response => {
.then(async response => {
if (response.ok) {
return response
return await response.json()
} else {
return Promise.reject(Error('HTTP error while attempting to sign in'))
throw new Error('HTTP error while attempting to sign in')
}
})
.then(data => {
if (data.success && data.success === true) {
return Promise.resolve(true)
} else {
return Promise.resolve(false)
}
})
.then(() => true)
.catch(() => Error('Unable to sign in'))
}
static async signout() {

View File

@@ -1,4 +1,4 @@
/**
/*
* Configures Passport Strategies
*/
'use strict'
@@ -31,7 +31,7 @@ module.exports = ({
throw new Error('functions must be a an object')
}
/**
/*
* Return functions ID property from a functions object
*/
passport.serializeUser((user, next) => {
@@ -44,7 +44,7 @@ module.exports = ({
})
})
/**
/*
* Return functions from a functions ID
*/
passport.deserializeUser((id, next) => {