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;
|
||||
}
|
||||
|
||||
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"}}
|
||||
|
||||
{{outlet}}
|
||||
|
||||
{{! The following component displays Ember's default welcome message. }}
|
||||
<WelcomePage />
|
||||
{{! Feel free to remove this! }}
|
||||
<div class="menu">
|
||||
{{#if this.session.isAuthenticated}}
|
||||
<button type="button" {{on "click" this.invalidateSession}}>Logout</button>
|
||||
{{else}}
|
||||
<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>
|
||||
Reference in New Issue
Block a user