Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[POC] add refreshtoken to extend accessToken #537

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/shopping/src/application.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,22 @@ export const CredentialsRequestBody = {
'application/json': {schema: CredentialsSchema},
},
};

const RefreshTokenSchema = {
type: 'object',
required: ['refreshToken'],
properties: {
refreshtoken: {
type: 'string',
minLength: 8,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minLength: 8

Usually the jwt tokens we generate are very long. What is the reasoning for 8? Why not 1? Or why specify this at all? Just curious

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@emonddr If you look at the generateToken function in
https://github.com/strongloop/loopback4-example-shopping/pull/537/files#diff-90f4ee64422c3d610fc44a185e6d0781R47

A refresh token is the id of a UserRefreshToken record, not a token generated by jwt service.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer a pattern check to the token, but i'm still not sure what is the best way to generate a refreshToken. Currently it's a objectId which might not be the best practice. On a mysql/postgresql database i would just use a UUID as refreshToken and use a regular expression for validation.

Do you have any other ideas how to generate a revokeable token?

},
},
};

export const RefreshTokenRequestBody = {
description: 'The input of refresh function',
required: true,
content: {
'application/json': {schema: RefreshTokenSchema},
},
};
53 changes: 52 additions & 1 deletion packages/shopping/src/controllers/user.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand All @@ -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<User, Credentials>,
) {}
Expand Down Expand Up @@ -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);

Expand All @@ -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.generateToken(
userProfile,
);

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(
Copy link
Contributor

@jannyHou jannyHou Feb 13, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When would the refresh function get called? I am trying to illustrate the flow of how a refresh token works:

  • first a user login
  • then it gets the token and refresh token
  • then how does it decide when to call the refresh endpoint to extend the login time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

then how does it decide when to call the refresh endpoint to extend the login time?

In a angular application i would use a serviceworker or service with a timer to call this endpoint periodically in background.

@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.verifyToken(
body.refreshtoken,
currentUserProfile,
);

// create a new JSON Web Token based on the user profile
const token = await this.jwtService.generateToken(currentUserProfile);

return {token};
}
}
13 changes: 13 additions & 0 deletions packages/shopping/src/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -38,3 +39,15 @@ export namespace UserServiceBindings {
'services.user.service',
);
}

export namespace RefreshtokenServiceBindings {
export const REFRESHTOKEN_ETERNAL_ALLOWED = BindingKey.create<boolean>(
'services.refreshtoken.eternal_allowed',
);
export const REFRESHTOKEN_EXPIRES_IN = BindingKey.create<number>(
'services.refreshtoken.expires_in',
);
export const REFRESHTOKEN_SERVICE = BindingKey.create<RefreshtokenService>(
'services.refreshtoken.service',
);
}
1 change: 1 addition & 0 deletions packages/shopping/src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
44 changes: 44 additions & 0 deletions packages/shopping/src/models/user-refreshtoken.model.ts
Original file line number Diff line number Diff line change
@@ -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<UserRefreshtoken>) {
super(data);
}
}

export interface UserRefreshtokenRelations {
// describe navigational properties here
}

export type UserRefreshtokenWithRelations = UserRefreshtoken &
UserRefreshtokenRelations;
4 changes: 4 additions & 0 deletions packages/shopping/src/models/user.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -51,6 +52,9 @@ export class User extends Entity {
@hasOne(() => UserCredentials)
userCredentials: UserCredentials;

@hasMany(() => UserRefreshtoken)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not hasOne? Can user have multiple?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently the refreshToken is newly generated at login time. As you can login multiple times or from multiple browsers/devices this needs to be a hasMany relation.

Alternatively we could share the refreshToken accross browsers/devices, than a hasOne would fit. But this might decrease security!?

WDYT?

Copy link
Contributor

@dougal83 dougal83 Feb 14, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For simplicity, sharing a single token across devices would have a single point of failure, any detected breach would log out all devices. Could be annoying to user.

Your solution is more flexible/complex, but might think about identifying the authorised device linked to the refresh token maybe? Identifying devices, allows the token to be individually invalidated (i.e. Lost device can have access revoked, although any serious security will remote wipe devices). If one token is compromised, is there any practical difference in access compromised? To get updated refresh token we need to log in regardless don't we? Hmm will need to think more.

Both solutions allow:

  • Local - individual devices to be logged out(delete local token)
  • Server - all devices to be logged out(expire one token vs. expire all tokens)
  • Server - update permissions (update one token vs. update all tokens)

Multiple refresh token adds:

  • Server - log out single device(given identity is known, expire the individual token)

Do what you think fits the scenario best. I'm just offering thoughts for consideration.

userRefreshtokens: UserRefreshtoken[];

@hasOne(() => ShoppingCart)
shoppingCart: ShoppingCart;

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
16 changes: 15 additions & 1 deletion packages/shopping/src/repositories/user.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,19 +32,32 @@ 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,
@repository.getter('UserCredentialsRepository')
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,
Expand Down
94 changes: 94 additions & 0 deletions packages/shopping/src/services/refreshtoken.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
// Copyright IBM Corp. 2020. All Rights Reserved.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@derdeka , cool stuff. I was going to just increase the token lifetime from 10 minutes to 6 hours in my last PR ;).

I am wondering :

  • are you going to add mocha tests?
  • have you interacted with the shopping cart API Explorer to see how this will affect the user experience?
    Developers may want to use the API Explorer to interact with the REST API to debug how things work
    for extended periods of time. It would annoy me when the jwt token would expire in 10 minutes. So I would
    set it for 6 hours while I debug and play with the REST API. How will these changes affect this?
  • What needs to change in the Shoppy application (UI front-end) that was recently added?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@emonddr All good questions. Above @derdeka said he'd add tests after initial feedback.

I think the general idea is for the refresh token to hold the session open on a device for a period of time(say 6 hours, two days or even longer) without the need to log back in. When your access token runs out after 10 minutes, then the refresh token would be used to get new access token without the need to log in again. This would also need to be designed so that the refresh token can be revoked and force a login. Is that correct @derdeka?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • are you going to add mocha tests?

I'll add some tests.

  • have you interacted with the shopping cart API Explorer to see how this will affect the user experience?

On a angular application i would use a serviceworker or service to periodically refresh the accesstoken. Is there something similar in the shopping example?

Developers may want to use the API Explorer to interact with the REST API to debug how things work
for extended periods of time. It would annoy me when the jwt token would expire in 10 minutes. So I would
set it for 6 hours while I debug and play with the REST API. How will these changes affect this?

Good catch, i have currently no concept for this. My use case is a regular user on a angular SPA.

  • What needs to change in the Shoppy application (UI front-end) that was recently added?

I did not have a deep look how the frontend is currently working. I guess a serviceworker could work here too?

// Node module: @loopback/authentication
// This file is licensed under the MIT License.
// License text available at https://opensource.org/licenses/MIT
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 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<UserProfile>;
/**
* Revokes a given token (if supported by token system)
*/
revokeToken(token: string, userProfile?: UserProfile): Promise<void>;
}

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 generateToken(userProfile: UserProfile): Promise<string> {
// TODO(derdeka) objectId as refreshtoken is a bad idea
const userRefreshtoken = await this.userRefreshtokenRepository.create({
creation: new Date(),
ttl: this.refreshtokenExpiresIn,
userId: userProfile[securityId],
});
return userRefreshtoken.id;
}

async verifyToken(
refreshtoken: string,
userProfile?: UserProfile,
): Promise<UserProfile> {
try {
if (!userProfile || !userProfile[securityId]) {
throw new HttpErrors.Unauthorized('Invalid refreshToken');
}
const {creation, ttl} = await this.userRefreshtokenRepository.findById(
refreshtoken,
{
where: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

usually findById should return only one result, any reason adding an additional where filter here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is to check if the refreshToken is valid for / belongsTo the current user, validated by the authorization system. It's a kind of additional security check.

Copy link
Contributor Author

@derdeka derdeka Feb 27, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I recently learned from @raymondfeng that findById does not respect the where condition. I have to do the check differently.

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 refreshToken');
}
}

async revokeToken(
refreshtoken: string,
userProfile: UserProfile,
): Promise<void> {
try {
await this.userRefreshtokenRepository.deleteById(refreshtoken, {
where: {
userId: userProfile[securityId],
},
});
} catch (e) {
// ignore
}
}
}