Autentizace v single-page aplikacích

Implementace přihlašování do aplikací, které jsou postaveny jako single-page, je obvykle řešeno jinak než u klasických server-side aplikací. Jak dosáhnout bezstavovosti serverové části a jak vytvořit chytré přihlašování do administrace v prostředí AngularJS je téma dalšího dílu.

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

Úvod

Článek bude rozdělen do dvou dílů. V tom dnešním bude řešena autentizace na straně prohlížeče a ve frameworku AngularJS. V tom příštím se podíváme na serverovou část, na implemenetaci v Node.js a na knihovnu Passport. Všechny zdrojové kódy dnešního dílu jsou jako obvykle dostupné na Githubu, můžete si je také stáhnout příkazem git checkout -f eshop014.

V tomto díle se budeme věnovat jen autentizaci, což je proces ověřování proklamované identity uživatele (wikipedia). Uživatel nám svěří své přihlašovací údaje a my se podíváme do databáze, zda někdo takový existuje. Pokud takový uživatel existuje, proběhla autentizace úspěšně.

Tradiční způsob řešení autentizace

Nejčastěji se vše řeší tak, že pokud uživatel potřebuje přistupovat do části, ve které již musí být přihlášen, přejde na přihlašovací formulář. Zde zadá svůj e-mail a heslo a formulář odešle. Na straně serveru se dotazem do databáze ověří, zda jsou přihlašovací údaje správné. Pokud ano, načtou se všechny potřebné údaje o uživateli a vytvoří se relace, což znamená, že na straně serveru se vygeneruje unikátní řetězec, který se jednak vloží do cookie a pošle se uživateli zpět, ale také se společně s vybranými údaji o uživateli někam na serveru uloží. Při příštím požadavku si server z cookie přečte onen unikátní řetězec, přes který načte dříve uložená data, takže uživatel již znova nemusí svojí identitu ověřovat a obejde se tím bezstavovost HTTP.

Autentizace u single-page aplikací

V single-page aplikacích využívajících REST API se tento způsob přihlašování nepoužívá. Jedním z principů REST architektury je bezstavovost, což přináší u SPA řadu výhod (třeba nemusíme řešit session management, expiraci session na serveru, atd.). Chceme-li však dosáhnout bezstavovosti, musí být v každém požadavku zaslány informace pro ověření identity. Jak toho dosáhnout? Možností je několik, dále popisuji metodu, kterou používám já.

Nejjednodušší způsob je použít basic HTTP autentizaci. Ta využívá hlavičku Authorization, jejíž hodnotou je uživatelské jméno (email) a heslo, které jsou uloženy ve formátu base64. Na straně serveru zjišťujeme, zda nějaký HTTP požadavek vyžaduje ověření identity, a pokud ano, stačí vzít data z hlavičky Authorization a ověřit, že uživatel má k danému zdroji přístup. Pokud nemá, zastavíme zpracování a vrátíme HTTP kód 401.

Základní HTTP autentizace má nevýhodu v tom, že posílá heslo v otevřeném nezašifrovaném formátu. Pokud by tedy někdo odposlouchával HTTP požadavky uživatele, snadno by heslo uživatele zjistil. Proto je lepší používat všude HTTPS, čímž je komunikace šifrovaná a s posíláním citlivých údajů v hlavičce pak problém není. Z pohledu výkonu serveru už dnes nepředstavuje použití HTTPS problém (odkaz vede na online verzi knihy High Performance Browser Networking od Ilya Grigorika, který je specialistou na výkon aplikací v Google a rozhodně ji vřele doporučuji k přečtení).

Ještě je potřeba vyřešit jeden problém. Co situace, kdy uživatel načte stránku znova? Předtím se přihlásil a přihlašovací údaje jsme drželi v paměti, ale pokud provede reload, vše se ztratí. Bude tedy potřeba někde uložit přihlašovací údaje, odkud je pak načteme do hlavičky Authorization při dotazech na API. Museli bychom však ukládat někam heslo v otevřené podobě, a to rozhodně nepatří mezi nejlepší praktiky.

To by šlo vyřešit tak, že bude u každého uživatele v databázi uveden ještě náhodný vygenerovaný hash, který po přihlášení uživatel dostane, a ten bude uložen na straně klienta a bude zasílán místo hesla. Jenže je tady jiný problém. Co se stane, když nějaký útočník získá kopii databáze? Heslo se sice nedozví, ale může nastavit sám hlavičku Authorization se zjištěným hashem a přihlásit se jako jakýkoliv jiný uživatel.

To lze vyřešit tím, že budeme generovat hash z hesla, a to dvakrát. Při registraci se z hesla vygeneruje první hash (označme ho pro účely článku proměnnou x) pomocí nějaké funkce (pro jednoduchost třeba SHA1 + nějaký salt)  a tento hash se použije pak jako vstup do další funkce (třeba opět SHA1 + nějaký jiný salt), čímž se vygeneruje druhý hash (který označíme pro účely článku proměnnou y):

//zaslane heslo od uživatele
var password = 'abc123456';

//sůl pro ztížení získání hesla z hashe
var salt1 = 'xyz1';
var salt2 = 'xyz2';

//generování prvního hashe
var x = sha1(password + salt1);

//generování druhého hashe - ten bude uložen v databázi
var y = sha1(x + salt2);

Jak pak bude probíhat přihlašování?

  1. Uživatel přistupuje do zabezpečené oblasti, bude vyzván k tomu, aby zadal své uživ. jméno (email) a heslo.

  2. Uživatel odešle formulář, prohlížeč zašle dotaz na API, zda existuje uživatel s daným uživ. jménem a heslem.

  3. Server vytvoří dvakrát hash podle dříve uvedeného postupu a podívá se, zda existuje záznam v databázi pro dané uživ. jméno a získaný hash z hesla (proměnná y).

  4. Pokud záznam existuje, vrátí uživateli odpověď, ve které odešle první vygenerovaný hash (promměná x).

  5. Prohlížeč si vrácený hash uloží (může to být cookie, sessionStorage atd.), aby se uživatel nemusel přihlašovat znova po reloadu.

Jak pak bude vypadat dotaz na API, kde je vyžadováno přihlašování?

  1. Framework nastaví HTTP hlavička Authorization s uživ. jménem a uloženým hashem (proměnná x) a odešle požadavek na API.

  2. Server zjišťuje, že se uživatel ptá na zabezpečené údaje. Podívá se proto, zda je zaslána HTTP hlavička Authorization, pokud ano, vybere z ní uživ. jméno a hash (proměnná x).

  3. Server vygeneruje druhý hash (proměnná y) z předaného hashe (proměnná x) v hlavičce Authorization a podívá se do databáze, zda daný uživatel existuje.

  4. Pokud ano, předá požadavek dále, pokud ne, vrátí chybu, po které bude uživatel vyzván k přihlášení.

Tím je zajištěno, že je komunikace bezstavová (tedy serverová část) a zároveň bezpečná.

Stav udržuje prohlížeč, takže třeba pro odhlášení není potřeba zasílat dotaz na server, ale stačí jen smazat získaný hash.

Vše budeme řešit na straně serveru přes framework Passport, který je pro Node.js nejlepší. Dříve byl populárnější Everyauth, doporučuji ales spíše použití Passportu kvůli snadnější rozšiřitelnosti. Kromě toho podporuje obrovské množství provideru (mj. Facebook, Twitter, Google atd.) a různých strategií pro přihlašování.

V našem případě uděláme ještě jednu malou změnu. Server nebude vracet jen daný hash (proměnnou x), ale vrátí rovnou kombinaci e-mailu a prvního hashe (proměnná x) v base64, a tento řetězec budeme dále v článku označovat jako autorizační token (authToken).

Autentizace u AngularJS aplikací

Použijeme upravené řešení, které popsal na svém blogu Witold Szczerba. V následujícím textu budeme používat vše, co již bylo v rámci seriálu popsáno, pokud jste však některé díly nečetli, pak je potřeba znát:

  • jak funguje zasílání zpráv v hierarichi scope (popsáno v 8. díle),

  • návrhový vzor Promise, response interceptors (popsáno ve 12. díle).

Jak bude tedy celý postup přihlašování na klientské straně vypadat?

  1. Nepřihlášený uživatel přijde na stránku, za které se dotazujeme na zabezpečená data. Prohlížeč zašle dotaz na API bez ohledu na to, zda je uživatel přihlášen či nikoliv.

  2. Server zjistí, že se uživatel ptá na data, která jsou k dispozici jen přihlášeným uživatelům. Podívá se tedy do hlavičky Authorization (popsáno dříve) a zjišťuje, že je uživatel nepřihlášen. Proto odešle HTTP kód 401.

  3. Aplikace zaregistruje, že byla vrácena tato chyba. Uchová informace o všech dotazech, které byly vráceny s HTTP kódem 401 a vyšle zprávu všem registrovaným posluchačům uvnitř aplikace, že je vyžadováno přihlášení.

  4. Tuto zprávu zachytí direktiva login, který zobrazí formulář pro přihlášení, a controller LoginCtrl, který bude formulář obsluhovat. Uživatel vyplní přihlašovací údaje a odešle formulář.

  5. Server vrátí authToken, který framework uloží do sessionStorage (nebo cookie) a nastaví hlavičku Authorization pro příští požadavky. Dále bude vyslána zpráva do aplikace, že přihlášení proběhlo úspěšně, což opět zaregistruje direktiva login, která přihlašovací formulář skryje.

  6. Protože jsme dříve všechny informace o neúspěšných požadavcích uložili, máme je k dispozici a po úspěšném přihlášení se pokusíme o jejich odeslání znova, takže uživatel nemusí provádět žádnou další akci a vše probíhá dále, jako kdyby vůbec přihlašování neproběhlo.

Řešení je tedy stejné jak pro situace, kdy poprvé přistupujeme jako nezaregistrovaní do nějaké zabezpečené sekce, tak pro situaci, kdy už jsme byli přihlášeni a s aplikací normálně pracujeme ale třeba aplikaci používáme na dvou místech zároveň (na tabletu a na mobilu) a třeba v tabletu změníme heslo. Pak se změní i authToken a objeví se na telefonu formulář pro přihlášení a uživatel je vyznán k zadání nového hesla i zde. Tím se řeší další bezpečnostní problém mnoha různých aplikací, kdy po změně hesla nejsou uživatelé okamžitě odhlášeni ze všech ostatních používání aplikace, takže když dojde třeba ke krádeži telefonu, přestože uživatel změní heslo, útočník si může pak s aplikací dělat co chce a uživatel je proti tomu zcela bezmocný.

Mimochodem bezstavovost na straně serveru + SPA architektura řeší ještě jeden velmi vážný bezpečnostní problém velkého množství aplikací, a to je obrana proti útoku CSRF. Zabezpečit se proti němu samozřejmě lze i v klasických aplikacích, ale vyžaduje to trochu nepříjemné práce navíc, což často vede k tomu, že je značná část webů k tomuto druhu útoku náchylná. Stačí v podstatě jen zaslat vybrané oběti mail s odkazem, který odklikne a dostane se na útočníkův web, kde už může útočník pod účtem uživatele provádět dotazy kamkoliv se mu zachce. Co třeba dostat majitelku konkurečního e-shopu na stránku, kde pod jeho účtem zvýšíme ceny zboží o 10%, takže u něj nikdo nebude chtít nakupovat? Stačí zaslat jeden mail ve stylu “Dobrý den, chci vás upozornit, že váš partner umístil na web fotografie z vašeho intimního života, víte o tom? Podívejte se.” Kolik lidí na něj asi odklikne?

Implementace v AngularJS

Vše bude vytvořeno tak, abychom řešení naprogramovali jen jednou a použili ho i na ostatních aplikacích. Budeme potřebovat několik částí:

  • službu error401, která detekuje, že přišla odpověď s HTTP kódem 401, uloží informace o odeslaném požadavku a vyšle notifikaci, že je potřeba přihlásit se;

  • službu authNotifier, která se bude starat o všechny notifikace, které se autentizace týkají (aby vše bylo na jednom místě);

  • službu requestStorage, která bude sloužit jako uložiště pro odeslané požadavky, která vyžadovaly autentizaci;

  • službu auth, která bude vše řídit, bude ukládat či mazat authToken, bude pracovat s HTTP hlavičkou Authorization, přepošle znova požadavky a zpracuje také pokus o přihlášení uživatele;

  • direktivu login, která se bude starat o zobrazení formuláře pro přihlášení, když to bude potřeba;

  • šablonu pro přihlašovací formulář;

  • controller LoginCtrl, který bude přihlašování obsluhovat.

Služba error401

angular.module('zdrojak.service').factory('error401', ['$q', 'authNotifier', 'requestStorage', function($q, authNotifier, requestStorage){
  return function(promise)  {
    return promise.then(null, function(res){
      if (res.status !== 401) return promise;

      var deferred = $q.defer();
      var req = {
        config: res.config,
        deferred: deferred
      };
      requestStorage.add(req);
      authNotifier.notifyRequired();
      return deferred.promise;
    });
  };
}]);

Pokud má odpověď HTTP kód 401, vytvoří se nová promise, která se jako odpověď přepošle dále, což znamená, že aplikace bude čekat na další zpracování, dokud nebude promise vyřešena. My si ji uložíme společně s konfigurací požadavku (req.config) do requestStorage a vyšleme notifikaci aplikaci, že je potřeba vyřídit přihlášení.

Služba se zaregistruje jako interceptor v app.js podobně, jako jsme to dělali se službou error4xx:

module.config(['$httpProvider', function($httpProvider){
  $httpProvider.responseInterceptors.push('error401');
  $httpProvider.responseInterceptors.push('error4xx');
}]);

Služba authNotifier a requestStorage

angular.module('zdrojak.service').factory('authNotifier', ['$rootScope', function($rootScope){
  function AuthNotifier() {}
  AuthNotifier.prototype.onRequired = function(scope, cb) {
    scope.$on('auth:loginRequired', cb);
  };
  AuthNotifier.prototype.onConfirmed = function(scope, cb) {
    scope.$on('auth:loginConfirmed', cb);
  };
  AuthNotifier.prototype.notifyRequired = function() {
    $rootScope.$broadcast('auth:loginRequired');
  };
  AuthNotifier.prototype.notifyConfirmed = function() {
    $rootScope.$broadcast('auth:loginConfirmed');
  };
  return new AuthNotifier();
}]);
angular.module('zdrojak.service').factory('requestStorage', function(){
  function RequestStorage() {
    this.requests = [];
  }
  RequestStorage.prototype.clear = function() {
    this.requests = [];
  };
  RequestStorage.prototype.getAll = function() {
    return this.requests;
  };
  RequestStorage.prototype.add = function(req) {
    this.requests.push(req);
  };
  return new RequestStorage();
});

Služba authNotifier pouze shlukuje práci s notifikacemi do jednoho místa, requestStorage je uložiště pro všechny neúspěšně poslané requesty, abychom je mohli znova přeposlat.

Šablona pro login, direktiva login a controller LoginCtrl

<form ng-show="mode" class="form-signin" ng-submit="login()">
  <h2>Přihlašování</h2>
  <messages></messages>
  <input ng-model="email" type="text" class="input-block-level" placeholder="E-mail" autofocus required>
  <input ng-model="password" type="password" class="input-block-level" placeholder="Heslo" required>
  <input class="btn btn-large btn-primary" value="Přihlásit se!" type="submit">
</form>
angular.module('zdrojak.directive').directive('login', ['authNotifier', function(authNotifier){
  var config = {
    restrict: 'E',
    templateUrl: '/partials/admin/login.html',
    replace: true,
    scope: {},
    controller: 'LoginCtrl',
    link: function(scope, element) {
      authNotifier.onRequired(scope, function(){
        scope.mode = true;
      });
      authNotifier.onConfirmed(scope, function(){
        scope.mode = false;
      });
    }
  };
  return config;
}]);
angular.module('zdrojak.controller').controller('LoginCtrl', ['$scope', 'auth', 'flash', function($scope, auth, flash) {
  $scope.login = function() {
    auth.login($scope.email, $scope.password, null, function(){
      //oznamit uzivateli chybu...
    });
  };
}]);

Pokud direktiva zaregistruje notifikaci o potřebě zobrazit přihlašovací formulář, tak to udělá. Když uživatel odešle formulář, vyvolá se metoda $scope.login() v LoginCtrl.

V konfiguraci direktivy si všimněte vlastnosti templateUrl, která umožňuje načíst šablonu odjinud a nedefinovat HTML přímo uvnitř direktivy. Také nově specifikujeme controller, který se má pro direktivu načíst. Oboje je výhodné oddělit, protože tím zůstane direktiva univerzální pro všechny aplikace, zatímco šablona i controller se může pro různé aplikace měnit.

Služba auth

angular.module('zdrojak.service').factory('auth', ['$http', '$window', 'authNotifier', 'requestStorage', 'api', function($http, $window, authNotifier, requestStorage, api){
  return new Auth($http, $window.sessionStorage, requestStorage, authNotifier, api);
}]);
function Auth($http, tokenStorage, requestStorage, notifier, api) {
  this.$http = $http;
  this.tokenStorage = tokenStorage;
  this.requestStorage = requestStorage;
  this.notifier = notifier;
  this.api = api;
}

Auth.TOKEN = 'authToken';

Auth.prototype.getToken = function() {
  return this.tokenStorage.getItem(Auth.TOKEN);
};

Auth.prototype.setToken = function(token) {
  this.tokenStorage.setItem(Auth.TOKEN, token);
};

Auth.prototype.initHeaders = function() {
  var token = this.getToken();
  if (!token) return false;
  this.setHeader(token);
};

Auth.prototype.setHeader = function(token) {
  this.$http.defaults.headers.common.Authorization = 'Basic ' + token;
};

Auth.prototype.clearHeader = function() {
  delete this.$http.defaults.headers.common.Authorization;
};

Auth.prototype.retry = function(req) {
  this.$http(req.config).then(function(response) {
    req.deferred.resolve(response);
  });
};

Auth.prototype.resendRequests = function() {
  var requests = this.requestStorage.getAll();
  for (var i = 0; i < requests.length; i++) {
    this.retry(requests[i]);
  }
  this.requestStorage.clear();
};

Auth.prototype.login = function(email, password, successCb, errorCb) {
  successCb = successCb || function() {};
  errorCb = errorCb || function() {};

  this.clearHeader();

  var credentials = {
    email: email,
    password: password
  };

  var auth = this;

  this.api.user.auth(credentials, function(res){
    if (res.authToken) {
      auth.setToken(res.authToken);
      auth.setHeader(res.authToken);
      auth.resendRequests();
      auth.notifier.notifyConfirmed();
      successCb(res);
    } else {
      errorCb(res);
    }
  });
};

Služba auth volá třídu Auth, které předává všechny důležité parametry. Parametr tokenStorage je uložiště pro authToken. Může to být třída obsluhující cookies, v našem případě pro jednoduchost používáme sessionStorage (rozdíl mezi localStorage a sessionStorage je v tom, že sessionStorage se vymaže, jakmile se zavře prohlížeč).

Metoda setToken(), getToken() buď nastavuje nebo vrací authToken z tokenStorage. Metody setHeader() a clearHeader() nastavují či odstraňují hlavičku, kterou bude AngularJS automaticky zasílat se všemi požadavky. Všechny takové hlavičky jsou uloženy v poli $http.defaults.headers.common. Metoda initHeaders() se zavolá při prvním spuštění aplikace, aby byla hlavička nastavena, pokud už uživatel je přihlášen (třeba po reloadu aplikace). Metoda initHeaders() se volá v bloku run() při spuštění aplikace (viz soubor app.js):

module.run(['auth', function(auth){
  auth.initHeaders();
}]);

Metoda login() zavolá především metodu auth() na $response objektu api.user. Ta zajistí zaslání požadavku pro ověření, zda existuje uživatel pro daný e-mail a heslo. Pokud ano, nastaví authToken do tokenStorage, nastaví hlavičku pro další požadavky, přes metodu resendRequests() přepošle znova požadavky, které dříve neprošly kvůli chybějícímu přihlášení a nakonec vyšle notifikaci, že byl uživatel úspěšně přihlášen.

Co dále

Příště se můžete těšit na druhou část, která bude zaměřena na serverovou část, podíváme se na implementaci v Node.js. Kromě toho se také budeme zabývat tím, aby se uživateli nezobrazovaly odkazy, které vedou tam, kam nemá přístup a podíváme se na to, co třeba  znamená tajemná zkratka CORS.

Na tvorbě tohoto článku se svými připomínkami podílel také Pavel Lang. Díky!

Komentáře: 31

Přehled komentářů

jbub chybicka singe => single
Igor Hlina CORS
jakubvrana Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Vrána Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Břetislav Wajtr Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Vrána Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jirka Kosek Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Vrána Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Vrána Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Vrána Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Vrána Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Břetislav Wajtr Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
Jakub Vrána Re: Nevynalézejte bezpečnostní protokoly
Jakub Mrozek Re: Nevynalézejte bezpečnostní protokoly
ivoszz Sice přicházím ...
Zdroj: https://www.zdrojak.cz/?p=9688