Angularjs authentication with JWT

Intro

Bài trước đã giới thiệu với basic http authentication, sau đây sẽ build một ứng dụng hoặt động tương tự basic http authenication nhưng sử dụng JSON Web Token(JWT).

JSON Web Token là gì?

JSON Web Token(JWT) là một chuẩn mở (RFC 7519) định nghĩa một cách nhỏ gọn và độc lập để truyền tải thông tin một cách an toàn giữa các bên đối tác là một đối tượng JSON . Thông tin này có thể xác minh và tin cậy vì nó được ký kết kỹ thuật số. JWTs có thể ký bằng cách sử dụng một bí mật (với thuật toán HMAC) hoặc một public/private key pair sử dụng RSA.

  • Compact: do kích thước nhỏ hơn, JWTs có thể gửi qua URL, POST parameter hoặc bên trong một HTTP header. Ngoài ra, kích thước nhỏ hơn nghĩa là truyền nhanh.
  • Độc lập: phần payload chứa tất cả các thông tin cần thiết về người dùng tránh sự cần thiết phải truy vấn cơ sở dữ liệu nhiều lần.

JWT có thể hiểu đơn gian hơn là cung cập cách để xác thực mọi yêu cầu của client mà không cần phải duy trì một session hoặc chuyển thông tin đăng nhập nhiều lần lên server. JSON Web Tokens bao gồm ba thành phần chính tách nhau bởi dấu chấm(.) đó là Header, Payload và Signature.

Header

Header thường bao gồm hai thành phần đó là kiểu của token đó là JWT và thuật toán hàm băm được sử dụng bao gồm HMAC SHA256 hoặc RSA. Ví dụ: JSON sau là mã hóa Base64URL để tạo thành phần đầu tiên của JWT.

{
    "alg": "HS256",
    "typ": "JWT"
}
Payload

Thành phần thứ hai của token là payload, nó chứa claims. Claims là các câu lệnh về một entity(thường là user) và metadata bổ sung. Claims có ba loại: reserved, public và private. Ví dụ: payload mã hóa Base64URL để tạo thành phần thứ hai cho JWT.

{
    "sub": "1234567890",
    "name": "John Doe",
    "admin": true
}
Signature

Để tạo phần signature chúng ta lấy header đã mã hóa, payload đã mã hóa, bí mật, thuật toán được chọn trong header và ký. Ví dụ: nếu muốn sử dụng thuật toán HMAC SHA256, signature sẽ tạo theo mẫu sau:

HMACSHA256(
    base64UrlEncode(header) + "." +
    base64UrlEncode(payload),
    secret)

Signature dùng để kiểm chứng người gửi JWT là người tạo và để đảm bảo thông điệp không thay đổi trên đường truyền tải.

So sánh Basic HTTP auth và JWT auth

Về phía angular điểm khác nhau giữa basic HTTP và JWT auth là ít tại vì angular app chỉ gửi data khác nhau trong HTTP authorization header để gọi API. Với basic authentication angular app gửi username và password được mã hóa base64 với tiền tố Basic còn JWT app gửi một JSON Web Token được mã hóa base64 với tiền tố Bearer.

Setup project

Ứng dụng sử dụng một fake backend với hộ trợ của angular service đó là $httpBackend là một phần của module ngMockE2E do vậy ứng dụng sẽ chạy như backend api thật. Để sử dụng backend thật chỉ xóa đi script fake-backend.js từ file index.html.

Cấu trúc của ứng dụng
angularjs-jwt-authentication
  - index.html
  - app.js
  - gulpfile.js
  /app
    /controllers
      - home_controller.js
      - login_controller.js
    /helper
      - fake_backend.js
    /services
      - authentication_server.js
   /views
     - home.html
     - login.html
  /bower_components
  • BrowserSync là một npm package nó cần cài đặt trước Node.js trước. Để install BrowserSync mở terminal và gõ: npm install -g browser-sync.
  • Bower để quản lý các framworks, libraries, assets và utilities. npm install -g bower
  • gulp một công cụ giúp tự động hóa nhiều task trong quá trình phát triển web
    • npm install gulp-cli -g
    • npm install gulp -D
    • npm install gulp-sourcemaps
    • npm install gulp-concat

Tải các thư viện để áp dụng vào project

- `bower init` (điền các thông tin cần thiết)
- `bower install bootstrap --save`
- `bower install angular --save`
- `bower install angular-ui-router --save`
- `bower install angular-messages --save`
- `bower install ngStorage --save`
- `bower install angular-mocks --save`

Config gulpfile.js

var gulp = require("gulp");
var concat = require("gulp-concat");
var sourcemaps = require("gulp-sourcemaps");
var browserSync = require("browser-sync").create();
var reload = browserSync.reload;

var paths = {
  "bower_components": ["bower_components/angular/angular.min.js",
    "bower_components/angular-messages/angular-messages.min.js",
    "bower_components/angular-ui-router/release/angular-ui-router.min.js",
    "bower_components/ngstorage/ngStorage.min.js",
    "bower_components/angular-mocks/angular-mocks.js",
    "app.js", "app/services/*.js", "app/helpers/*.js", "app/controllers/*.js"]
}

gulp.task("default", function() {
  return gulp.src(paths["bower_components"])
    .pipe(sourcemaps.init())
      .pipe(concat("requireJS.js"))
    .pipe(sourcemaps.write())
    .pipe(gulp.dest("."));
});

// process JS files and return the stream.
gulp.task("js", function () {
    return gulp.src('js/*js')
        .pipe(browserify())
        .pipe(uglify())
        .pipe(gulp.dest('dist/js'));
});

// Static server
gulp.task("browser-sync", function() {
  browserSync.init({
    server: {
      baseDir: "./"
    }
  });
  gulp.watch("*.html").on("change", reload);
});

Coding

AngularJS fake backend

fake backend giúp ứng dụng để chạy khi không có backend api thật (backend-less). Nó sử dụng service $httpBackend cùng cập bở module ngMockE2E để chặn các http requests tạo bở angular app và gửi lại response mock/fake.

Nó chứa một mock thực hiện của api end poit /api/authenticate để xác nhận username và password sau đó gửi lại một jwt token giả nếu thành công. Tất cả các request khác được đi qua trực tiếp đến service vì vậy static file như (.js, .html, .css) yêu cầu bởi angular app được cung cập chính xác.

# /app/helpers/fake_backend.js
(function() {
  "use strict";
  angular
    .module("autJWTApp")
    .run(setupFakeBackend);

  // setup fake backend for backend less development
  function setupFakeBackend($httpBackend) {
    var testUser = { username: "test", password: "test", firstName: "Test", lastName: "User"};

    //fake authenticate api end point
    $httpBackend.whenPOST("/api/authenticate").respond(function(method, url, data) {
      //get parameters from post request
      var params = angular.fromJson(data);

      // check user credentials and return jwt token if valid
      if (params.username === testUser.username && params.password === testUser.password) {
        return [200, {token: "fake-jwt-token"}, {}];
      } else {
        return [200, {}, {}];
      }
    });

    // pass through any urls not handled about so static files are served correctly
    $httpBackend.whenGET(/^\w+.*/).passThrough();
  }
})();

AngularJS authentication service

Authenticaiton service dùng để đăng nhập và đăng xuất ra khỏi ứng dụng, để đăng nhập nó gửi thông tin user đến api và kiểm tra xem nếu có jwt token trong response thì đăng nhập thành công và thông tin chi tiết về user được lưu trong local storage và token được thêm vào http authorization header cho tất cả các request tạo bởi $http serivce.

Thông tin của current user đã đăng nhập hiện tại được lưu trong local storage vì vậy user sẽ có trạng thái đã đăng nhập tùy họ refresh trình duyệt hoặc giữa các sessions của trình duyệt trừ khi đăng xuất. Nếu không muốn cho user giữ trạng thái đăng nhập trong khi refresh/session có thể thay đổi bằng cách dùng phương pháp lưu khác ngoài local storage để giữ thông tin của current user ví dụ như session storage hoặc root scope.

# /app/services/authentication_server.js
(function() {
  "use strict";

  angular
    .module("autJWTApp")
    .factory("AuthenticationService", Service);

  //Service.$inject = ["$http", "$localStorage"];

  function Service($http, $localStorage) {
    var service = {};
    service.Login = Login;
    service.Logout = Logout;

    return service;

    function Login(username, password, callback) {
      // return $http.post("/api/authenticate", { username: username, password: password })
      return $http({
        url: "/api/authenticate",
        method: "POST",
        data: { username: username, password: password }
      })
      .then(function(response) {
        // login successful if there's a token in the response
        if (response.data.token) {
          $localStorage.currentUser = {username: username, token: response.data.token};

          // add jwt token to auth header for all request made by the $http service
          $http.defaults.headers.common.Authorization = "Bear " + response.data.token;

          // execute call back with true to indicate successful login
          callback(true);
        } else {
          // execute callback with false to indicate fails login
          callback(false);
        }
      });
      function successLogin(response) {
        // login successful if there's a token in the response
        if (response.token) {
          $localStorage.currentUser = {username: username, token: response.token};

          // add jwt token to auth header for all request made by the $http service
          $http.defaults.headers.common.Authorization = "Bear " + response.token;

          // execute call back with true to indicate successful login
          callback(true);
        } else {
          // execute callback with false to indicate fails login
          callback(false);
        }
      }
    }

    function Logout() {
      // remove user from local storage and clear http auth header
      delete $localStorage.currentUser;
      $http.defaults.headers.common.Authorization = "";
    }
  }
})();

Home controller

Home controller xử lý tất cả tương tác và data cho index view do chưa có gì để xử lý nên chỉ có cấu trúc cơ bản của controller.

# /app/controllers/home_controller.js
(function() {
  "use strict";

  angular
    .module("autJWTApp")
    .controller("HomeController", HomeController);
  function HomeController() {
    var vm = this;
    initController();

    function initController() {

    }
  }
})();

Home index view

View mặc đinh cho thành phần home.

# /app/views/home.html
<div class="col-md-6 col-md-offset-3">
  <h1>Home</h1>
  <p>You're logged in with JWT!!</p>
  <p><a href="#!/login">Logout</a></p>
</div>

Login controller

Login controller xử lý tất cả tương tác và data cho login index view khi nó tải lần đầu nó đảm bảo user đã đăng nhập (trong initController() function) là lý do logout link trên home index view cần để dẫn user tới login page.

Chức năng login thể hiện bởi viewmodel(vm.login) sử dụng AuthenticationService để validate các thông tin của user và chuyển trang đến home (nếu thành công) hoặc hiển thị thông báo lỗi (khi thất bại).

# /app/controllers/login_controller.js
(function () {
  "use strict";

  angular
    .module("autJWTApp")
    .controller("LoginController", LoginController);

  function LoginController($location, AuthenticationService) {
    var vm = this;

    vm.login = login;

    initController();

    function initController() {
      // reset login status
      AuthenticationService.Logout();
    };

    function login() {
      vm.loading = true;
      AuthenticationService.Login(vm.username, vm.password, function (result) {
        if (result === true) {
          $location.path("/");
        } else {
          vm.error = "Username or password is incorrect";
          vm.loading = false;
        }
      });
    };
  }
})();

Login index view

Index view của login chứa form login với trường username và password, nó sử dụng ngMessages directive để hiển thị validation messages. Trong form đã tắt đi validation của browser bằng cách thêm thuộc tính novalidate cho form vậy angular validation message sẽ hiển thị khi submit form nếu form invalid.

# /app/views/login.html
<div class="col-md-6 col-md-offset-3">
  <div class="alert alert-info">
    Username: test<br />
    Password: test
  </div>
  <h2>Login</h2>
  <form name="form" ng-submit="form.$valid && vm.login()" novalidate>
    <div class="form-group" ng-class="{ 'has-error': form.$submitted && form.username.$invalid }">
      <label for="username">Username</label>
      <input type="text" name="username" class="form-control" ng-model="vm.username" required />
      <div ng-messages="form.$submitted && form.username.$error" class="help-block">
        <div ng-message="required">Username is required</div>
      </div>
    </div>
    <div class="form-group" ng-class="{ 'has-error': form.$submitted && form.password.$invalid }">
      <label for="password">Password</label>
      <input type="password" name="password" class="form-control" ng-model="vm.password" required />
      <div ng-messages="form.$submitted && form.password.$error" class="help-block">
        <div ng-message="required">Password is required</div>
      </div>
    </div>
    <div class="form-group">
      <button ng-disabled="vm.loading" class="btn btn-primary">Login</button>
      <img ng-if="vm.loading" src="data:image/gif;base64,R0lGODlhEAAQAPIAAP///wAAAMLCwkJCQgAAAGJiYoKCgpKSkiH/C05FVFNDQVBFMi4wAwEAAAAh/hpDcmVhdGVkIHdpdGggYWpheGxvYWQuaW5mbwAh+QQJCgAAACwAAAAAEAAQAAADMwi63P4wyklrE2MIOggZnAdOmGYJRbExwroUmcG2LmDEwnHQLVsYOd2mBzkYDAdKa+dIAAAh+QQJCgAAACwAAAAAEAAQAAADNAi63P5OjCEgG4QMu7DmikRxQlFUYDEZIGBMRVsaqHwctXXf7WEYB4Ag1xjihkMZsiUkKhIAIfkECQoAAAAsAAAAABAAEAAAAzYIujIjK8pByJDMlFYvBoVjHA70GU7xSUJhmKtwHPAKzLO9HMaoKwJZ7Rf8AYPDDzKpZBqfvwQAIfkECQoAAAAsAAAAABAAEAAAAzMIumIlK8oyhpHsnFZfhYumCYUhDAQxRIdhHBGqRoKw0R8DYlJd8z0fMDgsGo/IpHI5TAAAIfkECQoAAAAsAAAAABAAEAAAAzIIunInK0rnZBTwGPNMgQwmdsNgXGJUlIWEuR5oWUIpz8pAEAMe6TwfwyYsGo/IpFKSAAAh+QQJCgAAACwAAAAAEAAQAAADMwi6IMKQORfjdOe82p4wGccc4CEuQradylesojEMBgsUc2G7sDX3lQGBMLAJibufbSlKAAAh+QQJCgAAACwAAAAAEAAQAAADMgi63P7wCRHZnFVdmgHu2nFwlWCI3WGc3TSWhUFGxTAUkGCbtgENBMJAEJsxgMLWzpEAACH5BAkKAAAALAAAAAAQABAAAAMyCLrc/jDKSatlQtScKdceCAjDII7HcQ4EMTCpyrCuUBjCYRgHVtqlAiB1YhiCnlsRkAAAOwAAAAAAAAAAAA==" />
    </div>
    <div ng-if="vm.error" class="alert alert-danger">{{vm.error}}</div>
  </form>
</div>

Main app file

File app.js là điểm vào của ứng dụng angular là nơi mà app module được khai báo với các dependencies và chứa các cấu hình và khởi tạo logic khi ứng dụng load lần đầu tiên. Chức năng config() dùng để xác định routes của ứng dụng sử dụng Angular UI Router, sau đó chức năng run() chứa logic startup của ứng dụng bao gồm code để kiểm tra local storage để giữ trạng thái đăng nhập của user trong khi page refresh và phiên của browser và thêm một event handler cho $locationChangeStart redirect user tới trang login nếu chưa xác thực.

# /app.js
(function() {
  "use strict";
  var app = angular
    .module("autJWTApp", ["ui.router", "ngMessages", "ngStorage", "ngMockE2E"])
    .config(config)
    .run(run);

  function config($stateProvider, $urlRouterProvider) {
    // default route
    $urlRouterProvider.otherwise("/");

    // app routes
    $stateProvider
      .state("home", {
        url: "/",
        templateUrl: "app/views/home.html",
        controller: "HomeController",
        controllerAs: "vm"
      })
      .state("login", {
        url: "/login",
        templateUrl: "app/views/login.html",
        controller: "LoginController",
        controllerAs: "vm"
      });
  }

  function run($rootScope, $http, $location, $localStorage) {
    // Keep user logged in after page refresh
    if ($localStorage.currentUser) {
      $http.defaults.headers.common.Authorization = "Bearer " + $localStorage.currentUser.token;
    }

    // redirect to login page if not logged in and trying to access a restricted page
    $rootScope.$on("$locationChangeStart", function(event, next, current) {
      var publicPages = ["/login"];
      var restrictPage = publicPages.indexOf($location.path()) === -1;
      if(restrictPage && !$localStorage.currentUser) {
        $location.path("login");
      }
    });

    // $httpBackend.whenGET(/^\w+.*/).passThrough();
  }
})();

Main index file

Root file index.html là file html chính cho ứng dụng angualr nó chứa template ngoài cùng và bao gồm các stylesheets và scripts yêu cầu bởi ứng dụng. Ở đây có một cái lưu lý đó là <script src="requireJS.js"></script> chỉ cần require một file thay vì require rất nhiều file trong index.html này, việc gộp này nằm mục định để dễ quản lý các file yêu cầu dùng trong ứng dụng nhờ gulp để xử lý việc đó.

# /index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>AngularJS JWT Authentication</title>
    <script src="requireJS.js"></script>
    <link rel="stylesheet" href="bower_components/bootstrap/dist/css/bootstrap.min.css" />
  </head>
  <body ng-app="autJWTApp">
    <div class="jumbotron">
      <div class="container">
        <div class="col-sm-8 col-sm-offset-2">
          <ui-view></ui-view>
        </div>
      </div>
    </div>
  </body>
</html>

Run applicaiton

Lần đầu tiên chạy hoặc thêm thư viện cần build requireJS.jsgulp trong terminal.

Để chạy server gõ command browser-sync start --server --files "*.html" trong thư mục của project.

References