From 3b2ec133ee099c7af6d935ca31bd6e6f465b988c Mon Sep 17 00:00:00 2001 From: derdeka Date: Fri, 7 Feb 2020 16:46:32 +0100 Subject: [PATCH 1/2] feat(loopback4-example-shopping): add refreshtoken Signed-off-by: derdeka --- packages/shopping/src/application.ts | 5 ++ .../specs/user-controller.specs.ts | 19 +++++ .../src/controllers/user.controller.ts | 53 +++++++++++++- packages/shopping/src/keys.ts | 7 ++ packages/shopping/src/models/index.ts | 1 + .../src/models/user-refreshtoken.model.ts | 44 ++++++++++++ packages/shopping/src/models/user.model.ts | 4 ++ .../user-refreshtoken.repository.ts | 18 +++++ .../src/repositories/user.repository.ts | 16 ++++- .../src/services/refreshtoken.service.ts | 69 +++++++++++++++++++ 10 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 packages/shopping/src/models/user-refreshtoken.model.ts create mode 100644 packages/shopping/src/repositories/user-refreshtoken.repository.ts create mode 100644 packages/shopping/src/services/refreshtoken.service.ts diff --git a/packages/shopping/src/application.ts b/packages/shopping/src/application.ts index d06ee28f0..fcba5cd24 100644 --- a/packages/shopping/src/application.ts +++ b/packages/shopping/src/application.ts @@ -23,8 +23,10 @@ import { UserServiceBindings, TokenServiceConstants, PasswordHasherBindings, + RefreshtokenServiceBindings, } from './keys'; import {JWTService} from './services/jwt-service'; +import {MyRefreshtokenService} from './services/refreshtoken.service'; import {MyUserService} from './services/user-service'; import _ from 'lodash'; import path from 'path'; @@ -132,6 +134,9 @@ export class ShoppingApplication extends BootMixin( ); this.bind(TokenServiceBindings.TOKEN_SERVICE).toClass(JWTService); + this.bind(RefreshtokenServiceBindings.REFRESHTOKEN_SERVICE).toClass( + MyRefreshtokenService, + ); // // Bind bcrypt hash services this.bind(PasswordHasherBindings.ROUNDS).to(10); diff --git a/packages/shopping/src/controllers/specs/user-controller.specs.ts b/packages/shopping/src/controllers/specs/user-controller.specs.ts index 1eae60de4..ec5266d01 100644 --- a/packages/shopping/src/controllers/specs/user-controller.specs.ts +++ b/packages/shopping/src/controllers/specs/user-controller.specs.ts @@ -41,3 +41,22 @@ export const CredentialsRequestBody = { 'application/json': {schema: CredentialsSchema}, }, }; + +const RefreshTokenSchema = { + type: 'object', + required: ['refreshToken'], + properties: { + refreshtoken: { + type: 'string', + minLength: 8, + }, + }, +}; + +export const RefreshTokenRequestBody = { + description: 'The input of refresh function', + required: true, + content: { + 'application/json': {schema: RefreshTokenSchema}, + }, +}; diff --git a/packages/shopping/src/controllers/user.controller.ts b/packages/shopping/src/controllers/user.controller.ts index aa0fb03c0..2cdb942d1 100644 --- a/packages/shopping/src/controllers/user.controller.ts +++ b/packages/shopping/src/controllers/user.controller.ts @@ -28,6 +28,7 @@ import {UserProfile, securityId, SecurityBindings} from '@loopback/security'; import { CredentialsRequestBody, UserProfileSchema, + RefreshTokenRequestBody, } from './specs/user-controller.specs'; import {Credentials} from '../repositories/user.repository'; import {PasswordHasher} from '../services/hash.password.bcryptjs'; @@ -36,10 +37,12 @@ import { TokenServiceBindings, PasswordHasherBindings, UserServiceBindings, + RefreshtokenServiceBindings, } from '../keys'; import _ from 'lodash'; import {OPERATION_SECURITY_SPEC} from '../utils/security-spec'; import {basicAuthorization} from '../services/basic.authorizor'; +import {RefreshtokenService} from '../services/refreshtoken.service'; @model() export class NewUserRequest extends User { @@ -59,6 +62,8 @@ export class UserController { public passwordHasher: PasswordHasher, @inject(TokenServiceBindings.TOKEN_SERVICE) public jwtService: TokenService, + @inject(RefreshtokenServiceBindings.REFRESHTOKEN_SERVICE) + public refreshtokenService: RefreshtokenService, @inject(UserServiceBindings.USER_SERVICE) public userService: UserService, ) {} @@ -250,7 +255,7 @@ export class UserController { }) async login( @requestBody(CredentialsRequestBody) credentials: Credentials, - ): Promise<{token: string}> { + ): Promise<{token: string; refreshtoken: string}> { // ensure the user exists, and the password is correct const user = await this.userService.verifyCredentials(credentials); @@ -260,6 +265,52 @@ export class UserController { // create a JSON Web Token based on the user profile const token = await this.jwtService.generateToken(userProfile); + // create a refreshtoken + const refreshtoken = await this.refreshtokenService.generateRefreshtoken( + user, + ); + + return { + token, + refreshtoken, + }; + } + + @post('/users/newtoken', { + security: OPERATION_SECURITY_SPEC, + responses: { + '200': { + description: 'Refreshing the token', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + token: { + type: 'string', + }, + }, + }, + }, + }, + }, + }, + }) + @authenticate('jwt') + // TODO(derdeka) find out why @authorize.allowAuthenticated() is not working + async refresh( + @inject(SecurityBindings.USER) currentUserProfile: UserProfile, + @requestBody(RefreshTokenRequestBody) body: {refreshtoken: string}, + ): Promise<{token: string}> { + // check if the provided refreshtoken is valid, throws error if invalid + await this.refreshtokenService.verifyRefreshtoken( + body.refreshtoken, + currentUserProfile, + ); + + // create a new JSON Web Token based on the user profile + const token = await this.jwtService.generateToken(currentUserProfile); + return {token}; } } diff --git a/packages/shopping/src/keys.ts b/packages/shopping/src/keys.ts index 959b77b76..92fcfd06c 100644 --- a/packages/shopping/src/keys.ts +++ b/packages/shopping/src/keys.ts @@ -8,6 +8,7 @@ import {PasswordHasher} from './services/hash.password.bcryptjs'; import {TokenService, UserService} from '@loopback/authentication'; import {User} from './models'; import {Credentials} from './repositories'; +import {RefreshtokenService} from './services/refreshtoken.service'; export namespace TokenServiceConstants { export const TOKEN_SECRET_VALUE = 'myjwts3cr3t'; @@ -38,3 +39,9 @@ export namespace UserServiceBindings { 'services.user.service', ); } + +export namespace RefreshtokenServiceBindings { + export const REFRESHTOKEN_SERVICE = BindingKey.create< + RefreshtokenService + >('services.refreshtoken.service'); +} diff --git a/packages/shopping/src/models/index.ts b/packages/shopping/src/models/index.ts index 6c5c57ce1..836ba3237 100644 --- a/packages/shopping/src/models/index.ts +++ b/packages/shopping/src/models/index.ts @@ -8,4 +8,5 @@ export * from './shopping-cart-item.model'; export * from './shopping-cart.model'; export * from './order.model'; export * from './user-credentials.model'; +export * from './user-refreshtoken.model'; export * from './product.model'; diff --git a/packages/shopping/src/models/user-refreshtoken.model.ts b/packages/shopping/src/models/user-refreshtoken.model.ts new file mode 100644 index 000000000..6d78db855 --- /dev/null +++ b/packages/shopping/src/models/user-refreshtoken.model.ts @@ -0,0 +1,44 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: loopback4-example-shopping +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Entity, model, property} from '@loopback/repository'; + +@model() +export class UserRefreshtoken extends Entity { + @property({ + type: 'string', + id: true, + }) + id: string; + + @property({ + type: 'number', + required: true, + }) + ttl: number; + + @property({ + type: 'date', + }) + creation: Date; + + @property({ + type: 'string', + required: true, + mongodb: {dataType: 'ObjectID'}, + }) + userId: string; + + constructor(data?: Partial) { + super(data); + } +} + +export interface UserRefreshtokenRelations { + // describe navigational properties here +} + +export type UserRefreshtokenWithRelations = UserRefreshtoken & + UserRefreshtokenRelations; diff --git a/packages/shopping/src/models/user.model.ts b/packages/shopping/src/models/user.model.ts index ba1aa4164..f76a1ef78 100644 --- a/packages/shopping/src/models/user.model.ts +++ b/packages/shopping/src/models/user.model.ts @@ -6,6 +6,7 @@ import {Entity, model, property, hasMany, hasOne} from '@loopback/repository'; import {Order} from './order.model'; import {UserCredentials} from './user-credentials.model'; +import {UserRefreshtoken} from './user-refreshtoken.model'; import {ShoppingCart} from './shopping-cart.model'; @model({ @@ -51,6 +52,9 @@ export class User extends Entity { @hasOne(() => UserCredentials) userCredentials: UserCredentials; + @hasMany(() => UserRefreshtoken) + userRefreshtokens: UserRefreshtoken[]; + @hasOne(() => ShoppingCart) shoppingCart: ShoppingCart; diff --git a/packages/shopping/src/repositories/user-refreshtoken.repository.ts b/packages/shopping/src/repositories/user-refreshtoken.repository.ts new file mode 100644 index 000000000..b13f92fc3 --- /dev/null +++ b/packages/shopping/src/repositories/user-refreshtoken.repository.ts @@ -0,0 +1,18 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: loopback4-example-shopping +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {inject} from '@loopback/core'; +import {DefaultCrudRepository, juggler} from '@loopback/repository'; +import {UserRefreshtoken, UserRefreshtokenRelations} from '../models'; + +export class UserRefreshtokenRepository extends DefaultCrudRepository< + UserRefreshtoken, + typeof UserRefreshtoken.prototype.id, + UserRefreshtokenRelations +> { + constructor(@inject('datasources.mongo') dataSource: juggler.DataSource) { + super(UserRefreshtoken, dataSource); + } +} diff --git a/packages/shopping/src/repositories/user.repository.ts b/packages/shopping/src/repositories/user.repository.ts index 89b4e85a8..4514aabd2 100644 --- a/packages/shopping/src/repositories/user.repository.ts +++ b/packages/shopping/src/repositories/user.repository.ts @@ -10,10 +10,11 @@ import { repository, HasOneRepositoryFactory, } from '@loopback/repository'; -import {User, Order, UserCredentials} from '../models'; +import {User, Order, UserCredentials, UserRefreshtoken} from '../models'; import {inject, Getter} from '@loopback/core'; import {OrderRepository} from './order.repository'; import {UserCredentialsRepository} from './user-credentials.repository'; +import {UserRefreshtokenRepository} from './user-refreshtoken.repository'; export type Credentials = { email: string; @@ -31,6 +32,11 @@ export class UserRepository extends DefaultCrudRepository< typeof User.prototype.id >; + public readonly userRefreshtokens: HasManyRepositoryFactory< + UserRefreshtoken, + typeof User.prototype.id + >; + constructor( @inject('datasources.mongo') protected datasource: juggler.DataSource, @repository(OrderRepository) protected orderRepository: OrderRepository, @@ -38,12 +44,20 @@ export class UserRepository extends DefaultCrudRepository< protected userCredentialsRepositoryGetter: Getter< UserCredentialsRepository >, + @repository.getter('UserRefreshtokenRepository') + protected userRefreshtokenRepositoryGetter: Getter< + UserRefreshtokenRepository + >, ) { super(User, datasource); this.userCredentials = this.createHasOneRepositoryFactoryFor( 'userCredentials', userCredentialsRepositoryGetter, ); + this.userRefreshtokens = this.createHasManyRepositoryFactoryFor( + 'userRefreshtokens', + userRefreshtokenRepositoryGetter, + ); this.orders = this.createHasManyRepositoryFactoryFor( 'orders', async () => orderRepository, diff --git a/packages/shopping/src/services/refreshtoken.service.ts b/packages/shopping/src/services/refreshtoken.service.ts new file mode 100644 index 000000000..aa905f53d --- /dev/null +++ b/packages/shopping/src/services/refreshtoken.service.ts @@ -0,0 +1,69 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/authentication +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT +import {UserRefreshtokenRepository} from '../repositories/user-refreshtoken.repository'; +import {User} from '../models/user.model'; +import {repository} from '@loopback/repository'; +import {HttpErrors} from '@loopback/rest'; +import {UserProfile, securityId} from '@loopback/security'; + +export interface RefreshtokenService { + generateRefreshtoken(user: User): Promise; + verifyRefreshtoken( + refreshtoken: string, + userProfile: UserProfile, + ): Promise; + revokeRefreshtoken( + refreshtoken: string, + userProfile: UserProfile, + ): Promise; +} + +export class MyRefreshtokenService implements RefreshtokenService { + constructor( + @repository(UserRefreshtokenRepository) + public userRefreshtokenRepository: UserRefreshtokenRepository, + ) {} + + async generateRefreshtoken(user: User): Promise { + const userRefreshtoken = await this.userRefreshtokenRepository.create({ + creation: new Date(), + // TODO(derdeka) inject ttl setting + ttl: 60 * 60 * 6, + userId: user.id, + }); + return userRefreshtoken.id; + } + + async verifyRefreshtoken( + refreshtoken: string, + userProfile: UserProfile, + ): Promise { + try { + // TODO(derdeka) check ttl and creation date + await this.userRefreshtokenRepository.findById(refreshtoken, { + where: { + userId: userProfile[securityId], + }, + }); + } catch (e) { + throw new HttpErrors.Unauthorized('Invalid accessToken'); + } + } + + async revokeRefreshtoken( + refreshtoken: string, + userProfile: UserProfile, + ): Promise { + try { + await this.userRefreshtokenRepository.deleteById(refreshtoken, { + where: { + userId: userProfile[securityId], + }, + }); + } catch (e) { + // ignore + } + } +} From 980a065224a56fd7dbd6b512519c944bd0415991 Mon Sep 17 00:00:00 2001 From: derdeka Date: Mon, 10 Feb 2020 16:13:17 +0100 Subject: [PATCH 2/2] feat(loopback4-example-shopping): add refreshtoken - refactoring Signed-off-by: derdeka --- .../src/controllers/user.controller.ts | 8 +- packages/shopping/src/keys.ts | 12 ++- .../src/services/refreshtoken.service.ts | 79 ++++++++++++------- 3 files changed, 65 insertions(+), 34 deletions(-) diff --git a/packages/shopping/src/controllers/user.controller.ts b/packages/shopping/src/controllers/user.controller.ts index 2cdb942d1..386f618aa 100644 --- a/packages/shopping/src/controllers/user.controller.ts +++ b/packages/shopping/src/controllers/user.controller.ts @@ -63,7 +63,7 @@ export class UserController { @inject(TokenServiceBindings.TOKEN_SERVICE) public jwtService: TokenService, @inject(RefreshtokenServiceBindings.REFRESHTOKEN_SERVICE) - public refreshtokenService: RefreshtokenService, + public refreshtokenService: RefreshtokenService, @inject(UserServiceBindings.USER_SERVICE) public userService: UserService, ) {} @@ -266,8 +266,8 @@ export class UserController { const token = await this.jwtService.generateToken(userProfile); // create a refreshtoken - const refreshtoken = await this.refreshtokenService.generateRefreshtoken( - user, + const refreshtoken = await this.refreshtokenService.generateToken( + userProfile, ); return { @@ -303,7 +303,7 @@ export class UserController { @requestBody(RefreshTokenRequestBody) body: {refreshtoken: string}, ): Promise<{token: string}> { // check if the provided refreshtoken is valid, throws error if invalid - await this.refreshtokenService.verifyRefreshtoken( + await this.refreshtokenService.verifyToken( body.refreshtoken, currentUserProfile, ); diff --git a/packages/shopping/src/keys.ts b/packages/shopping/src/keys.ts index 92fcfd06c..a97335fab 100644 --- a/packages/shopping/src/keys.ts +++ b/packages/shopping/src/keys.ts @@ -41,7 +41,13 @@ export namespace UserServiceBindings { } export namespace RefreshtokenServiceBindings { - export const REFRESHTOKEN_SERVICE = BindingKey.create< - RefreshtokenService - >('services.refreshtoken.service'); + export const REFRESHTOKEN_ETERNAL_ALLOWED = BindingKey.create( + 'services.refreshtoken.eternal_allowed', + ); + export const REFRESHTOKEN_EXPIRES_IN = BindingKey.create( + 'services.refreshtoken.expires_in', + ); + export const REFRESHTOKEN_SERVICE = BindingKey.create( + 'services.refreshtoken.service', + ); } diff --git a/packages/shopping/src/services/refreshtoken.service.ts b/packages/shopping/src/services/refreshtoken.service.ts index aa905f53d..32f0da223 100644 --- a/packages/shopping/src/services/refreshtoken.service.ts +++ b/packages/shopping/src/services/refreshtoken.service.ts @@ -2,57 +2,82 @@ // Node module: @loopback/authentication // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {UserRefreshtokenRepository} from '../repositories/user-refreshtoken.repository'; -import {User} from '../models/user.model'; +import {TokenService} from '@loopback/authentication'; +import {inject} from '@loopback/core'; import {repository} from '@loopback/repository'; import {HttpErrors} from '@loopback/rest'; import {UserProfile, securityId} from '@loopback/security'; +import {UserRefreshtokenRepository} from '../repositories/user-refreshtoken.repository'; +import {RefreshtokenServiceBindings} from '../keys'; -export interface RefreshtokenService { - generateRefreshtoken(user: User): Promise; - verifyRefreshtoken( - refreshtoken: string, - userProfile: UserProfile, - ): Promise; - revokeRefreshtoken( - refreshtoken: string, - userProfile: UserProfile, - ): Promise; +export interface RefreshtokenService extends TokenService { + /** + * Verifies the validity of a token string and returns a user profile + * + * TODO(derdeka) move optional parameter userProfile to TokenService? + */ + verifyToken(token: string, userProfile?: UserProfile): Promise; + /** + * Revokes a given token (if supported by token system) + */ + revokeToken(token: string, userProfile?: UserProfile): Promise; } -export class MyRefreshtokenService implements RefreshtokenService { +export class MyRefreshtokenService implements RefreshtokenService { constructor( @repository(UserRefreshtokenRepository) public userRefreshtokenRepository: UserRefreshtokenRepository, + @inject(RefreshtokenServiceBindings.REFRESHTOKEN_ETERNAL_ALLOWED, { + optional: true, + }) + private refreshtokenEternalAllowed: boolean = false, + @inject(RefreshtokenServiceBindings.REFRESHTOKEN_EXPIRES_IN, { + optional: true, + }) + private refreshtokenExpiresIn: number = 60 * 60 * 24, ) {} - async generateRefreshtoken(user: User): Promise { + async generateToken(userProfile: UserProfile): Promise { + // TODO(derdeka) objectId as refreshtoken is a bad idea const userRefreshtoken = await this.userRefreshtokenRepository.create({ creation: new Date(), - // TODO(derdeka) inject ttl setting - ttl: 60 * 60 * 6, - userId: user.id, + ttl: this.refreshtokenExpiresIn, + userId: userProfile[securityId], }); return userRefreshtoken.id; } - async verifyRefreshtoken( + async verifyToken( refreshtoken: string, - userProfile: UserProfile, - ): Promise { + userProfile?: UserProfile, + ): Promise { try { - // TODO(derdeka) check ttl and creation date - await this.userRefreshtokenRepository.findById(refreshtoken, { - where: { - userId: userProfile[securityId], + if (!userProfile || !userProfile[securityId]) { + throw new HttpErrors.Unauthorized('Invalid refreshToken'); + } + const {creation, ttl} = await this.userRefreshtokenRepository.findById( + refreshtoken, + { + where: { + userId: userProfile[securityId], + }, }, - }); + ); + const isEternalToken = ttl === -1; + const elapsedSeconds = (Date.now() - creation.getTime()) / 1000; + const isValid = isEternalToken + ? this.refreshtokenEternalAllowed + : elapsedSeconds < ttl; + if (!isValid) { + throw new HttpErrors.Unauthorized('Invalid refreshToken'); + } + return userProfile; } catch (e) { - throw new HttpErrors.Unauthorized('Invalid accessToken'); + throw new HttpErrors.Unauthorized('Invalid refreshToken'); } } - async revokeRefreshtoken( + async revokeToken( refreshtoken: string, userProfile: UserProfile, ): Promise {