18
Web Authentication By the Numbers (Part 2)
This article is the second in a three part series. See steps 0 through 3 of Web Authentication By the Numbers (Part 1) to get up to speed.
The documentation for PassportJS delegates a lot to the individual implementing it. There is enough to get a general overview of what needs to be done but it's pretty abstract. It assumes you already understand authentication concepts.
So let's go ahead and build on the authentication concepts we started in Part 1 by implementing this in a new passport local branch.
This looks long but each section is commented to help tie things all together.
This is all the code in one place. Below this are specific thoughts on different key sections (what the section does and why it's there).
import express, { Application, NextFunction, Request, Response } from 'express'
import passport from 'passport'
import { Strategy } from 'passport-local'
import session, { SessionOptions } from 'express-session'
import { flash } from 'express-flash-message'
const app: Application = express()
const port = 3000
// The user "database". I kept this as simple as possible to avoid confusion.
const users: Express.User[] = [
{ id: 1, username: 'admin', password: 'supersecret' }
]
// This Express.User is a custom type/interface that is required by PassportJS if
// you're using Typescript. Making this conform to your user model makes sense
// but that isn't strictly required.
declare global {
namespace Express {
interface User {
id: number
username?: string
password?: string
}
}
}
// Normally the expression-session object doesn't contain a passport property.
// This extends the default session's data model to allow for passport to
// optionally exist. This is only needed if you're using Typescript.
declare module 'express-session' {
interface SessionData {
passport?: Object
}
}
// Enable parsing of data that is posted from the user login form to the login endpoint.
app.use(express.urlencoded())
// Create a session configuration.
const sessionConfig: SessionOptions = {
secret: 'Not Really A Secret But It Should Be In Production',
cookie: {
httpOnly: true, // Only let the browser modify this, not JS.
secure: process.env.NODE_ENV === 'production' ? true : false, // In production only set cookies if the TLS is enabled on the connection.
sameSite: 'strict' // Only send a cookie if the domain matches the browser url.
}
}
// Enable sessions.
app.use(session(sessionConfig))
// Setup the passport strategy with your custom local logic.
// It's worth noting that the passport-local strategy explicitly expects a
// user/password combo used to authenticate the user.
// These inputs (username/password) could be anything though.
// It's also worth noting that this function doesn't get called if your user
// is already authenticated.
passport.use(
new Strategy({}, (username: string, password: string, done: Function) => {
// For this example this wouldn't fail but for completeness this check should be done in a real project.
if (!users) return done(null, false, { message: 'Database Failure.' })
// Locate the user in your database with the credentials passed to this function.
const user = users.find((user) => {
if (user.username === username && user.password === password) {
return user
}
})
// Fail if the credentials don't exits.
const failedAuthMessage = "Username and password combo isn't registered."
if (!user) return done(null, false, { message: failedAuthMessage })
// Succeed without and error by passing a user without a name or password.
const sanitizedUser: Express.User = {
id: user.id
}
return done(null, sanitizedUser)
})
)
// Takes a user that is passed in from the auth strategy
// and serializes the user into the session.
// It's worth noting that this only gets called after the a user has been authenticated.
passport.serializeUser((user, done) => {
return done(null, user)
})
// Takes the user passed to it from the session and deserializes it so passport can use it.
// It's worth noting this gets called on every subsequent http request.
passport.deserializeUser((user: Express.User, done: Function) => {
return done(null, user)
})
// Add passport to the existing session handler.
app.use(passport.initialize())
app.use(passport.session())
// Enable flash messages to display auth errors.
app.use(flash())
// Custom Middleware:
// Apply this to any route to secure it.
const authRequired = (req: Request, res: Response, next: NextFunction) => {
if (!req.isAuthenticated()) return res.redirect('/login')
next()
}
// Route handlers below here:
app.get('/', (req: Request, res: Response) => {
return res.send(`
Home ${
req.isAuthenticated()
? '| <a href="/logout">Logout</a>'
: '| <a href="/login">Login</a>'
} | <a href="/members">Members</a>
<H1>Home</H1>
<p>This is a public route. No Authentication is needed.</p>
SessionID: ${req.session.id} <br/>
Authenticated: ${req.isAuthenticated() ? 'Yes' : 'No'} <br/>
User: ${JSON.stringify(req.user)}
`)
})
// Gathers credentials from the user or redirects them if they are already authenticated.
app.get('/login', async (req: Request, res: Response, next: NextFunction) => {
const errors = await req.consumeFlash('error')
if (!req.isAuthenticated()) {
return res.send(`
<a href="/">Home</a>
<H1>Login</H1>
<form action="/auth" method="post">
<div>
<label>Username:</label>
<input type="text" name="username"/>
</div>
<div>
<label>Password:</label>
<input type="password" name="password"/>
</div>
<div>
<input type="submit" value="Log In"/>
</div>
</form>
Errors: ${errors} <br/>
SessionID: ${req.session.id}
`)
}
return res.redirect('/members')
})
// Receives posted data that can be used for authenticating a user.
// Inputs are received here from the form located at /login.
app.post(
'/auth',
passport.authenticate('local', {
successRedirect: '/auth/success',
failureRedirect: '/login',
failureFlash: true
})
)
// NIST digital identity guidelines recommend recreating a session for security reasons.
// https://nvlpubs.nist.gov/nistpubs/SpecialPublications/NIST.SP.800-63b.pdf
// To change the session id you'll need to update the cookie on the client side.
// A response with the new session needs to be sent in order to update the client's cookie.
// You'll need a place for them to land once they've been authenticated to change the session id.
// This is also a good place to make updates to the session in the database if you need to.
app.get('/auth/success', (req: Request, res: Response, next: NextFunction) => {
const previousSessionData = req.session.passport
const previousUser = req.user
req.session.regenerate((err) => {
if (err) return res.status(500)
req.session.passport = previousSessionData
req.user = previousUser
req.session.save((err) => {
if (err) return res.status(500)
})
return res.send(`
<a href="/">Home</a> ${
req.isAuthenticated()
? '| <a href="/logout">Logout</a>'
: '| <a href="/login">Login</a>'
} | <a href="/members">Members</a>
<h1>Login Success!</h1>
<p>Would you like to go to the <a href="/members">Members Area</a>?</p>
<p>
A client side redirect here could allow this intermediary step to be skipped.
Note the new session ID!
</p>
SessionID: ${req.session.id} <br/>
Authenticated: ${req.isAuthenticated() ? 'Yes' : 'No'} <br/>
User: ${JSON.stringify(req.user)}
`)
})
})
// Logs a user out by destroying their session and redirecting them.
app.get('/logout', (req: Request, res: Response, next: NextFunction) => {
req.session.destroy(() => {
return res.redirect('/logout/success')
})
})
app.get(
'/logout/success',
(req: Request, res: Response, next: NextFunction) => {
return res.send(`
<a href="/">Home</a> ${
req.isAuthenticated()
? '| <a href="/logout">Logout</a>'
: '| <a href="/login">Login</a>'
} | <a href="/members">Members</a>
<h1>Logout Success!</h1>
SessionID: ${req.session.id} <br/>
Authenticated: ${req.isAuthenticated() ? 'Yes' : 'No'} <br/>
User: ${JSON.stringify(req.user)}
`)
}
)
// For authenticated users only.
app.get(
'/members',
authRequired,
(req: Request, res: Response, next: NextFunction) => {
return res.send(`
<a href="/">Home</a> | <a href="/logout">Logout</a>
<H1>Members Only!</H1>
<p>You're Authenticated! 😎</p>
SessionID: ${req.session.id} <br/>
User: ${JSON.stringify(req.user)}
`)
}
)
// Start the service.
app.listen(port)
At this point you've got an entire authentication system setup. You could use this for your own projects. The only things you would really need to change are the database and the presentation of each route handler.
There are quite a few things to note about how this is all setup. The order of execution on some of the code here is important.
One example of the execution order is how the express session and passport session modules are used. The express session has to come first and the passport session must come after express session. If not, the application behaves strangely.
Additionally, when you extend Express matters too and support for most of the middleware must be in place before creating any routes.
This could really be any database. But for the purpose of this article I've made it just an array of Users.
Typescript automatically infers the interface for the type is { id: number, username: string, password: string }
. This is caused by the global namespace we make changes to when extending Express a little further down in the code.
const users: Express.User[] = [
{ id: 1, username: 'admin', password: 'supersecret' }
]
We get the user at the point of authentication using this inside the local strategy we created.
const user = users.find((user) => {
if (user.username === username && user.password === password) {
return user
}
})
I intentionally have avoided creating users in the database because we will be using a special approach when it comes to Xumm integration in the next article.
Suffice it to say, all you really need to do is create a new user record in the database to add users. So I've not included that in this discussion because we'll be adding to this in the next article.
There are a few global changes here that are specific to using Typescript with Express. These two items aren't made available in the "@types/express"
or "@types/express-session"
packages.
Take a look at this addition of a User object to the Express global namespace.
declare global {
namespace Express {
interface User {
id: number
username?: string
password?: string
}
}
}
This essentially adds a User to Express because it's required by PassportJS to function. If you're using straight Javascript you won't need this.
Take a look at this change the the express session module.
declare module 'express-session' {
interface SessionData {
passport?: Object
}
}
This change allows us to make changes to the req.session.passport
object at build time (before it's been added to the session at runtime). We need to do this in order to conform to National Institute of Standards and Technology (NIST) recommendations that ask us to create a new session id when our client is authenticated.
In short these two items allow us to make changes to the session for our own purposes. These changes are changes that are unique to every project so it makes sense that these would not be in these packages.
To handle data that is posted to the application we need to be able to parse it. This is how parsing posted data is accomplished.
app.use(express.urlencoded())
These lines configure session support and then adds it to the middleware "sandwich".
const sessionConfig: SessionOptions = {
secret: 'Not Really A Secret But It Should Be In Production',
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production' ? true : false,
sameSite: 'strict'
}
}
app.use(session(sessionConfig))
Note the items related to the cookie.
-
httpOnly
tells the browser not to modify the cookie with Javascript. -
secure
enforces the use of TLS (Transport Layer Security) when in production. If there is no SSL session support just won't happen. -
sameSite
just ensures that cookies will only be sent to the caller from your domain (it will not be send on cross site requests from other domains).
This is the bit of code that actually does our authentication work.
passport.use(
new Strategy({}, (username: string, password: string, done: Function) => {
if (!users) return done(null, false, { message: 'Database Failure.' })
const user = users.find((user) => {
if (user.username === username && user.password === password) {
return user
}
})
const failedAuthMessage = "Username and password combo isn't registered."
if (!user) return done(null, false, { message: failedAuthMessage })
const sanitizedUser: Express.User = {
id: user.id
}
return done(null, sanitizedUser)
})
)
Once a user is authenticated passport calls the serializeUser function. You'll need to provide this when using the Passport-Local strategy.
Because the cookies are exposed to the client it's worth nothing that it's probably good to only serialize the user's id (or some other identifying information you can use to ).
passport.serializeUser((user, done) => {
return done(null, user)
})
It's worth noting this only get's called at the point of authentication. It's not called on subsequent requests from the client if the session if valid.
passport.deserializeUser((user: Express.User, done: Function) => {
return done(null, user)
})
Up to this point passport has been in your code but it would not be able to impact any incoming http request. This initializes passport and adds that initialized instance of PassportJS to your middleware stack.
app.use(passport.initialize())
Adding the PassportJS session to the middleware stack (after you've already initialized express-session
) binds all your PassportJS sessions to the express-session.
app.use(passport.session())
Occasionally you want to pass messages to your users which might be helpful (but don't give a bad actor information that will help them to infer things about your website's security).
To do this easily we will add some flash messaging support.
app.use(flash())
To easily require authentication for any specific route you can just add authRequired
to the middleware of any route to secure it.
If the user is not authenticated they will be automatically redirected to login if they attempt to access routes with this middleware attached.
const authRequired = (req: Request, res: Response, next: NextFunction) => {
if (!req.isAuthenticated()) return res.redirect('/login')
next()
}
Most of the other routes here are pretty self explanatory.
- Home (
/
) shows the home page. - Login (
/login
) allows your usr to login. - Members (/members) shows the user a page that requires authentication.
- Logout (
/logout
) - yep, you guessed it - logs the user out by destroying their session.
Of all these those there is one that might seem a little unusual. It's the /login/success
route.
This route takes an old session that has been authenticated and assigns a new session id to it.
app.get('/auth/success', (req: Request, res: Response, next: NextFunction) => {
const previousSessionData = req.session.passport
const previousUser = req.user
req.session.regenerate((err) => {
if (err) return res.status(500)
req.session.passport = previousSessionData
req.user = previousUser
req.session.save((err) => {
if (err) return res.status(500)
})
return res.send(`
<a href="/">Home</a> ${
req.isAuthenticated()
? '| <a href="/logout">Logout</a>'
: '| <a href="/login">Login</a>'
} | <a href="/members">Members</a>
<h1>Login Success!</h1>
<p>Would you like to go to the <a href="/members">Members Area</a>?</p>
<p>
A client side redirect here could allow this intermediary step to be skipped.
Note the new session ID!
</p>
SessionID: ${req.session.id} <br/>
Authenticated: ${req.isAuthenticated() ? 'Yes' : 'No'} <br/>
User: ${JSON.stringify(req.user)}
`)
})
})
This is a key activity to secure your site. It's optional but it goes a long way toward preventing man in the middle attacks based on intercepting authenticated session identifiers.
I hope this has helped to quickly get a working understanding of PassportJS. The information here is specific to using the Local Strategy but most of the context here applies to any of the strategies.
The next article will build on this with Step 5 (Implementing the PassportJS Xumm Strategy). Also, we'll include the use of a real database abstraction layer (TypeOrm) so you can see how your user models are impacted by sessions and how to store wallet information.
18