From cf15e3beb3166d708bf9dbd0091c44bf28b8b897 Mon Sep 17 00:00:00 2001 From: Dainii Date: Mon, 6 Jan 2025 21:34:23 +0100 Subject: [PATCH] setup base jwt authentication --- app/adapters/application.js | 18 ++ app/authenticators/jwt.js | 282 ++++++++++++++++++++++++++++++ app/controllers/application.js | 12 ++ app/controllers/login.js | 34 ++++ app/router.js | 4 +- app/routes/authenticated.js | 10 ++ app/services/session.js | 3 + app/session-stores/application.js | 3 + app/templates/application.hbs | 15 +- app/templates/login.hbs | 10 ++ package-lock.json | 39 +++++ package.json | 1 + 12 files changed, 425 insertions(+), 6 deletions(-) create mode 100644 app/adapters/application.js create mode 100644 app/authenticators/jwt.js create mode 100644 app/controllers/application.js create mode 100644 app/controllers/login.js create mode 100644 app/routes/authenticated.js create mode 100644 app/services/session.js create mode 100644 app/session-stores/application.js create mode 100644 app/templates/login.hbs diff --git a/app/adapters/application.js b/app/adapters/application.js new file mode 100644 index 0000000..3850d21 --- /dev/null +++ b/app/adapters/application.js @@ -0,0 +1,18 @@ +import JSONAPIAdapter from '@ember-data/adapter/json-api'; +import { computed } from '@ember/object'; +import { inject as service } from '@ember/service'; + +export default class ApplicationAdapter extends JSONAPIAdapter { + @service session; + + @computed('session.{data.authenticated.access_token,isAuthenticated}') + get headers() { + let headers = {}; + if (this.session.isAuthenticated) { + // OAuth 2 + headers['Authorization'] = `Bearer ${this.session.data.authenticated.access_token}`; + } + + return headers; + } +} diff --git a/app/authenticators/jwt.js b/app/authenticators/jwt.js new file mode 100644 index 0000000..c0bd0c2 --- /dev/null +++ b/app/authenticators/jwt.js @@ -0,0 +1,282 @@ +import RSVP from 'rsvp'; +import fetch from 'fetch'; +import { + run, later, cancel +} from '@ember/runloop'; +import { + isEmpty +} from '@ember/utils'; +import { + warn +} from '@ember/debug'; +import BaseAuthenticator from 'ember-simple-auth/authenticators/base'; + +export default BaseAuthenticator.extend({ + /** + The endpoint on the server that the authentication request is sent to. + @property serverTokenEndpoint + @type String + @default '/login' + @public + */ + serverTokenEndpoint: '/login', + + /** + The endpoint on the server that the refresh request is sent to. + @property serverRefreshTokenEndpoint + @type String + @default '/api/token-refresh' + @public + */ + serverRefreshTokenEndpoint: '/jwt-refresh', + + /** + The identification attribute name. __This will be used in the request.__ + @property identificationAttributeName + @type String + @default 'username' + @public + */ + identificationAttributeName: 'email', + + /** + The password attribute name. __This will be used in the request.__ + @property passwordAttributeName + @type String + @default 'password' + @public + */ + passwordAttributeName: 'password', + + /** + Time (ms) before the JWT expires to call the serverRefreshTokenEndpoint + @property refreshTokenOffset + @type Integer + @default '1000' + @public + */ + refreshTokenOffset: 1000, + + /** + Time (ms) after a call to serverRefreshTokenEndpoint during which no + further refresh token calls will be made. + + Used to reduce the number of refresh token calls made when the same + app is simultaneously open in multiple tabs/windows. + + For example: if the JWT is set to expire 30s after being issued, and the + 'refreshTokenAfter' is set at 25s, requests may only be sent out in the + last 5 seconds. + + @property refreshTokenAfter + @type Integer + @default '25000' + @public + */ + refreshTokenAfter: 25000, + + _refreshTokenTimeout: null, + + /** + Restores the session from a session data object; __will return a resolving + promise when there is a non-empty `access_token` in the session data__ and + a rejecting promise otherwise. + @method restore + @param {Object} data The data to restore the session from + @return {Ember.RSVP.Promise} A promise that when it resolves results in the session becoming or remaining authenticated + @public + */ + restore(data) { + if (this._refreshTokenTimeout) { + run.cancel(this._refreshTokenTimeout); + delete this._refreshTokenTimeout; + } + return this._refreshAccessToken(data); + }, + + /** + Authenticates the session with the specified `identification` & `password`. + Issues a `POST` request to the serverTokenEndpoint and receives the JWT token in response. + + If the credentials are valid and thus authentication succeeds, a promise that resolves with the + server's response is returned, otherwise a promise that rejects with the error as returned by + the server is returned. + + This method also schedules refresh requests for the access token before it expires. + TODO: make the refresh token support optional + @method authenticate + @param {String} identification The resource owner username + @param {String} password The resource owner password + @return {Ember.RSVP.Promise} A promise that when it resolves results in the session becoming authenticated + @public + */ + authenticate(identification, password) { + return new RSVP.Promise((resolve, reject) => { + const { + identificationAttributeName, + passwordAttributeName + } = this.getProperties('identificationAttributeName', 'passwordAttributeName'); + const data = { + [identificationAttributeName]: identification, + [passwordAttributeName]: password + }; + const serverTokenEndpoint = this.get('serverTokenEndpoint'); + + this.makeRequest(serverTokenEndpoint, data) + .then((response) => { + return this._validateTokenAndScheduleRefresh(response); + }) + .then((response) => { + run(() => { + resolve(response); + }); + }) + .catch((reason) => { + if (reason.responseJSON) { + reason = reason.responseJSON; + } + run(null, reject, reason); + }); + }); + }, + + /** + Deletes the JWT token + @method invalidate + @return {Ember.RSVP.Promise} A promise that when it resolves results in the session being invalidated + @public + */ + invalidate() { + run.cancel(this._refreshTokenTimeout); + delete this._refreshTokenTimeout; + return RSVP.Promise.resolve(); + }, + + /** + Makes a request to the JWT server. + @method makeRequest + @param {String} url The request URL + @param {Object} data The request data + @param {Object} headers Additional headers to send in request + @return {Promise} A promise that resolves with the response object + @protected + */ + makeRequest(url, data, headers = {}) { + headers['Content-Type'] = 'application/json'; + + const options = { + body: JSON.stringify(data), + headers, + method: 'POST' + }; + + return new RSVP.Promise((resolve, reject) => { + fetch(url, options).then((response) => { + response.text().then((text) => { + let json = text ? JSON.parse(text) : {}; + if (!response.ok) { + response.responseJSON = json; + reject(response); + } else { + window.localStorage.setItem('jwtLastRefreshAt', Date.now()); + resolve(json); + } + }); + }).catch(reject); + }); + }, + + /** + Validate that the response contains a valid JWT token + */ + _validate(data) { + // Validate that a token is present + if (isEmpty(data['access_token'])) { + return false; + } + + let jwtToken = data['access_token'].split('.'); + + // Validate the three elements of a JWT are present + if (jwtToken.length !== 3) { + return false; + } + + // Validate the JWT header + let jwtHeader = JSON.parse(atob(jwtToken[0])); + if (!jwtHeader.alg) { + return false; + } + + // Validate the JWT payload: + // iat: issued at time + // exp: expiration time + let jwtPayload = JSON.parse(atob(jwtToken[1])); + if (isNaN(jwtPayload['iat']) || isNaN(jwtPayload['exp'])) { + return false; + } + + return true; + }, + + _scheduleTokenRefresh(data) { + const jwtPayload = JSON.parse(atob(data['access_token'].split('.')[1])); + const jwtPayloadExpiresAt = jwtPayload.exp; + + const offset = 1000; // Refresh 1 sec before JWT expires + const now = Date.now(); + const waitMs = (jwtPayloadExpiresAt * 1000) - now - offset; //expiresAt is in sec + + if (this._refreshTokenTimeout) { + cancel(this._refreshTokenTimeout); + delete this._refreshTokenTimeout; + } + + // Reschedule if the JWT is still valid + if (waitMs > 0) { + this._refreshTokenTimeout = later(this, this._refreshAccessToken, data, waitMs); + } + }, + + _refreshAccessToken(data) { + var timeElapsedSinceLastRefresh = Date.now() - window.localStorage.getItem('jwtLastRefreshAt') + if (timeElapsedSinceLastRefresh <= this.get('refreshTokenAfter')) { + // Request attempted too soon! Reschedule + return this._validateTokenAndScheduleRefresh(data); + } + + const serverRefreshTokenEndpoint = this.get('serverRefreshTokenEndpoint'); + + return new RSVP.Promise((resolve, reject) => { + this.makeRequest(serverRefreshTokenEndpoint, data) + .then((response) => { + return this._validateTokenAndScheduleRefresh(response); + }) + .then((response) => { + run(() => { + this.trigger('sessionDataUpdated', response); + resolve(response); + }); + }) + .catch((reason) => { + if (reason.responseJSON) { + reason = JSON.stringify(reason.responseJSON); + } + warn(`JWT token could not be refreshed: ${reason}.`, false, { + id: 'ember-simple-auth-jwt.failedJWTTokenRefresh' + }); + + reject(); + }); + }); + }, + + _validateTokenAndScheduleRefresh(response) { + if (!this._validate(response)) { + return RSVP.Promise.reject('token is missing or invalid in server response'); + } + + this._scheduleTokenRefresh(response); + return RSVP.Promise.resolve(response); + } +}); diff --git a/app/controllers/application.js b/app/controllers/application.js new file mode 100644 index 0000000..6be8ccd --- /dev/null +++ b/app/controllers/application.js @@ -0,0 +1,12 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from "@ember/object"; + +export default class ApplicationController extends Controller { + @service session; + + @action + invalidateSession() { + this.session.invalidate(); + } +} diff --git a/app/controllers/login.js b/app/controllers/login.js new file mode 100644 index 0000000..137ad4b --- /dev/null +++ b/app/controllers/login.js @@ -0,0 +1,34 @@ +import Controller from '@ember/controller'; +import { inject as service } from '@ember/service'; +import { action } from "@ember/object"; +import { tracked } from "@glimmer/tracking"; + +export default class LoginController extends Controller { + @tracked errorMessage; + @service session; + + @action + async authenticate(e) { + e.preventDefault(); + let { identification, password } = this; + try { + await this.session.authenticate('authenticator:jwt', identification, password); + } catch(error) { + this.errorMessage = error.error || error; + } + + if (this.session.isAuthenticated) { + // What to do with all this success? + } + } + + @action + updateIdentification(e) { + this.identification = e.target.value; + } + + @action + updatePassword(e) { + this.password = e.target.value; + } +} diff --git a/app/router.js b/app/router.js index 752b634..95d5333 100644 --- a/app/router.js +++ b/app/router.js @@ -6,4 +6,6 @@ export default class Router extends EmberRouter { rootURL = config.rootURL; } -Router.map(function () {}); +Router.map(function() { + this.route('login'); +}); diff --git a/app/routes/authenticated.js b/app/routes/authenticated.js new file mode 100644 index 0000000..5dcc2e0 --- /dev/null +++ b/app/routes/authenticated.js @@ -0,0 +1,10 @@ +import Route from '@ember/routing/route'; +import { inject as service } from '@ember/service'; + +export default class AuthenticatedRoute extends Route { + @service session; + + beforeModel(transition) { + this.session.requireAuthentication(transition, 'login'); + } +} diff --git a/app/services/session.js b/app/services/session.js new file mode 100644 index 0000000..3da3895 --- /dev/null +++ b/app/services/session.js @@ -0,0 +1,3 @@ +import Service from 'ember-simple-auth/services/session'; + +export default class SessionService extends Service {} diff --git a/app/session-stores/application.js b/app/session-stores/application.js new file mode 100644 index 0000000..5778059 --- /dev/null +++ b/app/session-stores/application.js @@ -0,0 +1,3 @@ +import AdaptiveStore from 'ember-simple-auth/session-stores/adaptive'; + +export default class SessionStore extends AdaptiveStore {} diff --git a/app/templates/application.hbs b/app/templates/application.hbs index 3d850a6..210ca43 100644 --- a/app/templates/application.hbs +++ b/app/templates/application.hbs @@ -1,7 +1,12 @@ {{page-title "Holygamesapp"}} -{{outlet}} - -{{! The following component displays Ember's default welcome message. }} - -{{! Feel free to remove this! }} \ No newline at end of file + +
+ {{outlet}} +
diff --git a/app/templates/login.hbs b/app/templates/login.hbs new file mode 100644 index 0000000..bb00ac0 --- /dev/null +++ b/app/templates/login.hbs @@ -0,0 +1,10 @@ +
+ + + + + + {{#if this.errorMessage}} +

{{this.errorMessage}}

+ {{/if}} +
diff --git a/package-lock.json b/package-lock.json index 6bddf0f..751e74a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "ember-page-title": "^8.2.3", "ember-qunit": "^8.1.1", "ember-resolver": "^13.1.0", + "ember-simple-auth": "^7.1.1", "ember-source": "~6.1.0", "ember-template-imports": "^4.2.0", "ember-template-lint": "^6.0.0", @@ -12105,6 +12106,21 @@ "semver": "bin/semver" } }, + "node_modules/ember-cookies": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/ember-cookies/-/ember-cookies-1.3.0.tgz", + "integrity": "sha512-nhVDm9lql4EVLpbjxyosyEITFvuNAmHr7cod8K2FmIyw2KcAFWSS0v88quIWc+GvcawBTz3KSMRXOJq/0InVpg==", + "dev": true, + "dependencies": { + "@embroider/addon-shim": "^1.7.1" + }, + "engines": { + "node": ">= 16.*" + }, + "peerDependencies": { + "ember-source": ">=4.0" + } + }, "node_modules/ember-data": { "version": "5.3.9", "resolved": "https://registry.npmjs.org/ember-data/-/ember-data-5.3.9.tgz", @@ -13576,6 +13592,29 @@ "node": "8.* || 10.* || >= 12" } }, + "node_modules/ember-simple-auth": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ember-simple-auth/-/ember-simple-auth-7.1.1.tgz", + "integrity": "sha512-76XtSXtn6QhqAcIAx+gPYAADnSCwSlnRzFrGYkzxj3WPRcaW0phMqtjQxQZWreXN3hWwIqWKd+B9Cu+hF2CoOQ==", + "dev": true, + "dependencies": { + "@ember/test-waiters": "^3", + "@embroider/addon-shim": "^1.0.0", + "@embroider/macros": "^1.0.0", + "ember-cli-is-package-missing": "^1.0.0", + "ember-cookies": "^1.3.0", + "silent-error": "^1.0.0" + }, + "peerDependencies": { + "@ember/test-helpers": ">= 3 || > 2.7", + "ember-source": ">=4.0" + }, + "peerDependenciesMeta": { + "@ember/test-helpers": { + "optional": true + } + } + }, "node_modules/ember-source": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/ember-source/-/ember-source-6.1.0.tgz", diff --git a/package.json b/package.json index 82077e8..5c9963b 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "ember-page-title": "^8.2.3", "ember-qunit": "^8.1.1", "ember-resolver": "^13.1.0", + "ember-simple-auth": "^7.1.1", "ember-source": "~6.1.0", "ember-template-imports": "^4.2.0", "ember-template-lint": "^6.0.0",