Skip to content

Commit

Permalink
Update to match standard Meteor login and Account token storage
Browse files Browse the repository at this point in the history
Meteor Accounts package uses hashedTokens instead of tokens. I updated the code to
match Accounts package.
Fix for kahmali#79
  • Loading branch information
jazeee committed Jun 11, 2015
1 parent c632129 commit a19dfa6
Show file tree
Hide file tree
Showing 9 changed files with 161 additions and 23 deletions.
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Change Log

## [Unreleased]

#### Warning - Potentially breaking change
- Restivus used to store the account login token in the user document: services.resume.loginTokens.token
- Restivus now stores the account login token as a hashed token, in the user document: services.resume.loginTokens.hashedToken
- This matches Meteor Accounts package

#### Fixed
- Issue #79:
- Update to match standard Meteor login and Account token storage

#### Changed
- Return "Unauthorized" for failed authentication
- To match Meteor, storepassword token as hashedToken
- Add unit tests for authentication


## [v0.6.6] - 2015-05-25

#### Fixed
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,8 @@ but all properties are optional):
##### `auth`
- _Object_
- `token` _String_
- Default: `'services.resume.loginTokens.token'`
- The path to the auth token in the `Meteor.user` document. This location will be checked for a
- Default: `'services.resume.loginTokens.hashedToken'`
- The path to the auth hashed token in the `Meteor.user` document. This location will be checked for a
matching token if one is returned in `auth.user()`.
- `user` _Function_
- Default: Get user ID and auth token from `X-User-Id` and `X-Auth-Token` headers
Expand All @@ -286,7 +286,8 @@ but all properties are optional):
- Partial auth
- `userId`: The ID of the user being authenticated
- `token`: The auth token to be verified
- If both a `userId` and `token` are returned, authentication will succeed if the `token`
- If both a `userId` and `token` are returned, Restivus will hash the token, then,
authentication will succeed if the `hashed token`
exists in the given `Meteor.user` document at the location specified in `auth.token`
- Complete auth
- `user`: The fully authenticated `Meteor.user`
Expand Down
11 changes: 6 additions & 5 deletions lib/auth.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ getUserQuerySelector = (user) ->
###
@Auth.loginWithPassword = (user, password) ->
if not user or not password
return undefined # TODO: Should we throw a more descriptive error here, or is that insecure?
throw new Meteor.Error 403, 'Unauthorized'

# Validate the login input types
check user, userValidator
Expand All @@ -46,17 +46,18 @@ getUserQuerySelector = (user) ->
authenticatingUser = Meteor.users.findOne(authenticatingUserSelector)

if not authenticatingUser
throw new Meteor.Error 403, 'User not found'
throw new Meteor.Error 403, 'Unauthorized'
if not authenticatingUser.services?.password
throw new Meteor.Error 403, 'User has no password set'
throw new Meteor.Error 403, 'Unauthorized'

# Authenticate the user's password
passwordVerification = Accounts._checkPassword authenticatingUser, password
if passwordVerification.error
throw new Meteor.Error 403, 'Incorrect password'
throw new Meteor.Error 403, 'Unauthorized'

# Add a new auth token to the user's account
authToken = Accounts._generateStampedLoginToken()
Meteor.users.update authenticatingUser._id, {$push: {'services.resume.loginTokens': authToken}}
hashedToken = Accounts._hashLoginToken authToken.token
Accounts._insertHashedLoginToken authenticatingUser._id, {hashedToken}

return {authToken: authToken.token, userId: authenticatingUser._id}
20 changes: 15 additions & 5 deletions lib/restivus.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ class @Restivus
version: 1
prettyJson: false
auth:
token: 'services.resume.loginTokens.token'
token: 'services.resume.loginTokens.hashedToken'
user: ->
userId: @request.headers['x-user-id']
token: @request.headers['x-auth-token']
Expand Down Expand Up @@ -289,10 +289,12 @@ class @Restivus
# Get the authenticated user
# TODO: Consider returning the user in Auth.loginWithPassword(), instead of fetching it again here
if auth.userId and auth.authToken
searchQuery = {}
searchQuery[self.config.auth.token] = Accounts._hashLoginToken auth.authToken
@user = Meteor.users.findOne
'_id': auth.userId
'services.resume.loginTokens.token': auth.authToken
@userId = @user._id
searchQuery
@userId = @user?._id

# TODO: Add any return data to response as data.extra
# Call the login hook with the authenticated user attached
Expand All @@ -309,11 +311,19 @@ class @Restivus
get: ->
# Remove the given auth token from the user's account
authToken = @request.headers['x-auth-token']
Meteor.users.update @user._id, {$pull: {'services.resume.loginTokens': {token: authToken}}}
hashedToken = Accounts._hashLoginToken authToken
tokenPaths = self.config.auth.token.split '.'
tokenPrefix = tokenPaths[..-2].join '.'
tokenSuffix = tokenPaths[-1..][0]
pullQueryTarget = {}
pullQueryTarget[tokenSuffix] = hashedToken
pullQuery = {}
pullQuery[tokenPrefix] = pullQueryTarget
Meteor.users.update @user._id, {$pull: pullQuery}

# TODO: Add any return data to response as data.extra
# Call the logout hook with the logged out user attached
self.config.onLoggedOut.call this
self.config.onLoggedOut?.call this

{status: "success", data: message: 'You\'ve been logged out!'}

Expand Down
11 changes: 6 additions & 5 deletions lib/route.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,12 @@ class @Route
auth = @api.config.auth.user.call(endpointContext)

# Get the user from the database
if not auth?.user and auth?.userId and auth?.token
userSelector = {}
userSelector._id = auth.userId
userSelector[@api.config.auth.token] = auth.token
auth.user = Meteor.users.findOne userSelector
if not auth?.user
if auth?.userId and auth?.token
userSelector = {}
userSelector._id = auth.userId
userSelector[@api.config.auth.token] = Accounts._hashLoginToken auth.token
auth.user = Meteor.users.findOne userSelector

# Attach the user and their ID to the context if the authentication was successful
if auth?.user
Expand Down
4 changes: 4 additions & 0 deletions package.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Package.onUse(function (api) {
api.use('coffeescript');
api.use('underscore');
api.use('iron:router@1.0.6');
api.use('accounts-base');

// Package files
api.addFiles('lib/restivus.coffee', 'server');
Expand All @@ -33,7 +34,10 @@ Package.onTest(function (api) {
api.use('http');
api.use('coffeescript');
api.use('peterellisjones:describe');
api.use('accounts-base');
api.use('accounts-password');

api.addFiles('test/route_tests.coffee', 'server');
api.addFiles('test/api_tests.coffee', 'server');
api.addFiles('test/authentication_tests.coffee', 'server');
});
12 changes: 8 additions & 4 deletions test/api_tests.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Meteor.startup ->
test.equal Restivus.config.apiPath, 'api/'
test.isFalse Restivus.config.useAuth
test.isFalse Restivus.config.prettyJson
test.equal Restivus.config.auth.token, 'services.resume.loginTokens.token'
test.equal Restivus.config.auth.token, 'services.resume.loginTokens.hashedToken'

it 'should allow you to add an unconfigured route', (test) ->
Restivus.addRoute 'test1', {authRequired: true, roleRequired: 'admin'},
Expand Down Expand Up @@ -43,15 +43,19 @@ Meteor.startup ->
Restivus.configure
apiPath: 'api/v1'
useAuth: true
auth: token: 'apiKey'
auth:
token: 'services.resume.loginTokens.hashedToken'
user: ->
userId: @request.headers['x-user-id']
token: @request.headers['x-auth-token']
defaultHeaders:
'Content-Type': 'text/json'
'X-Test-Header': 'test header'

config = Restivus.config
test.equal config.apiPath, 'api/v1/'
test.equal config.useAuth, true
test.equal config.auth.token, 'apiKey'
test.equal config.auth.token, 'services.resume.loginTokens.hashedToken'
test.equal config.defaultHeaders['Content-Type'], 'text/json'
test.equal config.defaultHeaders['X-Test-Header'], 'test header'
test.equal config.defaultHeaders['Access-Control-Allow-Origin'], '*'
Expand Down Expand Up @@ -105,7 +109,7 @@ Meteor.startup ->
description: 'test description'
response = JSON.parse result.content
responseData = response.data
test.equal result.statusCode, 201
test.equal result.statusCode, 200
test.equal response.status, 'success'
test.equal responseData.name, 'test name'
test.equal responseData.description, 'test description'
Expand Down
100 changes: 100 additions & 0 deletions test/authentication_tests.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
Meteor.startup ->
describe 'The default authentication endpoints', ->
token = null
emailLoginToken = null
username = 'test'
email = 'test@ivus.com'
password = 'password'

# Delete the test account if it's still present
Meteor.users.remove username: username

userId = Accounts.createUser {
username: username
email
password: password
}

it 'should allow a user to login', (test, next) ->
HTTP.post Meteor.absoluteUrl('/api/v1/login'), {
data:
user: username
password: password
}, (error, result) ->
response = JSON.parse result.content
test.equal result.statusCode, 200
test.equal response.status, 'success'
test.equal response.data.userId, userId
test.isTrue response.data.authToken

# Store the token for later use
token = response.data.authToken

next()

it 'should allow a user to login again, without affecting the first login', (test, next) ->
HTTP.post Meteor.absoluteUrl('/api/v1/login'), {
data:
user: email
password: password
}, (error, result) ->
response = JSON.parse result.content
test.equal result.statusCode, 200
test.equal response.status, 'success'
test.equal response.data.userId, userId
test.isTrue response.data.authToken
test.notEqual token, response.data.authToken

# Store the token for later use
emailLoginToken = response.data.authToken

next()

it 'should not allow a user with wrong password to login', (test, next) ->
HTTP.post Meteor.absoluteUrl('/api/v1/login'), {
data:
user: username
password: "NotAllowed"
}, (error, result) ->
response = JSON.parse result.content
test.equal result.statusCode, 403
test.equal response.status, 'error'

next()

it 'should allow a user to logout', (test, next) ->
HTTP.get Meteor.absoluteUrl('/api/v1/logout'), {
headers:
'X-User-Id': userId
'X-Auth-Token': token
}, (error, result) ->
response = JSON.parse result.content
test.equal result.statusCode, 200
test.equal response.status, 'success'
next()

it 'should remove the logout token after logging out', (test, next) ->
Restivus.addRoute 'prevent-access-after-logout', {authRequired: true},
get: -> true

HTTP.get Meteor.absoluteUrl('/api/v1/prevent-access-after-logout'), {
headers:
'X-User-Id': userId
'X-Auth-Token': token
}, (error, result) ->
response = JSON.parse result.content
test.isTrue error
test.equal result.statusCode, 401
test.equal response.status, 'error'
next()

it 'should allow a second logged in user to logout', (test, next) ->
HTTP.get Meteor.absoluteUrl('/api/v1/logout'), {
headers:
'X-User-Id': userId
'X-Auth-Token': emailLoginToken
}, (error, result) ->
response = JSON.parse result.content
test.equal result.statusCode, 200
test.equal response.status, 'success'
next()
2 changes: 1 addition & 1 deletion test/route_tests.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,4 @@ if Meteor.isServer
test.equal Restivus.config.apiPath, 'api/'
test.isFalse Restivus.config.useAuth
test.isFalse Restivus.config.prettyJson
test.equal Restivus.config.auth.token, 'services.resume.loginTokens.token'
test.equal Restivus.config.auth.token, 'services.resume.loginTokens.hashedToken'

0 comments on commit a19dfa6

Please sign in to comment.