Securing user data is today an absolute must.
Users want privacy and laws required it.
Still we hear about breaches and stolen passwords.
What can we do about it?
Today we will look at the “key” part of security which is allowing each user access to only the data they have permission to use.
The very first step is making sure the user who wants to use our app/web/service, is who she says she is.
Technically: authenticating the user.
If you look around the internet, the way this usually works is that we require the user to present something she and only she knows or has.
This is a long way to say, we ask for a password (something she knows).
Or we ask for something she has (like access to the phone or email, where we send a secret key).
With biometrics this gets even trickier but let’s keep that for later.
Email and password login is the dinosaur of authentication. Widely regarded as not really secure anymore.
Because we all just reuse password… (even if we know we shouldn’t and there are password managers).
And because it is often implemented incorrectly, allowing one hacked service to leak all our reused passwords.
And because “management” forces us to change passwords regularly so we end up with predictable passwords like January24.
Now for all the shortcommings you will have to do it… so just make sure you use
When creating a user record:
const createUser = async (orgName: string, username: string, email: string, password: string) => {
const user = await getUser(orgName, email);
if (user) {
throw Error("User already exists");
}
const hh = await pbkdf2_makeHash(password);
const makeParam = (value:string, index:number) => ({name: "P" + index, type:"string", value: value});
const db = await DatabaseManager.connect(orgName);
await db.nonQuery("INSERT INTO users (name, emailaddress, pwdhash, pwdsalt) VALUES (@P0, @P1,@P2,@P3)",
[username, email, hh.hash, hh.salt].map(makeParam));
}
When you want to authenticate the user
const loadAndVerifyUser = async (orgName: string, email: string, password: string): Promise<IUser|undefined> => {
let resultUser: IUser|undefined = undefined;
try {
const user = await getUser(orgName, email);
if (user) {
let ok = false;
if (user.pwdhash && user.pwdsalt) {
ok = await pbkdf2_verifyHash(password, { hash: user.pwdhash, salt: user.pwdsalt });
}
if (ok) {
resultUser = dbUserToSessionUser(user);
}
}
} catch (err) {
// FIXME: proper log. And send emails if too many of these!
console.log("login err:" + err);
}
await sleep(randomInt(1000, 1500));
return resultUser;
}
const getUser = async (orgName: string, email: string) => {
// 1. Validate and normalize orgName and email if you haven't already
// Usually you want length<100 and ASCII.
// 2. Get db handle for orgName
const db = await DatabaseManager.connect(orgName);
// 3. Get info user from db
const users = await db.query("SELECT * FROM users WHERE email=@P0", {param: "P0", type: "string", value: email});
return users[0]; // undefined if not found.
}
const dbUserToSessionUser = (dbUser: any) => {
// filter field we don't need
return {id: dbUser.id, name: dbUser.name, email: dbUser.email};
}
Oneway hashing functions. Adjust the defaults for faster/slower runtime.
import crypto from "crypto";
export interface ICredentials {
salt: string;
hash: string;
}
export const pbkdf2_makeHash = async (password: string, optionsIn?: HashingOptions): Promise<ICredentials> => {
const p = new Promise<ICredentials>((res, rej) => {
const options = defaultOptions(optionsIn);
crypto.randomBytes(options.saltlen, function (err, buf) {
if (err) { return rej(err); }
var salt = buf.toString(options.encoding);
crypto.pbkdf2(password, salt, options.iterations, options.keylen, options.digestAlgorithm, (err, hashRaw) => {
if (err) { return rej(err); }
var hash = hashRaw.toString(options.encoding);
res({ salt: salt, hash: hash });
});
});
});
return p;
}
export const pbkdf2_verifyHash = async (password: string, credentials: ICredentials, optionsIn?: HashingOptions) => {
const p = new Promise<boolean>((res, rej) => {
const options = defaultOptions(optionsIn);
try {
crypto.pbkdf2(password, credentials.salt, options.iterations, options.keylen, options.digestAlgorithm, function (err, hashRaw) {
if (err) { return rej(err); }
try {
const sourceHashRaw = Buffer.from(credentials.hash, options.encoding);
const result = crypto.timingSafeEqual(hashRaw, sourceHashRaw);
res(result);
} catch (e) {
rej(e);
}
});
}
catch (e) {
rej(e);
}
});
return p;
}
export interface HashingOptions {
saltlen: number;
encoding: BufferEncoding;
iterations: number;
keylen: number;
digestAlgorithm: string;
}
function defaultOptions(optionsIn?: HashingOptions) {
const options: HashingOptions = {
saltlen: optionsIn?.saltlen || 32,
encoding: optionsIn?.encoding || 'hex',
iterations: optionsIn?.iterations || 32000,
keylen: optionsIn?.keylen || 512,
digestAlgorithm: optionsIn?.digestAlgorithm || 'SHA256',
}
return options;
}
Important
Do not send a special error to the browser if you haven't found the user.
Use a single user not found or invalid password 403 response.
Keep a log of user authentications with the user name, IP and the User-Agent.
Use a rate-limiter for your login function.
A single IP (or username) should not be allowed to call the endpoint more than X times a second (5 is a nice number).
Of course this only applies if you cache the authenticated state in a cookie.
If your case requires authentication of every request this would be not practical.
Just keep in mind that the hash algs are designed to be slow!
Always add a 1 second (or more) random delay (whether we succeed or fail!)
This will make it harder for an attacker to just try many alternatives.
Make sure you add the delay, even in the user-record not found case or the success-case.
Marketing name aside, these are just simple one time passwords sent to the user’s email.
You want to make them hard to guess (obviously) and you want to make them valid for a short time.
If this sounds like a glorified password reset scheme, that is exactly what it is (minus the new password typing).
Since you already built a forget password functionality, this should be easy to do.
Recipe is very similar to the createUser function.
const generateMagicLogin = async (orgName: string, email: string, redirect: string|undefined, reason: string|undefined) => {
const tempPwd = crypto.randomBytes(50);
const pwd = tempPwd.toString("hex");
// Generate one time password
const hh = await pbkdf2_makeHash(pwd);
const maxValid = (new Date((new Date().valueOf()) + 300 * 1000)).toISOString();
// Get user
const user = await getUser(orgName, email);
if (!user) {
sleep(randomInt(2000, 3000));
return; // Do not report an error! Just tell the browser an email was sent.
}
// Update user
const makeParam = (value: string, index: number) => ({name: "P" + index, type: "string", value: value});
const db = await DatabaseManager.connect(orgName);
await db.nonQuery("UPDATE table_name SET magic_hash=@P1, magic_salt=@P2, magic_date=@P3 WHERE id=@P0",
[user.id, hh.hash, hh.salt, maxValid]);
// Create link
let url = "https://yourservice.com/magic?q=" + encodeURIComponent(pwd) + "&u=" + encodeURIComponent(email) + "&o=" + encodeURIComponent(orgName);
if (redirect)
url += "&ret=" + encodeURIComponent(redirect);
if (reason)
url += "&reason=" + encodeURIComponent(reason);
const body = `Hi ${user.name}
You have requested a magic link login.
If you haven't disregard this email.
click here to login to ${orgName}
${url}
`;
sendEmail([user.emailaddress], [], [], "Sign in to " + orgName, body);
}
I prefer not to be responsible for educating users on security. Especially whether or not it is good to have passsords on a sticky note.
Security is a though process of technology, education and constant vigilance. Therefore I try to defer to the experts whenever possible.
OAuth allows us to defer all the password stuff to security experts.
It is convenient for users, since they don't need to remeber one more password.
OAuth is widely used by organizations, since it helps them centralize user management.
The idea is quite straightforward.
I'm sure that at this point almost everyone used or at least seen a "Sign-in with Google" button. The alg above is what is going on.
Who provides this OAuth functionality?
...
We will implement OAuth for Nodejs and Express with the help of the great Passportjs package.
First we need to put a button for Sign-in with Google (and/or other providers) on our login page. This button should call this:
const doLogin = (provider: string = "google") => {
let origin = window.location.origin;
let orgName = "user_multi_tenant_organization"; // this is usually the subdomain eg: some_company.yourservice.com
// debug code for when origin is localhost
const url = origin + "/auth/" + provider + "/" + orgName + "/?origin=" + encodeURIComponent(window.location.origin);
window.location.assign(url);
}
Then in nodejs we will handle the call.
Please read the custom state part carefully.
We have to work-around a problem here.
With a multitenant service we usually want a domain for each customer like company.yourservice.com
.
But, no provider will let us add a redirect url for every customer we have!
To fix this we have to:
const app: Express = express();
// Redirect the user to the Google signin page
app.get("/auth/google/:orgname", (req, res, next) => {
executeGoogleAuth(req, res, next);
});
const executeGoogleAuth = (req:Request, res: Response, next: NextFunction, signup?: boolean) => {
const nonce = crypto.randomBytes(19).toString("hex");
const currentUrlStr = (req.header("x-forwarded-proto") || req.protocol) +
"://" +
(req.header("x-forwarded-host") || req.headers.host) || "";
// Store the original domain in the state param (eg: user_company.myservice.com)
const customState = nonce + (req.query["origin"] || currentUrlStr);
const session = req.session as any;
session["orgName"] = req.params.orgname;
session["redirectUrl"] = req.query["redirectUrl"];
session["google_nonce"] = nonce;
if (signup) {
session["signup"] = 1;
}
passport.authenticate("google", { scope: ["email"], state: customState } as any)(req, res, next);
}
// Called by google
app.get("/auth/google/callback", (req, res, next) => {
const customState = (req.query.state as string || "");
const redirect = customState.substring(38); // First 19 bytes * 2 to Hex are a non-reply nonce, skip.
const nonce = customState.substring(0, 38);
oauthCommonHandleSubDomainRedirect(redirect, req, res, () => {
if ((req.session as any)['google_nonce'] !== nonce) {
console.log("nonce error: got:" + (req.session as any)['google_nonce'] + "\nExpected: " + nonce);
next(new Error("Authentication Failed"));
}
next();
});
},
(req, res, next) => {
passport.authenticate("google", { session: true })(req, res, next);
},
(req, res) => {
// User is logged in!
res.redirect("/dashboard"); // your service home page
}
);
const oauthCommonHandleSubDomainRedirect = (customState: string, req: Request, res: Response, next: ()=>void) => {
if (customState) {
const customerOrigin = new URL(customState);
const currentUrlStr = (req.header("x-forwarded-proto") || req.protocol) +
"://" +
(req.header("x-forwarded-host") || req.headers.host) || "";
const currentUrl = new URL(currentUrlStr);
// called on myservice.com instead of usercompany.mysevice.com -> redirect to
if (currentUrl.origin !== customerOrigin.origin) {
let path = req.originalUrl;
if (path[0] !== '/')
path = "/" + path;
//console.log("OAUTH subdomain redirect: " + path);
res.redirect(307, customState + path);
return; // Do not call next(). Auth will resume in the redirect.
}
}
next();
}
Let's setup google auth with Passportjs.
import passport from "passport";
import { Strategy as GoogleStrategy } from "passport-google-oauth2";
app.use(passport.initialize());
app.use(passport.session());
passport.use(new GoogleStrategy({
clientID: GOOGLE_CLIENT_ID,
clientSecret: GOOGLE_CLIENT_SECRET,
callbackURL: process.env.NODE_ENV === "dev" ? "http://localhost:3001/auth/google/callback" : "https://yourservice.com/auth/google/callback",
passReqToCallback: true
},
async (req: any, accessToken: string, refreshToken: string, profile: any, done: any) => {
try {
const orgName = sanitizeOrgName((req.session as any)["orgName"]);
const user = await getUser(orgName, profile.email);
if (user) {
return done(null, { googleId: profile.id, id: user.id, username: user.name, orgName: orgName });
}
// Uer not found? Registering a new user?
if (!(req.session as any)["signup"]) {
return done(new Error("User not found!"), false);
}
const pwd = crypto.randomBytes(20).toString("hex");
const userId = await createUser(orgName, profile.displayName, profile.email, pwd);
return done(null, { googleId: profile.id, id: userId, username: profile.displayName, orgName: orgName });
} catch (error) {
return done(error, false)
}
}));
Microsoft
Add a Sign-in with Microsoft button and call doLogin("azure")
.
In nodejs we will handle it, in the code below. Please note, that azure allows us to get the callback with POST (great for debugging).
// Redirect the user to the Google signin page
app.get("/auth/azure/:orgname", (req, res, next) => {
// Pass user_company.myservice.com origin as custom state.
// The azure passport package will add a nonce for us.
const customState = req.query["origin"];
(req.session as any)["orgName"] = req.params.orgname;
(req.session as any)["redirectUrl"] = req.query["redirectUrl"];
passport.authenticate("azure", { customState: customState } as any)(req, res, next);
});
// Retrieve user data using the access token received
// OBSERVE we use POST here
app.post("/auth/azure/callback", (req, res, next) => {
const customState = (req.body.state || "").substring(38); // Skip nonce random chars
oauthCommonHandleSubDomainRedirect(customState, req, res, next);
}, passport.authenticate("azure", { session: true }),
(req, res) => {
// User is logged in!
res.redirect("/dashboard"); // your service home page
}
);
Passportjs setup for Azure.
import { OIDCStrategy } from "passport-azure-ad";
// Register your app in Azure to get the configuration. In production, load this from env instead.
const AZURE_CLIENT_ID = "";
const AZURE_CLIENT_SECRET = "";
// https://stackoverflow.com/questions/57665008/where-do-i-find-the-issuer-url-in-azure-active-directory
const AZURE_ISSUER_ID = "https://sts.windows.net/your-tenant-id";
passport.use("azure", new OIDCStrategy({
identityMetadata: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
clientID: AZURE_CLIENT_ID,
clientSecret: AZURE_CLIENT_SECRET,
responseType: 'code id_token',
issuer: AZURE_ISSUER_ID,
// We want azure to call our post endpoint
responseMode: 'form_post',
redirectUrl: process.env.NODE_ENV === "dev" ? "http://localhost:3001/auth/azure/callback" : "https://yourservice.com/auth/azure/callback",
allowHttpForRedirectUrl: process.env.NODE_ENV === "dev",
// Choose the scopes that make sense for your service.
scope: "openid profile email offline_access Files.Read Files.ReadWrite.All Sites.ReadWrite.All User.Read Calendars.Read.Shared",
passReqToCallback: true,
validateIssuer: false,
}, async function (req: Request, iss: any, sub: any, profile: any, accessToken: any, refreshToken: any, done: VerifyCallback) {
try {
const orgName = sanitizeOrgName((req.session as any)["orgName"]);
const user = await getUser(orgName, profile._json.email || profile._json.unique_name);
if (user) {
return done(null, { googleId: profile.id, id: user.id, username: user.name, orgName: orgName, refreshToken: refreshToken });
}
if (!(req.session as any)["signup"]) {
return done(new Error("User not found!"), false);
}
const pwd = crypto.randomBytes(20).toString("hex");
const userId = await createUser(orgName, profile.displayName, profile.email, pwd);
return done(null, { googleId: profile.id, id: userId, username: profile.displayName, orgName: orgName });
} catch (error) {
return done(error, false)
}
}));
As you can see the underlying algorithm is 99% similar.
But there is some non-trivial setup for each of the provider.
So decide which ones make sense for your service and users.
Before users can login with a provider's OAuth you will have to register your service with each provider.
http://localhost:3001/auth/google/callback
https://yourservice.com/auth/google/callback
Test that login works and then record a short video and put in on youtube (private link). In the video:
Once the video is uploaded, add the link in the OAuth consent screen. Google will review and approve your app.
Microsoft / Azure
Now it gets a little hairy.
For multitenant apps, you will have to become an office Microsoft Partner.
The partnership is free (as of 2024), but it might take a few days to complete.
Register here
Once you are a MS partner, create the app registration.
It might complain that you need MFA enabled for your azure organization.
It is enough to start a free trail and enable MFA.
(Yes this is quite painful.)
Apple Update from the future, checkout our Sign in with Apple blog.
I hope the code was useful on your way to a secure multitenant SaaS service.
(Btw. I would love to hear about it.)
Security is a process
Please keep it in mind. No matter which solution(s) you will end up using.
You always have to educate (yourself and your users), monitor and improve.
Happy hacking!