Autentizace v single-page aplikacích – serverová část

Zatímco v minulých dílech byl hlavním tématem frontend, dnes se budeme věnovat pouze serverové části a podíváme se na to, jak lze implementovat autentizaci v Node.js.

Seriál: E-shop pomocí moderních technologií (15 dílů)

  1. Úvodní analýza pro moderní e-shop 4.1.2013
  2. Návrh uživatelské části e-shopu 11.1.2013
  3. Tvorba uživatelské části e-shopu 18.1.2013
  4. Nákupní košík pomocí HTML5 Web Storage 25.1.2013
  5. Tvorba moderního eshopu: kategorie a parametrické hledání 1.2.2013
  6. Tvorba moderního e-shopu: dokončení uživatelské části 8.2.2013
  7. Tvorba moderního e-shopu: plánování administrace 15.2.2013
  8. Tvorba moderního e-shopu: správa objednávek 22.2.2013
  9. Tvorba moderního e-shopu: nahrávání obrázků k produktu 1.3.2013
  10. Tvorba moderního e-shopu: Bower, Yeoman a Gemnasium 15.7.2013
  11. Tvorba moderního e-shopu: HTML5 drag & drop a kategorie 29.7.2013
  12. Tvorba moderního e-shopu: zpracování chyb 12.8.2013
  13. Tvorba moderního e-shopu: Rich-Text Editing a dokončení administrace 26.8.2013
  14. Autentizace v single-page aplikacích 9.9.2013
  15. Autentizace v single-page aplikacích – serverová část 7.10.2013

Všechny zdrojové kódy jsou na Githubu a můžete si je stáhnout příkazem git checkout -f eshop015. Dnes je také dostupné demo (přihlašovací údaje test@example.com, heslo 123).

Úpravy z minulého dílu

V minulém díle byl nastíněn postup, jakým budeme vytvářet přihlašování. Popsané řešení mělo ale jeden nepříjemný problém. Protože se authToken ukládal do HTML5 session storage, existuje k němu i API pro přístup k datům přímo přes JavaScript, a pokud by aplikace obsahovala chyby a umožnila útok XSS, mohl by se útočník přihlásit jako jiný uživatel.

Naštěstí to lze snadno obejít tím, že se authToken uloží do cookie, která bude nastavena s příznakem HTTP-only, což znamená, že je neviditelná pro klientské skriptování. To samo o sobě ještě nestačí, protože zasílání cookie řídí prohlížeč a nemůžeme do toho nijak zasáhnout. Závislost jen na cookie by umožnila útok CSRF, proto potřebujeme posílat ještě jeden jiný authToken, jehož zasílání v HTTP požadavku naopak bude v plné režii aplikace. Navíc bude mít pouze omezenou časovou platnost a pro každou relaci bude jiný. Přesto však nebude potřeba využívat klasické server-side relace.

Jak to bude fungovat

Nejprve nástin toho, jak bude autorizační token vytvořen pro cookie i HTML5 session/local storage. Základní pojmy:

  • fn = funkce pro vygenerování hashe z hesla v plaintextu (např. bcrypt, pbkdf2 ap.),
  • cookie token = to, co bude uloženo v cookie v prohlížeči,
  • storage token = to, co bude uloženo v HTML5 storage,
  • system salt 1 – 3 = unikátní řetězec, “sůl”, která se bude přidávat ke každému heslu,
  • user salt 1 – 3 = unikátní řetězec, “sůl”, která se rovněž bude přidávat ke každému heslu, ale je pro každého uživatele jiná,
  • random string = vygenerovaný náhodný řetězec unikátní pro danou relaci,
  • expirace = datum, kdy má relace vypršet,
  • password hash = řetězec, který bude uložen v databázi.

Vše pak bude vypadat takto:

cookie token = fn (heslo v plaintextu + system salt 1 + user salt 1)
storage token = fn (system salt 2 + user salt 2 + random string, expirace)
password hash = fn (cookie token + system salt 3 + user salt 3)

Při tvorbě nové aplikace se vytvoří 4 náhodné řetězce, které budou sloužit jako sůl. Řetězec 1 až 3 bude sloužit pro vytvoření tokenů a hesla, poslední 4. řetězec pro podpis cookie (aby ji nemohl uživatel změnit, viz dále).

Jak bude vypadat založení nového uživatele

Uživatel zadá svůj e-mail a heslo. V databázi se vytvoří 3 náhodné řetězce a výše uvedeným postupem se vytvoří i hash hesla.

Jak bude vypadat přihlášení uživatele

Uživatel zadá svůj e-mail a heslo. Vytvoří se nejprve cookie token a z něj i password hash. V cookie se také odesílá ID uživatele, pomocí něj se vybere z databáze uživatel a porovná se uložený password hash s tím, který byl právě vygenerován z přihlašovacích údajů. Jestliže je vše v pořádku, vygeneruje se náhodný řetězec a nastaví se datum expirace, do kdy má být uživatel přihlášen. Tyto údaje pak slouží k vytvoření storage token.

Po úspěšném přihlášení se tedy uživateli odešle storage token v JSON odpovědi a jedna cookie, která se skládá ze čtyř částí: cookie token, random string, datum expirace a ID uživatele. Cookie bude navíc podepsaná, takže není možné měnit její hodnotu.

Jak bude vypadat kontrola přístupu k chráněným datům

Nejprve se ověří, zda je cookie v pořádku a zda nedošlo k změně jejího podpisu. Dále se podle daného ID uživatele vyberou jeho data. Z cookie se přečte random string a datum expirace. Ze všech těchto údajů se vytvoří storage token a porovná se s tím, který je zaslaný. Porovná se navíc i datum expirace, zda je vše v platnosti. Pokud je vše v pořádku vytvoří se z cookie tokenu password hash a porovná se s tím, který je uložen v databázi. Jestliže vše souhlasí, je požadavek prohlášen za oprávněný a může být vyřízen.

Zabezpečení proti útokům

Byť na první pohled vypadá vše velmi složitě, ve skutečnosti se jedná o poměrně snadný a nenáročný způsob přihlašování, který navíc automaticky řeší zabezpečení proti řadě útoků.

Celá komunikace s citlivými daty je šifrovaná a probíhá přes HTTPS. Každá aplikace, která pracuje s hesly, musí protokol HTTPS používat minimálně pro login, změnu hesla ap., protože lidé často používají jedno heslo pro všechno, a tedy přestože může být jedna aplikace velmi dobře zabezpečená, pokud uživatel použije stejné heslo i na jiném webu, kde komunikace není šifrovaná, útočník pak může použít údaje ze špatně zabezpečeného webu pro přihlášení do správně zabezpečené aplikace.

Aplikace je immuní vůči útokům CSRF, jednomu z nejrozšířenějších útoků, proti kterému je zabezpečno nepříliš mnoho aplikací. I kdybychom uživateli podstrčili nějakou URL s dotazem na API, sice by se mohl uživatel přihlásit s cookie, ale již nebude odeslán storage token, takže bude požadavek zamítnut. V single-page aplikacích se neprovádějí citlivé operace na jiné URL (třeba /articles/delete/123) jako u klasických server-side aplikací, ale tyto akce jsou vždy vyvolány nějakou událostí v JavaScriptu (např. kliknutí na odkaz “Smazat článek”), kterou z jiného webu však vyvolat nelze.

Automatické odhlášení ze všech ostatních zařízení při změně hesla. Když si třeba pro Google účet změníte heslo, dojde automaticky k odhlášení ze všech zařízení, kde byl uživatel se starým heslem přihlášen. To je logické, protože pokud jsem se s nějakými přihlašovacími údaji někde přihlásil, když už nejsou validní, přihlášen bych tam být neměl. Když pak třeba dojde ke krádeži notebooku, stačí změnit heslo (které bych musel změnit tak či tak, protože si ho prohlížeč pamatuje) a mám jistotu, že v dané aplikaci bude zloděj odhlášen, takže nemůže napáchat už žádnou škodu. I když to jde naprogramovat i s klasickými server-side relacemi, tady to mám zdarma jako bonus, což je velmi výhodné, protože od stavu “můžu to udělat taky” do stavu “mám to uděláno taky” je hodně daleko.

I kdyby náhodou došlo k útoku XSS a uživatel by odcizil session storage, byl by mu k ničemu. Z něj heslo nezíská, protože z hesla tvořen není. Navíc je validní jen po krátkou dobu, protože jeho součástí je datum expirace.

Kdyby někdo odcizil z prohlížeče cookie token (komunikace je šifrovaná, takže by to muselo být jinak než odposloucháváním či útoky, ve kterých se využívá JavaScript jako XSS), je silně nepravděpodobné, že by z něj dokázal původní heslo získat. Hesla jsou chráněna pomalou funkcí PBKDF2 určenou přímo pro hesla. Vytvořený hash se generuje jednak z hesla v plain textu, tak také z náhodného řetězce na straně serveru (server salt) i z náhodného řetězce pro daného uživatele (user salt). Při současném nastavení (viz dále) se tak používá více než 170 znaků dlouhá sůl, která je navíc pro každé heslo jiná. Jinak řečeno, i kdybyste zapojili všechny počítače na této planetě a zkusili útok hrubou silou, nikdy byste původní heslo nezískali.

Kdyby došlo ke krádeži databáze, opět by nebylo možné původní hesla získat. I kdybyste vyzkoušeli všechny kombinace známých hesel + sůl specifickou pro uživatele, stále neznáte sůl, která se používá na straně serveru, takže byste původní hesla neobjevili. A i kdyby došlo ke krádeži jak databáze, tak zdrojových kódů, stále jsme jištěni typem funkce, PBKDF2, která je pomalá a dokáže vyzkoušet minimum kombinací za vteřinu. I když existují tabulky pro nejčastější hashe pro různé funkce, zase je to k ničemu, protože 128 dlouhá uživ. sůl se generuje jako zcela náhodný řetězec a pro tohle již žádné tabulky nejsou. Navíc se heslo generuje jednou z plaintextu a podruhé ještě z cookie token.

Implementace

Pojďme se tedy podívat na to, jak vše implementovat.

Konfigurace

Nejprve si vytvoříme skript lib/auth/config.js, kde si uložíme celou konfiguraci:

exports.options = {};

exports.set = function(options) {
  var required = [
    'systemCookieSalt',
    'systemStorageSalt',
    'systemPasswordSalt',
    'systemSignedCookieSalt',
    'cookieIterations',
    'passwordIterations',
    'cookieKeylen',
    'passwordKeylen',
    'randomBytesSize',
    'maxAge',
    'tokenName',
    'httpHeader'
  ];

  for (var i = 0; i < required.length; ++i) {
    if (!(required[i] in options)) {
      throw new Error('Option ' + required[i] + ' is required.');
    }
  }

  this.options = options;
};

exports.setModel = function(model) {
  this.options.model = model;
};

Funkce set() se volá v konfiguraci aplikace a nastavuje několik důležitých parametrů. Protože se zavolá ještě před obsloužením prvního požadavku, nevadí, že vyhodí výjimku v případě chyby. Model User se nastavuje zvlášť, protože jeho konfigurace závisí na předchozím nastavení (např. na maxAge)..

V souboru config.js pak bude konfigurace vypadat takto:

  authConfig.set({
    'systemCookieSalt': '5GiNxOayeGDEIImNyzsEDspRJLhaIAZsG9vMqnjlXnTgX2ELzk',
    'systemSignedCookieSalt': 'JuOkxMXBquIDQMrojSBz4vGq0EsGLhOQXK78VIri5tPkHH8Wt',
    'systemStorageSalt': 'Kjl6LVkXE2XTw3TE84lP5sebXkNPwAOb6Y9ess7ua2MQim6Wv1',
    'systemPasswordSalt': 'nT.31_F!8z.Q[ of^$PEmWSddddY&cG%n#L|]}',
    'cookieIterations': 10000,
    'passwordIterations': 10000,
    'cookieKeylen': 64,
    'passwordKeylen': 64,
    'randomBytesSize': 64,
    'tokenName': 'authToken',
    'httpHeader': 'X-Authorization',
    'maxAge': 3 * 24 * 60 * 60 //3 dny
  });
  authConfig.setModel(require('./models/User'));

Co jednotlivé hodnoty znamenají?

  • systemCookieSalt – systémová sůl přidávaná do cookie token,
  • systemSignedCookieSalt – sůl pro podpis cookie (aby nebylo možné měnit její hodnotu),
  • systemStorageSalt – systémová sůl přidávaná do storage token,
  • systemPasswordSalt – systémová sůl přidávaná do password hash,
  • cookieIterations, passwordIterations, cookieKeylen, passwordKeylen – nastavení pro funkci PBKDF2, viz dokumentace Node.js,
  • randomBytesSize – velikost náhodně vygenerovaného řetězce pro storage token,
  • tokenName – název cookie, hodnoty v HTML5 storage,
  • httpHeader – přes jakou hlavičku se storage token bude zasílat,
  • maxAge – defaultní hodnota, jak dlouho bude relace platná.

Model User

Dále vytvoříme model User (jak se model vytváří v Mongoose bylo popsáno v 6. díle seriálu o Node.js).

var mongoose = require('mongoose');
var Schema = mongoose.Schema;
var generateHash = require('mongoose-hash');
var config = require(process.cwd() + '/lib/auth/config');
var password = require(process.cwd() + '/lib/auth/plugins/password');

var fields = {
  email: {
    type: String,
    required: true
  },
  name: {
    type: String,
    required: true
  },
  tokenMaxAge: {
    type: Number,
    required: true,
    default: config.options.maxAge
  },
  cookieTokenSalt: {
    type: String,
    required: true
  },
  storageTokenSalt: {
    type: String,
    required: true
  },
  passwordSalt: {
    type: String,
    required: true
  },
  password: {
    type: String,
    required: true
  },
};

var UserSchema = new Schema(fields);

UserSchema.plugin(generateHash, {
  field: 'cookieTokenSalt',
  size: 64
});

UserSchema.plugin(generateHash, {
  field: 'storageTokenSalt',
  size: 64
});

UserSchema.plugin(generateHash, {
  field: 'passwordSalt',
  size: 64
});

UserSchema.plugin(password, {
  field: 'password'
});

module.exports = mongoose.model('User', UserSchema);

Nejprve specifikujeme, jak bude vypadat schéma kolekce users: e-mail, jméno uživatele, několik solí a zahashované heslo.

A také tokenMaxAge. Zde budeme uchovávat dobu, po kterou má být jedna relace validní. Budeme vytvářet jednoho testovacího uživatele, přes kterého budeme spouštět integrační testy, když budeme testovat API a tento uživatel bude mít nastavenu relaci navždy. Kdybychom používali klasické server-side relace, museli bychom se nejprve vždy přihlásit a pro každý test mít jinou hodnotu cookie. V našem případě bude testování mnohem snazší.

Každá sůl bude generovaná pomocí Mongoose pluginu mongoose-hash. Ten automaticky při vytvoření nového uživatele vygeneruje několik náhodných řetězců (cookieTokenSalt, storageTokenSalt, passwordSalt). O Mongoose pluginech jsme se ještě nebavili, jsou to skripty, které se při různých událostech mohou spouštět. Existuje jejich rozsáhlá databáze, každý je plugin je npm baliček. Vlastní pluginy lze vytvářet samozřejmě taky. Takto bude vypadat náš plugin pro vytvoření hesla:

var crypto = require('crypto');
var crypt = require(process.cwd() + '/lib/auth/crypt');

module.exports = exports = function (schema, options) {
  schema.pre('validate', function (next) {

    var field = options.field || 'password';

    if (typeof this[field] === 'undefined') {
      return next();
    }

    password = this[field];

    if (password === '') {
      return next();
    }

    if (!this.isModified(field)) {
      return next();
    }

    crypt.createPassword(password, this.cookieTokenSalt, this.passwordSalt, function(err, result){
      if (err)return next(err);
      this.password = result;
      return next()
    }.bind(this));

  });
};

Plugin se spouští před validací a kontroluje, zda byla změna položka password, pokud ano, vytvoří se nové heslo pomocí funkce crypt.createPassword(), jak uvidíme za okamžik. Plugin tedy bude aplikován automaticky jak při vytvoření uživatele, tak při změně hesla.

Funkce pro vytváření tokenů

Nejprve několik funkcí pro tvorbu tokenů:

var crypto = require('crypto');
var filter = require('password-filter');
var config = require(process.cwd() + '/lib/auth/config');

exports.createSalt = function(systemSalt, userSalt) {
  return systemSalt + '|' + userSalt;
};

exports.createCookieToken = function(passwordPlain, userCookieSalt, cb) {
  var salt = this.createSalt(config.options.systemCookieSalt, userCookieSalt);

  var options = {
    salt: salt,
    iterations: config.options.cookieIterations,
    keylen: config.options.cookieKeylen
  };

  filter(passwordPlain, options, cb);
};

var createStorageToken = function(userStorageSalt, randomString, date) {
  var token = crypto.createHash('sha256')
      .update(config.options.systemStorageSalt)
      .update(userStorageSalt)
      .update(randomString)
      .update(String(date))
      .digest('hex');
  return token;
};

exports.createStorageToken = function(userStorageSalt, date, cb) {
  crypto.randomBytes(config.options.randomBytesSize, function(err, buf) {
    if (err) return cb(err);
    var randomString = buf.toString('hex');
    var randomHash = createStorageToken(userStorageSalt, randomString, date);
    return cb(null, {
      str: randomString,
      hash: randomHash
    })
  });
};

exports.isValidDate = function(expires) {
  var today = Date.now();
  return expires >= today;
};

exports.isValidStorageToken = function(storageToken, userStorageSalt, randomString, date) {
  var result = createStorageToken(userStorageSalt, randomString, date);
  var result = storageToken === result;
  if (!result) return false;
  return this.isValidDate(date);
};

exports.createPassword = function(passwordPlain, userCookieSalt, userPasswordSalt, cb) {
  this.createCookieToken(passwordPlain, userCookieSalt, function(err, cookieToken){
    if (err) return cb(err);
    this.createPasswordFromCookieToken(cookieToken, userPasswordSalt, cb)
  }.bind(this));
};

exports.createPasswordFromCookieToken = function(cookieToken, userPasswordSalt, cb) {
  var salt = this.createSalt(config.options.systemPasswordSalt, userPasswordSalt);

  var options = {
    salt: salt,
    iterations: config.options.passwordIterations,
    keylen: config.options.passwordKeylen
  };

  filter(cookieToken, options, cb);
};

Většina funkcí využíván npm balíček password-filter, který vytvoří PBKDF2 hash z předaných parametrů. Používá se také Node.js modul crypto.

Funkce createCookieToken() vytváří token, který bude zaslán uživateli v cookie, createStorageToken() vytváří také token, který ale bude zaslán v odpovědi uživateli. Využívá funkci SHA256, která je rychlá. Storage token se nevytváří z hesla, takže nevadí použití rychlé funkce. Funkce isValidStorageToken() zkontroluje, zda je storage token validní, a to včetně nastaveného data. Nakonec funkce createPassword() a createPasswordFromCookieToken() slouží k vytvoření hashe, který pak bude uložen v databázi jako heslo.

Přihlášení uživatele

Zdrojový kód pro přihlášení uživatele by mohl vypadat třeba takto:

var config = require(process.cwd() + '/lib/auth/config');
var crypt = require(process.cwd() + '/lib/auth/crypt');
var error = require(process.cwd() + '/lib/error');

module.exports = function() {
  return login;
};

var login = function(req, res, next) {
  var password = req.body.password;

  var condition = {
    email: req.body.email
  };

  config.options.model.findOne(condition, function(err, user){
    if (err) return next(err);
    if (!user) return sendError(next);

    crypt.createCookieToken(password, user.cookieTokenSalt, function(err, cookieToken){
      if (err) return next(err);
      crypt.createPasswordFromCookieToken(cookieToken, user.passwordSalt, function(err, hash){
        if (err) return next(err);

        if (user.password === hash) {
          sendSuccess(res, user, cookieToken, next);
        } else {
          sendError(next);
        }

      });
    });

  });
};

var sendSuccess = function(res, user, cookieToken, next) {

  var expires = Date.now() + user.tokenMaxAge * 1000;

  crypt.createStorageToken(user.storageTokenSalt, expires, function(err, random){
    if (err) return next(err);

    var cookie = {
      token: cookieToken,
      user: user.id,
      randomString: random.str,
      expires: expires
    };

    res.cookie(config.options.tokenName, cookie, {
      expires: new Date(expires),
      httpOnly: true,
      secure: true,
      signed: true
    });

    var response = {};
    response[config.options.tokenName] = random.hash;
    res.send(response);
  });

};

var sendError = function(next) {
  return next(new error.InvalidLoginData());
};

Ve funkci login se nejprve pokusíme vybrat uživatele s daným e-mailem a zkontrolujeme, zda sedí vytvořený password hash s tím, co je v databázi. Pokud ano, vytvoříme storage token a také nastavíme cookie. Musí mít nastavení httpOnly (nelze přečíst JavaScriptem), secure (zasílá se jen při komunikaci přes https) a signed, což je speciální funkce Express frameworku, která umožní funkci podepsat (viz dokumentace frameworku) tak, že pak není možné hodnoty cookie změnit.

Login se pak dá nastavit jako klasické pravidlo v Expressu:

app.post('/users/login', require('./lib/auth/login')());

Odhlášení uživatele

Pro odhlášení stačí smazat storage token na straně klienta a smazat cookie token, což na straně klienta nejde, takže odhlášení bude vypadat takto:

var config = require(process.cwd() + '/lib/auth/config');

module.exports = function() {
  return function(req, res, next) {
    res.clearCookie(config.options.tokenName);
    res.end();
  }
};

Stejně jako v případě přihlášení i logout nastavíme jako klasické pravidlo v Expressu:

app.post('/api/v1/users/logout', require('./lib/auth/logout')());

Ověření identity uživatele

Zbývá skript pro ověření, že uživatel je skutečně přihlášen. Půjde o middleware, které se bude přidávat ke každému pravidlu URL a bude vypadat nějak takto:

var config = require(process.cwd() + '/lib/auth/config');
var crypt = require(process.cwd() + '/lib/auth/crypt');

var hasAccess = function(req, cb) {
  var cookie = req.signedCookies[config.options.tokenName];
  var storage = req.get(config.options.httpHeader);

  if (!(storage && cookie && cookie.user && cookie.token && cookie.randomString && cookie.expires)) {
    return cb(null, false);
  }

  config.options.model.findOne({_id: cookie.user}, function(err, user){
    if (err) return cb(err);
    if (!user) return cb(null, false);

    if (!crypt.isValidStorageToken(storage, user.storageTokenSalt, cookie.randomString, cookie.expires)) {
      return cb(null, false);
    }

    crypt.createPasswordFromCookieToken(cookie.token, user.passwordSalt, function(err, passwordToken){
      if (err) return cb(err);
      var result = passwordToken === user.password;
      return cb(null, result);
    });
  })
};

module.exports = function() {
  return function(req, res, next) {
    hasAccess(req, function(err, result){
      if (err) return next(err);
      if (result) return next();
      res.send(401);
    });
  }
};

Opět se zeptáme na databázi, zda existuje uživatel s daným ID, a poté ověříme, zda je cookie token i storage token validní.

Co dále

Příště tuto část definitivně uzavřeme, budeme se věnovat především komunikaci přes protokol https.

Věděli jste, že nám můžete zasílat zprávičky? (Jen pro přihlášené.)

Komentáře: 4

Přehled komentářů

Petr Blahoš Overkill?
Michal Gebauer Re: Overkill?
Michal Gebauer Re: Overkill?
Václav Oborník Mobilní klient a aplikace třetích stran
Zdroj: https://www.zdrojak.cz/?p=9985