Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue 294 roles #1696

Merged
merged 36 commits into from
Nov 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
92bd264
Refactor session.
fniessink Nov 21, 2020
ebd1398
Add authorization of sessions.
fniessink Nov 21, 2020
49463a0
Rename AuthenticationPlugin to AuthPlugin as it does both authenticat…
fniessink Nov 21, 2020
181c0c1
Remove some duplication. Use email for rights.
fniessink Nov 21, 2020
a161296
Fix SonarLint issue.
fniessink Nov 21, 2020
049a048
Fix SonarLint issue.
fniessink Nov 21, 2020
26dc9f0
Introduce Session model class.
fniessink Nov 21, 2020
c4e2111
Refactoring.
fniessink Nov 21, 2020
42be625
Add input field to frontend.
fniessink Nov 22, 2020
84e8585
Make sure users don't remove themselves as editor by accident.
fniessink Nov 24, 2020
ca3897d
Remove duplication.
fniessink Nov 24, 2020
36f55d7
Return whether user has edit rights from the login route.
fniessink Nov 24, 2020
c99cbda
Refactor.
fniessink Nov 24, 2020
2461bf4
Fix quality issues.
fniessink Nov 24, 2020
a0fdab2
Update docs.
fniessink Nov 25, 2020
a24fa74
Increase feature test coverage.
fniessink Nov 25, 2020
40c7d4b
Add users to LDAP on CircleCI.
fniessink Nov 25, 2020
5b4b45a
Run integration tests on the machine executor so we can use our own l…
fniessink Nov 26, 2020
54deca7
Use latest Ubuntu.
fniessink Nov 26, 2020
b489a44
Install Python 3.9.
fniessink Nov 26, 2020
c1b5669
Install python3.9-venv. Hopefully python3.9 will be installed automat…
fniessink Nov 26, 2020
06c9d80
Python 3.9 is in Ubuntu 20.04?
fniessink Nov 26, 2020
be8e16c
Try pyenv.
fniessink Nov 26, 2020
72fe10a
Try apt-get instead of apt.
fniessink Nov 26, 2020
168034a
Another try.
fniessink Nov 26, 2020
3f00125
Add public key.
fniessink Nov 26, 2020
b445e9f
Add packagecloud.io key.
fniessink Nov 26, 2020
617974e
Add sudo.
fniessink Nov 26, 2020
647c937
Install certificates.
fniessink Nov 26, 2020
23e7558
Fix username.
fniessink Nov 26, 2020
e77543c
Don't wait for user input.
fniessink Nov 26, 2020
1a9e451
Don't wait for user input.
fniessink Nov 26, 2020
1917ae8
Wrong spelling of deadsnakes ppa?
fniessink Nov 26, 2020
e8d1af7
Revert.
fniessink Nov 27, 2020
3d66717
Use test LDAP image.
fniessink Nov 27, 2020
0bd2c8f
Increase feature test coverage.
fniessink Nov 27, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
auth:
username: $DOCKERHUB_USERNAME
password: $DOCKERHUB_PASSWORD
- image: osixia/openldap:1.4.0
- image: ictu/quality-time_testldap:v3.15.0-rc.4
auth:
username: $DOCKERHUB_USERNAME
password: $DOCKERHUB_PASSWORD
Expand Down
13 changes: 8 additions & 5 deletions components/frontend/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ class App extends Component {
loading: false,
datamodel: data_model,
reports: reports.reports || [],
reports_overview: { layout: reports.layout, subtitle: reports.subtitle, title: reports.title },
reports_overview: {
layout: reports.layout, subtitle: reports.subtitle, title: reports.title, editors: reports.editors },
last_update: now
})
}
Expand All @@ -125,7 +126,7 @@ class App extends Component {
check_session(json) {
if (json.ok === false && json.status === 401) {
this.set_user(null);
if(this.login_forwardauth() === false){
if (this.login_forwardauth() === false) {
show_message("warning", "Your session expired", "Please log in to renew your session", "user x");
}
}
Expand Down Expand Up @@ -188,12 +189,12 @@ class App extends Component {
let self = this;
login("", "")
.then(function (json) {
if(json.ok) {
if (json.ok) {
self.set_user(json.email, json.email);
return true;
}
});
return false;
return false;
}

set_user(username, email) {
Expand All @@ -211,7 +212,9 @@ class App extends Component {
render() {
const report_date = this.report_date();
const current_report = this.state.reports.filter((report) => report.report_uuid === this.state.report_uuid)[0] || null;
const readOnly = this.state.user === null || this.state.report_date_string || this.state.report_uuid.slice(0, 4) === "tag-";
const editors = this.state.reports_overview.editors || [];
const editor = editors.length === 0 || editors.includes(this.state.user) || editors.includes(this.state.email);
const readOnly = this.state.user === null || this.state.report_date_string || this.state.report_uuid.slice(0, 4) === "tag-" || !editor;
const props = {
reload: (json) => this.reload(json), report_date: report_date, reports: this.state.reports, history: this.history
};
Expand Down
1 change: 1 addition & 0 deletions components/frontend/src/report/Reports.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export function Reports(props) {
return (
<div id="dashboard">
<ReportsTitle
editors={props.reports_overview.editors || []}
reload={props.reload}
subtitle={props.reports_overview.subtitle}
title={props.reports_overview.title}
Expand Down
15 changes: 14 additions & 1 deletion components/frontend/src/report/ReportsTitle.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ import { HeaderWithDetails } from '../widgets/HeaderWithDetails';
import { Grid } from 'semantic-ui-react';
import { ChangeLog } from '../changelog/ChangeLog';
import { StringInput } from '../fields/StringInput';
import { MultipleChoiceInput } from '../fields/MultipleChoiceInput';
import { set_reports_attribute } from '../api/report';

export function ReportsTitle(props) {
return (
<HeaderWithDetails level="h1" header={props.title} subheader={props.subtitle}>
<Grid stackable>
<Grid.Row columns={3}>
<Grid.Row columns={2}>
<Grid.Column>
<StringInput
label="Report overview title"
Expand All @@ -25,6 +26,18 @@ export function ReportsTitle(props) {
/>
</Grid.Column>
</Grid.Row>
<Grid.Row columns={1}>
<Grid.Column>
<MultipleChoiceInput
allowAdditions
label="Users allowed to edit reports (user name or email address)"
options={props.editors}
placeholder="All authenticated users"
set_value={(value) => set_reports_attribute("editors", value, props.reload)}
value={props.editors}
/>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<ChangeLog />
Expand Down
13 changes: 6 additions & 7 deletions components/ldap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,10 @@ This is a LDAP server included for test purposes. It is based on the `osixia/ope

## LDAP users

The LDAP database has three users:

| User | Username | Password |
| ------------- | -------- | -------- |
| Administrator | admin | admin |
| Jane Doe | jadoe | secret |
| John Doe | jodoe | secret |
The LDAP database has three users:

| User | Email address | Username | Password |
| ------------- | ------------------- | -------- | -------- |
| Administrator | | admin | admin |
| Jane Doe | janedoe@example.org | jadoe | secret |
| John Doe | johndoe@example.org | jodoe | secret |
28 changes: 12 additions & 16 deletions components/server/src/database/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,33 +10,29 @@


def upsert(
database: Database, username: str, email: str, session_id: SessionId,
session_expiration_datetime: datetime) -> None:
database: Database, username: str, email: str, session_id: SessionId, session_expiration_datetime: datetime
) -> None:
"""Update the existing session for the user or insert a new session."""
database.sessions.update(
dict(user=username),
dict(user=username, email=email, session_id=session_id,
session_expiration_datetime=session_expiration_datetime),
upsert=True)
dict(
user=username, email=email, session_id=session_id, session_expiration_datetime=session_expiration_datetime
),
upsert=True,
)


def delete(database: Database, session_id: SessionId) -> None:
"""Remove the session."""
database.sessions.delete_one(dict(session_id=session_id))


def valid(database: Database, session_id: SessionId) -> bool:
"""Return whether the session is present in the database and has not expired."""
session = find_one(database, session_id)
return bool(session.get("session_expiration_datetime", datetime.min) > datetime.now()) if session else False
def user(database: Database):
"""Return the user sending the request."""
session_id = cast(SessionId, bottle.request.get_cookie("session_id"))
return find_session(database, session_id)


def find_one(database: Database, session_id: SessionId):
def find_session(database: Database, session_id: SessionId):
"""Return the session."""
return database.sessions.find_one(dict(session_id=session_id))


def user(database: Database):
"""Return the user sending the request."""
session_id = cast(SessionId, bottle.request.get_cookie("session_id"))
return find_one(database, session_id)
18 changes: 15 additions & 3 deletions components/server/src/initialization/bottle.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,29 @@
import bottle
from pymongo.database import Database

from routes.plugins import AuthenticationPlugin, InjectionPlugin
from routes.plugins import AuthPlugin, InjectionPlugin

# isort: off
# pylint: disable=unused-import
from routes import ( # lgtm [py/unused-import]
auth, changelog, datamodel, documentation, measurement, metric, notification, report, reports, source, subject)
auth,
changelog,
datamodel,
documentation,
measurement,
metric,
notification,
report,
reports,
source,
subject,
)

# isort: on


def init_bottle(database: Database) -> None:
"""Initialize bottle."""
bottle.BaseRequest.MEMFILE_MAX = 1024 * 1024 # Max size of POST body in bytes
bottle.install(InjectionPlugin(value=database, keyword="database"))
bottle.install(AuthenticationPlugin())
bottle.install(AuthPlugin())
22 changes: 22 additions & 0 deletions components/server/src/model/session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Session model class."""

from datetime import datetime
from typing import Dict, List, Union, cast


class Session:
"""Class representing a user session."""

def __init__(self, session_data: Dict[str, Union[datetime, str]]) -> None:
self.__session_data = session_data or {}

def is_valid(self) -> bool:
"""Return whether the session is valid."""
expiration_datetime = cast(datetime, self.__session_data.get("session_expiration_datetime", datetime.min))
return bool(expiration_datetime > datetime.now())

def is_authorized(self, authorized_users: List[str]) -> bool:
"""Return whether the session's user is an authorized user."""
if authorized_users:
return bool({self.__session_data["user"], self.__session_data.get("email")} & set(authorized_users))
return True
7 changes: 4 additions & 3 deletions components/server/src/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ def check_password(ssha_ldap_salted_password, password) -> bool:
"""Check the OpenLDAP tagged digest against the given password."""
# See https://www.openldap.org/doc/admin24/security.html#SSHA%20password%20storage%20scheme
# We should (also) support SHA512 as SHA1 is no longer considered to be secure.
ssha_prefix = b'{SSHA}'
ssha_prefix = b"{SSHA}"
if not ssha_ldap_salted_password.startswith(ssha_prefix): # pragma: no cover-behave
logging.warning("Only SSHA LDAP password digest supported!")
raise exceptions.LDAPInvalidAttributeSyntaxResult
digest_salt_b64 = ssha_ldap_salted_password.removeprefix(ssha_prefix)
digest_salt = base64.b64decode(digest_salt_b64)
digest = digest_salt[:20]
salt = digest_salt[20:]
sha = hashlib.sha1(bytes(password, 'utf-8')) # noqa: DUO130, # nosec
sha = hashlib.sha1(bytes(password, "utf-8")) # noqa: DUO130, # nosec
sha.update(salt) # nosec
return digest == sha.digest()

Expand All @@ -72,6 +72,7 @@ def get_credentials() -> Tuple[str, str]:

def verify_user(username: str, password: str) -> Tuple[bool, str]:
"""Authenticate the user and return whether they are authorized to login and their email address."""

def user(username: str, email: str) -> str:
"""Format user and email for logging purposes."""
return f"user {username} <{email or 'unknown email'}>"
Expand All @@ -89,7 +90,7 @@ def user(username: str, email: str) -> str:
if not lookup_connection.bind(): # pragma: no cover-behave
username = ldap_lookup_user_dn
raise exceptions.LDAPBindError
lookup_connection.search(ldap_root_dn, ldap_search_filter, attributes=['userPassword', 'mail'])
lookup_connection.search(ldap_root_dn, ldap_search_filter, attributes=["userPassword", "mail"])
result = lookup_connection.entries[0]
username, salted_password = result.entry_dn, result.userPassword.value
email = result.mail.value or ""
Expand Down
2 changes: 1 addition & 1 deletion components/server/src/routes/plugins/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Bottle route plugins."""

from .authentication_plugin import AuthenticationPlugin
from .auth_plugin import AuthPlugin
from .injection_plugin import InjectionPlugin
46 changes: 46 additions & 0 deletions components/server/src/routes/plugins/auth_plugin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Route authentication and authorization plugin."""

import logging

import bottle

from database import sessions
from database.reports import latest_reports_overview
from model.session import Session


class AuthPlugin: # pylint: disable=too-few-public-methods
"""This plugin checks authentication and authorization for post and delete routes."""

api = 2

def __init__(self) -> None:
self.name = "route-auth"

@classmethod
def apply(cls, callback, context):
"""Apply the plugin to the route."""
path = context.rule.strip("/").split("/")
if context.method not in ("DELETE", "POST") or path[-1] == "login" or path[0] == "internal-api":
return callback # Unauthenticated access allowed

def wrapper(*args, **kwargs):
"""Wrap the route."""
database = kwargs["database"]
session_id = str(bottle.request.get_cookie("session_id"))
session = Session(sessions.find_session(database, session_id))
if not session.is_valid():
cls.abort(401, "%s-access to %s denied: session %s not authenticated", context, session_id)
authorized_users = latest_reports_overview(database).get("editors")
if not session.is_authorized(authorized_users):
cls.abort(403, "%s-access to %s denied: session %s not authorized", context, session_id)
return callback(*args, **kwargs)

# Replace the route callback with the wrapped one.
return wrapper

@staticmethod
def abort(status_code: int, message: str, context, session_id: str) -> None:
"""Log the message and abort."""
logging.warning(message, context.method, context.rule, session_id)
bottle.abort(status_code, bottle.HTTP_CODES[status_code])
34 changes: 0 additions & 34 deletions components/server/src/routes/plugins/authentication_plugin.py

This file was deleted.

7 changes: 5 additions & 2 deletions components/server/src/routes/reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,14 @@ def post_reports_attribute(reports_attribute: str, database: Database):
old_value = overview.get(reports_attribute)
if value == old_value:
return dict(ok=True) # Nothing to do
user = sessions.user(database)
if reports_attribute == "editors" and len(value) > 0 and user["user"] not in value and user["email"] not in value:
value.append(user["user"]) # Make sure users don't remove themselves as editor by accident
overview[reports_attribute] = value
value_change_description = "" if reports_attribute == "layout" else f" from '{old_value}' to '{value}'"
user = sessions.user(database)
overview["delta"] = dict(
email=user["email"],
description=f"{user['user']} changed the {reports_attribute} of the reports overview"
f"{value_change_description}.")
f"{value_change_description}.",
)
return insert_new_reports_overview(database, overview)
Loading