Social Cooking – autentykacja w AngularJS

W poprzednim wpisie (Social Cooking – autentykacja w WebAPI) opisałem sposób w jaki został przeze mnie zaimplementowany mechanizm uwierzytelnienia w WebAPI. Aby w jakikolwiek sposób wykorzystać tamten kod potrzebuję aplikacji, która będzie chciała dać użytkownikom możliwość rejestracji i zalogowania. Jak już wcześniej wspominałem aplikacja ta zostanie napisana w AngularJS, a w tym wpisie zobaczycie w jaki sposób poradziłem sobie z początkami wizualnej części portalu Social Cooking.

Na wstępie chciałbym jeszcze zaznaczyć, że wpis ten jest przede wszystkim opisem mojego sposobu implementacji zagadnienia. Jeżeli jesteście zainteresowani tutorialem w którym opiszę krok po kroku cały proces proszę dajcie znać, a na pewno stworzę o tym serię wpisów (a właściwie stworzę ją wcześniej niż planowałem 🙂 ).

Co dzisiaj powstanie?

Pod koniec tego wpisu będę miał gotową stronę na której w zasadzie jedyne co będzie można zrobić to zarejestrować się i zalogować. Niby niewiele, a jednak moim zdaniem bardzo dużo. Na początek nie będę przejmował się zbytnio kwestią wizualną, dlatego strona będzie bardzo uboga w jakąkolwiek grafikę.

Czego potrzebuję?

Mimo tego, że jeszcze nie przykładam dużej wagi do wyglądu strony, to jednak nie chcę mieć „czystych” formularzy. Dlatego też na początku będę używał:

  • Angular Loading Bar – ładnie wyglądający pasek i kółko ładowania dla stron typu SPA
  • UI Bootstrap – standardowa biblioteka do „upiększania” projektu

Dodatkowo AngularJS zostanie rozszerzony o swoje dwie biblioteki:

  • Angular Route – do nawigacji po projekcie
  • Angular Local Storage – biblioteka ułatwiająca zarządzaniem zmiennymi globalnymi

Organizacja projektu

Kiedy zaczynałem swoją przygodę z AngularJS zauważyłem, że istnieje sporo sposobów na to jak organizować swój projekt. Za jakiś czas o wszystkich tych sposobach napiszę. Tutaj zaznaczę jedynie, że w SocialCooking przyjąłem układ jak poniżej:

organizacja projektu

 Ten sposób daje mi przejrzyste spojrzenie na wszystkie moduły, które będą również w późniejszym czasie powstawać, a także w konkretnym module nie będę miał problemów z odróżnieniem Kontrolerów od Serwisów.

Index

Przyszła pora zmienić index strony z testowej dupy na coś bardziej funkcjonalnego 🙂

<!DOCTYPE html>
<html ng-app="socialCookingApp">
<head>
    <title>Social Cooking</title>
    <link href="content/css/bootstrap.min.css" rel="stylesheet" />
    <link href="content/css/Site.css" rel="stylesheet" />
    <link href="content/css/loading-bar.css" rel="stylesheet" />
    <link href="content/css/social-buttons.css" rel="stylesheet" />
</head>
<body>
<nav class="navbar navbar-inverse" role="navigation" ng-controller="indexController">
    <div class="container">
        <div class="navbar-header">
            <button class="btn btn-success navbar-toggle" ng-click="navbarExpanded = !navbarExpanded">
                <span class="glyphicon glyphicon-chevron-down"></span>
            </button>
            <a class="navbar-brand" href="#/">Home</a>
        </div>
        <div class="collapse navbar-collapse" data-collapse="!navbarExpanded">
            <ul class="nav navbar-nav navbar-right">
                <li ng-hide="!authentication.isAuth"><a href="#">Welcome {{authentication.userName}}</a></li>
                <li ng-hide="!authentication.isAuth"><a href="" ng-click="logOut()">Logout</a></li>
                <li ng-hide="!authentication.isAuth"><a href="#/dish" >Dodaj przepis</a></li>
                <li ng-hide="authentication.isAuth"> <a href="#/login">Login</a></li>
                <li ng-hide="authentication.isAuth"> <a href="#/signup">Sign Up</a></li>
            </ul>
        </div>
    </div>
</nav>
<div class="container">
    <div ng-view>
    </div>
</div>
 <!-- 3rd party libraries --> 
<script src="https://code.jquery.com/jquery-1.11.3.min.js"></script> 
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular.min.js"></script> 
<script src="//ajax.googleapis.com/ajax/libs/angularjs/1.4.8/angular-route.min.js"></script> 
<script src="Scripts/bootstrap.min.js"></script> <script src="scripts/angular-local-storage.min.js"></script> 
<script src="scripts/loading-bar.min.js"></script> 
<!-- Load app main script --> 
<!-- Load services --> 
<!-- Load controllers -->
</body>
</html>
No i co my tu mamy?

Na dobry początek inicjalizujemy aplikację angularową w tagu <html ng-app=”socialCookingApp”>. Dalej linkujemy wszystkie potrzebne arkusze styli, których część już teraz wykorzystam, a część dopiero na późniejszym etapie.

W sekcji <body> mamy stworzony pasek nawigacyjny, który będzie obsługiwany przez oddzielny angularowy kontroler (na razie nie wyrzucam go nigdzie indziej ze względu na to, że najprawdopodobniej nawigacja będzie rozwiązana w zupełnie inny sposób).

Dalej w kontenerze mamy wywołanie ng-view, gdzie będzie wyświetlała się cała treść strony.

Na samym końcu miejsce na definicje skryptów JavaScriptowych. Zostały one podzielone na cztery kategorie:

  • 3rd party libraries – gdzie będą pojawiać się wszelkie biblioteki nie stworzone przeze mnie,
  • Load app main script – w zasadzie tutaj tylko jeden plik znajdzie swoje miejsce i będzie to app.js (o którym za chwilę)
  • Load services – wszystkie services, factories, itp.
  • Load controllers – tu sprawa zupełnie oczywista. Wszystkie kontrolery AngularJSowe.

App.js

app.js, czyli serce aplikacji zawiera wszystkie trasy aplikacji, oraz stałe globalne ustawienia takie jak adres API, identyfikator klienta, a także konfiguracja interceptora (mamy jakieś polskie słowo na ten mechanizm) komunikacji z WebAPI.

    var app = angular.module('socialCookingApp', ['ngRoute', 'LocalStorageModule', 'angular-loading-bar']);
    app.config(function ($routeProvider) {

        $routeProvider.when("/home", {
            controller: "homeController",
            templateUrl: "/app/home/views/home.html"
        });

        $routeProvider.when("/login", {
            controller: "loginController",
            templateUrl: "/app/auth/views/login.html"
        });

        $routeProvider.when("/signup", {
            controller: "signupController",
            templateUrl: "/app/auth/views/signup.html"
        });

        $routeProvider.when("/tokens", {
            controller: "tokensManagerController",
            templateUrl: "/app/auth/views/tokens.html"
        });

        $routeProvider.when("/associate", {
            controller: "associateController",
            templateUrl: "/app/auth/views/associate.html"
        });

        $routeProvider.when("/dish", {
            controller: "dishController",
            templateUrl: "/app/dish/views/addDish.html"
        });

        $routeProvider.otherwise({ redirectTo: "/home" });
    });
    app.constant('ngAuthSettings', {
        apiServiceBaseUri: 'http://localhost:82/',
        clientId: 'ngApp'
    });

    app.config(function ($httpProvider) {
        $httpProvider.interceptors.push('authInterceptorService');
    });

    app.run(['authService', function (authService) {
        authService.fillAuthData();
    }]);

Serwisy

Do obsługi autoryzacji i uwierzytelnienia będę potrzebował 3 serwisów. AuthInterceptorService został już wspomniany przy pliku app.js. Będzie on odpowiedzialny za przechwytywanie żądań i odpowiedzi z WebAPI. AuthService.js będzie obsługiwał rejestrację nowych użytkowników oraz ich logowanie. TokensManagerService.js jak sama nazwa wskazuje będzie zajmował się tokenami (ich odświeżaniem oraz usuwaniem).

'use strict';
app.factory('authInterceptorService', ['$q', '$injector', '$location', 'localStorageService', function ($q, $injector, $location, localStorageService) {

    var authInterceptorServiceFactory = {};
    var $http;

    var request = function (config) {

        config.headers = config.headers || {};

        var authData = localStorageService.get('authorizationData');
        if (authData) {
            config.headers.Authorization = 'Bearer ' + authData.token;
        }

        return config;
    }
    var retryHttpRequest = function (config, deferred) {
        $http = $http || $injector.get('$http');
        $http(config).then(function (response) {
            deferred.resolve(response);
        }, function (response) {
            deferred.reject(response);
        });
    }

    var responseError = function (rejection) {
        var deferred = $q.defer();
        if (rejection.status === 401) {
            var authService = $injector.get('authService');
            authService.refreshToken().then(function (response) {
                retryHttpRequest(rejection.config, deferred);
            }, function () {
                authService.logOut();
                $location.path('/login');
                deferred.reject(rejection);
            });
        } else {
            deferred.reject(rejection);
        }
        return deferred.promise;
    }

    

    authInterceptorServiceFactory.request = request;
    authInterceptorServiceFactory.responseError = responseError;

    return authInterceptorServiceFactory;
}]);

Serwis ten ma za zadanie przechwycić każde żądanie wysyłane do API i sprawdzić czy użytkownik jest zalogowany. Jeśli tak to dodaje nagłówek z tokenem. W przypadku kiedy API zwróci błąd i będzie to błąd 401 (czyli nieautoryzowany dostęp) interceptor próbuje odświeżyć token. Kiedy to zawiedzie użytkownik zostanie przekierowany na stronę logowania.

'use strict';
app.factory('authService', ['$http', '$q', 'localStorageService', 'ngAuthSettings', function ($http, $q, localStorageService, ngAuthSettings) {

    var serviceBase = ngAuthSettings.apiServiceBaseUri;
    var authServiceFactory = {};

    var _authentication = {
        isAuth: false,
        userName: "",
        useRefreshTokens: false
    };

    var _externalAuthData = {
        provider: "",
        userName: "",
        externalAccessToken: ""
    };
    authServiceFactory.saveRegistration = _saveRegistration;
    authServiceFactory.login = _login;
    authServiceFactory.logOut = _logOut;
    authServiceFactory.fillAuthData = _fillAuthData;
    authServiceFactory.authentication = _authentication;
    authServiceFactory.refreshToken = _refreshToken;

    authServiceFactory.obtainAccessToken = _obtainAccessToken;
    authServiceFactory.externalAuthData = _externalAuthData;
    authServiceFactory.registerExternal = _registerExternal;

    return authServiceFactory;

Serwis autoryzacyjny jest nieco długi dlatego nie wrzucam całości, a jedynie przypisanie metod i inicjalizację podstawowych obiektów (przypominam, że całość znajduje się na GitHubie do wglądu). Jak widać serwis odpowiedzialny będzie za rejestracje (zarówno „wewnętrzną” jak i za pośrednictwem zewnętrznych serwisów), a także ich logowanie. Z ciekawych rzeczy polecam przyjrzeć się (zarówno po stronie API jak i Angulara metodzie obtainAccessToken, która odpowiedzialna jest za wygenerowanie tokena dostępowego po rejestracji z Facebooka oraz Google. Użytkownik na początku przedstawia się za pomocą zewnętrznego tokena odebranego przez wspomniane portale i na tej podstawie przyznawany jest dostęp do SocialCooking.

'use strict';
app.factory('tokensManagerService', ['$http', 'ngAuthSettings', function ($http, ngAuthSettings) {

    var serviceBase = ngAuthSettings.apiServiceBaseUri;

    var tokenManagerServiceFactory = {};

    var getRefreshTokens = function () {

        return $http.get(serviceBase + 'api/refreshtokens').then(function (results) {
            return results;
        });
    };

    var deleteRefreshTokens = function (tokenid) {

        return $http.delete(serviceBase + 'api/refreshtokens/?tokenid=' + tokenid).then(function (results) {
            return results;
        });
    };

    tokenManagerServiceFactory.deleteRefreshTokens = deleteRefreshTokens;
    tokenManagerServiceFactory.getRefreshTokens = getRefreshTokens;

    return tokenManagerServiceFactory;

}]);

Ten serwis nie do końca jest potrzebny do samej autoryzacji. Ma on za zadanie zwrócić wszystkie tokeny, które aktualnie przetrzymuje system i ew. wybrany usunąć. Serwis stworzyłem tylko do zarządzania i przeglądania tokenów.

Kontrolery

Z kontrolerami jest taka sytuacja, że właściwie służą tylko do wywoływania metod zawartych w serwisach i przekierowania na odpowiednią stronę, albo zwróceniu odpowiedniego komunikatu.  Na potrzeby autoryzacji i uwierzytelnienia powstały 4 kontrolery:

  • SignupController.js – jak sama nazwa wskazuje kontroler używany do rejestracji (uwaga tylko wewnętrznej),
  • AssociateController.js – jak wyżej, ale dla serwisów społecznościowych,
  • LoginController.js – kontroler do logowania, bo do czego innego :),
  • RefreshController.js – kontroler do odświeżania tokenów.
'use strict';
app.controller('signupController', ['$scope', '$location', '$timeout', 'authService', function ($scope, $location, $timeout, authService) {

    $scope.savedSuccessfully = false;
    $scope.message = "";

    $scope.registration = {
        userName: "",
        password: "",
        confirmPassword: ""
    };
    var startTimer = function () {
        var timer = $timeout(function () {
            $timeout.cancel(timer);
            $location.path('/login');
        }, 2000);
    }
    $scope.signUp = function () {

        authService.saveRegistration($scope.registration).then(function (response) {

            $scope.savedSuccessfully = true;
            $scope.message = "User has been registered successfully, you will be redicted to login page in 2 seconds.";
            startTimer();

        },
         function (response) {
             var errors = [];
             for (var key in response.data.modelState) {
                 for (var i = 0; i < response.data.modelState[key].length; i++) {
                     errors.push(response.data.modelState[key][i]);
                 }
             }
             $scope.message = "Failed to register user due to:" + errors.join(' ');
         });
    };
}]);

Pierwszy kontroler z jedną, ale jakże kluczową dla każdego systemu funkcją. signUp pozwala na rejestrację i po magicznych 2 sekundach przenosi na stronę logowania (oczywiście jeśli nic nie spieprzyliśmy).

'use strict';
app.controller('associateController', ['$scope', '$location', '$timeout', 'authService', function ($scope, $location, $timeout, authService) {

    $scope.savedSuccessfully = false;
    $scope.message = "";

    $scope.registerData = {
        userName: authService.externalAuthData.userName,
        provider: authService.externalAuthData.provider,
        externalAccessToken: authService.externalAuthData.externalAccessToken
    };

    $scope.registerExternal = function () {

        authService.registerExternal($scope.registerData).then(function (response) {

            $scope.savedSuccessfully = true;
            $scope.message = "User has been registered successfully, you will be redicted to dish page in 2 seconds.";
            startTimer();

        },
          function (response) {
              var errors = [];
              for (var key in response.modelState) {
                  errors.push(response.modelState[key]);
              }
              $scope.message = "Failed to register user due to:" + errors.join(' ');
          });
    };

    var startTimer = function () {
        var timer = $timeout(function () {
            $timeout.cancel(timer);
            $location.path('/addDish');
        }, 2000);
    }

}]);

Kontoler funkcjonalnością niczym się nie różni. Rejestruje użytkowników, ale takich którzy postanowili połączyć swoje konta Facebookowe, Googlowe z SocialCooking. Tak właściwie w dzisiejszych czasach może to właśnie ten kontroler będzie częściej używany 🙂

'use strict';
app.controller('loginController', ['$scope', '$location', 'authService', 'ngAuthSettings', function ($scope, $location, authService, ngAuthSettings) {

    $scope.loginData = {
        userName: "",
        password: "",
        useRefreshTokens: true
    };

    $scope.message = "";

    $scope.login = function () {

        authService.login($scope.loginData).then(function (response) {

            $location.path('/home');

        },
         function (err) {
             $scope.message = err.error_description;
         });
    };
    $scope.authExternalProvider = function (provider) {

        var redirectUri = location.protocol + '//' + location.host + '/authcomplete.html';

        var externalProviderUrl = ngAuthSettings.apiServiceBaseUri + "api/Account/ExternalLogin?provider=" + provider
                                                                    + "&response_type=token&client_id=" + ngAuthSettings.clientId
                                                                    + "&redirect_uri=" + redirectUri;
        window.$windowScope = $scope;

        var oauthWindow = window.open(externalProviderUrl, "Authenticate Account", "location=0,status=0,width=600,height=750");
    };

    $scope.authCompletedCB = function (fragment) {

        $scope.$apply(function () {

            if (fragment.haslocalaccount == 'False') {

                authService.logOut();

                authService.externalAuthData = {
                    provider: fragment.provider,
                    userName: fragment.external_user_name,
                    externalAccessToken: fragment.external_access_token
                };

                $location.path('/associate');

            }
            else {
                var externalData = { provider: fragment.provider, externalAccessToken: fragment.external_access_token };
                authService.obtainAccessToken(externalData).then(function (response) {

                    $location.path('/home');

                },
             function (err) {
                 $scope.message = err.error_description;
             });
            }

        });
    }
}]);

No i w końcu dochodzimy do kontrolera, który jest tematem całego posta, czyli autentykacji. Kontroler pozwala na zalogowanie użytkownika zarówno za pomocą loginu i hasła, jak i poprzez połączenie z portalami społecznościowymi. Po udanym logowaniu przenosi użytkownika na stronę główną (ta część w przyszłości zostanie zmieniona, bo nic mnie osobiście tak nie wkurza jak przekierowanie do strony głównej zamiast do ostatnio odwiedzonej).

'use strict';
app.controller('refreshController', ['$scope', '$location', 'authService', function ($scope, $location, authService) {

    $scope.authentication = authService.authentication;
    $scope.tokenRefreshed = false;
    $scope.tokenResponse = null;

    $scope.refreshToken = function () {

        authService.refreshToken().then(function (response) {
            $scope.tokenRefreshed = true;
            $scope.tokenResponse = response;
        },
         function (err) {
             $location.path('/login');
         });
    };

}]);

I na koniec ostatni bohater dzisiejszej historyjki. Drobny kontroler obsługujący odświeżanie tokenów (tzw. mały, ale wariat 🙂 ).

Mamy to!

W końcu. Mechanizmy autoryzacji i autentykacji (nadal w dosyć prostej formie, ale jednak) zostały zaimplementowane. W końcu mogę wziąć się za bardziej widoczne moduły aplikacji. Kosztowało mnie to trochę czasu i zdrowia, ale cieszę się, że nie zostawiłem tego na później jak zwykle.

W poście nie napisałem nic o widokach, które towarzyszą tym mechanizmom. To wszytko dlatego, że nie dzieje się w nich specjalnie nic ciekawego, ot zwykłe formularze. Jeżeli chcecie obejrzeć kod to jak zwykle zapraszam na GitHuba, a sama aplikacja dostępna jest tutaj.

I na koniec prośba do osób, które dotrwały do tego punktu. Jeżeli zauważycie jakikolwiek błąd, czy też możecie podzielić się jakimiś best practices proszę napiszcie to w komentarzach. Wciąż uczę się nowych rzeczy w .NETcie oraz Angularze i bardzo chętnie przyjmę krytykę i podpowiedzi co mogę zmienić żeby było lepiej 🙂

Related posts