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ů)
- Úvodní analýza pro moderní e-shop 4. 1. 2013
- Návrh uživatelské části e-shopu 11. 1. 2013
- Tvorba uživatelské části e-shopu 18. 1. 2013
- Nákupní košík pomocí HTML5 Web Storage 25. 1. 2013
- Tvorba moderního eshopu: kategorie a parametrické hledání 1. 2. 2013
- Tvorba moderního e-shopu: dokončení uživatelské části 8. 2. 2013
- Tvorba moderního e-shopu: plánování administrace 15. 2. 2013
- Tvorba moderního e-shopu: správa objednávek 22. 2. 2013
- Tvorba moderního e-shopu: nahrávání obrázků k produktu 1. 3. 2013
- Tvorba moderního e-shopu: Bower, Yeoman a Gemnasium 15. 7. 2013
- Tvorba moderního e-shopu: HTML5 drag & drop a kategorie 29. 7. 2013
- Tvorba moderního e-shopu: zpracování chyb 12. 8. 2013
- Tvorba moderního e-shopu: Rich-Text Editing a dokončení administrace 26. 8. 2013
- Autentizace v single-page aplikacích 9. 9. 2013
- Autentizace v single-page aplikacích – serverová část 7. 10. 2013
Nálepky:
Ú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í?
-
Uživatel přistupuje do zabezpečené oblasti, bude vyzván k tomu, aby zadal své uživ. jméno (email) a heslo.
-
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.
-
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
). -
Pokud záznam existuje, vrátí uživateli odpověď, ve které odešle první vygenerovaný hash (promměná
x
). -
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í?
-
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. -
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
). -
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. -
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?
-
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.
-
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.
-
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í.
-
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ář. -
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 direktivalogin
, která přihlašovací formulář skryje. -
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 mazatauthToken
, 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!
v nadpise by to asi malo byt „single“ nie „singe“
Me: „Do you know much about AJAX?“
Potential employee: „CORS I do“
Me: „Hired“
Vdaka za pekny serial. Paci sa mi ako si to rozbil na niekolko komponentov. Musi to byt radost testovat.
„Bezpečnostní protokol“ použitý v článku je z několika důvodů špatně. V první řadě je hashovací funkce SHA-1 příliš rychlá a pro ukládání nepříliš složitých hesel nevhodná. Kvůli tomu jde z Y odvodit X a z X heslo, pokud není příliš složité. Lepší by bylo použít třeba Scrypt.
V druhé řadě – pokud útočník získá X, tak už heslo získávat nepotřebuje, protože X mu stačí pro jakoukoliv práci s aplikací. Takže argumentace tím, že ukládat heslo je příliš nebezpečné, proto uložíme něco, co se dá použít prakticky stejně jako heslo, je špatná. Řeší se to pomocí náhodného session identifikátoru, jehož hash na serveru uložíme a z klienta ho posíláme. Ten jednak nemá nic společného s heslem (protože není jediný důvod, aby měl) a jednak má jen omezenou platnost. Takže když ho útočník získá, tak ho může použít jen nějakou dobu. Vymlouvat se na bezstavovost serveru není potřeba, protože stejně používáme databázi jako prvek dostupný odevšud, takže stav (session ID) klidně můžeme uložit tam.
Nene, s tím nesouhlasím:-)
1) SHA1 je pomalá, to ano, proto je v článku uvedeno „pomocí nějaké funkce (pro jednoduchost třeba SHA1)“ – je to jen příklad.
(pro zájemce přednáška od Michala Špačka proč tomu tak je: http://www.zdrojak.cz/zpravicky/videa-prednasek-konference-devel-cz-2013-byla-zverejnena/)
2) Pokud získá X. Ano, pokud získá. Komunikace je ale šifrovaná a není jak X získat jinak, než že se dostanu k počítači, ze kterého se uživatel přihlašuje. Pak můžu získat X a pak už se dokážu aplikace zmocnit. Nicméně stejně tak se můžu dostat k session ID a úplně stejně si můžu s aplikací dělat co potřebuji.
Co se týče bezstavovosti, to není výmluva, ale jeden z (důležitých) principů RESTful architektury, který přináší řadu dalších výhod. Můžu se o tom dále rozepsat, pokud bude zájem.
Oprava:
„SHA1 je rychlá, to ano, proto je v článku uvedeno „pomocí nějaké funkce (pro jednoduchost třeba SHA1)“ – je to jen příklad.“
1) Příklad je nešťastný. Stejně tak by v něm mohlo být uvedeno, že pro jednoduchost uložíme heslo v plaintextu a na obhajobu by se dal použít stejný argument. Je potřeba si uvědomit, že použití nevhodné hashovací funkce je srovnatelné s uložením hesla v plaintextu (pokud nemáme stanovené požadavky na dlouhé a složité heslo, což v článku samozřejmě není).
2) Uvedl jsem konkrétní argument, co je na postupu popsaném v článku špatně. Uvedl jsem, jak se s tím vypořádat. Mnou popsaný standardní postup nemá žádnou nevýhodu a není jediný důvod se zuby nehty držet zranitelnějšího řešení popsaného v článku.
Aplikace bezstavová není – stav je uložen v databázi, s kterou aplikace komunikuje. Když už tam ukládáme informace o hesle uživatele, tak se tam dá stejně dobře uložit session identifikátor.
1) Ten kód ale nefunguje. Žádná funkce SHA1 v JavaScriptu není, je to jen ukázka. Ale pro klid duše tohle v článku změním.
2) Tohle není pravda. Naopak řešení přes session má řadu problémů, rozhodně neplatí, že nemá žádnou nevýhodu.
Tohle řešení je naprosto standardní. Jak řešíš login form v aplikaci? Uživatel zadá uživ. jméno a heslo a odešle na server request, ne? Tak v tomhle případě se děje přesně to samé, jen se uživ. jméno a heslo zasílá s každým požadavkem. Je to stejné, jako kdybys zadával ručně heslo při každé requestu. Používám HTTP hlavičku, která pro to určena je (to má taky svůj význam), není na tom vůbec nic nového.
Nene, nerozumíme si, co je myšleno pojmem stav, viz http://en.wikipedia.org/wiki/Representational_state_transfer (Constraints -> Stateless)
2) Musím se pana Vrány v tomto případě zastat. Neříkám, že řešení přes session je všespásné, ale přesto mi sedí více než to vaše. Napadá mě například jak si ve vašem řešení vynutíte znovupříhášení (znovuověření) uživatele. Např v situaci, kdy počítač (notebook, mobil) někdo ukradne a cookie už obsahuje validní token. Nic útočníkovi nezabrání prodloužit si token do nekonečna (stav přeci drží klient) a tím pádem používat aplikaci neomezeně. Dá se to jistě řešit tím, že token bude mít server-side expiraci, ale to už jsme v podstatě u řešení pana Vrány. Píšete, že „Nicméně stejně tak se můžu dostat k session ID a úplně stejně si můžu s aplikací dělat co potřebuji.“, ale rozdíl je v tom, že server nechá session po čase vyexpirovat. Vaše řešení ne.
Nevím, asi mi něco uniká, ale nějakou zásadní výhodu ve vašem řešení (oproti standartnímu) moc nevidím…
Na straně prohlížeče je uložen authToken, který je tvořen i z hesla. To znamená, že když mi někdo ukradne notebook a já si hned změním heslo, ten authToken, který je uložen v prohlížeči, se stane nevalidním a útočníkovi je už k ničemu. Naopak v tomhle případě mám jistotu, že při změně hesla dojde okamžitě k odhlášení na všech zařízeních, kde je uživatel pro daný účet přihlášen. U klasického řešení přes session tohle řešeno právě vůbec není a je potřeba doprogramovat zvláštní funkci, která při změně hesla odhlásí uživatele na jiných zařízeních a troufám si tvrdit, že je jen velmi malé procento aplikací, které mají tohle řešeno.
Bezstavovost podle principů REST má řadu výhod a příštím článku se o nich rozepíšu.
Řešení rozhodně standardní není. Žádné rozumné řešení neukládá na klientu heslo ani žádnou jeho odvozeninu. Rozdíl proti ručnímu zadání hesla při každém požadavku je právě v tom, že při ručním zadání není heslo na klientu uloženo.
Heslo a session identifikátor jsou dvě různé věci. Odvozovat druhé z prvního jen proto, abychom nemuseli programovat nějakou funkci, je ledabylost. Navíc funkčnost, kterou tímto dostaneme, je hloupá – abychom se odhlásili z ostatních zařízení, tak si musíme změnit heslo. Proč? Naopak když si změním heslo, tak nemusím být chtít jinde odhlášen. Já si občas měním hesla na citlivých službách, ale rozhodně kvůli tomu nechci být odhlášen ze všech zařízení, kde je používám.
Lepší je tyto funkce rozdělit – změna hesla pouze změní heslo. Odhlášení z ostatních zařízení je triviální operace – v případě session jen smažu session identifikátory daného uživatele, v bezstavovém režimu jen změním centrální session identifikátor (v článku je nešťastně pojmenován X).
Bezpečnostní protokol popsaný v článku zkrátka zbytečně riskuje data uživatele jen proto, aby přinesl funkci, jejíž chování je hloupé. Neexistuje jediný důvod, proč session identifikátor odvozovat z hesla a článek by v této části měl být opraven, aby čtenáře nesváděl na scestí.
„Řešení rozhodně standardní není. Žádné rozumné řešení neukládá na klientu heslo ani žádnou jeho odvozeninu.“
Ne, to není pravda. Vůbec to nejzákladnější řešení, klasická HTTP autentizace, funguje tak, že zasílá uživ. jméno a heslo v otevřeném formátu a je uloženo v prohlížeči v čisté podobě, velmi snadno se k němu tedy dostaneš. Tvůj session identifikátor je to samé, také si ho můžu vzít a dále pod ním používat aplikaci a jsi v háji.
„Heslo a session identifikátor jsou dvě různé věci. Odvozovat druhé z prvního jen proto, abychom nemuseli programovat nějakou funkci, je ledabylost.“
Ano, to máš pravda. Vtip je v tom, že řešení popsané v článku ŽÁDNÝ session identifikátor nepoužívá. Ten hash není session identifikátor. Prostě zapomeňme úplně na pojem session identifikátor, nic takového tady není.
„Navíc funkčnost, kterou tímto dostaneme, je hloupá – abychom se odhlásili z ostatních zařízení, tak si musíme změnit heslo. “
Co? Abychom se odhlásili? Odhlásit se můžeš normálně na klientovi, nepotřebuješ komunikovat se serverem.
„Naopak když si změním heslo, tak nemusím být chtít jinde odhlášen. Proč? Naopak když si změním heslo, tak nemusím být chtít jinde odhlášen. Já si občas měním hesla na citlivých službách, ale rozhodně kvůli tomu nechci být odhlášen ze všech zařízení, kde je používám. “
Tohle ale chyba je a řešit bys to měl. Na jednom zařízení jsem se přihlásil s nějakými údaji a pokud ty údaje nejsou už validní, pak tam přihlášen už být nemůžu. Na těch zabezpečených službách to tak funguje, třeba když si změním heslo na Gmailu, tablet i mobil se zablokuje a požaduje zadání nového hesla, jinak nemůžu ke službám přistupovat. Tak je to správně.
“Bezpečnostní protokol popsaný v článku zkrátka zbytečně riskuje data uživatele jen proto, aby přinesl funkci, jejíž chování je hloupé. Neexistuje jediný důvod, proč session identifikátor odvozovat z hesla a článek by v této části měl být opraven, aby čtenáře nesváděl na scestí.”
Jak jsem psal, o session ID píšeš jen ty, ne já. Bezstavovost není proto, aby funkce jako odhlášení existovaly, ale má mnoho jiných výhod. V příštím článku je tedy podrobněji popíšu. V každém případě není nejmenší důvod cokoliv v článku měnit, tvé aktuální argumenty nevnímám jako oprávněné.
HTTP autentizaci snad nikdo soudný nebude používat, ne? (Teď se nabavíme o tom, že spousta aplikací ji používá.)
A proč? U klasických server-side aplikací to problém je, protože se musí použít ten způsob, kdy vše řídí prohlížeč sám a pak je problém třeba s odhlášením. Ale v popisovaném případě žádný problém nevidím, pokud jde komunikace přes HTTPS. U SPA se používá často.
Řešení popsané v článku se standardními řešeními pokulhává i v bezpečnosti dat uložených v prohlížeči. Při HTTP autentizaci zajišťované prohlížečem např. neexistuje API, které by mi dovolilo získat heslo, pod kterým je uživatel k dané doméně přihlášen. Při session identifikátoru se tento obvykle posílá v HTTP-only cookie, takže je z JS také nedostupný. Řešení popsané v článku data ukládá do normálně přístupného úložiště, takže jediný XSS v aplikaci znamená trvalou kompromitaci potenciálně všech uživatelů. Jediný způsob, jak se z tohoto průšvihu po opravení případného XSS vyhrabat, je změnit algoritmus, který na serveru používáme pro vytvoření proměnné v článku označené jako x. Tím taky všechny uživatele odhlásíme (a postavíme na hlavu bezstavovost aplikace).
Neexistuje API, ale pokud už můžu vložit JavaScript do nějaké stránky, pak stejně můžu JavaScriptem odeslat požadavek a přečíst odeslané HTTP hlavičky. Ale to bude doufám prohlížečem zabezpečeno a přečíst Auth hlavičku nepůjde.
Nicméně pokud už budu mít v aplikaci díru pro XSS, pak si s aplikací už můžu dělat skoro co chci:-)
Ještě reakce na to, co jsem pře chvílí přehlédl:
„jediný XSS v aplikaci znamená trvalou kompromitaci potenciálně všech uživatelů. “
Proč? V prohlížeči není uložen stejný hash jako v databázi. Kdyby byl, tak ano, to by byl problém. Pokud je použita třeba funkce zmíněná v odkazovaném videu z develu, bude velmi nepravděpodobné, že dokážeš rozlousknout způsob, jakým byla vytvořena proměnná x. Ale i kdybys na to přišel, je ti to stejně k ničemu, protože nevíš, jakým způsobem byla vytvořena proměnná y, protože v databázi není x, ale y a z x se tvoří y výhradně na serveru.
Už vím, jak to myslíš. Nicméně, aby se ti podařilo aplikaci nabourat, musely by být splněny dvě podmínky:
a) Aplikace by musela mít XSS díru, což je v případě Angularu docela problém, protože je vše defaultně escapováno.
b) Musel bys dokázat rozluštit, jakým způsobem je vytvořena proměnná x.
Ale musíš už teď uvažovat, že aplikace musí být špatně napsaná. Stejně tak bych ale mohl uvažovat, že server bude špatně zabezpečen a získám databáze s hashi a jsem na tom stejně.
Pro další diskusi prosím Tě navazuj na tento příspěvek, ať se v tom dá vyznat.
K jedné věci jsi mě inspiroval. Jedna nepříjemná věc je, že pokud by se přeci jen aplikace stala k XSS náchylnou a ty bys čistě hypoteticky dokázal rozlousknout způsob, jakým je X vytvořeno, mohl bys získat i hesla ostatních v plain textu. To by se ale dalo vyřešit tak, že u každého uživatele budu generovat náhodný řetězec a X pak bude vytvořeno jak z hesla, tak z toho řetězce. O nic složitější to není. I kdyby pak aplikace pro XSS náchylná byla a tys získal z X jedno heslo v plaintextu, heslo v plain textu pro jiného ho už nezískáš, takže hesla jsou chráněna dostatečně.
Pokud by se aplikace stala oběť útoku, tak si nemyslím, že násilné odhlášení uživatelů je problém, naopak to beru spíše jako výhodu.
Heslo v plaintextu na nic nepotřebuji. Pokud se mi podaří získat X (které je dostupné z JavaScriptu), tak uživatele v aplikaci plně ovládám bez časového omezení. To je fatální rozdíl oproti standardním způsobům přihlašování, kde takovouto hodnotu získat nelze a dveře do aplikace jsou otevřené, jen dokud XSS nezalepím.
Věř tomu nebo ne, ale i ve velkých aplikacích s miliardou a více uživatelů se XSS opravuje velmi často. Pokud by se s každou opravou měli všichni uživatelé odhlásit, tak by za chvíli žádný nezbyl. U malých aplikací bude chyba statisticky nejspíš ještě častější, i když kódu a uživatelů tolik není, takže není tolik vidět. Řešení je to každopádně nepoužitelné.
Správně napsaná aplikace není průstřelná ani při získání databáze s hashi. Stačí, aby byla použita správná metoda uložení hesel (např. scrypt) a aby stejnou metodou byly ošetřeny i náhodně vygenerované session identifikátory. Říká se tomu Security by Design.
Další nápad zmíněný v této diskusi, totiž „bude velmi nepravděpodobné, že dokážeš rozlousknout způsob, jakým byla vytvořena proměnná x“ je ryzí ukázkou přístupu Security through Obscurity. Jeho důsledkem je mimo jiné to, že nikdy nemůžeme propustit žádného zaměstnance a místo toho je musíme zastřelit. A to už je poněkud nepříjemné i z právního hlediska.
Ještě poslední dodatek. Chtěl bych, aby to bylo totálně neprůstřelné, tedy i kdyby došlo k XSS útoku a ty získal X uživatelů třeba z localstorage, tak stejně aby ti byly k ničemu. A vyřešit se to dá třeba tak, že budu posílat autentizační údaje ve dvou hlavičkách, kromě té standardní Authentization přidám ještě vlastní, třeba X-Auth. Ta X-Auth bude fungovat jako dříve fungovala Authentization. A v tu HTTP Authentization použiji pro klasickou HTTP basic authentizaci, avšak nebudu v ní posílat heslo, nýbrž nějaký vygenerovaný řetězec, který jsem vytvořil při registraci uživatele. Na straně serveru se pak bude ověřovat obě hlavičky: jak X-Auth, tak Authentization.
Jaké je teď zabezpečení?
a) Vše je přes HTTPS, takže nikdo nic poslouchat nemůže.
b) Hesla uživatelů jsou v bezpečí jako hashe, takže i když někdo získá kopii databáze, je mu k ničemu.
c) Nepoužívá se jen standardní HTTP Basic Auth, musí být nastavena i hlavička X-Auth, takže řešení je automaticky chráněno i proti CSRF, taky je řešeno odhlášení uživatele.
d) Při změně hesla dojde k odhlášení jako to dělá Google, já to považuji za správné řešení, tobě to vadí. Tady se asi neshodneme. Ale myslím si, že mám pravdu já, protože moje řešení to řeší automaticky a když chci nechat uživatele přihlášeného, dokážu to zajistit třeba přes server-side events.
e) I kdyby byla v aplikaci díra a ty jsi získal hash X z local storage, stejně je ti to na nic. I kdybys ho dokázal rozlousknout a získal z něj heslo v plain textu, nedokázal bys získat heslo v plain textu jinde.
f) I kdyby byla aplikace náchylná k XSS a ty získal hashe X všech uživatelů, pořád je ti to k ničemu, protože uživatel je zároveň přihlášen přes klasickou HTTP Basic Auth a zde nedokážeš přečíst, co se v hlavičce posílá. Takže je ti to opět na nic a s hashi nic dělat nemůžeš.
g) Aplikace je plně bezstavová.
Ještě jeden dodatek:-) Řešení může být jako předtím, ale místo X-Auth se bude posílat druhý hash v cookie, která bude nastavena jako http-only. Znamená to, že ji bude automaticky posílat browser, ale nebude ji možné přečíst JavaScriptem. Princip zůstává stejný, je splněna podmínka bezstavovosti, session management na straně serveru není, takže zůstávají všechny výhody, které to přináší. Navíc dostanu for free automatické zabezpečení proti CSRF, automatické odhlášení po změně hesla ala Google (pokud chci) atd.
Máš k tomu ještě nějaké výhrady? Pokud ne, tak bych to uzavřel a doplnil článek.
V každém případě díky za tuhle noční diskusi:-)
Odhlášení po změně hesla není „pokud chci“, ale nevyhnutelné.
Použití standardního mechanismu prohlížeče (HTTP-only cookie) tomu pomohlo, ale bezpečnost ve srovnání s náhodně generovaným session identifikátorem platným omezenou dobu je pořád o řád horší.
Jak jsem psal, hash nemusí být nutně generovaný z hesla, takže pokud tuhle funkčnost mít nechci, programovat ji nemusím. Ale já to považuji za velkou výhodu, protože pokud aplikace neposkytuje možnost odhlásit se z ostatních relací, tak to beru za bezpečnostní problém.
Tohle řešení je dobré, protože už jeho implementace řeší některé problémy, třeba několikrát zmíněnou ochranu proti CSRF. To je hodně důležité, protože od stavu „když chci, můžu to doprogramovat“ do stavu „mám to naprogramováno“ je hodně daleko.
Omezení platnosti identifikátoru lze samozřejmě taky doplnit (toho druhého, který není generován z hesla)
Ještě k té omezené platnosti identifikátoru. Standardně se používá session_regenerate_id() po přihlášení, že? Aby se přegenerovalo session ID.
To by ale nebyl problém zajistit. U každé aplikace ukládám i informace o přihlašování, tj. každý pokus, čas, IP adresu atd. Tyhle informace být uloženy musí ať jde o jakoukoliv aplikaci. Nicméně tyhle informace se dají dobře využít i jinak a mohou být součástí vygenerovaného hashe. Tzn. po loginu máš stále oba identifikátory uloženy v prohlížeči. Kdyby útočník oba ukradl a použil je u sebe, po loginu uživatele už budou k ničemu, protože datum přihlášení u dané relace bude v databázi už jiné, takže bude požádán o zopakování přihlašovacích údajů.
Ano, teď už se to o něco více přiblížilo tomu klasickému řešení, ale i tak mi pořád zůstávají všechny výhody stateless aplikace, které potřebuji a nic jsem obětovat nemusel.
Lze argumentovat tím, že je to nestandardní řešení, to ano, ale mám řadu výhod, které klasické server-side aplikace nemají.
Vygenerovaný hash, který server posílá klientovi (v článku označený jako x), lze chápat jako session identifikátor, jen má nekonečnou platnost. Neexistuje jediný důvod, proč by neměl být náhodný a proč by měl být odvozen z hesla. Důvod v článku označený jako výhoda je ve skutečnosti nevýhoda – spojuje dvě funkce, které spolu nijak nesouvisí. Např. Facebook mě dovoluje odhlásit z jiných zařízení, aniž bych si kvůli tomu musel měnit heslo a je to správné chování. Stejně tak si můžu chtít změnit heslo, aniž bych se musel chtít odhlásit z ostatních zařízení. Návrh popsaný v článku obě tyto funkce znemožňuje. Navíc zbytečně vystavuje heslo riziku prolomení.
Ne, není to session identifikátor a ani ho tak chápat nelze.
Facebook to nabízí jako možnost, to lze ale snadno doplnit i do této aplikace, třeba přes ss-events či webesockets. Je to easy.
Myslím si, že správně je to, jak to řeší Google.
Koukám že jste to řešili celou noc, to jste teda nadšenci :).
Jen krátce:
„když mi někdo ukradne notebook a já si hned změním heslo, ten authToken, který je uložen v prohlížeči, se stane nevalidním a útočníkovi je už k ničemu“
Ano, to jistě. Tzn. že váš bezpečnostní protokol předpokládá akci uživatele, aby došlo k odhlášení na odcizeném zařízení. To mi nepřijde šťastné. Musím se přiznat, že u každé aplikace, kam se přihlašuji, předpokládám, že „session“ (ať už je to cokoliv) po čase vyprší – u vás se to neděje (je to tak?) a to mě trochu děsí
„U klasického řešení přes session…“
Musím se přiznat že já sám k těmto účelům session také nepoužívám, zato po ověření uživatele generuji náhodný authToken (zdůrazňuji náhodný, na hesle nezávislý), který posílám zpět klientovi a zároveň ho ukládám na serveru do distribuované cache (přístupné všem serverům v clusteru) a na serverové straně omezuji čas, kdy je tento token validní. Po vypršení se uživatel musí přihlásit znovu.
„Bezstavovost podle principů REST má řadu výhod a příštím článku se o nich rozepíšu.“
Na článek se upřímě těším. Bezestavovost má opravdu spoustu výhod, v tomto bodě se s vámi přít nebudu. Nicméně „tlačit“ bezestavovost i za cenu snížení bezpečnosti uživatele? To určitě ne… A zatím jste mě nepřesvědčil, že váš bezpečnostní protokol je steně bezpečný jako ty, které využívají stav na serveru…
Pro mě končí pracovní doba až ráno, takže spíše bych byl nadšenec, kdybych to řešil přes den:-)
„Ano, to jistě. Tzn. že váš bezpečnostní protokol předpokládá akci uživatele, aby došlo k odhlášení na odcizeném zařízení.“
Právě že ne. Pokud by k odcizení došlo, tak při změně hesla už nikde přihlášen nejsem. Naopak, tohle je mnohem bezpečnější, authToken uložený u uživatele je už nevalidní. Klasické server-side aplikace tohle právě často neřeší, je to funkce navíc, která se musí dodat (= zaplatit). Shodu okolností jsem zrovna tohle musel nedávno řešit, protože jsem se zapomněl odhlásit z Gmailu v jedné kavárně. Google tohle řešeno má, takže změna hesla a můžu být klidný.
Autor aplikace má právo si rozhodnout, jak se jeho aplikace bude chovat. Mně u většiny aplikací vyhovuje chování, že pokud si změním heslo, tak kvůli tomu nechci být odhlášen ze všech zařízení, kde aplikaci používám. To s postupem popsaným v článku nejde. Naopak chci mít možnost se odhlásit, aniž bych si kvůli tomu musel měnit heslo. Tuto funkci bych tedy měl naprogramovat tak jako tak.
Požadavek Břetislava je zcela oprávněný. Změna hesla je přesně ta akce uživatele, o které Břetislav píše. Pokud si neuvědomím, že k zařízení, kde jsem přihlášen, má přístup někdo cizí, tak očekávám, že mě z něj aplikace sama časem odhlásí, aniž bych musel cokoliv dělat. Délkou této doby samozřejmě vyvažujeme pohodlnost a bezpečnost, ale je chyba dobu nijak neomezit (a tím na bezpečnost rezignovat).
Tak X se samozřejmě z hesla generovat nemusí, hash se generovat může jakkoliv.
Já to jako problém vnímám. Mně vyhovuje řešení Google a jsem za něj rád. Tobě ne, v pořádku, v tomhle se neshodneme.
V článku je popsáno použití session storage a jeho obsah se vymaže po zavření prohlížeče, takže to řešené je. Pokud chci trvalejší dobu přihlášení, můžu použít cookie.
Vidíš ještě někde problém v řešení popsaném zde: http://www.zdrojak.cz/clanky/autentizace-v-singe-page-aplikacich/?show=comments#comment-24975 ? Tedy kromě toho odhlašování po změně hesla, kde se neshodneme.
… s křížkem po funuse, ale přijde mi poněkud nešťastné v článku zmiňovat SHA1 v době, kdy je součástí standardní knihovny
crypto
metoda [crypto.pbkdf2(password, salt, iterations, keylen, callback)](http://nodejs.org/api/crypto.html#crypto_crypto_pbkdf2_password_salt_iterations_keylen_callback).