setup base jwt authentication
This commit is contained in:
18
app/adapters/application.js
Normal file
18
app/adapters/application.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
282
app/authenticators/jwt.js
Normal file
282
app/authenticators/jwt.js
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
12
app/controllers/application.js
Normal file
12
app/controllers/application.js
Normal file
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/controllers/login.js
Normal file
34
app/controllers/login.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,4 +6,6 @@ export default class Router extends EmberRouter {
|
|||||||
rootURL = config.rootURL;
|
rootURL = config.rootURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
Router.map(function () {});
|
Router.map(function() {
|
||||||
|
this.route('login');
|
||||||
|
});
|
||||||
|
|||||||
10
app/routes/authenticated.js
Normal file
10
app/routes/authenticated.js
Normal file
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
3
app/services/session.js
Normal file
3
app/services/session.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import Service from 'ember-simple-auth/services/session';
|
||||||
|
|
||||||
|
export default class SessionService extends Service {}
|
||||||
3
app/session-stores/application.js
Normal file
3
app/session-stores/application.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import AdaptiveStore from 'ember-simple-auth/session-stores/adaptive';
|
||||||
|
|
||||||
|
export default class SessionStore extends AdaptiveStore {}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
{{page-title "Holygamesapp"}}
|
{{page-title "Holygamesapp"}}
|
||||||
|
|
||||||
{{outlet}}
|
<div class="menu">
|
||||||
|
{{#if this.session.isAuthenticated}}
|
||||||
{{! The following component displays Ember's default welcome message. }}
|
<button type="button" {{on "click" this.invalidateSession}}>Logout</button>
|
||||||
<WelcomePage />
|
{{else}}
|
||||||
{{! Feel free to remove this! }}
|
<LinkTo @route="login">Login</LinkTo>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div class="main">
|
||||||
|
{{outlet}}
|
||||||
|
</div>
|
||||||
|
|||||||
10
app/templates/login.hbs
Normal file
10
app/templates/login.hbs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<form {{on "submit" this.authenticate}}>
|
||||||
|
<label for="identification">Login</label>
|
||||||
|
<input id='identification' placeholder="Enter Login" value={{this.identification}} {{on "change" this.updateIdentification}}>
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input id='password' placeholder="Enter Password" value={{this.password}} {{on "change" this.updatePassword}}>
|
||||||
|
<button type="submit">Login</button>
|
||||||
|
{{#if this.errorMessage}}
|
||||||
|
<p>{{this.errorMessage}}</p>
|
||||||
|
{{/if}}
|
||||||
|
</form>
|
||||||
39
package-lock.json
generated
39
package-lock.json
generated
@@ -37,6 +37,7 @@
|
|||||||
"ember-page-title": "^8.2.3",
|
"ember-page-title": "^8.2.3",
|
||||||
"ember-qunit": "^8.1.1",
|
"ember-qunit": "^8.1.1",
|
||||||
"ember-resolver": "^13.1.0",
|
"ember-resolver": "^13.1.0",
|
||||||
|
"ember-simple-auth": "^7.1.1",
|
||||||
"ember-source": "~6.1.0",
|
"ember-source": "~6.1.0",
|
||||||
"ember-template-imports": "^4.2.0",
|
"ember-template-imports": "^4.2.0",
|
||||||
"ember-template-lint": "^6.0.0",
|
"ember-template-lint": "^6.0.0",
|
||||||
@@ -12105,6 +12106,21 @@
|
|||||||
"semver": "bin/semver"
|
"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": {
|
"node_modules/ember-data": {
|
||||||
"version": "5.3.9",
|
"version": "5.3.9",
|
||||||
"resolved": "https://registry.npmjs.org/ember-data/-/ember-data-5.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/ember-data/-/ember-data-5.3.9.tgz",
|
||||||
@@ -13576,6 +13592,29 @@
|
|||||||
"node": "8.* || 10.* || >= 12"
|
"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": {
|
"node_modules/ember-source": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/ember-source/-/ember-source-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/ember-source/-/ember-source-6.1.0.tgz",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"ember-page-title": "^8.2.3",
|
"ember-page-title": "^8.2.3",
|
||||||
"ember-qunit": "^8.1.1",
|
"ember-qunit": "^8.1.1",
|
||||||
"ember-resolver": "^13.1.0",
|
"ember-resolver": "^13.1.0",
|
||||||
|
"ember-simple-auth": "^7.1.1",
|
||||||
"ember-source": "~6.1.0",
|
"ember-source": "~6.1.0",
|
||||||
"ember-template-imports": "^4.2.0",
|
"ember-template-imports": "^4.2.0",
|
||||||
"ember-template-lint": "^6.0.0",
|
"ember-template-lint": "^6.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user