This week, I had to design an API with protected routes, which needed the user to be logged in. There is even more to it, said API should be used by a website (where you have access to cookies).
Technologies used: NodeJS & expressjs
Making a simple server
Let's create a small server for this demonstration:
const express = require('express')
const app = express()
const port = 3000
app.get('/', (req, res) => {
res.send('Hello World!')
})
app.get('/protected', (req, res) => {
res.send('This should be protected')
})
app.listen(port)
We have two routes here, one unprotected /
and one which we want to protect. If we only had to use this route through the browser, the answer would be easy: just use express-session
and a cookie parser, to send a cookie to the user, and retrieve it to check if they are logged in or not.
Basic protection of a route
Said idea would look as follows
// ...
const session = require('express-session')
const cookieParser = require('cookie-parser')
// ...
app.use(cookieParser())
app.use(session({
secret: 'a secret phrase',
resave: true,
saveUninitialized: false,
}))
app.get('/protected', (req, res, next) => {
// this is very basic, don't do this at home
if (req.session.userID !== null) {
res.send('This should be protected')
} else {
next(new Error('You need to be logged in to view this page'))
}
})
Easy and quick to use, just set a session and check if some data is present (here we are checking against userID
).
We can even make it simpler to use by making a middleware:
// ...
const authMiddleware = (req, _, next) => {
if ("userID" in req.session && req.session.userID !== null) {
return next()
} else {
const err = new Error("Not authorized, please log in")
err.status = 403
return next(err)
}
}
app.get('/protected', authMiddleware, (req, res) => {
res.send('This should be protected')
})
But there is more to it, how would you use those cookies from an API?
- Just add a
Cookie
header with the cookie value? Doesn't work, and even if it does, it's quite ugly - Send the userID in our requests? The API could be bruteforced until the attacker finds a valid user identifier they can use
Making the API callable from outside the browser
The idea I went with is using the Authorization
header. It can take multiple values, but the one I'm interested in is Authorization: Basic <base64 token>
. Once the base64 token is decoded, we will have something like userID:hash
.
We can get those information like this in our middleware:
const authMiddleware = async (req, _, next) => {
if (req.headers.authorization) {
const auth = new Buffer.from(req.headers.authorization.split(' ')[1], 'base64').toString().split(':')
const user = auth[0]
const token = auth[1]
if (await checkToken(user, token)) {
// creating a cookie, just in case it's used by another route
req.session.userID = user
return next()
}
} else if ("userID" in req.session && req.session.userID !== null) {
return next()
} // ...
}
Security concerns
Now this API could work in a browser with cookies, and with curl (if we don't forget to send the authorization header). This sounds too easy, right?
Indeed. If the hash
in our base64 token is just the password hash, then again, an attacker could bruteforce it, though it would take much longer. Even worse, someone could listen to packets on your network and use your token, for as long as they want!
The way I've chosen to address the latter is
- to avoid sending the password hash in the authorization token (someone might try to bruteforce it)
- to send a unique hash from which you can't recover the password
- to have a time bound token (eg the token is unusable/deleted after 15 or 60 minutes)
To accomplish this, I could have just send userID:hash(now.timestamp + 3600)
. But anyone can forge said token easily, so it's not secure. How about a double hash?
We can send something like userID:hash(creation_timestamp + hash(secret + password))
. Good luck making a hash table to reverse this (note: the secret is server side, unknown by the client, to make the password hash robust against hash tables attacks). Then we only have to store something like "tokens": [{"expireAt": Date.now(), "value": token}]
in our user database, to be able to check if we got a valid token.
Our checkToken
function can look like this:
const checkToken = async (user, token) => {
const db = getDatabase("users")
const rows = await db.select(user)
// checking that the user exists
if (rows.length === 1 && rows[0].tokens) {
const validTokens = rows[0].tokens.filter(tok => tok.value === token && tok.expireAt > Date.now())
if (validTokens.length > 0) {
return true
}
}
return false
}
Conclusion
Avoid sending raw credentials in your authorization header, as they could be stolen by an attacker. Also use time based tokens, to automatically remove tokens from users' accounts when they are expired, setting the security level of your application a bit higher. You could even delete the tokens when they have been used more than X times, it's up to you.
Originally from lexp.lt
Top comments (0)