From 89932ab6511d2407320eaf2bdee8e9535140fcc6 Mon Sep 17 00:00:00 2001 From: Dimitrie Stefanescu Date: Sat, 28 Mar 2020 20:08:40 +0000 Subject: [PATCH] feat(users): users & api tokens logic + tests for services --- modules/core/migrations/000-users.js | 10 ++- modules/core/streams/queries.js | 2 +- modules/core/tests/actors.spec.js | 115 ++++++++++++++++++++------- modules/core/tests/streams.spec.js | 10 ++- modules/core/users/queries.js | 53 +++++++++--- package-lock.json | 13 +++ package.json | 1 + 7 files changed, 160 insertions(+), 44 deletions(-) diff --git a/modules/core/migrations/000-users.js b/modules/core/migrations/000-users.js index a24cf55b6..4db52f45e 100644 --- a/modules/core/migrations/000-users.js +++ b/modules/core/migrations/000-users.js @@ -16,15 +16,17 @@ exports.up = async knex => { } ) await knex.schema.createTable( 'api_token', table => { - table.uuid( 'id' ).defaultTo( knex.raw( 'gen_random_uuid()' ) ).unique( ) - table.string( 'token_digest' ).unique( ).primary( ) + table.text( 'id' ).unique( ).primary( ) + table.text( 'token_digest' ).unique( ) table.uuid( 'owner_id' ).references( 'id' ).inTable( 'users' ).notNullable( ) table.text( 'name' ) + table.text( 'last_chars' ) + table.specificType( 'scopes', 'text[]' ) table.boolean( 'revoked' ).defaultTo( false ) - table.text( 'revoke_reason' ) + table.timestamp( 'created_at' ).defaultTo( knex.fn.now( ) ) + table.timestamp( 'last_used' ).defaultTo( knex.fn.now( ) ) } ) - } exports.down = async knex => { diff --git a/modules/core/streams/queries.js b/modules/core/streams/queries.js index 2728a3e6d..62104486d 100644 --- a/modules/core/streams/queries.js +++ b/modules/core/streams/queries.js @@ -10,7 +10,7 @@ module.exports = { createStream: ( stream ) => { delete stream.id delete stream.created_at - return Streams( ).returning( 'id' ).insert( stream ) + // return Streams( ).returning( 'id' ).insert( stream ) }, getStream: ( id ) => { diff --git a/modules/core/tests/actors.spec.js b/modules/core/tests/actors.spec.js index 5b48ea059..88c42a71f 100644 --- a/modules/core/tests/actors.spec.js +++ b/modules/core/tests/actors.spec.js @@ -10,13 +10,14 @@ chai.use( chaiHttp ) const knex = require( `${root}/db/knex` ) -const { createUser, getUser, updateUser, deleteUser, createToken, revokeToken } = require( '../users/queries' ) +const { createUser, getUser, updateUser, deleteUser, validatePasssword, createToken, revokeToken, revokeTokenById, validateToken, getUserTokens } = require( '../users/queries' ) describe( 'Actors & Tokens', ( ) => { let myTestActor = { username: 'dim', name: 'Dimitrie Stefanescu', - email: 'didimitrie@gmail.com' + email: 'didimitrie@gmail.com', + password: 'sn3aky-1337-b1m' } before( async ( ) => { @@ -34,40 +35,100 @@ describe( 'Actors & Tokens', ( ) => { describe( 'Services/Queries', ( ) => { - it( 'Should create an actor', async ( ) => { - let newUser = { ...myTestActor } - newUser.name = 'Bill Gates' - newUser.email = 'bill@gates.com' - newUser.username = 'bill' - newUser.password = 'testthebest' + describe( 'Users', ( ) => { - let actor = await createUser( newUser ) - newUser.id = actor.id + it( 'Should create an actor', async ( ) => { + let newUser = { ...myTestActor } + newUser.name = 'Bill Gates' + newUser.email = 'bill@gates.com' + newUser.username = 'bill' + newUser.password = 'testthebest' + + let actor = await createUser( newUser ) + newUser.id = actor.id + } ) + + it( 'Should get an actor', async ( ) => { + let actor = await getUser( myTestActor.id ) + expect( actor ).to.not.have.property( 'password_digest' ) + } ) + + it( 'Should update an actor', async ( ) => { + let updatedActor = { ...myTestActor } + updatedActor.username = 'didimitrie' + + await updateUser( myTestActor.id, updatedActor ) + + let actor = await getUser( myTestActor.id ) + expect( actor.username ).to.equal( updatedActor.username ) + + } ) + + it( 'Should not update password', async ( ) => { + let updatedActor = { ...myTestActor } + updatedActor.password = "failwhale" + + await updateUser( myTestActor.id, updatedActor ) + + let match = await validatePasssword( myTestActor.id, 'failwhale' ) + expect( match ).to.equal( false ) + } ) + + it( 'Should validate user password', async ( ) => { + let actor = {} + actor.password = 'super-test-200' + actor.email = 'e@ma.il' + actor.username = 'dimitrie' + actor.name = 'Bob Gates' + let id = await createUser( actor ) + + let match = await validatePasssword( id, 'super-test-200' ) + expect( match ).to.equal( true ) + let match_wrong = await validatePasssword( id, 'super-test-2000' ) + expect( match_wrong ).to.equal( false ) + + } ) } ) - it( 'Should get an actor', async ( ) => { - let actor = await getUser( myTestActor.id ) + describe( 'API Tokens', ( ) => { + let myFirstToken + let pregeneratedToken + let revokedToken - } ) + before( async ( ) => { + pregeneratedToken = await createToken( myTestActor.id, 'Whabadub', [ 'useless', 'scope:useless' ] ) + revokedToken = await createToken( myTestActor.id, 'Mr. Revoked', [ ] ) + } ) - it( 'Should update an actor', async ( ) => { - let updatedActor = { ...myTestActor } - updatedActor.username = 'didimitrie' + it( 'Should create an api token', async ( ) => { + let scopes = [ 'streams', 'user:read' ] + let name = 'My Test Token' - await updateUser( myTestActor.id, updatedActor ) + myFirstToken = await createToken( myTestActor.id, name, scopes ) + expect( myFirstToken ).to.have.lengthOf( 42 ) + } ) - let actor = await getUser( myTestActor.id ) - expect( actor.username ).to.equal( updatedActor.username ) - // assert.equal( myTestActor.username, actor.username ) - } ) + it( 'Should validate a token', async ( ) => { + let res = await validateToken( pregeneratedToken ) + expect( res ).to.have.property( 'valid' ) + expect( res.valid ).to.equal( true ) + expect( res ).to.have.property( 'scopes' ) + expect( res ).to.have.property( 'userId' ) + } ) - it( 'Should create an api_token', async ( ) => { - assert.fail( ) - } ) - - it( 'Should revoke an api_token', async ( ) => { - assert.fail( ) + it( 'Should revoke an api token', async ( ) => { + await revokeToken( revokedToken ) + let res = await validateToken( revokedToken ) + expect( res ).to.have.property( 'valid' ) + expect( res.valid ).to.equal( false ) + } ) + it( 'Should get the tokens of an user', async ( ) => { + let userTokens = await getUserTokens( myTestActor.id ) + expect( userTokens ).to.be.an( 'array' ) + expect( userTokens ).to.have.lengthOf( 2 ) + // assert.fail( ) + } ) } ) } ) diff --git a/modules/core/tests/streams.spec.js b/modules/core/tests/streams.spec.js index fea22bd4c..2cacda27b 100644 --- a/modules/core/tests/streams.spec.js +++ b/modules/core/tests/streams.spec.js @@ -24,20 +24,25 @@ describe( 'Streams', ( ) => { // await knex.migrate.rollback( ) } ) + + describe( 'Services/Queries', ( ) => { - describe( 'CRUD', ( ) => { + } ) + + describe( 'Integration', ( ) => { let myTestStream = { name: 'woowowo', id: 'noids', description: 'wonderful test stream' } it( 'Should create a stream', async ( ) => { const res = await chai.request( app ).post( '/streams' ).send( myTestStream ) - + assert.fail( ) expect( res ).to.have.status( 200 ) expect( res.body ).to.have.property( 'id' ) } ) it( 'Should get a stream', async ( ) => { const res = await chai.request( app ).get( `/streams/${myTestStream.id}` ) + assert.fail( ) expect( res ).to.have.status( 200 ) expect( res.body ).to.have.property( 'id' ) @@ -47,6 +52,7 @@ describe( 'Streams', ( ) => { it( 'Should update a stream', async ( ) => { const res = await chai.request( app ).put( `/streams/${myTestStream.id}` ).send( { name: 'new name' } ) const resUpdated = await chai.request( app ).get( `/streams/${myTestStream.id}` ) + assert.fail( ) expect( res ).to.have.status( 200 ) expect( res.body ).to.have.property( 'id' ) diff --git a/modules/core/users/queries.js b/modules/core/users/queries.js index 82f65d9b7..ef9b0522e 100644 --- a/modules/core/users/queries.js +++ b/modules/core/users/queries.js @@ -1,11 +1,9 @@ 'use strict' const bcrypt = require( 'bcrypt' ) +const crs = require( 'crypto-random-string' ) const root = require( 'app-root-path' ) const knex = require( `${root}/db/knex` ) -// const Streams = ( ) => knex( 'streams' ) -// const References = () => knex('references') - const Users = ( ) => knex( 'users' ) const Keys = ( ) => knex( 'api_token' ) @@ -24,28 +22,63 @@ module.exports = { }, getUser: async ( id ) => { - let res = await Users( ).returning( 'id username name email profiles verified' ).where( { id: id } ).first( ) - return res + return Users( ).where( { id: id } ).select( 'id', 'username', 'name', 'email', 'profiles', 'verified' ).first( ) }, updateUser: async ( id, user ) => { delete user.id delete user.password_digest + delete user.password delete user.email await Users( ).where( { id: id } ).update( user ) + }, - // throw new Error( 'not implemented' ) + validatePasssword: async ( userId, password ) => { + var { password_digest } = await Users( ).where( { id: userId } ).select( 'password_digest' ).first( ) + return bcrypt.compare( password, password_digest ) }, deleteUser: ( id ) => { throw new Error( 'not implemented' ) }, - createToken: ( userId, name, scopes ) => { - throw new Error( 'not implemented' ) + createToken: async ( userId, name, scopes ) => { + let tokenId = crs( { length: 10 } ) + let tokenString = crs( { length: 32 } ) + let tokenHash = await bcrypt.hash( tokenString, 10 ) + + let last_chars = tokenString.slice( tokenString.length - 6, tokenString.length ) + + let res = await Keys( ).returning( 'id' ).insert( { id: tokenId, token_digest: tokenHash, last_chars: last_chars, owner_id: userId, name: name, scopes: scopes } ) + + return tokenId + tokenString }, - revokeToken: ( ) => { - throw new Error( 'not implemented' ) + validateToken: async ( tokenString ) => { + let tokenId = tokenString.slice( 0, 10 ) + let tokenContent = tokenString.slice( 10, 32 ) + + let token = await Keys( ).where( { id: tokenId } ).select( '*' ).first( ) + + if ( !token ) { + return { valid: false } + } + + let valid = bcrypt.compare( tokenContent, token.token_digest ) + + if ( valid ) { + await Keys( ).where( { id: tokenId } ).update( { last_used: knex.fn.now( ) } ) + return { valid: true, userId: token.owner_id, scopes: token.scopes } + } else + return { valid: false } + }, + + revokeToken: async ( tokenId ) => { + tokenId = tokenId.slice( 0, 10 ) + await Keys( ).where( { id: tokenId } ).del( ) + }, + + getUserTokens: async ( userId ) => { + return Keys( ).where( { owner_id: userId } ).select( 'id', 'name', 'last_chars', 'scopes', 'created_at', 'last_used' ) } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index f09b104d1..75a74c729 100644 --- a/package-lock.json +++ b/package-lock.json @@ -854,6 +854,14 @@ } } }, + "crypto-random-string": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-3.2.0.tgz", + "integrity": "sha512-8vPu5bsKaq2uKRy3OL7h1Oo7RayAWB8sYexLKAqvCXVib8SxgbmoF1IN4QMKjBv8uI8mp5gPPMbiRah25GMrVQ==", + "requires": { + "type-fest": "^0.8.1" + } + }, "cz-conventional-changelog": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/cz-conventional-changelog/-/cz-conventional-changelog-3.1.0.tgz", @@ -3631,6 +3639,11 @@ "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true }, + "type-fest": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", + "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==" + }, "type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", diff --git a/package.json b/package.json index 83c26deae..2832b4305 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "app-root-path": "^3.0.0", "bcrypt": "^4.0.1", "body-parser": "^1.19.0", + "crypto-random-string": "^3.2.0", "debug": "^4.1.1", "express": "^4.17.1", "knex": "^0.20.12",