diff --git a/src/backend/app.js b/src/backend/app.js index c947ceafee..52da820504 100644 --- a/src/backend/app.js +++ b/src/backend/app.js @@ -8,9 +8,14 @@ const express = require('express'); const bodyParser = require('body-parser'); const jwt = require('jwt-simple'); const auth = require('./auth.js')(); +const auth_helpers = require('./auth_helpers.js'); const users = require('./users.js'); +const tokens = require('./token_helpers.js'); +const wines = require('./wines.js'); const cfg = require('./config.js'); const app = express(); +const moment = require('moment'); + app.use(bodyParser.json()); app.use(auth.initialize()); @@ -34,6 +39,10 @@ app.get('/api/user', auth.authenticate(), function (req, res) { }); }); +app.get('/api/wines', auth.authenticate(), function (req,res) { + res.json(wines); +}) + app.post('/api/auth/login', function (req, res) { if (req.body.email && req.body.password) { @@ -43,16 +52,10 @@ app.post('/api/auth/login', function (req, res) { return u.email === email && u.password === password; }); if (user) { - var payload = { - id: user.id, - email: user.email, - role: 'user', - }; - var token = jwt.encode(payload, cfg.jwtSecret); return res.json({ data: { message: 'Successfully logged in!', - token: token + token: tokens.createAccessToken(user), } }); } @@ -73,17 +76,11 @@ app.post('/api/auth/token', function (req, res) { return u.email === email && u.password === password; }); if (user) { - var payload = { - id: user.id, - email: user.email, - role: 'user', - }; - var token = jwt.encode(payload, cfg.jwtSecret); return res.json({ token_type: 'Bearer', - access_token: token, - expires_in: 3600, - refresh_token: 'eb4e1584-0117-437c-bfd7-343f257c4aae', + access_token: tokens.createAccessToken(user), + expires_in: cfg.accessTokenExpiresIn, + refresh_token: tokens.createRefreshToken(user), }); } } @@ -155,18 +152,45 @@ app.delete('/api/auth/logout', function (req, res) { }); app.post('/api/auth/refresh-token', function (req, res) { - var payload = { - id: users[0].id, - email: users[0].email, - role: 'user', - }; - var token = jwt.encode(payload, cfg.jwtSecret); + + // token issued by oauth2 strategy + if (req.body.refresh_token) { + var token = req.body.refresh_token; + var parts = token.split('.'); + if (parts.length !== 3) { + return res.status(401).json({ + error: 'invalid_token', + error_description: 'Invalid refresh token' + }); + } + var payload = JSON.parse(auth_helpers.urlBase64Decode(parts[1])); + var exp = payload.exp; + var userId = payload.sub; + var now = moment().unix(); + if (now > exp) { + return res.status(401).json({ + error: 'unauthorized', + error_description: 'Refresh Token expired.' + }) + } else { + return res.json({ + token_type: 'Bearer', + access_token: tokens.createAccessToken(users[userId - 1]), + expires_in: cfg.accessTokenExpiresIn, + }); + } + } + + // token issued via email strategy + if (req.body.token) { return res.json({ - data: { - message: 'Successfully refreshed token.', - token: token - } - }); + data: { + message: 'Successfully refreshed token!', + token: tokens.createAccessToken(users[0]), + } + }); + }; + }); app.listen(4400, function () { diff --git a/src/backend/auth.js b/src/backend/auth.js index f947e06aca..589a6eaf50 100644 --- a/src/backend/auth.js +++ b/src/backend/auth.js @@ -12,12 +12,12 @@ var ExtractJwt = passportJWT.ExtractJwt; var Strategy = passportJWT.Strategy; var params = { secretOrKey: cfg.jwtSecret, - jwtFromRequest: ExtractJwt.fromAuthHeader() + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken() }; module.exports = function () { var strategy = new Strategy(params, function (payload, done) { - var user = users[payload.id] || null; + var user = users[payload.sub -1 ] || null; if (user) { return done(null, { id: user.id diff --git a/src/backend/auth_helpers.js b/src/backend/auth_helpers.js new file mode 100644 index 0000000000..0b7e710aa6 --- /dev/null +++ b/src/backend/auth_helpers.js @@ -0,0 +1,46 @@ +function b64decode(str) { + var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + var output = ''; + + str = String(str).replace(/=+$/, ''); + + if (str.length % 4 === 1) { + console.error("'atob' failed: The string to be decoded is not correctly encoded."); + } + + for ( + // initialize result and counters + var bc=0, bs, buffer, idx= 0; + // get next character + buffer = str.charAt(idx++); + // character found in table? initialize bit storage and add its ascii value; + ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, + // and if not first of each 4 characters, + // convert the first 8 bits to one ascii character + bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0 + ) { + // try to find character in table (0-63, not found => -1) + buffer = chars.indexOf(buffer); + } + return output; +} + +function b64DecodeUnicode(str) { + return decodeURIComponent(Array.prototype.map.call(b64decode(str), function(c) { + return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); + }).join('')); +} + +module.exports.urlBase64Decode = function (str) { + var output = str.replace(/-/g, '+').replace(/_/g, '/'); + switch (output.length % 4) { + case 0: { break; } + case 2: { output += '=='; break; } + case 3: { output += '='; break; } + default: { + throw new Error('Illegal base64url string!'); + } + } + return b64DecodeUnicode(output); +} + diff --git a/src/backend/config.js b/src/backend/config.js index 0d07b4533f..2acd25e67c 100644 --- a/src/backend/config.js +++ b/src/backend/config.js @@ -8,5 +8,7 @@ module.exports = { jwtSecret: 'MyS3cr3tK3Y', jwtSession: { session: false - } + }, + accessTokenExpiresIn : 60, + refreshTokenExpiresIn: 120 }; diff --git a/src/backend/package.json b/src/backend/package.json index 9c309f04b3..f415dd4b13 100644 --- a/src/backend/package.json +++ b/src/backend/package.json @@ -13,7 +13,8 @@ "body-parser": "^1.17.1", "express": "^4.15.2", "jwt-simple": "^0.5.1", + "moment": "^2.22.2", "passport": "^0.3.2", - "passport-jwt": "^2.2.1" + "passport-jwt": "^4.0.0" } } diff --git a/src/backend/token_helpers.js b/src/backend/token_helpers.js new file mode 100644 index 0000000000..007b6baf10 --- /dev/null +++ b/src/backend/token_helpers.js @@ -0,0 +1,33 @@ +const moment = require('moment'); +const jwt = require('jwt-simple'); +const cfg = require('./config.js'); + +module.exports.permanentRefreshToken = 'eb4e15840117437cbfd7343f257c4aae'; + +module.exports.createAccessToken = function(user) { + var payload = { + sub: user.id, + exp: moment().add(cfg.accessTokenExpiresIn, 'seconds').unix(), + iat: moment().unix(), + id: user.id, + email: user.email, + role: 'user', + }; + var token = jwt.encode(payload, cfg.jwtSecret); + return token; +} + +module.exports.createRefreshToken = function(user) { + var refreshPayload = { + sub: user.id, + exp: moment().add(cfg.refreshTokenExpiresIn, 'seconds').unix(), + iat: moment().unix(), + id: user.id, + email: user.email, + role: 'REFRESH_TOKEN', + }; + var refreshToken = jwt.encode(refreshPayload, cfg.jwtSecret); + return refreshToken; +} + +module.exports diff --git a/src/backend/wines.js b/src/backend/wines.js new file mode 100644 index 0000000000..ffc1428429 --- /dev/null +++ b/src/backend/wines.js @@ -0,0 +1,22 @@ +const wines = [ + { + id: 1, + name: 'Pommard 1er cru', + region: 'Bourgogne', + year: 2012, + }, + { + id: 2, + name: 'Aloxe Corton Grand cru', + region: 'Bourgogne', + year: 2008, + }, + { + id: 3, + name: 'Meursault 1er cru', + region: 'Bourgogne', + year: 1997, + }, +]; + +module.exports = wines; diff --git a/src/playground/auth/api-calls/api-calls.component.ts b/src/playground/auth/api-calls/api-calls.component.ts new file mode 100644 index 0000000000..26264b4af0 --- /dev/null +++ b/src/playground/auth/api-calls/api-calls.component.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright Akveo. All Rights Reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + */ + +import { Component, Inject } from '@angular/core'; +import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { Router } from '@angular/router'; +import { Observable, of as observableOf } from 'rxjs'; +import { catchError, delay } from 'rxjs/operators'; +import { NbAuthResult, NbAuthService, NbAuthToken } from '../../../framework/auth/services'; +import { NB_AUTH_OPTIONS } from '../../../framework/auth/auth.options'; +import { getDeepFromObject } from '../../../framework/auth/helpers'; +import { Wine } from './wine'; + +@Component({ + selector: 'nb-playground-api-calls', + template: ` + + + + + + You are authenticated + You can call the secured API + Call API + Sign out + + + + + Alain'wines + + + + {{ wine.region }}, {{ wine.name }} ({{ wine.year }}) + + + + + + `, +}) + +export class NbPlaygroundApiCallsComponent { + + token: NbAuthToken; + wines$: Observable; + redirectDelay: number = 0; + strategy: string = ''; + + constructor(private authService: NbAuthService, + private http: HttpClient, + private router: Router, + @Inject(NB_AUTH_OPTIONS) protected options = {}) { + + this.redirectDelay = this.getConfigValue('forms.logout.redirectDelay'); + this.strategy = this.getConfigValue('forms.logout.strategy'); + + this.authService.onTokenChange() + .subscribe((token: NbAuthToken) => { + this.token = null; + if (token && token.isValid()) { + this.token = token; + } + }); + } + + logout() { + this.authService.logout(this.strategy) + .pipe( + delay(this.redirectDelay), + ) + .subscribe((result: NbAuthResult) => this.router.navigate(['/auth/login'])); + } + + loadWines() { + this.wines$ = this.http.get('http://localhost:4400/api/wines') + .pipe( + catchError(err => { + if (err instanceof HttpErrorResponse && err.status === 401) { + this.router.navigate(['/auth/login']); + } + return observableOf([]); + }), + ); + } + + getConfigValue(key: string): any { + return getDeepFromObject(this.options, key, null); + } +} diff --git a/src/playground/auth/api-calls/wine.ts b/src/playground/auth/api-calls/wine.ts new file mode 100644 index 0000000000..61829cd26b --- /dev/null +++ b/src/playground/auth/api-calls/wine.ts @@ -0,0 +1,6 @@ +export interface Wine { + id: number; + name: string; + region: string; + year: number +} diff --git a/src/playground/auth/auth-routing.module.ts b/src/playground/auth/auth-routing.module.ts index eb9b408096..f9c4ddb723 100644 --- a/src/playground/auth/auth-routing.module.ts +++ b/src/playground/auth/auth-routing.module.ts @@ -18,6 +18,7 @@ import { } from '@nebular/auth'; import { NbAclTestComponent } from './acl/acl-test.component'; import { NbAuthGuard } from './auth-guard.service'; +import { NbPlaygroundApiCallsComponent } from './api-calls/api-calls.component'; export const routes: Routes = [ @@ -66,6 +67,11 @@ export const routes: Routes = [ canActivate: [NbAuthGuard], component: NbAuthPlaygroundComponent, }, + { + path: 'auth/api-calls.component', + canActivate: [NbAuthGuard], + component: NbPlaygroundApiCallsComponent, + }, ]; @NgModule({ diff --git a/src/playground/auth/auth.module.ts b/src/playground/auth/auth.module.ts index 84ce008cea..1974c54ee9 100644 --- a/src/playground/auth/auth.module.ts +++ b/src/playground/auth/auth.module.ts @@ -8,18 +8,23 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { RouterModule } from '@angular/router'; -import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; +import { HTTP_INTERCEPTORS, HttpClientModule, HttpRequest } from '@angular/common/http'; import { NbCardModule, NbLayoutModule, + NbListModule, } from '@nebular/theme'; - import { NbAuthJWTToken, NbAuthModule, NbPasswordAuthStrategy, - NbDummyAuthStrategy, NbAuthJWTInterceptor, + NbDummyAuthStrategy, + NbAuthJWTInterceptor, + NbOAuth2GrantType, + NbOAuth2AuthStrategy, + NbAuthOAuth2Token, + NB_AUTH_TOKEN_INTERCEPTOR_FILTER, } from '@nebular/auth'; import { NbSecurityModule, NbRoleProvider } from '@nebular/security'; @@ -28,7 +33,14 @@ import { NbAuthPlaygroundRoutingModule } from './auth-routing.module'; import { NbCustomRoleProvider } from './role.provider'; import { NbAclTestComponent } from './acl/acl-test.component'; import { NbAuthGuard } from './auth-guard.service'; +import { NbPlaygroundApiCallsComponent } from './api-calls/api-calls.component'; +export function filterInterceptorRequest(req: HttpRequest) { + return ['http://localhost:4400/api/auth/', + 'http://other.url/with/no/token/injected/', + ] + .some(url => req.url.includes(url)); +} @NgModule({ imports: [ @@ -40,11 +52,13 @@ import { NbAuthGuard } from './auth-guard.service'; NbCardModule, NbLayoutModule, + NbListModule, NbAuthModule.forRoot({ forms: { login: { - redirectDelay: 3000, + strategy: 'password', + redirectDelay: 1000, socialLinks: [ { url: 'https://github.com/akveo', @@ -101,6 +115,21 @@ import { NbAuthGuard } from './auth-guard.service'; key: 'data.errors', }, }), + NbOAuth2AuthStrategy.setup({ + name: 'password', + clientId: 'test', + clientSecret: 'secret', + baseEndpoint: 'http://localhost:4400/api/auth/', + token: { + endpoint: 'token', + grantType: NbOAuth2GrantType.PASSWORD, + class: NbAuthOAuth2Token, + }, + refresh: { + endpoint: 'refresh-token', + grantType: NbOAuth2GrantType.REFRESH_TOKEN, + }, + }), ], }), NbSecurityModule.forRoot({ @@ -123,10 +152,12 @@ import { NbAuthGuard } from './auth-guard.service'; declarations: [ NbAuthPlaygroundComponent, NbAclTestComponent, + NbPlaygroundApiCallsComponent, ], providers: [ NbAuthGuard, { provide: HTTP_INTERCEPTORS, useClass: NbAuthJWTInterceptor, multi: true }, + { provide: NB_AUTH_TOKEN_INTERCEPTOR_FILTER, useValue: filterInterceptorRequest }, { provide: NbRoleProvider, useClass: NbCustomRoleProvider }, ], })
You can call the secured API