akc42 / akc-route

A distributed router inspired by PolymerElements/app-route
MIT License
2 stars 0 forks source link

Securing Routes? #9

Open c256985 opened 6 years ago

c256985 commented 6 years ago

I was wondering if there was any way to secure routes? Some combination of the route plus the roles required to access the route.

akc42 commented 6 years ago

For a while, I have had a situation in which certain routes required specific user permissions. I handle these on the client by hiding the links that take me to that route, and on the server by returning a not authorised (clients are logged on via an encrypted cookie which contains user permissions). But just in the last week I now have a requirement to prompt the user for the password also when they try and access some particular urls (which generate reports of particularly sensitive information)

The way I do it, is to have a "password gateway" element between <akc-location> and the highest <akc-route> in the chain.It has an inRoute and an outRoute. It has a list of urls that constitute the secure area and an internal flag that remembers if the last request was within or without the secure area. Changes in inRoute are passed straight through to outRoute, unless its a transition from non-secure to secure - in which case a dialog box is raised to prompt for the password. Upon entry of the password, an api request is sent to the server to validate password, and only when a successful response is received is the inRoute passed through to the outRoute.

Changes to outRoute have to be passed back up to the inRoute, so changes made by an element to a route down the chain can be reflected back to the location bar. The only issue with that was some care has to be taken as when to copy route objects or just reference them to avoid an infinite loop of changes

Here is the guts of that element

<dom-module id="pas-password-gateway">
  <template>
    <style>
      :host {
        display: block;
      }
      paper-button {
        background-color:var(--app-button-color);
        color:var(--app-button-text);
        text-align: center;
      }

      paper-button.data {
        background-color: var(--app-button-data-color);
      }

      paper-button.cancel {
        background-color: var(--app-cancel-color);
      }
    </style>
    <pas-waiting waiting="[[waiting]]"></pas-waiting>
    <iron-a11y-keys
      target="[[keyTarget]]"
      keys="enter"
      stop-keyboard-event-propagation
      on-keys-pressed="_keyUpdate"></iron-a11y-keys>
    <iron-a11y-keys
      target="[[keyTarget]]"
      keys="esc"
      stop-keyboard-event-propagation
      on-keys-pressed="_keyCancel"></iron-a11y-keys>
    <paper-dialog
      id="inputdialog"
      horizontal-align="center"
      vertical-align="top"
      always-on-top
      no-cancel-on-outside-click
      on-iron-overlay-closed="_inputClosed">
      <h2>Please enter your password</h2>
      <paper-input
      label="Password"
      autofocus
      error-message="Password Incorrect"
      type="password"
      name="password"
      id="pw"
      invalid="[[invalid]]"
      value="{{password}}"></paper-input>
      <div class="buttons">
        <paper-button
          class="action"
          raised
          dialog-confirm><iron-icon icon="pas:check"></iron-icon>Check</paper-button>
        <paper-button
          class="cancel"
          raised
          dialog-dismiss><iron-icon icon="pas:cancel"></iron-icon>Cancel</paper-button>
      </div>
    </paper-dialog>
  </template>
  <script>
    class PasPasswordGateway extends PAS.Ajax(Polymer.Element) {
      static get is() {return 'pas-password-gateway';}
      static get properties() {
        return {
          inRoute: {
            type: Object,
            notify: true
          },
          outRoute: {
            type: Object,
            value: function() {return {path: '', active: false, params: {}, query: {}};},
            notify: true
          },
          keyTarget: {
            type: Object
          },
          waiting: {
            type: Boolean,
            value: false
          },
          password: {
            type: String,
            value: ''
          },
          invalid: {
            type: Boolean,
            value: false
          }
        };
      }
      static get observers() {
        return [
          '_inRouteChanged(inRoute.*)',
          '_outRouteChanged(outRoute.*)'
        ];
      }
      static get secureRoutes() {
        return [
          '/reports/bydate/bill',
          '/reports/queries/paysum',
          '/reports/queries/payres',
          '/reports/queries/paymet',
          '/reports/queries/costeye',
          '/reports/queries/lenspay',
          '/reports/queries/foretot',
          '/reports/queries/sumfore',
          '/reports/queries/detfore'
        ];
      }
      ready() {
        super.ready();
        this.keyTarget = this.$.inputdialog;
        this.inSecureSection = false;
        this.dialogOpen = false;
        this.changesPath = '';
        this.changesValue = {path: '', active: false, params: {}, query: {}};
        this.passingDown = false;
      }
      _inputClosed(e) {
        e.stopPropagation();
        if (e.target !== this.$.inputdialog) return;
        if (e.detail.confirmed || (e.detail.confirmed === undefined && !e.detail.canceled)) {
          this.waiting = true;
          this.api('checkpass', {pass: this.password}).then(response => {
            this.waiting = false;
            if (response.pass) {
              this.dialogOpen = false;
              this.invalid = false;
              this.inSecureSection = true;
              this.set('outRoute' + this.changesPath, this.changesValue);
              this.changesPath = '';
              this.changesValue = {path: '', active: false, params: {}, query: {}};
              this.password = '';
            } else {
              this.invalid = true;
              this.$.inputdialog.open(); //need to collect password
            }
          });
        } else {
          this.dialogOpen = false;
          this.changesPath = '';
          this.changesValue = {path: '', active: false, params: {}, query: {}};
          this.password = '';
          window.history.go(-1); //have to return to previous route
        }
      }

      _inRouteChanged(changes) {
        if (changes.base === undefined) return;
        if (this._isASecureRoute(changes.base.path)) {
          if(!this.inSecureSection) {
            if (!this.dialogOpen) {
              this.dialogOpen = true;
              this.changesPath = changes.path.substring(7);
              this.passingDown = true;
              if (this.changesPath.length === 0) {
                this.changesValue = Object.assign({}, changes.value);
              } else {
                this.changesValue = changes.value;
              }
              this.passingDown = false;
              this.$.inputdialog.open(); //need to collect password
            }
          } else {
            //we already entered  secure section and not left it so we can continue
            this.passingDown = true;
            if (changes.path.length === 7) {
              this.set('outRoute', Object.assign({}, changes.value));
            } else {
              this.set('outRoute' + changes.path.substring(7), changes.value);
            }
            this.passingDown = false;
          }
        } else {
          this.inSecureSection = false;
          this.passingDown = true;
          if (changes.path.length === 7) {
            this.set('outRoute', Object.assign({}, changes.value));
          } else {
            this.set('outRoute' + changes.path.substring(7), changes.value);
          }
          this.passingDown = false;
        }
      }
      _isASecureRoute(path) {
        for (let sp of PasPasswordGateway.secureRoutes) {
          if (path.substring(0,sp.length) === sp) return true;
        }
        return false;
      }
      _keyCancel(e) {
        e.stopPropagation();
        this.$.inputdialog.cancel();
      }
      _keyUpdate(e) {
        e.stopPropagation();
        this.$.inputdialog.close();
      }
      _outRouteChanged(changes) {
        //don't reflext upwards if not ready yet
        if (this.inRoute === undefined || changes.base === undefined || this.passingDown) return;
        if (changes.base.active) {
          if (changes.path.length === 8) {
            this.set('inRoute', Object.assign({},changes.value));
          } else {
            this.set('inRoute' + changes.path.substring(8), changes.value);
          }
        }
      }
    }
    customElements.define(PasPasswordGateway.is, PasPasswordGateway);
  </script>
</dom-module>

I realise looking above there are a couple of things that might need some explanation,

PAS.Ajax is a class mixin which provides this.api wrapper around the fetch api, but with lots of the specifics sorted from my app. It sends the the userid and password to /api/checkpass and fulfils the return promise with the json response.

<pas-waiting> just puts a rotating busy icon in the middle screen when this.waiting is true

c256985 commented 6 years ago

Interesting. You might want to use a map or a set for secure routes, since this would obviate the need to iterate through the routes. The API wrapper is also an interesting idea, since this presumably gives you a more natural REST interface with the appropriate PUT/GET/DELETE/PATCH verbs.

It might also be good to factor out the routes as a configuration and have some sort of named routes. You could then have production, test, and dev routes, and the API wrapper would construct the appropriate URL for you. This would let the developer then say, "I need the "getUsers" route for production, and it then checks to see if the user has the appropriate role, and builds the appropriate URL on the fly.

akc42 commented 6 years ago

Problem with a map (or set) is that I am comparing a partial string with the secure route as the actual path has quite a bit more in it, so its helpful when iterating the array to have the length of each secure path, so I can just check the front of the actual path.