Skip to content

Commit

Permalink
Exercise 4 (#11)
Browse files Browse the repository at this point in the history
* added token signing and veryfing, context verification, added userids with uuid and userid  in token conformant to jwt spec, fixed some api definitions

* fixed signup not generating token

* implements login

* added rules and permissions, fixed project structure, added proper api methods

* added data seeding for tests

* fixes resolver to return author.name by author.id

* test fixed reslover maping

* exports context

* adds Authorisation check for create

* fixes Post shield

* tests fixed

* Update backend/src/tests/tests.test.js

Co-authored-by: Robert Schäfer <git@roschaefer.de>

* Update backend/src/datasources/userApi.js

Co-authored-by: Robert Schäfer <git@roschaefer.de>

Co-authored-by: nasden <nasdenkov@gmail.com>
Co-authored-by: Robert Schäfer <git@roschaefer.de>
  • Loading branch information
3 people committed Dec 14, 2020
1 parent 6def6cd commit f01584a
Show file tree
Hide file tree
Showing 12 changed files with 932 additions and 171 deletions.
597 changes: 566 additions & 31 deletions backend/package-lock.json

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@
"apollo-datasource-rest": "^0.9.5",
"apollo-server": "^2.19.0",
"apollo-server-testing": "^2.19.0",
"graphql": "^15.4.0"
"bcrypt": "^5.0.0",
"dotenv": "^8.2.0",
"graphql": "^15.4.0",
"graphql-middleware": "^4.0.2",
"graphql-shield": "^7.4.2",
"jsonwebtoken": "^8.5.1",
"uuid": "^8.3.1"
},
"devDependencies": {
"eslint": "^7.13.0",
Expand Down
13 changes: 13 additions & 0 deletions backend/src/context.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const jwt = require('jsonwebtoken');

module.exports = ({ req }) => {
let token = req.headers.authorization || '';
token = token.replace('Bearer ', '');
try {
const decodedToken = jwt.verify(token, process.env.JWT_SECRET);
return { token: decodedToken }
}
catch (e) {
return {}
}
};
16 changes: 16 additions & 0 deletions backend/src/datasources/authenticationApi.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const { RESTDataSource } = require('apollo-datasource-rest');
const jwt = require('jsonwebtoken');

class AuthAPI extends RESTDataSource {

constructor() {
super();
}

async createToken(userId) {
return jwt.sign({ uId: userId }, process.env.JWT_SECRET, { algorithm: 'HS256', expiresIn: '1h' });
}

}

module.exports = AuthAPI;
15 changes: 1 addition & 14 deletions backend/src/datasources/postApi.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,7 @@ class PostsAPI extends RESTDataSource {

constructor() {
super();
this.posts = [
{
title: 'The Thing',
votes: 0,
author: 'Peter',
upvoters:[]
},
{
title: 'The Nothing',
votes: 0,
author: 'Peter',
upvoters:[]
},
];
this.posts = [];
}

async getPost(title) {
Expand Down
35 changes: 22 additions & 13 deletions backend/src/datasources/userApi.js
Original file line number Diff line number Diff line change
@@ -1,27 +1,36 @@
const { RESTDataSource } = require('apollo-datasource-rest');
const { v4: uuidv4 } = require('uuid');
const bcrypt = require('bcrypt');

class UsersAPI extends RESTDataSource {
constructor() {
super();
this.users = [
{
name: 'Peter',
posts: [],
},
{
name: 'Max',
posts: [],
},
];
this.users = [];
}

async getUser(name) {
return this.users.find(user => user.name === name);
async getUserById(id) {
return this.users.find(user => user.id == id);
}

async getUsers() {
return this.users;
}

async getUserByEmail(email) {
return this.users.find(user => user.email === email);
}

async createUser(name, email, password) {
let obj = {id: uuidv4(), name: name, email: email, posts: [], password: await this.hashPassword(password)};
this.users.push(obj);
return obj;
}

async hashPassword(password) {
const saltRounds = parseInt(process.env.SALT_ROUNDS);
return bcrypt.hash(password, saltRounds);
}

}

module.exports = UsersAPI;
module.exports = UsersAPI;
31 changes: 23 additions & 8 deletions backend/src/index.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,41 @@
const { ApolloServer } = require('apollo-server');
const typeDefs = require('./typeDefs');
const resolvers = require('./resolvers');
const permissions = require('./security/permissions');
const UsersAPI = require('./datasources/userApi');
const PostsAPI = require('./datasources/postApi');
const AuthAPI = require('./datasources/authenticationApi');
require('dotenv').config();
const { applyMiddleware } = require('graphql-middleware');
const { makeExecutableSchema } = require('graphql-tools');


// DO NOT initialize the endpoint inside the dataSources function!
// See https://github.com/apollographql/apollo-server/issues/3150
// Alternatively, set schema.polling.enable to false. (Haven't tested if this works tho)
const usersApi = new UsersAPI();
const postsApi = new PostsAPI();
const authApi = new AuthAPI();

const context = require('./context');

const schema = applyMiddleware(
makeExecutableSchema({
typeDefs,
resolvers,
}),
permissions,
);

const server = new ApolloServer({
typeDefs,
resolvers,
schema,
context: ({req}) => context({req}),
dataSources: () => {
return {
usersApi: usersApi,
postsApi: postsApi
postsApi: postsApi,
authApi: authApi,
}
}
});


server.listen().then(({url}) => {
console.log(`Server ready at ${url}`);
})
});
63 changes: 21 additions & 42 deletions backend/src/resolvers.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
const { UserInputError } = require('apollo-server');

module.exports = {
Query: {
async posts(parent, args, { dataSources }) {
Expand All @@ -11,57 +9,38 @@ module.exports = {
},
User: {
async posts(parent, args , { dataSources }) {
const posts = await dataSources.postsApi.getPosts();
return posts.filter(post => post.author === parent.name);
let posts = await dataSources.postsApi.getPosts();
return posts.filter(post => post.author === parent.id);
}
},
Post: {
author(parent) {
async author(parent, args , { dataSources }) {
const userById = await dataSources.usersApi.getUserById(parent.author);
return {
name: parent.author
name: userById.name
};
}
},
Mutation:{
async write(parent, args, { dataSources }) {
Mutation: {
async write(parent, args, { token, dataSources }) {
let {
title,
author: {name}
} = args.post;

// Check if a post with the title already exists.
let persistedPost = await dataSources.postsApi.getPost(title);
if (persistedPost !== undefined) {
throw new UserInputError("Post with this title already exists.", {invalidArgs: [title]});
}

// Check if the author exists
let author = await dataSources.usersApi.getUser(name);
if (author === undefined) {
throw new UserInputError("No such author.", {invalidArgs: [author]});
}

return await dataSources.postsApi.createPost({title, author: name});
} = args.post;
return await dataSources.postsApi.createPost({ title, author: token.uId });
},
async upvote(parent, args, { dataSources }) {
// Why must we mock the current user? We have him right here, in the voter field?

async upvote(parent, args, { token, dataSources }) {
let postToUpvote = await dataSources.postsApi.getPost(args.title);
if (postToUpvote === undefined) {
throw new UserInputError("Post with this title doesn't exist", {invalidArgs: [args.title]});
}

let upvoter = await dataSources.usersApi.getUser(args.voter.name);
if (upvoter === undefined) {
throw new UserInputError("No such voter.", {invalidArgs: [args.voter]});
}

let alreadyVoted = postToUpvote.upvoters.includes(args.voter.name);
if (alreadyVoted) {
throw new UserInputError("This voter has already upvoted this article", {invalidArgs: [args.title, args.voter]});
}

return await dataSources.postsApi.upvotePost(postToUpvote, args.voter.name);
return await dataSources.postsApi.upvotePost(postToUpvote, token.uId);
},
async signup(parent, args, { dataSources }) {
const createdUser = await dataSources.usersApi.createUser(args.name, args.email, args.password);
const token = await dataSources.authApi.createToken(createdUser.id);
return token;
},
async login(parent, args, { dataSources }) {
const user = await dataSources.usersApi.getUserByEmail(args.email);
const token = await dataSources.authApi.createToken(user.id);
return token;
}
}
};
43 changes: 43 additions & 0 deletions backend/src/security/permissions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
const { isAuthenticated, isPostUpvoted, isPostWithTitlePresent, passwordIsValid, passwordIsTooShort, emailIsTaken, canSeeEmail} = require("./rules");
const { shield, and, not, deny, allow, chain} = require('graphql-shield');
const { UserInputError } = require('apollo-server');

const permissions = shield({
Query: {
"*": allow,
},
User: {
"*": deny,
name: allow,
posts: allow,
email: and(isAuthenticated, canSeeEmail),
},
Post: {
"*": allow,
},
Mutation: {
"*": deny,
signup: and(
not(emailIsTaken, new UserInputError("A user with this email already exists.")),
not(passwordIsTooShort, new UserInputError("The password must be at least 8 characters long."))
),
login: chain(
// This may look strange, but if an already authenticated user authenticates (logs in) a second time, she'll have two tokens! This is not good practice.
// There are two options: either implement a cache layer for the tokens, or just disallow authenticated users to login a second time.
not(isAuthenticated, new Error("Already logged in. Redirect to home page.")),
not(passwordIsTooShort, new UserInputError("The password must be at least 8 characters long.")),
passwordIsValid,
),
write: and(
isAuthenticated,
not(isPostWithTitlePresent, new UserInputError("Post with this title already exists."))
),
upvote: and(
isAuthenticated,
isPostWithTitlePresent,
not(isPostUpvoted, new UserInputError("You've already upvoted this post"))
)
}
});

module.exports = permissions;
67 changes: 67 additions & 0 deletions backend/src/security/rules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
const { rule } = require('graphql-shield');
const bcrypt = require('bcrypt');


const isAuthenticated = rule({ cache: 'contextual' })(
async (parent, args, { token, dataSources }) => {
const user = await dataSources.usersApi.getUserById(token.uId);
return user !== undefined;
}
);

const canSeeEmail = rule({ cache: 'strict' })(
async (parent, args, { token }) => {
return token.uId === parent.id;
}
);

const emailIsTaken = rule({ cache: 'strict' })(
async(parent, args, { token, dataSources }) => {
const user = await dataSources.usersApi.getUserByEmail(args.email);
return user !== undefined;
}
);

const passwordIsTooShort = rule({ cache: 'strict' })(
async(parent, args, { token, dataSources }) => {
return args.password.length < 8;
}
);

const passwordIsValid = rule({ cache: 'strict' })(
async (parent, args, { dataSources }) => {
const user = await dataSources.usersApi.getUserByEmail(args.email);
return user !== undefined && bcrypt.compareSync(args.password, user.password);
}
);

const isPostWithTitlePresent = rule({ cache: 'strict'})(
async (parent, args, { dataSources }) => {
let title;
if (args.post === undefined) {
title = args.title;
}
else {
title = args.post.title
}

const post = await dataSources.postsApi.getPost(title);
return post !== undefined;
}
);

const isPostUpvoted = rule({ cache: 'strict'})(
async (parent, args, { dataSources, token }) => {
const title = args.title;
const post = await dataSources.postsApi.getPost(title);
return post.upvoters.includes(token.uId);
}
);

exports.isAuthenticated = isAuthenticated;
exports.isPostUpvoted = isPostUpvoted;
exports.isPostWithTitlePresent = isPostWithTitlePresent;
exports.passwordIsValid = passwordIsValid;
exports.passwordIsTooShort = passwordIsTooShort;
exports.emailIsTaken = emailIsTaken;
exports.canSeeEmail = canSeeEmail;

0 comments on commit f01584a

Please sign in to comment.