Merge pull request 'security-setup' (#3) from security-setup into develop

seems to work in my qubes VM
This commit is contained in:
Willi Junga 2020-05-31 15:31:10 +02:00
commit c2d26108f6
16 changed files with 529 additions and 86 deletions

6
.env
View file

@ -1,5 +1,7 @@
COMPOSE_FILE=docker-compose.dev.yml
COMPOSE_PROJECT_NAME=candymat
#Postgraphile vars
DATABASE_URL=postgres://candymat:postgres!dev@postgres:5432/candymat_db
# Backend vars
POSTGRES_PASSWORD=postgres!dev
DATABASE_URL=postgres://candymat_postgraphile:postgres!dev@postgres:5432/candymat_db
JWT_SECRET=asdfasdfasdf

View file

@ -1,5 +1,40 @@
# Candymat - Wahl-o-Mat fuer Personalwahlen
## Introduction
The candymat is a Wahl-o-Mat for elections of candidates.
## Services
The project consists of three services:
* GraphQL backend (+ postgres)
* Redaktions-App
* User-App
### Redaktions-App
The Redaktions-App is used for editors and candidates to provide questions and answers.
The app is written with react and appollo-react to access the backend.
See also: [Service Readme](redaktions-app/README.md)
### User-App
The User-App is based on the [EuroMat](https://www.euromat.info/en) (Source: https://github.com/morkro/euromat)
and is used to find the perfect candidate for everyone who is allowed to vote.
It is written in vue.js.
See also: [Service Readme](https://git.verdigado.com/Netzbegruenung/candymat-user-app/src/README.md)
### Postgraphile (Backend)
A package which creates an GraphQL api based on an underlying postgres schema.
For more on this (e.g. how to use the graphQl api by yourself) see [backend readme](backend/README.md)
## Development Setup
### Check-out repository
@ -12,16 +47,22 @@
### Start the services
```docker-compose up -d``` for dev setup.
The database will use a volume to persist changes in-between runs.
To start with a clean database, either delete the volume from the postgres configuration in the compose file
or run ```docker volume rm candymat_db-data``` before starting the containers.
### Where to access the services
For dev setup:
```
docker-compose up
```
* GraphQL IDE/GUI: http://localhost:5433/graphiql
* GraphQL Endpoint: http://localhost:5433/graphql
* UserApp: http://localhost:8080
* RedaktionsApp: http://localhost:8081
* Postgres database: http://localhost:5432
**Note:** The database will use a volume to persist changes in-between runs.
To start with a clean database, either delete the volume from the postgres configuration in the compose file
or run
```
docker container rm candymat_postgres_1
docker volume rm candymat_db-data
```
before starting docker-compose.

View file

@ -1,4 +0,0 @@
POSTGRES_PASSWORD=postgres!dev
POSTGRES_USER=candymat
POSTGRES_DB=candymat_db
POSTGRES_SCHEMA=candymat_data

View file

@ -1,6 +0,0 @@
FROM postgres:11.5
COPY ./sql/* /docker-entrypoint-initdb.d/
RUN localedef -i de_DE -c -f UTF-8 -A /usr/share/locale/locale.alias de_DE.UTF-8
ENV LANG de_DE.utf8

View file

@ -1,11 +1,197 @@
# Candymat Backend
## Setup dev environment
The candymat backend consists of a postgres database and a [postgraphile](https://www.graphile.org/postgraphile/introduction/) instance.
Postgraphile generates a Graphql backend based on the underlying postgres schema.
### Postgres via Docker
missing
## Introduction
### Postgres on your machine
* Install postgres and start it
* Create a new database
* Execute the scripts in the `./sql` folder in chronological order
### Basic structure
There are three "data" tables:
* category
* question
* answer
Questions can belong to categories.
Answers belong to questions and candidates.
### User management
There are four types of roles:
* editor
* candidate
* user
* anonymous
Editors handle questions and categories, candidates handle their specific answers and users are only important
in setups where there is no public access to the data.
#### Authentication
Authentication is handled via jwt.
The candymat setup roughly follows the instructions in the [postgraphile docu](https://www.graphile.org/postgraphile/postgresql-schema-design/#authentication-and-authorization).
## Manually test the backend
To test the backend manually an (enhanced) graphiql instance is started in dev mode.
To access it navigate to http://localhost:5433/graphiql.
#### Authenticate (or how to pose as a member of one of the roles)
To pose as one of the three roles authenticate as
* `erika@musterman.de` (editor)
* `max@mustermann.de` (candidate)
* `happy@user.de` (normal user)
The password is always "password".
Use following graphQL mutation to get the jwtToken of an editor:
```
mutation Authenticate {
__typename
authenticate(input: {email: "erika@mustermann.de", password: "password"}) {
jwtToken
}
}
```
The jwtToken in the response has to be added to the headers in the following way:
```
{
"Authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiY2FuZHltYXRfY2FuZGlkYXRlIiwicGVyc29uX2lkIjoyLCJleHAiOjE1OTEwNDgzMzgsImlhdCI6MTU5MDg3NTUzNywiYXVkIjoicG9zdGdyYXBoaWxlIiwiaXNzIjoicG9zdGdyYXBoaWxlIn0.21Lu51_suJ5O2RU-UKN2Y6fvKw4SYe-oqx_QqlU0-GE"
}
```
#### Query the data tables
This is possible as member of any role, including no role (anonymous).
You can use the schema to get familiar with possible queries.
As an example, here is a query which retrieves all questions including the category they belong to:
```graphql
{
allQuestions {
nodes {
categoryByCategoryId {
title
description
}
text
description
}
}
}
```
Example response:
```json
{
"data": {
"allQuestions": {
"nodes": [
{
"categoryByCategoryId": {
"title": "Umwelt",
"description": "Themen rund um Naturschutz usw."
},
"text": "Was sagen Sie zur 10H Regel?",
"description": "In Bayern dürfen Windräder nur ..."
}
]
}
}
}
```
#### Creating new users
aka "register"
Only possible if no bearer token is set in the headers.
```graphql
mutation Register {
registerPerson(input: {firstName: "Ford", lastName: "Prefect", email: "ford@prefect.com", password: "password"}) {
person {
id
}
}
}
```
#### Creating questions
This is only possible as "editor". Use the following mutation:
```
mutation CreateQuestion($text: String!) {
createQuestion(input: {question: {text: $text}}) {
question {
text
id
}
}
}
```
with the variables
```
{
"text": "Die Antwort auf die Frage nach dem Leben, dem Universum und dem ganzen Rest?"
}
```
#### Creating categories
This is only possible as "editor".
Mutation:
```
mutation CreateCategory($title: String!) {
createCategory(input: {category: {title: $title}}) {
category {
title
id
}
}
}
```
Variables
```
{
"title": "Verkehr"
}
```
##### Creating answers
This is only possible as "candidate".
Also the `personId` needs to be `2` (the id of Max Mustermann).
It is impossible for a candidate to pose as a different candidate when answering a question.
Mutation:
```
mutation CreateAnswer($position: Int!, $questionId: Int!, $personId: Int!) {
createAnswer(input: {answer: {position: $position, questionId: $questionId, personId: $personId}}) {
answer {
position
}
}
}
}
```
Variables
```
{
"questionId": 1,
"personId": 2,
"position": 2
}
```
Also change the `personId` to see that the candidate can only answer for themself.
##### Updating, Deleting
Works the same as creating and has the same restrictions for the specific roles.
The exact mutations can be inferred looking at the schema definitions.

5
backend/backend.env Normal file
View file

@ -0,0 +1,5 @@
# Postgres database setup
POSTGRES_USER=candymat_postgraphile
# Password is handled by docker-compose
POSTGRES_DB=candymat_db
POSTGRES_SCHEMA=candymat_data

View file

@ -0,0 +1,34 @@
## Basic security
Testing the security of the backend is substantial for obvious reasons. Write automated penetration tests.
There should be testcases for
| table | editor | candidate | user(v) | user | other |
|------------|--------|-----------|---------|------|-------|
| person | sdU | sDU | sDU | | |
| account | S | S | S | S | | not sure about this
| answer | s | sDUI | s | | |
| question | sdui | s | s | | |
| categories | sdui | s | s | | |
| function | editor | candidate | user(v) | user | other |
|--------------|--------|-----------|---------|------|-------|
| register | | | | | E |
| authenticate | E | E | E | E | |
| change pw | E | E | E | | |
| change role | e | | | | |
where
* s: select
* d: delete
* u: update
* i: insert
* e: execute
An uppercase version of the above letters means that the operation is only possible on rows directly related to the user id, e.g. a candidate can only delete, update and insert the own answer(s).
## Passwords
DO NOT LOG THE PASSWORDS
postgres logging conf may need adoption to NOT log passwords in plain text.

View file

@ -1,57 +0,0 @@
\connect candymat_db
-- Create schema for candymat_data
CREATE SCHEMA candymat_data;
-- Create table for users
CREATE TABLE candymat_data."user"
(
login character varying(8) primary key,
name character varying(300),
surname character varying(300),
email character varying(320)
);
-- Create table for user groups
CREATE TABLE candymat_data."group"
(
id serial primary key,
name character varying(300) UNIQUE,
access_right character varying(1000)
);
-- Create table for relation of users and groups
CREATE TABLE candymat_data.user_group
(
group_id integer REFERENCES candymat_data."group" (id) ON UPDATE CASCADE ON DELETE CASCADE,
user_login character varying(8) REFERENCES candymat_data."user" (login) ON UPDATE CASCADE ON DELETE CASCADE,
primary key (group_id, user_login)
);
-- Create table for categories
CREATE TABLE candymat_data.category
(
id serial primary key,
title character varying(300) UNIQUE NOT NULL,
description character varying(5000)
);
-- Create table for questions
CREATE TABLE candymat_data.question
(
id serial primary key,
category_id integer REFERENCES candymat_data."category" (id) ON UPDATE CASCADE ON DELETE SET NULL,
text character varying(3000) NOT NULL,
description character varying(5000)
);
-- Create table for answers
CREATE TABLE candymat_data.answer
(
question_id integer REFERENCES candymat_data."question" (id) ON UPDATE CASCADE ON DELETE CASCADE,
user_login character varying(8) REFERENCES candymat_data."user" ON UPDATE CASCADE ON DELETE CASCADE,
position integer NOT NULL,
text character varying(5000),
primary key (question_id, user_login)
);

View file

@ -0,0 +1,28 @@
\connect candymat_db
-- Create schema for candymat_data
create SCHEMA candymat_data;
create SCHEMA candymat_data_privat;
-- create roles
create role candymat_person;
create role candymat_anonymous;
create role candymat_editor;
create role candymat_candidate;
grant candymat_editor to candymat_postgraphile;
grant candymat_candidate to candymat_postgraphile;
grant candymat_person to candymat_postgraphile, candymat_candidate, candymat_editor;
grant candymat_anonymous to candymat_postgraphile;
create type candymat_data.role as enum (
'candymat_editor',
'candymat_candidate',
'candymat_person'
);
-- set table wide permissions
grant usage on schema candymat_data to candymat_anonymous, candymat_person;
-- make functions non executeable w/o further checks
alter default privileges revoke execute on functions from public;

View file

@ -0,0 +1,34 @@
-- create table for users
create table candymat_data.person
(
id serial primary key,
first_name character varying(200),
last_name character varying(200),
about character varying(2000),
created_at timestamp default now(),
role candymat_data.role not null default 'candymat_person'
);
grant select, update, delete on table candymat_data.person to candymat_person;
-- the following is only necessary as long as anonymous should be able to view candidates and editors
grant select on table candymat_data.person to candymat_anonymous;
-- create table for accounts
create table candymat_data_privat.person_account
(
person_id integer primary key references candymat_data.person (id) on delete cascade,
email character varying(320) not null unique check (email ~* '^.+@.+\..+$'),
password_hash character varying(256) not null
);
alter table candymat_data.person
enable row level security;
create policy update_person on candymat_data.person for update to candymat_person
with check (id = nullif(current_setting('jwt.claims.person_id', true), '')::integer);
create policy delete_person on candymat_data.person for delete to candymat_person
using (id = nullif(current_setting('jwt.claims.person_id', true), '')::integer);
-- The following enables viewing candidates and editors information for every person.
-- This may be changed to only enable registered (and verified) persons.
create policy select_person_public
on candymat_data.person
for select
to candymat_anonymous, candymat_person -- maybe change to candymat_person only in the future
using (role in ('candymat_editor', 'candymat_candidate'));

View file

@ -0,0 +1,51 @@
-- create table for categories
create table candymat_data.category
(
id serial primary key,
title character varying(300) UNIQUE NOT NULL,
description character varying(5000)
);
grant select on table candymat_data.category to candymat_person;
-- the following line is only necessary as long as the candymat should be publicly accessible
grant select on table candymat_data.category to candymat_anonymous;
grant insert, update, delete on table candymat_data.category to candymat_editor;
grant usage on sequence candymat_data.category_id_seq to candymat_editor;
-- create table for questions
create table candymat_data.question
(
id serial primary key,
category_id integer REFERENCES candymat_data.category (id) ON UPDATE CASCADE ON DELETE SET NULL,
text character varying(3000) NOT NULL,
description character varying(5000)
);
grant select on table candymat_data.question to candymat_person;
-- the following line is only necessary as long as the candymat should be publicly accessible
grant select on table candymat_data.question to candymat_anonymous;
grant insert, update, delete on table candymat_data.question to candymat_editor;
grant usage on sequence candymat_data.question_id_seq to candymat_editor;
-- create table for answers
create table candymat_data.answer
(
question_id integer REFERENCES candymat_data.question (id) ON UPDATE CASCADE ON DELETE CASCADE,
person_id integer REFERENCES candymat_data.person (id) ON UPDATE CASCADE ON DELETE CASCADE,
position integer NOT NULL,
text character varying(5000),
created_at timestamp default now(),
primary key (question_id, person_id)
);
grant select on table candymat_data.answer to candymat_person;
-- the following line is only necessary as long as the candymat should be publicly accessible
grant select on table candymat_data.answer to candymat_anonymous;
grant insert, update, delete on table candymat_data.answer to candymat_candidate;
alter table candymat_data.answer
enable row level security;
create policy change_answer on candymat_data.answer to candymat_candidate
using (person_id = nullif(current_setting('jwt.claims.person_id', true), '')::integer);
create policy select_answer
on candymat_data.answer
for select
to candymat_anonymous, candymat_person -- maybe change to candymat_person only in the future
using (true);

View file

@ -0,0 +1,80 @@
create extension if not exists "pgcrypto";
-- Define JWT claim structure
create type candymat_data.jwt_token as (
role text,
person_id integer,
exp bigint
);
create function candymat_data.current_person() returns candymat_data.person as $$
select *
from candymat_data.person
where id = nullif(current_setting('jwt.claims.person_id', true), '')::integer
$$ language sql stable;
grant execute on function candymat_data.current_person() to candymat_person;
create function candymat_data.register_person(
first_name text,
last_name text,
email text,
password text
) returns candymat_data.person as $$
declare
person candymat_data.person;
begin
insert into candymat_data.person (first_name, last_name)
values ($1, $2)
returning * into person;
insert into candymat_data_privat.person_account (person_id, email, password_hash)
values (person.id, $3, crypt($4, gen_salt('bf')));
return person;
end;
$$ language plpgsql strict security definer;
grant execute on function candymat_data.register_person(text, text, text, text) to candymat_anonymous;
create function candymat_data.authenticate(
email text,
password text
) returns candymat_data.jwt_token as $$
declare
account candymat_data_privat.person_account;
declare person candymat_data.person;
begin
select a.*
into account
from candymat_data_privat.person_account as a
where a.email = $1;
select p.*
into person
from candymat_data.person as p
where p.id = account.person_id;
if account.password_hash = crypt(password, account.password_hash) then
return (person.role, account.person_id,
extract(epoch from (now() + interval '2 days')))::candymat_data.jwt_token;
else
return null;
end if;
end;
$$ language plpgsql strict security definer;
grant execute on function candymat_data.authenticate(text, text) to candymat_anonymous, candymat_person;
create function candymat_data.change_role(
person_id integer,
new_role candymat_data.role
) returns table(first_name text, last_name text, role candymat_data.role) as $$
begin
update candymat_data.person
set role = new_role
where candymat_data.person.id = $1;
return query select candymat_data.person.first_name::text, candymat_data.person.last_name::text, new_role
from candymat_data.person
where person.id = person_id;
end;
$$ language plpgsql;
grant execute on function candymat_data.change_role(integer, candymat_data.role) to candymat_editor;

View file

@ -0,0 +1,30 @@
select candymat_data.register_person(
'Erika',
'Mustermann',
'erika@mustermann.de',
'password'
);
select candymat_data.change_role(
1,
'candymat_editor'
);
select candymat_data.register_person(
'Max',
'Mustermann',
'max@mustermann.de',
'password'
);
select candymat_data.change_role(
2,
'candymat_candidate'
);
select candymat_data.register_person(
'Happy',
'User',
'happy@user.de',
'password'
);
select candymat_data.change_role(
3,
'candymat_person'
);

View file

@ -0,0 +1,5 @@
insert into candymat_data.category (title, description) values
('Umwelt', 'Themen rund um Naturschutz usw.');
insert into candymat_data.question (category_id, text, description) values
(1, 'Was sagen Sie zur 10H Regel?', 'In Bayern dürfen Windräder nur ...');

View file

@ -28,7 +28,9 @@ services:
build:
dockerfile: ./Dockerfile
context: ./backend/
env_file: ./backend/.env-backend
environment:
- "POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
env_file: ./backend/backend.env
ports:
- "5432:5432"
restart: always
@ -42,9 +44,21 @@ services:
image: graphile/postgraphile
depends_on:
- postgres
env_file: ./backend/backend.env
ports:
- "5433:5000"
command: ["--connection", $DATABASE_URL, "--host", "0.0.0.0", "--port", "5000", "--schema", "candymat_data", "--watch"]
command: [
"--connection", $DATABASE_URL,
"--host", "0.0.0.0",
"--port", "5000",
"--schema", "candymat_data",
"--default-role", "candymat_anonymous",
"--jwt-token-identifier", "candymat_data.jwt_token",
"--jwt-secret", $JWT_SECRET,
"--watch",
"--retry-on-init-fail",
"--enhance-graphiql"
]
networks:
- frontend
- backend

View file

@ -6,7 +6,7 @@ import { CustomAppBar } from 'components/CustomAppBar/CustomAppBar';
import { Overview } from 'components/Overview/Overview';
export const dataApi = '/api'
export const dataApi = 'http://localhost:5000/graphql'
const styles = createStyles({