import { FortuneOptions , GraphQLGenie , getRecordFromResolverReturn } from 'graphql-genie' ; import subscriptionPlugin from 'graphql-genie-subscriptions' ; import { PubSub } from 'graphql-subscriptions' ; import { ApolloServer , AuthenticationError , ForbiddenError } from 'apollo-server' ; import redisAdapter from 'fortune-redis' ; import RedisMock from 'ioredis-mock' ; import authPlugin from 'graphql-genie-authentication' ; import { mergeSchemas } from 'graphql-tools' ; import bcrypt from 'bcryptjs' ; import { GraphQLSchema , assertObjectType } from 'graphql' ; import { isArray , isEmpty , pick } from 'lodash' ; import jwt from 'jsonwebtoken' ; const typeDefs = `
enum Role {
	# Open to all requests
	ANY
	# Must be logged in
	USER
	# User must have created/be the type
	OWNER
	ADMIN
}

# Only users can create posts, anybody can read posts, only the person who created the post can update/delete it
type Post @model @auth(create: USER, read: ANY, update: OWNER, delete: OWNER) {
  id: ID! @unique
	title: String!
	text: String
	author: User @relation(name: "posts") @auth(rules: "SELF", create: USER, read: ANY, update: OWNER, delete: OWNER)
}

# Anyone can create users (aka signup), be able to see user info by default, can only update yourself, and only admins can delete
type User @model @auth(create: ANY, read: ANY, update: OWNER, delete: ADMIN) {
	id: ID! @unique
	username: String! @unique
	# don't let anyone read email
	email: String! @unique @auth(create: ANY, read: OWNER, update: OWNER, delete: ADMIN)
	# only admins can read password
	password: String! @auth(create: ANY, read: ADMIN, update: OWNER, delete: ADMIN)
  name : String
	posts: [Post] @relation(name: "posts")
	# Only admins can alter roles, will need additional logic in authenticate function so users can only set themself to USER role
	roles: [Role] @default(value: "USER") @auth(create: ANY, read: ADMIN, update: ADMIN, delete: ADMIN, rules: "only:USER")
}

# Notice this isn't part of the model, so queries/mutations won't be created for it.
# We will still add @auth to it though as the importData resolver could alter it otherwise
# Exists so we can store identifiers in all lowercase, but we will use the backend data resolver instead of graphql
type UserIdentifiers @auth {
	id: ID!
	userID: ID
	password: String
	identifiers: [String]
	roles: [Role]
}

` ; interface CurrentUser { id ? : string ; roles ? : string [ ] ; } const jwtSecret = '8adaf8ceea87f545e600477c37d9b5b461afe95fb26402646b0b58ecd9a2dbab' ; const jwtOptions = { expiresIn : '7d' } ; const fortuneOptions = { adapter : [ redisAdapter , { createClientFactory ( ) { return new RedisMock ( ) ; } } ] } ; const genie = new GraphQLGenie ( { typeDefs , fortuneOptions , generatorOptions : { generateGetAll : true , generateCreate : true , generateUpdate : true , generateDelete : true , generateUpsert : true } } ) ; const startServer = async ( genie ) => { const dataResolver = genie . getDataResolver ( ) ; const adminUserID = dataResolver . computeId ( 'User' , 'admin' ) ; const adminPassword = bcrypt . hashSync ( 'admin' , 10 ) ; const userIdentifierID = dataResolver . computeId ( 'UserIdentifiers' , dataResolver . getOriginalIdFromObjectId ( adminUserID ) ) ; await genie . importRawData ( [ { id : adminUserID , username : 'Admin' , email : 'Admin@example.com' , password : adminPassword , roles : [ 'ADMIN' , 'USER' ] , __typename : 'User' } , { id : userIdentifierID , userID : adminUserID , password : adminPassword , roles : [ 'ADMIN' , 'USER' ] , identifiers : [ 'admin' , 'admin@example.com' ] , __typename : 'UserIdentifiers' } ] , true ) ; genie . use ( subscriptionPlugin ( new PubSub ( ) ) ) ; genie . use ( authPlugin ( ) ) ; const schema = getSchemaWithAuth ( genie ) ; dataResolver . addInputHook ( 'User' , ( context , record , update ) => { switch ( context . request . method ) { case 'create' : if ( record . password ) { record . password = bcrypt . hashSync ( record . password , 10 ) ; } return record ; case 'update' : if ( update . replace . password ) { update . replace . password = bcrypt . hashSync ( update . replace . password , 10 ) ; } return update ; } } ) ; dataResolver . addOutputHook ( 'User' , async ( context , record ) => { const method = context . request . method ; const id = dataResolver . computeId ( 'UserIdentifiers' , dataResolver . getOriginalIdFromObjectId ( record . id ) ) ; const username = record . username ? record . username . toLowerCase ( ) : null ; const email = record . email ? record . email . toLowerCase ( ) : null ; const identifiers = [ ] ; if ( username ) { identifiers . push ( username ) ; } if ( email ) { identifiers . push ( email ) ; } const meta = { context : { authenticate : ( ) => true } } ; switch ( method ) { case 'update' : case 'create' : const idRecord = { id , userID : record . id , identifiers , password : record . password , roles : record . roles } ; await dataResolver [ method ] ( 'UserIdentifiers' , idRecord , meta ) ; return record ; case 'delete' : await dataResolver . delete ( 'UserIdentifiers' , [ id ] , meta ) ; return record ; } } ) ; const opts = { port : 4000 } ; const server = new ApolloServer ( { schema , context : request => { const bearer = parseAuthorizationBearer ( request . req || request . connection ) ; let currUser = { } ; if ( bearer ) { currUser = jwt . verify ( bearer , jwtSecret ) ; } return { request , currUser , authenticate : ( method , requiredRoles , records , filterRecords , _updates , typeName , fieldName , _isFromFilter ) => { const requiredRolesForMethod = requiredRoles [ method ] ; const rules = requiredRoles . rules || [ ] ; const currRoles = ! isEmpty ( currUser ) ? currUser [ 'roles' ] : [ ] ; if ( currRoles . includes ( 'ADMIN' ) ) { return true ; } records = records || [ ] ; records . forEach ( record => { rules . forEach ( rule => { if ( [ 'create' , 'update' ] . includes ( method ) && rule . includes ( 'only:' ) ) { const allowedValue = rule . split ( ':' ) [ 1 ] ; if ( record [ fieldName ] ) { if ( isArray ( record [ fieldName ] ) ) { if ( record [ fieldName ] . length > 1 || record [ fieldName ] [ 0 ] !== allowedValue ) { throw new ForbiddenError ( `${ fieldName } must be [${ allowedValue }]` ) ; } } else if ( record [ fieldName ] !== allowedValue ) { throw new ForbiddenError ( `${ fieldName } must be ${ allowedValue }` ) ; } } } else if ( rule === 'SELF' ) { if ( [ 'create' , 'update' ] . includes ( method ) ) { if ( isEmpty ( currUser ) ) { throw new ForbiddenError ( `Must be logged in to set ${ fieldName }` ) ; } else if ( record [ fieldName ] && record [ fieldName ] !== currUser [ 'id' ] ) { throw new ForbiddenError ( `${ fieldName } field must be set to logged in USER` ) ; } } } } ) ; } ) ; if ( requiredRolesForMethod . includes ( 'ANY' ) ) { return true ; } if ( requiredRolesForMethod . includes ( 'OWNER' ) && ! isEmpty ( currUser ) && ! isEmpty ( records ) ) { const userIds = getUserIDsOfRequestedData ( records , filterRecords ) ; if ( userIds . size === 1 && userIds . values ( ) . next ( ) . value === currUser . id ) { return true ; } } const hasNecessaryRole = requiredRolesForMethod . some ( ( role ) => { return currRoles . includes ( role ) ; } ) ; if ( ! hasNecessaryRole ) { if ( fieldName ) { throw new AuthenticationError ( `Not authorized to ${ method } ${ fieldName } on type ${ typeName }` ) ; } else { throw new AuthenticationError ( `Not authorized to ${ method } ${ typeName }` ) ; } } return true ; } } ; } } ) ; server . listen ( opts ) . then ( ( { url } ) => { console . log ( `🚀 Server ready at ${ url }` ) ; } ) . catch ( ) ; } ; const getUserIDsOfRequestedData = ( records , filterRecords ) : Set < string > => { const userIDs = new Set < string > ( ) ; records . push ( filterRecords ) ; try { records = isArray ( records ) ? records : [ records ] ; records . forEach ( record => { if ( record [ '__typename' ] === 'User' ) { userIDs . add ( record [ 'id' ] ) ; } else if ( record [ '__typename' ] === 'Post' && record [ 'author' ] ) { userIDs . add ( record [ 'author' ] ) ; } } ) ; } catch ( e ) { } return userIDs ; } ; const getSchemaWithAuth = ( genie ) : GraphQLSchema => { const schema = genie . getSchema ( ) ; const createUserField = assertObjectType ( schema . getType ( 'Mutation' ) ) . getFields ( ) [ 'createUser' ] ; const createUserResolver = createUserField . resolve ; createUserField . resolve = async function ( record , args , context , info ) { const createdUser = await createUserResolver . apply ( this , [ record , args , context , info ] ) ; if ( createdUser ) { if ( context && context . hasOwnProperty ( 'currUser' ) && isEmpty ( context . currUser ) ) { const userData = getRecordFromResolverReturn ( createdUser ) ; context . currUser [ 'id' ] = userData [ 'id' ] ; context . currUser [ 'roles' ] = userData [ 'roles' ] ; } } return createdUser ; } ; return mergeSchemas ( { schemas : [ schema , `extend type Mutation {
				login(identifier: String!, password: String!): ID
			}
			extend type UserPayload {
				"""
				Provided on signup (createUser mutation)
				"""
				jwt: String
			}` ] , resolvers : { UserPayload : { jwt : { fragment : `... on UserPayload { id, roles }` , resolve ( _record , _args , context , _info ) { let token ; if ( context && ! isEmpty ( context . currUser ) ) { token = jwt . sign ( pick ( context . currUser , [ 'id' , 'roles' ] ) , jwtSecret , jwtOptions ) ; } return token ; } } } , Mutation : { login : async ( _ , { identifier , password } ) => { identifier = identifier . toLowerCase ( ) ; const identifiedUser = await genie . getDataResolver ( ) . find ( 'UserIdentifiers' , undefined , { match : { identifiers : identifier } } ) ; if ( ! isEmpty ( identifiedUser ) ) { if ( bcrypt . compareSync ( password , identifiedUser [ 0 ] . password ) ) { return jwt . sign ( { id : identifiedUser [ 0 ] . userID , roles : identifiedUser [ 0 ] . roles } , jwtSecret , jwtOptions ) ; } throw new AuthenticationError ( 'Incorrect password.' ) ; } throw new AuthenticationError ( 'No Such User exists.' ) ; } , } , } } ) ; } ; const parseAuthorizationBearer = params => { let authorization = params . headers && params . headers . authorization ; authorization = authorization ? authorization : params . context && params . context . authorization ; if ( ! authorization ) return ; const headerParts = authorization . split ( ' ' ) ; if ( headerParts [ 0 ] . toLowerCase ( ) === 'bearer' ) return headerParts [ 1 ] ; } ; startServer ( genie ) . catch ( ) ;