Compare commits

..

24 Commits

Author SHA1 Message Date
d90151ce25 Handle user already in group 2020-10-12 18:55:36 +02:00
1333c03954 Nicer prints 2020-10-12 18:42:09 +02:00
eb9827bb33 Lower log level 2020-10-12 18:38:13 +02:00
6003807e44 Update readme 2020-10-11 22:03:21 +02:00
69959d0b18 Add to group 2020-10-11 14:53:32 +02:00
8b901ca5d5 Add and delete users 2020-10-11 12:41:44 +02:00
ae81ec3885 Move cli abstraction 2020-10-11 11:55:36 +02:00
d762251476 Command line sugar 2020-10-11 01:11:56 +02:00
a5bb63fb14 Show user 2020-10-10 23:27:08 +02:00
d6f48e4861 Load custom configs 2020-10-10 21:14:21 +02:00
75e2ee1b04 Begin phicli 2020-10-10 18:45:16 +02:00
86e8ed669b Hash password 2020-10-10 12:59:41 +02:00
affcc47fe9 Clean user and group dictionaries 2020-10-10 12:54:28 +02:00
b46b4fa01e Get all groups 2020-10-10 11:25:14 +02:00
6d14ed9246 Users and group dictionaries 2020-10-05 14:51:16 +02:00
8efd4bd75c Add to groups 2020-10-04 23:01:38 +02:00
cdddc250fb Clean abstraction 2020-10-04 17:45:09 +02:00
User Identifier
0b91ee1f22 Delete user after adding it 2020-10-04 16:47:33 +02:00
User Identifier
81b81c6a50 Log as root 2020-10-04 14:23:26 +02:00
User Identifier
93328aa2cf Failing add user test 2020-10-03 18:42:18 +02:00
User Identifier
dbf6ca966f Search by organizational unit 2020-09-30 18:42:28 +02:00
User Identifier
3e299f96b1 Fix typo 2020-09-30 16:42:53 +02:00
User Identifier
4dd11a881e Remove attribute mapping 2020-09-30 16:38:31 +02:00
7883b2d1c0 Make tests run again
I had to remove all the aiohttp crap for the moment.
2020-09-30 12:43:15 +02:00
52 changed files with 900 additions and 4115 deletions

3
.gitignore vendored
View File

@ -90,9 +90,6 @@ ENV/
env.bak/
venv.bak/
# Personal local venv standard ("*" matches the version number)
/cpy*
# Spyder project settings
.spyderproject
.spyproject

27
Pipfile
View File

@ -4,30 +4,9 @@ url = "https://pypi.org/simple"
verify_ssl = true
[dev-packages]
ipython = "*"
pytest = "*"
ipdb = "*"
async-generator = "*"
pytest-cov = "*"
pytest-asyncio = "*"
pytest-aiohttp = "*"
mock = "*"
yarl = {editable = true, path = "."}
pytest-integration = "*"
iniconfig = "*"
[packages]
phi = {editable = true, path = "."}
aiohttp = "==3.8.1"
click = "==8"
pyYAML = "*"
bonsai = "==1.3.0"
passlib = "==1.7.1"
bcrypt = "==3.2.0"
multidict = "*"
iniconfig = "*"
aiohttp-session = {version = "==2.11.0", extras = ["secure"]}
jinja2 = "==3"
phi = {editable = true,path = "."}
[pipenv]
allow_prereleases = true
[requires]
python_version = "3.7"

1637
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,4 +8,51 @@ APIs for the Unit hacklab.
Requirements:
* Python >= 3.9
* Python >= 3.5
Create a virtual environment and activate it (optional):
```
virtualenv --python=/usr/bin/python3 env
source env/bin/activate
```
Run the setup:
```
python setup.py install
```
## Setup
In the ldap section of `config.yml` change host, port and password according to
your setup.
## Command Line
```
usage: phicli [-h] [--config config.yml]
{showuser,adduser,deluser,showgroup,listgroups,addtogroup} ...
optional arguments:
-h, --help show this help message and exit
--config config.yml custom configuration file
actions:
showuser dispaly user fields
adduser add a new user
deluser delete an user
showgroup show a group
listgroups list all groups
addtogroup add an user to a group
```
```
phicli showuser [-h] user_id
phicli adduser [-h] user_id
phicli deluser [-h] user_id
phicli showgroup [-h] common_name
phicli listgroups [-h]
phicli addtogroup [-h] user_id group_common_name
```

View File

@ -1,117 +0,0 @@
# -*- encoding: utf-8 -*-
from contextlib import contextmanager
import logging
import mock
import pytest
from phi.async_ldap.client import (
parse_host,
checked_port,
compose_dn_username,
AsyncClient,
)
BASE_DN = "dc=unit,dc=macaomilano,dc=org"
@contextmanager
def does_not_raise():
yield
@pytest.mark.parametrize(
"test_url, exp_proto, exp_addr, exp_port",
[
("1.3.1.2", "ldap", "1.3.1.2", 389),
("ldap://localhost:1312", "ldap", "localhost", 1312),
("localhost:1312", "ldap", "localhost", 1312),
("localhost", "ldap", "localhost", 389),
("ldap://localhost", "ldap", "localhost", 389),
("ldaps://localhost", "ldaps", "localhost", 636),
("ldaps://localhost:1312", "ldaps", "localhost", 1312),
],
)
def test_parse_host(test_url, exp_proto, exp_addr, exp_port):
proto, addr, port = parse_host(test_url)
assert proto == exp_proto
assert addr == exp_addr
assert port == exp_port
@pytest.mark.parametrize(
"manual, auto, exp_port", [(None, 389, 389), (1312, 389, 1312), (1312, 1312, 1312)]
)
def test_checked_port(manual, auto, exp_port, caplog):
port = checked_port(manual, auto)
if manual and manual != auto:
with caplog.at_level(logging.WARNING):
"The former prevails" in caplog.text
assert port == exp_port
@pytest.mark.parametrize(
"username, base_dn, ou, attribute_id, exp_dn",
[
(
f"uid=conte_mascetti,{BASE_DN}",
BASE_DN,
None,
"uid",
f"uid=conte_mascetti,{BASE_DN}",
),
("root", BASE_DN, None, "cn", f"cn=root,{BASE_DN}"),
("necchi", BASE_DN, "Hackers", "uid", f"uid=necchi,ou=Hackers,{BASE_DN}"),
("perozzi", BASE_DN, "Phrackers", "cn", f"cn=perozzi,ou=Phrackers,{BASE_DN}"),
],
)
def test_compose_dn_username(username, base_dn, ou, attribute_id, exp_dn):
dn = compose_dn_username(username, base_dn, ou, attribute_id)
assert dn == exp_dn
@pytest.mark.parametrize(
"url, encryption, validate, ca_cert, expectation",
[
("localhost", None, False, None, does_not_raise()),
("localhost", True, False, None, does_not_raise()),
("localhost", False, True, None, does_not_raise()),
("localhost", True, True, "path/to/cert.pem", does_not_raise()),
("ldaps://localhost", False, False, None, pytest.raises(ValueError)),
],
)
def test_AsyncClient_init(url, encryption, validate, ca_cert, expectation):
with expectation as exp:
cl = AsyncClient(
host=url,
port=389,
encryption=encryption,
ciphers=None,
validate=validate,
ca_cert=ca_cert,
username="conte_mascetti",
password="pass",
base_dn=BASE_DN,
ou="Hackers",
)
if exp is not None:
assert "Incompatible provided protocol" in str(exp.value)
return
assert cl.base_dn == BASE_DN
assert url in cl.full_uri
assert "389" in cl.full_uri
assert cl._tls if encryption else not cl._tls
if validate:
assert cl.cert_policy == -1
else:
assert cl.cert_policy == 0
if ca_cert:
assert cl.ca_cert == ca_cert
else:
assert cl.ca_cert == ""

View File

@ -1,196 +0,0 @@
# -*- encoding: utf-8 -*-
import asyncio
from async_generator import asynccontextmanager
from bonsai import LDAPEntry
import mock
import pytest
from phi.async_ldap.model import get_dn, User
from phi.async_ldap.mixins import Member
from phi.exceptions import PhiCannotExecute
BASE_DN = "dc=test,dc=domain,dc=tld"
class MockClient(object):
def __init__(self, *args, **kwargs):
self.return_value = kwargs.get("return_value")
self.connect_called = False
self.conn = mock.MagicMock()
self.search_event = asyncio.Event()
self.add_event = asyncio.Event()
self.delete_event = asyncio.Event()
async def connect_called_with_search(self):
return await self.search_event.wait()
async def connect_called_with_add(self):
return await self.add_event.wait()
async def connect_called_with_delete(self):
return await self.delete_event.wait()
@property
def base_dn(self):
return BASE_DN
@asynccontextmanager
async def connect(self, *args, **kwargs):
self.connect_called = True
async def _search(*a, **kw):
self.search_event.set()
return self.return_value
async def _add(*a, **kw):
self.add_event.set()
return self.return_value
async def _modify(*a, **kw):
return self.return_value
async def _delete(*a, **kw):
self.delete_event.set()
return self.return_value
self.conn.search = mock.MagicMock(side_effect=_search)
self.conn.add = mock.MagicMock(side_effect=_add)
self.conn.modify = mock.MagicMock(side_effect=_modify)
self.conn.delete = mock.MagicMock(side_effect=_delete)
yield self.conn
cl = mock.MagicMock()
cl.base_dn = BASE_DN
@pytest.mark.parametrize(
"input_obj, expected_result",
[
(User(cl, "test_user"), f"uid=test_user,ou=Hackers,{BASE_DN}"),
(
LDAPEntry(f"uid=test_user,ou=Hackers,{BASE_DN}"),
f"uid=test_user,ou=Hackers,{BASE_DN}",
),
(f"uid=test_user,ou=Hackers,{BASE_DN}", f"uid=test_user,ou=Hackers,{BASE_DN}"),
],
)
def test_get_dn(input_obj, expected_result):
assert get_dn(input_obj) == expected_result
def test_get_dn_raises():
with pytest.raises(ValueError) as e:
_ = get_dn(object)
assert "Unacceptable input:" in str(e.value)
def test_repr():
_cl = MockClient(return_value=None)
u = User(_cl, "test_user")
assert repr(u) == f"<User uid=test_user,ou=Hackers,{BASE_DN}>"
def test_str():
_cl = MockClient(return_value=None)
u = User(_cl, "test_user")
assert str(u) == f"<User uid=test_user,ou=Hackers,{BASE_DN}>"
@pytest.mark.parametrize(
"input_obj",
[
User(cl, "test_user"),
LDAPEntry(f"uid=test_user,ou=Hackers,{BASE_DN}"),
f"uid=test_user,ou=Hackers,{BASE_DN}",
],
)
def test_eq(input_obj):
u = User(cl, "test_user")
assert u == input_obj
@pytest.mark.asyncio
async def test_User_add():
_cl = MockClient(return_value=None)
u = User(_cl, "test_user")
assert u.dn == f"uid=test_user,ou=Hackers,{BASE_DN}"
_ = await u.save()
assert _cl.connect_called
assert await _cl.connect_called_with_add()
@pytest.mark.asyncio
async def test_User_modify():
"""
This test does not use the MockClient check facilities because
of implementation details of the Entry class.
"""
_cl = MockClient(
return_value=[
LDAPEntry(f"uid=test_user,ou=Hackers,{BASE_DN}"),
]
)
u = User(_cl, "test_user")
# This is the asyncio equivalent of a semaphore
modified = asyncio.Event()
async def _mock_modify():
modified.set()
u._entry = mock.MagicMock()
u._entry.modify = mock.MagicMock(side_effect=_mock_modify)
u["cn"] = "random_cn"
_ = await u.modify()
assert _cl.connect_called
assert await _cl.connect_called_with_search()
# The `wait()` call here is needed to wait for `_mock_modify`
# to end its async execution
assert await modified.wait()
@pytest.mark.asyncio
async def test_User_delete():
_cl = MockClient(return_value=None)
u = User(_cl, "test_user")
_ = await u.delete()
assert _cl.connect_called
assert await _cl.connect_called_with_delete()
@pytest.mark.asyncio
async def test_User_get_invalid_attr_raises():
_cl = MockClient(return_value=None)
u = User(_cl, "test_user")
with pytest.raises(PhiCannotExecute) as ex:
_ = u["iDoNotExist"]
assert "iDoNotExist" in str(ex.value)
assert "is not an allowed ldap attribute" in str(ex.value)
@pytest.mark.asyncio
async def test_User_set_invalid_attr_raises():
_cl = MockClient(return_value=None)
u = User(_cl, "test_user")
with pytest.raises(PhiCannotExecute) as ex:
u["iDoNotExist"] = "hello"
assert "iDoNotExist" in str(ex.value)
assert "is not an allowed ldap attribute" in str(ex.value)

View File

@ -3,8 +3,6 @@ core:
listen:
host: 127.0.0.1
port: 8080
# generated with: openssl rand -hex 16
cookiestore_secret: "e41133b5cfdd8660815b8d5cc2c74843"
ldap:
@ -13,13 +11,13 @@ ldap:
encryption: TLSv1.2 # Can either be None or TLSv1.2. Default: None
ciphers: "HIGH"
validate: False # Can either be True or False. Default: False
validate: True # Can either be True or False. Default: False
ca_certs: openldap/cert.pem
# username: uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org
# password: phi
# username: cn=root,dc=unit,dc=macaomilano,dc=org
# password: root
username: cn=root,dc=unit,dc=macaomilano,dc=org
password: root
base_dn: dc=unit,dc=macaomilano,dc=org
@ -42,16 +40,11 @@ logging:
loggers:
phi:
level: DEBUG
level: WARNING
handlers: [console, file]
asyncio:
level: DEBUG
handlers: [console, file]
propagate: yes
aiohttp:
level: DEBUG
level: WARNING
handlers: [console, file]
propagate: yes
bonsai:
level: DEBUG
ldap3:
level: WARNING
handlers: [console, file]

View File

@ -1,361 +0,0 @@
# -*- coding: utf-8 -*-
import asyncio
from async_generator import asynccontextmanager
import pytest
from phi.async_ldap.model import (
Hackers,
User,
Robots,
Service,
Group,
Roles,
)
from phi.async_ldap.mixins import build_heritage
from phi.async_ldap.client import AsyncClient
import phi.exceptions as e
BASE_DN = "dc=unit,dc=macaomilano,dc=org"
cl = AsyncClient(
"ldap://localhost",
port=389,
encryption=True,
# validate=True,
ca_cert="../openldap/cert.pem",
username="root",
password="root",
base_dn=BASE_DN,
attribute_id="cn",
)
@asynccontextmanager
async def clean_db():
h = Hackers(cl)
r = Robots(cl)
c = Roles(cl)
h.delete_cascade = True
r.delete_cascade = True
c.delete_cascade = True
await h.delete()
await r.delete()
await c.delete()
yield
await h.delete()
await r.delete()
await c.delete()
async def init_achilles():
u = User(cl, "achilles")
u["cn"] = "Achilles"
u["sn"] = "achilles"
u["mail"] = "achilles@phthia.gr"
u["userPassword"] = "Patroclus123"
await u.save()
return u
async def init_patroclus():
u = User(cl, "patroclus")
u["cn"] = "Patroclus"
u["sn"] = "patroclus"
u["mail"] = "patroclus@phthia.gr"
u["userPassword"] = "WannabeAnHero"
await u.save()
return u
async def init_athena():
s = Service(cl, "athena")
s["userPassword"] = "ἁ θεονόα"
await s.save()
return s
async def init_group(group_name, members):
g = Group(cl, group_name, member=members)
await g.save()
return g
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_init():
async with clean_db():
u = await init_achilles()
h = Hackers(cl)
res = await h.search("achilles")
assert u == res
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_exists():
async with clean_db():
u1 = await init_achilles()
u2 = User(cl, "enea")
assert await u1.exists()
assert not await u2.exists()
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_double_save_raises():
async with clean_db():
u = await init_achilles()
# Read all the data from the db
await u.sync()
with pytest.raises(e.PhiEntryExists) as ex:
await u.save()
assert u.dn in str(ex.value)
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_describe():
async with clean_db():
u = await init_achilles()
res = await u.describe()
assert res == {
"uid": "achilles",
"cn": "Achilles",
"sn": "achilles",
"dn": f"uid=achilles,ou=Hackers,{BASE_DN}",
"mail": "achilles@phthia.gr",
}
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_modify():
async with clean_db():
u = await init_achilles()
NEW_EMAIL = "a@myrmidons.mil"
u["mail"] = NEW_EMAIL
await u.modify()
h = Hackers(cl)
res = await h.search("achilles")
await u.sync()
assert u["mail"] == res["mail"] == NEW_EMAIL
for attr in u.ldap_attributes:
assert u[attr] == res[attr]
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_modify_raises():
"""Modifying a not-yet-existing user raises."""
async with clean_db():
u = User(cl, "enea")
with pytest.raises(e.PhiEntryDoesNotExist) as ex:
await u.modify()
assert u.dn in str(ex.value)
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_delete():
async with clean_db():
u = await init_achilles()
await u.delete()
h = Hackers(cl)
with pytest.raises(e.PhiEntryDoesNotExist) as ex:
await h.search("achilles")
assert u.dn in str(ex.value)
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_Service_init():
async with clean_db():
s = await init_athena()
r = Robots(cl)
res = await r.search("athena")
assert s == res
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_Service_describe():
async with clean_db():
s = await init_athena()
res = await s.describe()
assert res == {
"ou": "Robots",
"uid": "athena",
"dn": f"uid=athena,ou=Robots,{BASE_DN}",
}
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_Group_init():
async with clean_db():
u = await init_achilles()
g = await init_group("achaeans", [u])
c = Roles(cl)
res = await c.search("achaeans")
assert g == res
assert [u] == [a async for a in g.get_members()]
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_Group_describe():
async with clean_db():
u1 = await init_achilles()
u2 = await init_patroclus()
g = await init_group("achaeans", [u1, u2])
res = await g.describe()
assert res == {
"ou": "Roles",
"cn": "achaeans",
"dn": f"cn=achaeans,ou=Roles,{BASE_DN}",
"member": [u1.dn, u2.dn],
}
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_Group_add_member():
async with clean_db():
u = await init_achilles()
a = await init_athena()
g1 = await init_group("achaeans", [u])
g2 = await init_group("gods", [u])
await g2.add_member(a)
m1 = [m async for m in g1.get_members()]
m2 = [m async for m in g2.get_members()]
assert u in m1
assert u in m2
assert a not in m1
assert a in m2
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_Group_remove_member():
async with clean_db():
u = await init_achilles()
a = await init_athena()
g = await init_group("achaeans", [u, a])
m = [a async for a in g.get_members()]
assert u in m
assert a in m
await g.remove_member(a)
assert [u] == [el async for el in g.get_members()]
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_User_groups():
async with clean_db():
u = await init_achilles()
a = await init_athena()
g1 = await init_group("achaeans", [u])
g2 = await init_group("gods", [u, a])
res1 = await u.groups()
res2 = await a.groups()
assert g1 in res1
assert g2 in res1
assert g1 not in res2
assert g2 in res2
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_OU_delete_raises():
async with clean_db():
u1 = await init_achilles()
u2 = await init_patroclus()
a = await init_athena()
g1 = await init_group("achaeans", [u1, u2])
g2 = await init_group("gods", [a, u1])
h = Hackers(cl)
_saved_val = h.delete_cascade
h.delete_cascade = False
assert not h.delete_cascade
with pytest.raises(e.PhiCannotExecute) as ex:
await h.delete()
assert "delete_cascade is not set" in str(ex.value)
h.delete_cascade = _saved_val
@pytest.mark.asyncio
@pytest.mark.integration_test
async def test_OU_delete_cascade():
async with clean_db():
u1 = await init_achilles()
u2 = await init_patroclus()
a = await init_athena()
g1 = await init_group("achaeans", [u1, u2])
g2 = await init_group("gods", [a, u1])
h = Hackers(cl)
_saved_val = h.delete_cascade
h.delete_cascade = True
assert h.delete_cascade
await h.delete()
g2_members = [e async for e in g2.get_members()]
h_members = [e async for e in h]
assert not await u1.exists()
assert not await u2.exists()
assert h_members == []
assert not await g1.exists()
assert u1 not in g2_members
assert a in g2_members
h.delete_cascade = _saved_val

View File

@ -1,7 +1,5 @@
FROM alpine:3.7
ENV LDAPTLS_REQCERT=never
RUN apk add --no-cache \
openldap \
openldap-back-mdb \

View File

@ -16,8 +16,6 @@ gen-cert:
.PHONY: clean
clean:
docker rm $(CONTAINER) || true
docker rmi unit/slapd
rm -f key.pem cert.pem
.PHONY: run
@ -37,10 +35,6 @@ prepare:
run-bg:
make prepare
.PHONY: logs
logs:
docker logs -f phi_slapd
.PHONY: stop
stop: is-running
docker stop $(CONTAINER)

View File

@ -11,93 +11,36 @@ objectClass: organizationalUnit
objectClass: top
ou: Hackers
dn: ou=Robots,dc=unit,dc=macaomilano,dc=org
dn: ou=Services,dc=unit,dc=macaomilano,dc=org
objectClass: top
objectClass: organizationalUnit
ou: Robots
ou: Services
dn: ou=Roles,dc=unit,dc=macaomilano,dc=org
dn: ou=Groups,dc=unit,dc=macaomilano,dc=org
objectClass: top
objectClass: organizationalUnit
ou: Roles
ou: Groups
dn: uid=phi,ou=Robots,dc=unit,dc=macaomilano,dc=org
dn: uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org
objectClass: account
objectClass: simpleSecurityObject
objectClass: top
uid: phi
userPassword: {SHA}REu9CtcqSaA1c5J+sEYlTgg0H+M=
dn: uid=irc,ou=Robots,dc=unit,dc=macaomilano,dc=org
objectClass: account
objectClass: simpleSecurityObject
objectClass: top
uid: irc
userPassword: {SHA}0WvxFW9MSsesf55SOh4vnuwdkgY=
dn: uid=git,ou=Robots,dc=unit,dc=macaomilano,dc=org
objectClass: account
objectClass: simpleSecurityObject
objectClass: top
uid: git
userPassword: {SHA}RvGgvVWSovkkTKMhsSmQKga1PgM=
dn: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
memberOf: cn=Admins,ou=Roles,dc=unit,dc=macaomilano,dc=org
memberOf: cn=GitUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
memberOf: cn=IRCUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
cn: Raffaello
sn: Mascetti
mail: rmascetti@autistici.org
uid: conte_mascetti
userPassword: {SHA}oLY7P6V+DWaMJhix7vbMYGIfA+E=
dn: uid=necchi,ou=Hackers,dc=unit,dc=macaomilano,dc=org
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
memberOf: cn=GitUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
memberOf: cn=IRCUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
cn: Guido
sn: Necchi
mail: gnecchi@autistici.org
uid: necchi
userPassword: {SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g=
dn: uid=perozzi,ou=Hackers,dc=unit,dc=macaomilano,dc=org
objectClass: inetOrgPerson
objectClass: organizationalPerson
objectClass: person
objectClass: top
memberOf: cn=GitUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
cn: Giorgio
sn: Perozzi
mail: gperozzi@autistici.org
uid: perozzi
userPassword: {SHA}0+CRQKqsTj1I82PHxvZ4ebbddXQ=
dn: cn=Admins,ou=Roles,dc=unit,dc=macaomilano,dc=org
member: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org
cn: Admins
dn: cn=WikiUsers,ou=Groups,dc=unit,dc=macaomilano,dc=org
objectClass: groupOfNames
objectClass: top
dn: cn=GitUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
cn: WikiUsers
member: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org
member: uid=necchi,ou=Hackers,dc=unit,dc=macaomilano,dc=org
member: uid=perozzi,ou=Hackers,dc=unit,dc=macaomilano,dc=org
cn: GitUsers
objectClass: groupOfNames
objectClass: top
dn: cn=IRCUsers,ou=Roles,dc=unit,dc=macaomilano,dc=org
member: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org
member: uid=necchi,ou=Hackers,dc=unit,dc=macaomilano,dc=org
cn: IRCUsers
objectClass: groupOfNames
objectClass: top

View File

@ -1,3 +0,0 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta:__legacy__"

View File

@ -1,11 +1,7 @@
[aliases]
test=pytest
[pycodestyle]
max-line-length=88
[flake8]
max-line-length=88
exclude =
.git,
__pycache__,

View File

@ -1,29 +1,22 @@
from setuptools import setup, find_packages
from setuptools import setup
setup(
name="phi",
version="0.0.1",
description="Post-Human Interface",
name='phi',
version='0.0.1',
description='Post-Human Interface',
# license='',
url="https://git.abbiamoundominio.org/unit/phi",
author="unit",
author_email="unit@paranoici.org",
package_dir={"": "src"},
packages=find_packages("src"),
entry_points={
"console_scripts": ["phid=phi.web.app:cli", "phiadm=phi.cli.adm:cli"]
},
setup_requires=["pytest-runner"],
install_requires=[
"aiohttp==3.8.1",
"aiohttp_session[secure]==2.11.0",
"click==8",
"pyYAML",
"bonsai==1.3.0",
"passlib==1.7.1",
"bcrypt==3.2.0",
"jinja2==3",
],
tests_require=["pytest", "pytest-aiohttp", "pytest-asyncio", "async-generator"],
python_requires=">=3.9",
url='https://git.abbiamoundominio.org/unit/phi',
author='unit',
author_email='unit@paranoici.org',
package_dir={'': 'src'},
packages=['phi', 'phi.api', 'phi.ldap'],
scripts=['src/phid', 'src/phicli'],
setup_requires=['pytest-runner'],
install_requires=['pyYAML', 'ldap3'],
tests_require=['pytest']
)

View File

@ -1,21 +1,30 @@
# -*- encoding: utf-8 -*-
from aiohttp import web
from phi.logging import get_logger
from phi.ldap.client import Client
from phi.api.routes import api_routes
from phi.web.auth_middleware import authenticated
log = get_logger(__name__)
def api_app(store):
def api_startup(app):
app['ldap_client'].open()
def api_shutdown(app):
app['ldap_client'].close()
def api_app(config):
log.info("Initializing API sub-app.")
app = web.Application(middlewares=[authenticated])
app = web.Application()
app["store"] = store
app["log"] = log
ldap_client = Client(**config.get('ldap', {}))
app['ldap_client'] = ldap_client
app.on_startup.append(api_startup)
app.on_shutdown.append(api_shutdown)
app.router.add_routes(api_routes)

View File

@ -1,131 +1,24 @@
from aiohttp.web import json_response, View
from aiohttp.web import (
HTTPNotFound,
HTTPUnprocessableEntity,
HTTPServerError,
HTTPNoContent,
HTTPBadRequest,
HTTPCreated,
)
from aiohttp.web import HTTPNotFound, HTTPUnprocessableEntity
from phi.logging import get_logger
# from phi.api.utils import serialize
from phi.async_ldap.model import User
from phi.exceptions import (
PhiEntryDoesNotExist,
PhiUnexpectedRuntimeValue,
PhiCannotExecute,
)
from phi.ldap.user import get_user_by_uid
from phi.api.utils import serialize
log = get_logger(__name__)
class UserView(View):
def get_log(self):
return self.request.app["log"]
def get_uid(self):
try:
return self.request.match_info["uid"]
except KeyError:
raise HTTPUnprocessableEntity
async def find_user(self, uid):
log = self.get_log()
try:
user = await self.request.app["users"].search(uid)
log.info("Found user %s", user)
except PhiEntryDoesNotExist as e:
log.info(e)
raise HTTPNotFound
return user
class User(View):
async def get(self):
log = self.get_log()
log.debug(f"{self.__class__.__name__}.get")
uid = self.get_uid()
uid = self.request.match_info.get('uid', None)
user = await self.find_user(uid)
if uid is None:
return HTTPUnprocessableEntity()
result = await user.describe()
log.debug("Returning result %s", result)
client = self.request.app['ldap_client']
user = get_user_by_uid(client, uid)
return json_response(result)
if not user:
return HTTPNotFound()
async def post(self):
log = self.get_log()
log.debug(f"{self.__class__.__name__}.post")
uid = self.get_uid()
user = await self.find_user(uid)
try:
user = await self.request.app["users"].search(uid)
log.info("Found user %s", user)
except PhiEntryDoesNotExist as e:
log.debug(e)
raise HTTPNotFound(text=str(e))
except PhiUnexpectedRuntimeValue as e:
log.error("Exception: %s", e)
raise HTTPServerError
body = await self.request.json()
for k, v in body.items():
try:
user[k] = v
except PhiCannotExecute as e:
err = str(e)
log.error(err)
raise HTTPBadRequest(text=err)
try:
await user.modify()
except Exception as e:
log.error("Exception: %s", e)
raise HTTPNoContent
async def put(self):
log = self.get_log()
log.debug(f"{self.__class__.__name__}.put")
uid = self.get_uid()
user = User(self.request.app["ldap_client"], uid)
body = await self.request.json()
try:
for k, v in body.items():
user[k] = v
log.debug(f"Saving: {user}")
await user.save()
except PhiCannotExecute as e:
err = str(e)
log.error(err)
raise HTTPBadRequest(text=err)
log.info(f"New user saved: {user}")
raise HTTPCreated
async def delete(self):
log = self.get_log()
log.debug(f"{self.__class__.__name__}.delete")
uid = self.get_uid()
try:
user = await self.request.app["users"].search(uid)
log.info("Found user %s", user)
await user.delete()
except PhiEntryDoesNotExist as e:
log.debug(e)
raise HTTPNotFound(text=str(e))
except PhiUnexpectedRuntimeValue as e:
log.error("Exception: %s", e)
raise HTTPServerError
raise HTTPNoContent
return json_response(serialize(user))

View File

@ -1,9 +1,10 @@
from aiohttp.web import view
from aiohttp.web import route
from phi.api.rest import User
from phi.api.rest import UserView
api_routes = [
view("/user", UserView),
view("/user/", UserView),
view("/user/{uid}", UserView),
route('*', '/user', User),
route('*', '/user/', User),
route('*', '/user/{uid}', User)
]

View File

@ -1,8 +1,6 @@
from datetime import datetime
def serialize(obj):
return {
k: (v.isoformat() if isinstance(v, datetime) else v)
for k, v in dict(obj).items()
}
def serialize(d):
return {k: (v.isoformat() if isinstance(v, datetime) else v)
for k, v in d.items()}

22
src/phi/app.py Normal file
View File

@ -0,0 +1,22 @@
from asyncio import get_event_loop
from aiohttp import web
from phi.api.app import api_app
def setup_app(config):
loop = get_event_loop()
app = web.Application(loop=loop)
app['config'] = config
api = api_app(config)
app.add_subapp('/api', api)
return app
def run_app(app):
web.run_app(app,
host=app['config']['core']['listen'].get('host', '127.0.0.1'),
port=app['config']['core']['listen'].get('port', '8080'))

View File

@ -1,135 +0,0 @@
from urllib.parse import urlparse
from bonsai import LDAPClient
from phi.logging import get_logger
log = get_logger(__name__)
def parse_host(host):
"""
Helper function to decompose the host in the address
and the (optional) protocol and the (optional) port.
If missing, protocol defaults to "ldap" and port to 389,
in case protocol is missing or is "ldap", or 636, in case
protocol is "ldaps".
"""
if "://" not in host:
host = f"//{host}"
p = urlparse(host)
if p.scheme is not None and p.scheme != "":
proto = p.scheme
else:
proto = "ldap"
if p.port is not None:
port = p.port
else:
port = None
if port is not None:
addr = p.netloc.split(":")[0]
else:
addr = p.netloc
if proto == "ldap":
port = 389
elif proto == "ldaps":
port = 636
return proto, addr, port
def checked_port(provided, auto):
"""
Check consistency of ports given via the connection string
and the explicit parameter.
"""
_provided = provided is not None
if _provided and provided != auto:
log.warning(
"Explicitly provided port ({}) does not match "
"the automatically provided one ({}). The former prevails.".format(
provided, auto
)
)
return provided
if _provided:
return provided
return auto
def compose_dn_username(username, base_dn, ou=None, attribute_id=None):
"""
Output the distinguished name of the user to use as login.
"""
if base_dn in username:
return username
if ou is None:
return f"{attribute_id}={username},{base_dn}"
return f"{attribute_id}={username},ou={ou},{base_dn}"
class AsyncClient(LDAPClient):
"""
Wrapper around LDAPClient.
"""
def __init__(
self,
host=None,
port=None,
encryption=None,
ciphers=None,
validate=False,
ca_cert=None,
username=None,
password=None,
base_dn=None,
attribute_id="uid",
ou=None,
method="SIMPLE",
**kwargs,
):
self.proto, self.host, _port = parse_host(host)
self.port = checked_port(port, _port)
self.full_uri = "{}://{}:{}".format(self.proto, self.host, self.port)
self.base_dn = base_dn
if encryption:
self._tls = True
else:
if self.proto == "ldaps":
raise ValueError(
'Incompatible provided protocol ("%s") and encryption configuration: TLS=%s',
self.proto,
encryption,
)
self._tls = False
super().__init__(self.full_uri, self._tls)
log.info(
"Connected at %s (TLS -> %s)", self.full_uri, "ON" if self.tls else "OFF"
)
if not validate:
self.set_cert_policy("never")
if ca_cert is not None:
self.set_ca_cert(ca_cert)
self.username = compose_dn_username(username, self.base_dn, ou, attribute_id)
self.password = password
self.method = method
self.set_auto_page_acquire(True)
self.set_credentials(self.method, user=self.username, password=self.password)
def __repr__(self):
return f"AsyncClient[{self.full_uri}]<{self.username}>"

View File

@ -1,344 +0,0 @@
# -*- encoding: utf-8 -*-
from bonsai import LDAPEntry, LDAPModOp, NoSuchObjectError # type: ignore
from bonsai.ldapvaluelist import LDAPValueList
import bonsai.errors
from phi.exceptions import (
PhiEntryDoesNotExist,
PhiEntryExists,
PhiUnexpectedRuntimeValue,
PhiCannotExecute,
)
from phi.security import hash_pass, handle_password
def de_listify(elems):
if not isinstance(elems, list):
return elems
if len(elems) == 1:
return elems[0]
else:
return elems
async def build_heritage(obj, child_class, attribute_id="uid"):
"""
Given the object and the child class, yields the
instances of the children.
"""
async for child in obj.get_children():
if attribute_id in child:
_name = child[attribute_id][0]
yield child_class(obj.client, _name)
class Singleton(object):
"""
Mixin to singletonize a class. The class is crafted to be used with the mixins
that implement the compatible __init__.
"""
def __new__(cls, client, *args, **kwargs):
if "name" in kwargs:
name = f"{cls.__name__}-{args['name']}-{id(client)}"
elif args:
name = f"{cls.__name__}-{args[0]}-{id(client)}"
else:
name = f"{cls.__name__}-{id(client)}"
if not hasattr(cls, "_instances"):
cls._instances = dict()
if name not in cls._instances:
cls._instances[name] = object.__new__(cls)
return cls._instances[name]
class Entry(object):
"""
Mixin to interact with LDAP.
"""
def __repr__(self):
return f"<{self.__class__.__name__} {self.dn}>"
def __str__(self):
return f"<{self.__class__.__name__} {self.dn}>"
async def _create_new(self):
self._entry["objectClass"] = self.object_class
async with self.client.connect(is_async=True) as conn:
await conn.add(self._entry)
return self._entry
async def _get(self):
async with self.client.connect(is_async=True) as conn:
# This returns a list of dicts. It should always contain only one item:
# the one we are interested in.
_res = await conn.search(self.dn, 0)
if len(_res) == 0:
raise PhiEntryDoesNotExist(self.dn)
elif len(_res) > 1:
raise PhiUnexpectedRuntimeValue(
"return value should be no more than one", res
)
return _res[0]
async def _modify(self):
_ = await self._get()
async with self.client.connect(is_async=True) as conn:
self._entry.connection = conn
await self._entry.modify()
async def _delete(self):
async with self.client.connect(is_async=True) as conn:
await conn.delete(self.dn, recursive=self.delete_cascade)
async def describe(self):
_internal = await self._get()
values = dict()
for attr in self.ldap_attributes:
value = _internal.get(attr)
if value:
values[attr] = de_listify(value)
if "userPassword" in self.ldap_attributes:
values.pop("userPassword")
values["dn"] = self.dn
return values
@property
def delete_cascade(self):
if hasattr(self, "_delete_cascade"):
return self._delete_cascade
return False
@delete_cascade.setter
def delete_cascade(self, value):
if not isinstance(value, bool):
raise ValueError("delete_cascade must be a boolean")
self._delete_cascade = value
class OrganizationalUnit(object):
"""
Mixin that represents an OrganizationalUnit. It provides the methods to interact
with the LDAP db _and_ to supervise its `Member`s.
To properly use it, one must specify the `ou` and `child_class` class attributes
when inheriting.
"""
object_class = ["organizationalUnit", "top"]
def __init__(self, client, **kwargs):
self.client = client
self.base_dn = client.base_dn
self.name = self.__class__.__name__
self.children = build_heritage(self, self.child_class, self.child_class.id_tag)
self._entry = LDAPEntry(self.dn)
for k, v in kwargs.items():
if k in self.ldap_attributes:
self._entry[k] = v
if "delete_cascade" in kwargs:
self.delete_cascade = delete_cascade
def __aiter__(self):
return self
async def __anext__(self):
try:
return await self.children.__anext__()
except StopAsyncIteration:
self.children = build_heritage(
self, self.child_class, self.child_class.id_tag
)
raise
async def get_children(self):
async with self.client.connect(is_async=True) as conn:
for el in await conn.search(self.dn, 1):
yield el
@property
def dn(self):
return f"ou={self.ou},{self.base_dn}"
async def save(self):
"""
This function iterates over the OU's children and invokes its `save` method,
ignoring errors from yet existing ones.
"""
async for child in self:
try:
await child.save()
except PhiEntryExists:
pass
async def search(self, member_name):
"""
This function allows one to search through the OU's children. The search
function is the one from the underlying library (bonsai) and is strict as such.
"""
result = None
async with self.client.connect(is_async=True) as conn:
result = await conn.search(
f"{self.child_class.id_tag}={member_name},{self.dn}", 0
)
if not result:
raise PhiEntryDoesNotExist(
f"{self.child_class.id_tag}={member_name},{self.dn}"
)
if isinstance(result, list) and len(result) > 1:
raise PhiUnexpectedRuntimeValue(
"return value should be no more than one", result
)
return self.child_class(self.client, member_name, **result[0])
async def delete(self):
"""
Delete all the members of this OU only if `delete_cascade` is set to `True`,
raises otherwise.
"""
if self.delete_cascade:
async for member in self:
await member.delete()
else:
raise PhiCannotExecute("Cannot delete an OU and delete_cascade is not set")
def _hydrate(obj, data):
"""
Iterate over the structure of the given `data`. Using the key name, filtering
only on the values that the given `obj` accepts (`obj.ldap_attributes`),
appropriately set the corresponding value in the given `obj`. In particular:
- append to lists
- handle password setting
- set scalars
This is called `_hydrate` because its aim is to fill a structure (the `obj`)
with substance.
"""
for k, v in data.items():
if k in obj.ldap_attributes:
if isinstance(v, list) and not isinstance(v, LDAPValueList):
obj._entry[k] = []
for _v in v:
obj._entry[k].append(_v.dn)
elif k == "userPassword":
obj._entry[k] = handle_password(v)
else:
obj._entry[k] = v
class Member(object):
"""
Mixin that represents a generic member of an `OrganizationalUnit`.
It provides the methods to interact with the LDAP db.
To properly use, `ou`, `object_class` and `ldap_attributes` class attributes must
be specified when inheriting.
## Usage
The initialization needs an `phi.async_ldap.AsyncClient` and a `name`, that is used
as value in the identification attribute (i.e. `uid`).
This inits an object in memory that may or may not exist in the ldap database yet.
To test it, one can invoke the async method `exists` or may try to `sync`, handling
the corresponding exception (`PhiEntryDoesNotExist`).
To save a new instance, one can `save`. The instance accepts dict-like get and set
on the aforementioned `ldap_attributes`. Once an attribute value has been modified,
one can invoke `modify` to persist the changes.
To remove an instance from the database, one can invoke `delete`.
## Comparisons
The comparison operation with a `Member` is quite loose: it returns `True` with
either:
- an instance of the same `type` (i.e. the same class whose this mixin is used
into) whose `dn` matches
- an `LDAPEntry` whose `dn` matches
- a string matching the `dn`
"""
def __init__(self, client, name, **kwargs):
super().__init__()
self.client = client
self.base_dn = client.base_dn
self.name = name
self._entry = LDAPEntry(self.dn)
self[self.id_tag] = name
self._entry["ou"] = self.ou
if kwargs:
_hydrate(self, kwargs)
def __eq__(self, other):
if isinstance(other, type(self)):
return other.dn == self.dn
elif isinstance(other, str):
return other == self.dn
elif isinstance(other, LDAPEntry):
return other["dn"] == self.dn
else:
return False
@property
def dn(self):
return f"{self.id_tag}={self.name},ou={self.ou},{self.base_dn}"
def __setitem__(self, attr, val):
if attr not in self.ldap_attributes:
raise PhiCannotExecute(
f"{attr} is not an allowed ldap attribute: {self.ldap_attributes}"
)
self._entry[attr] = val
def __getitem__(self, attr):
if attr not in self.ldap_attributes:
raise PhiCannotExecute(
f"{attr} is not an allowed ldap attribute: {self.ldap_attributes}"
)
return self._entry[attr][0]
async def save(self):
"""
This method persists on the ldap database an inited instance. Raises
`PhiEntryExists` in case of a yet existing instance. Raises a specific error if
the instance misses any of the needed attributes (accoding to
`ldap_attributes`).
"""
try:
await self._create_new()
except bonsai.errors.AlreadyExists:
raise PhiEntryExists(self.dn)
async def modify(self):
"""
This method saves the changes made to the instance on the ldap database. Raises
`PhiEntryDoesNotExist` in case of an instance not yet persisted.
"""
await self._modify()
async def delete(self):
"""
This method removes the instance from the database. Raises
`PhiEntryDoesNotExist` in case the entry does not exist.
"""
await self._delete()
async def sync(self):
"""
This method reads the `ldap_attributes` of an existing instance from the ldap
database and assigns the values to `self`. It is needed at first instantiation
of the object, in case an instance exists on the database.
"""
res = await self._get()
_hydrate(self, res)
return self
async def exists(self):
"""
This method returns `True` if the instance exists on the ldap database, `False`
if it does not. It might raise `PhiUnexpectedRuntimeValue` if the ldap state is
inconsistent.
"""
try:
_ = await self.sync()
return True
except PhiEntryDoesNotExist:
return False

View File

@ -1,140 +0,0 @@
# -*- encoding: utf-8 -*-
from bonsai import LDAPEntry
from multidict import MultiDict
from phi.async_ldap import mixins
from phi.exceptions import (
PhiUnexpectedRuntimeValue,
)
def parse_dn(dn):
return MultiDict(e.split("=") for e in dn.split(","))
def get_dn(obj):
if isinstance(obj, mixins.Entry):
return obj.dn
elif isinstance(obj, LDAPEntry):
return obj["dn"]
elif isinstance(obj, str):
return obj
else:
raise ValueError(f"Unacceptable input: {obj}")
class User(mixins.Member, mixins.Entry, mixins.Singleton):
object_class = [
"inetOrgPerson",
"simpleSecurityObject",
"organizationalPerson",
"person",
"top",
]
_instances = dict() # type: ignore
id_tag = "uid"
ou = "Hackers"
ldap_attributes = ["uid", "cn", "sn", "mail", "userPassword"]
async def iter_groups(self): # To be monkeypatched later
pass # pragma: no cover
async def groups(self):
return [g async for g in self.iter_groups()]
async def delete(self):
async for group in self.iter_groups():
await group.remove_member(self)
await super().delete()
class Hackers(mixins.OrganizationalUnit, mixins.Entry, mixins.Singleton):
_instances = dict() # type: ignore
ou = "Hackers"
child_class = User
class Service(mixins.Member, mixins.Entry, mixins.Singleton):
object_class = ["simpleSecurityObject", "account", "top"]
_instances = dict() # type: ignore
id_tag = "uid"
ou = "Robots"
ldap_attributes = ["uid", "ou", "userPassword"]
async def iter_groups(self): # To be monkeypatched later
raise NotImplemented
async def groups(self):
return [g async for g in self.iter_groups()]
async def delete(self):
async for group in self.iter_groups():
await group.remove_member(self)
await super().delete()
class Robots(mixins.OrganizationalUnit, mixins.Entry, mixins.Singleton):
_instances = dict() # type: ignore
ou = "Robots"
child_class = Service
class Group(mixins.Member, mixins.Entry, mixins.Singleton):
object_class = ["groupOfNames", "top"]
_instances = dict() # type: ignore
id_tag = "cn"
ou = "Roles"
ldap_attributes = ["cn", "ou", "member"]
memeber_classes = {"Hackers": User, "Robots": Service}
empty = False
async def add_member(self, member):
member_dn = get_dn(member)
self._entry["member"].append(member_dn)
await self.modify()
async def remove_member(self, member):
new_group_members = [get_dn(m) async for m in self.get_members() if member != m]
if len(new_group_members) == 0:
await self.delete()
self.empty = True
else:
self._entry["member"] = new_group_members
await self.modify()
async def get_members(self):
await self.sync()
for member in self._entry.get("member", []):
dn = parse_dn(member)
yield self.memeber_classes.get(dn["ou"])(self.client, dn["uid"])
class Roles(mixins.OrganizationalUnit, mixins.Entry, mixins.Singleton):
_instances = dict() # type: ignore
ou = "Roles"
child_class = Group
# We define this async method here **after** `User`, `Service` and `Group` have been
# defined, in order to avoid definition loops that would prevent the code from running.
# Indeed, this function explicitely uses `Group` but is needed as a `User` and `Service`
# method. In turn, `Group` definition relies on both `User` and `Service` being yet
# defined.
async def iter_groups(self):
async with self.client.connect(is_async=True) as conn:
res = await conn.search(f"{self.dn}", 2, attrlist=["memberOf"])
if not res or len(res) == 0:
return
elif len(res) == 1:
for group in res[0].get("memberOf", []):
yield Group(self.client, parse_dn(get_dn(group))["cn"])
else:
raise PhiUnexpectedRuntimeValue(
"return value should be no more than one", res
)
# Monkeypatch
User.iter_groups = iter_groups # type: ignore
Service.iter_groups = iter_groups # type: ignore

47
src/phi/cli.py Normal file
View File

@ -0,0 +1,47 @@
import sys
import argparse
import inspect
from phi.logging import setup_logging, get_logger
log = get_logger(__name__)
parser = argparse.ArgumentParser()
subparses = parser.add_subparsers(title='actions', dest='action')
cli_callbacks = {}
def register(action_info='', param_infos=[]):
def decorator(action):
# Get function name and arguments
action_name = action.__name__
param_names = inspect.getfullargspec(action)[0]
# Create subparser for specific action
subparser = subparses.add_parser(action_name, help=action_info)
for i, name in enumerate(param_names):
info = param_infos[i] if i<len(param_infos) else ''
subparser.add_argument(dest=name, help=info)
# Register action
cli_callbacks[action_name] = action, param_names
return action
return decorator
def run(args):
for action_name, (action, param_names) in cli_callbacks.items():
if args['action'] == action_name:
action(**{pname: args[pname] for pname in param_names})
def add_arg(name, example, info):
parser.add_argument(name, metavar=example, help=info)
def get_args():
namespace = parser.parse_args(sys.argv[1:])
args = namespace.__dict__
return args

View File

@ -1,119 +0,0 @@
# -*- encoding: utf-8 -*-
import importlib.resources
from fnmatch import fnmatch
import re
import click
from phi.cli.utils import generate_from_templates
from phi.security import hash_pass
SERVICE_RE = re.compile(r"^(.+?):(.+)$")
templates = [
f.name.strip(".j2")
for f in importlib.resources.files("phi.cli.templates").iterdir()
if not f.name.endswith(".py")
]
def debug(ctx, out):
if ctx.obj["debug"]:
click.echo(f"DEBUG: {out}", err=True)
def validate_services(ctx, _, value):
for service in value:
if not SERVICE_RE.match(service):
click.echo(f"Unparsable service '{service}' - must match /^(.+?):(.+)$/")
ctx.exit()
return value
@click.group(
name="phiadm", help="This cli may be used to interact with a local phid instance"
)
@click.option("-d", "--debug", is_flag=True, help="Enable debugging information")
@click.pass_context
def cli(ctx, debug):
ctx.ensure_object(dict)
ctx.obj["debug"] = debug
@cli.command(
"generate",
help="The base name of this LDAP directory (e.g. 'dc=example,dc=com')",
)
@click.option(
"-t",
"--template",
type=click.STRING,
multiple=True,
help=f"Name of the template (allowed: {', '.join(templates)};"
+ " accepts also glob-like patterns)",
)
@click.option(
"-s",
"--default-service",
type=click.STRING,
multiple=True,
callback=validate_services,
help="A pair <name>:<password> representing a service (and the associated password)"
)
@click.option(
"--root-password",
type=click.STRING,
required=True,
help="The cleartext password for the root user",
)
@click.option(
"--phi-password",
type=click.STRING,
required=True,
help="The cleartext password for the phi service",
)
@click.argument(
"base_dn",
type=click.STRING,
)
@click.pass_context
def generate(ctx, template, default_service, root_password, phi_password, base_dn):
config = get_config(base_dn, phi_password, root_password)
debug(ctx, f"default_service: {default_service}")
if default_service:
add_default_services(config, default_service)
debug(ctx, f"config: {config}")
debug(ctx, f"templates: {templates}")
if not template:
template = templates
for name, content in generate_from_templates(config):
debug(ctx, f"current template: {name}")
if any(fnmatch(name, t) for t in template):
click.echo(content)
def get_config(base_dn, phi_password, root_password):
config = {"default_services": []}
config["base_dn"] = base_dn
config["phi_password"] = hash_pass(phi_password)
config["root_password"] = hash_pass(root_password)
return config
def add_default_services(config, services):
for service in services:
user, password = parse_service(service)
config["default_services"].append(
{"name": user, "password": hash_pass(password)})
def parse_service(service):
res = SERVICE_RE.search(service).groups()
if len(res) != 2:
raise ValueError(res)
return res[0], res[1]

View File

@ -1,23 +0,0 @@
version: 1
dn: {{ base_dn }}
objectClass: organization
objectClass: dcObject
dc: unit
o: Unit
dn: ou=Hackers,{{ base_dn }}
objectClass: organizationalUnit
objectClass: top
ou: Hackers
dn: ou=Robots,{{ base_dn }}
objectClass: top
objectClass: organizationalUnit
ou: Robots
dn: ou=Roles,{{ base_dn }}
objectClass: top
objectClass: organizationalUnit
ou: Roles

View File

@ -1,17 +0,0 @@
version: 1
dn: uid=phi,ou=Robots,{{ base_dn }}
objectClass: account
objectClass: simpleSecurityObject
objectClass: top
uid: phi
userPassword: {{ phi_password }}
{%- for service in default_services %}
dn: uid={{ service.name }},ou=Robots,{{ base_dn }}
objectClass: account
objectClass: simpleSecurityObject
objectClass: top
uid={{ service.name }}
userPassword={{ service.password }}
{%- endfor %}

View File

@ -1,6 +0,0 @@
version: 1
dn: cn=Admins,ou=Roles,{{ base_dn }}
cn: Admins
objectClass: groupOfNames
objectClass: top

View File

@ -1,14 +0,0 @@
access to dn.base=""
by * read
by group.exact="cn=Admins,{{ base_dn }}" manage
access to attrs=entry
by * read
access to attrs=userPassword
by self write
by anonymous auth
access to dn.subtree="ou=Hackers,{{ base_dn }}"
by self write
by dn.subtree="ou=Services,{{ base_dn }}" read

View File

@ -1,87 +0,0 @@
#######################################################################
# Modules
#######################################################################
include /etc/openldap/schema/core.schema
include /etc/openldap/schema/cosine.schema
include /etc/openldap/schema/corba.schema
include /etc/openldap/schema/inetorgperson.schema
include /etc/openldap/schema/nis.schema
include /etc/openldap/schema/collective.schema
include /etc/openldap/schema/openldap.schema
modulepath /usr/lib/openldap
moduleload back_mdb
moduleload refint
moduleload memberof
#######################################################################
# Core
#######################################################################
pidfile /var/slapd/slapd.pid
argsfile /var/slapd/slapd.args
loglevel conns
serverID 0
#######################################################################
# Security
#######################################################################
#TLSCACertificateFile /var/slapd/fullchain.pem
TLSCertificateFile /var/slapd/cert.pem
TLSCertificateKeyFile /var/slapd/key.pem
TLSCipherSuite HIGH
# Sample security restrictions
# Define global ACLs to disable default read access.
# Require integrity protection (prevent hijacking)
# Require 112-bit (3DES or better) encryption for updates
# Require 63-bit encryption for simple bind
security ssf=1 simple_bind=256 update_ssf=256
#######################################################################
# MDB database definitions
#######################################################################
database mdb
maxsize 1073741824
suffix "{{ base_dn }}"
# Overlays to be loaded for the database.
overlay memberof
# Cleartext passwords, especially for the rootdn, should
# be avoid. See slappasswd(8) and slapd.conf(5) for details.
# Use of strong authentication encouraged.
rootdn "cn=root,{{ base_dn }}"
rootpw {{ root_password }}
# The database directory MUST exist prior to running slapd AND
# should only be accessible by the slapd and slap tools.
# Mode 700 recommended.
directory /var/slapd
mode 0700
password-hash {CRYPT}
password-crypt-salt-format "$6$%.16s"
# Indices to maintain
index objectClass pres,eq
index uid,cn,sn,mail eq,sub
index memberof pres,eq
#######################################################################
# MemberOf configuration
#######################################################################
memberof-group-oc groupOfNames
memberof-memberof-ad memberOf
memberof-member-ad member
memberof-dangling error
memberof-refint true

View File

@ -1,25 +0,0 @@
# -*- encoding: utf-8 -*-
import os.path
from jinja2 import Environment, ChoiceLoader, FileSystemLoader, PackageLoader
def get_jinja_env():
pkg_path_templates = os.path.realpath(os.path.join(__name__, "./templates"))
loaders = [
FileSystemLoader(pkg_path_templates),
PackageLoader("phi.cli", package_path="templates"),
]
return Environment(loader=ChoiceLoader(loaders))
def generate_from_templates(config):
env = get_jinja_env()
for t in env.list_templates(extensions="j2"):
tmpl = env.get_template(t)
content = tmpl.render(**config)
# This is loaded from a resource and won't be None
name = tmpl.name.strip(".j2")
# name = os.path.basename(tmpl.filename).strip(".j2")
yield (name, content)

View File

@ -1,80 +1,31 @@
import os.path
import pkg_resources
import yaml
NAME = "phi"
DEFAULT_CONFIG = {
"core": {"listen": {"host": "localhost", "port": 8080}, "cookiestore_secret": None},
"ldap": {
"host": "localhost",
"port": 389,
"encryption": "TLSv1.2",
"ciphers": "HIGH",
"validate": True,
"ca_certs": pkg_resources.resource_filename(NAME, "openldap/cert.pem"),
"base_dn": None,
"attribute_mail": "mail",
},
"logging": {
"version": 1,
"formatters": {"default": {"format": "[%(name)s %(levelname)s] %(message)s"}},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "default",
"stream": "ext://sys.stdout",
},
"file": {
"class": "logging.FileHandler",
"formatter": "default",
"filename": "phi.log",
},
},
"loggers": {
"phi": {
"level": "INFO",
"handlers": ["console", "file"],
},
"aiohttp": {
"level": "INFO",
"handlers": ["console", "file"],
},
"bonsai": {
"level": "WARNING",
"handlers": ["console", "file"],
},
},
},
}
NAME = 'phi'
DUMMY_CONFIG = {"core": {}, "ldap": {}, "logging": {}}
CONFIG_FILE = "config.yml"
CONFIG_PATHS = [
"./",
"~/.config/" + NAME + "/",
"/usr/local/etc/" + NAME + "/",
"/etc/" + NAME + "/",
]
CONFIG_FILES = [os.path.join(p, CONFIG_FILE) for p in CONFIG_PATHS]
CONFIG_FILE = 'config.yml'
CONFIG_PATHS = ['./',
'~/.config/' + NAME + '/',
'/usr/local/etc/' + NAME + '/',
'/etc/' + NAME + '/']
CONFIG_FILES = [os.path.join(p, CONFIG_FILE)
for p in CONFIG_PATHS]
def get_config(config_path=None):
def get_config(custom_config=None):
"""Return the path of the found configuration file and its content
:param config_path: optional path to a config file.
:returns: (path, config)
:rtype: (str, dict)
"""
if config_path:
with open(config_path) as c:
config = yaml.safe_load(c)
return config_path, config
if custom_config:
global CONFIG_FILES
CONFIG_FILES = [custom_config]
for f in CONFIG_FILES:
try:
with open(f, "r") as c:
with open(f, 'r') as c:
config = yaml.safe_load(c)
return (f, config)
except FileNotFoundError:
@ -83,62 +34,11 @@ def get_config(config_path=None):
# accessible or if the file is not present at all
# in any of CONFIG_PATHS.
pass
return None, DUMMY_CONFIG
def merge_config(cli_config, file_config):
"""
Merge the cli-provided and file-provided config.
"""
return recursive_merge(cli_config, file_config)
def _init_with_shape_of(element):
if isinstance(element, dict):
return {}
elif isinstance(element, list):
return []
return None
def recursive_merge(main_config, aux_config):
def _recursive_merge(main, aux, default, key, _ret_config):
if isinstance(default, dict):
_sub_conf = {}
for k, v in default.items():
_main = main[k] if k in main else _init_with_shape_of(v)
_aux = aux[k] if k in aux else _init_with_shape_of(v)
_recursive_merge(_main, _aux, v, k, _sub_conf)
_ret_config[key] = _sub_conf
elif isinstance(default, list):
_main = main.copy()
if aux is not None:
_main.extend(aux)
_ret_config[key] = list(set(_main))
elif isinstance(default, bool):
_ret_config[key] = default and aux and main
else:
if custom_config:
raise FileNotFoundError('Config file {} not found.'
.format(custom_config))
else:
if main is not None:
_ret_config[key] = main
elif aux is not None:
_ret_config[key] = aux
else:
_ret_config[key] = default
_config = {}
_recursive_merge(main_config, aux_config, DEFAULT_CONFIG, "ROOT", _config)
return _config["ROOT"]
def extract_secret(config):
try:
secret = config["core"]["cookiestore_secret"].encode("utf-8")
if len(secret) != 32:
raise ValueError(
"The provided core.cookiestore_secret must be 32 bytes long"
)
return secret
except KeyError:
raise RuntimeError("You must provide a core.cookiestore_secret")
raise FileNotFoundError("Could not find {} in any of {}."
.format(CONFIG_FILE,
', '.join(CONFIG_PATHS)))

View File

@ -1,54 +0,0 @@
# -*- encoding: utf-8 -*-
class PhiEntryExists(Exception):
def __init__(self, dn):
self.dn = dn
def __str__(self):
return f"Entry exists yet ({self.dn})"
def __repr__(self):
return f"PhiEntryExists({self.dn})"
class PhiEntryDoesNotExist(Exception):
def __init__(self, dn):
self.dn = dn
def __str__(self):
return f"Entry does not exist ({self.dn})"
def __repr__(self):
return f"PhiEntryDoesNotExist({self.dn})"
class PhiAttributeMissing(Exception):
def __init__(self, dn, attr):
self.dn = dn
self.attr = attr
class PhiUnauthorized(Exception):
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs)
self.user = user
class PhiUnexpectedRuntimeValue(RuntimeWarning):
def __init__(self, msg, result):
super().__init__(self)
self.msg = msg
self.result = result
class PhiCannotExecute(RuntimeWarning):
def __init__(self, msg):
super().__init__(self)
self.msg = msg
def __str__(self):
return f"Cannot execute: {self.msg}"
def __repr__(self):
return f"PhiCannotExecute({self.msg})"

55
src/phi/ldap/client.py Normal file
View File

@ -0,0 +1,55 @@
from threading import Lock
from ldap3.utils.log import set_library_log_detail_level, PROTOCOL
from phi.logging import get_logger
from phi.ldap.connection import make_connection
from phi.ldap.connection import open_connection, close_connection
log = get_logger(__name__)
set_library_log_detail_level(PROTOCOL)
class Client:
def __init__(self,
host=None, port=389,
encryption=None, ciphers=None, validate=False, ca_certs=None,
username=None, password=None,
base_dn=None):
log.info("Initializing LDAP Client.")
self.host = host
self.port = port
self.encryption = encryption
self.ciphers = ciphers
self.validate = validate
self.ca_certs = ca_certs
self.username = username
self.password = password
self.base_dn = base_dn
self.connection_lock = Lock()
self.connection = make_connection(host=self.host, port=self.port,
encryption=self.encryption,
ciphers=self.ciphers,
validate=self.validate,
ca_certs=self.ca_certs,
username=self.username,
password=self.password)
def open(self):
self.connection_lock.acquire()
if self.connection.closed is True:
open_connection(self.connection)
self.connection_lock.release()
else:
self.connection_lock.release()
raise Exception("Trying to open a connection, "
"but it is already open.")
def close(self):
self.connection_lock.acquire()
close_connection(self.connection)
self.connection_lock.release()

View File

@ -0,0 +1,59 @@
from ssl import CERT_REQUIRED, PROTOCOL_TLSv1_2
from ldap3 import Tls, Server, Connection, ASYNC
from phi.logging import get_logger
log = get_logger(__name__)
def make_connection(host=None, port=389,
encryption=None, ciphers=None, validate=False,
ca_certs=None, username=None, password=None):
# TLSv1.2 is supported since Python 3.4
if encryption is None:
log.warning("The connection to the LDAP server will not be encrypted.")
tls = None
elif encryption == "TLSv1.2":
log.info("The connection to the LDAP server will use TLSv1.2.")
tls = Tls(version=PROTOCOL_TLSv1_2)
else:
raise NotImplementedError("Sorry, use TLSv1.2.")
if encryption is not None and ciphers is not None:
log.info("The connection to the LDAP server will use the "
"following ciphers: {}".format(ciphers))
tls.ciphers = ciphers
if encryption is not None and validate is True:
log.info("The certificate hostname will be checked to match the "
"remote hostname.")
tls.validate = CERT_REQUIRED
if encryption is not None and validate is True and ca_certs is not None:
log.info("Using the following CA certificates: {}"
.format(ca_certs))
tls.ca_certs_file = ca_certs
server = Server(host=host, port=port, tls=tls)
connection = Connection(server, user=username, password=password,
client_strategy=ASYNC)
return connection
def open_connection(connection):
log.info("Opening connection to LDAP server.")
connection.open()
if connection.server.tls is not None and connection.server.ssl is False:
log.info("Issuing StartTLS command.")
connection.start_tls()
log.info("Issuing BIND command.")
connection.bind()
def close_connection(connection):
log.info("Closing connection to LDAP server.")
log.info("Issuing UNBIND command.")
connection.unbind()

61
src/phi/ldap/group.py Normal file
View File

@ -0,0 +1,61 @@
from ldap3 import ALL_ATTRIBUTES, MODIFY_ADD
from phi.ldap.utils import get_response, make_group_dict
from phi.logging import get_logger
log = get_logger(__name__)
def get_group_by_cn(client, cn):
log.info("Searching groups with common name: {}".format(cn))
dn = 'cn={},ou=Groups,{}'.format(cn, client.base_dn)
log.debug("Search dn: {}".format(dn))
response_id = client.connection.search(
dn, '(objectclass=groupOfNames)',
search_scope='SUBTREE',
attributes=[ALL_ATTRIBUTES]
)
response = get_response(client, response_id)
if not response:
return None
if len(response) > 1:
log.error("Looking for exactly one result but server gave {}. "
"Taking the first and ignoring the rest."
.format(len(response)))
group = make_group_dict(client, response[0])
return group
def get_all_groups(client):
log.info("Searching all the groups")
dn = 'ou=Groups,{}'.format(client.base_dn)
log.debug("Search dn: {}".format(dn))
response_id = client.connection.search(
dn, '(objectclass=groupOfNames)',
search_scope='SUBTREE',
attributes=[ALL_ATTRIBUTES]
)
response = get_response(client, response_id)
groups = [make_group_dict(client, entry) for entry in response]
return groups
def add_group_member(client, group, user):
group_dn = group['dn']
member_dn = user['dn']
log.debug('Found adding {} to {}'.format(member_dn, group_dn))
response_id = client.connection.modify(
group_dn,
{'member': [(MODIFY_ADD, [member_dn])]}
)
return get_response(client, response_id)

77
src/phi/ldap/user.py Normal file
View File

@ -0,0 +1,77 @@
from ldap3 import ALL_ATTRIBUTES, HASHED_SALTED_SHA
from ldap3.utils.hashed import hashed
from phi.ldap.utils import get_response, make_user_dict, add_entry, delete_entry
from phi.logging import get_logger
log = get_logger(__name__)
def get_user_by_uid(client, uid):
log.info("Searching entry with identifier: {}".format(uid))
filter_ = "({}={})".format('uid', uid)
log.debug("Search filter: {}".format(filter_))
response_id = client.connection.search(
client.base_dn, filter_,
search_scope='SUBTREE',
attributes=[ALL_ATTRIBUTES]
)
response = get_response(client, response_id)
if not response:
return None
if len(response) > 1:
log.error("Looking for exactly one result but server gave {}. "
"Taking the first and ignoring the rest."
.format(len(response)))
return make_user_dict(client, response[0])
def get_all_users(client):
log.info("Searching all the users")
dn = 'ou=Hackers,{}'.format(client.base_dn)
log.debug("Search dn: {}".format(dn))
response_id = client.connection.search(
dn, '(objectclass=person)',
search_scope='SUBTREE',
attributes=[ALL_ATTRIBUTES]
)
response = get_response(client, response_id)
users = [make_user_dict(client, entry) for entry in response]
return users
def add_user(client, uid, cn, sn, mail, password):
dn = 'uid={},ou=Hackers,{}'.format(uid, client.base_dn)
hashed_password = hashed(HASHED_SALTED_SHA, password)
attributes={
'objectClass': [
'inetOrgPerson',
'organizationalPerson',
'person', 'top'
],
'cn': cn,
'sn': sn,
'mail': mail,
'userPassword': hashed_password
}
add_entry(client, dn, attributes)
def delete_user(client, user):
delete_entry(client, user['dn'])
def delete_user_by_uid(client, uid):
dn = 'uid={},ou=Hackers,{}'.format(uid, client.base_dn)
delete_entry(client, dn)

70
src/phi/ldap/utils.py Normal file
View File

@ -0,0 +1,70 @@
import re
from phi.logging import get_logger
log = get_logger(__name__)
def make_user_dict(client, entry):
attributes = entry['attributes']
user = {}
user['uid'] = attributes['uid'][0]
user['dn'] = 'uid={},ou=Hackers,{}'.format(user['uid'], client.base_dn)
user['cn'] = attributes['cn'][0]
user['sn'] = attributes['sn'][0]
user['mail'] = attributes['mail'][0]
user['password'] = attributes['userPassword'][0]
return user
def get_uid_from_dn(client, dn):
uid = re.search('uid=(.+?),ou=Hackers,{}'.format(client.base_dn),
dn).group(1)
return uid
def make_group_dict(client, entry):
attributes = entry['attributes']
cn = attributes['cn'][0]
dn = 'cn={},ou=Groups,{}'.format(cn, client.base_dn)
members = [get_uid_from_dn(client, u_dn)
for u_dn in attributes['member']]
group = {}
group['dn'] = dn
group['cn'] = cn
group['members'] = members
return group
def get_response(client, response_id):
response, result, request = client.connection.get_response(
response_id, get_request=True
)
log.debug("Request: {}".format(request))
log.debug("Response: {}".format(response))
log.debug("Result: {}".format(result))
if result['description'] is not 'success':
raise Exception(result['description'])
return response
def add_entry(client, dn, attributes):
log.info('Adding entry with distinguiscet name: {}'
'and attributes {}'.format(dn, attributes))
response_id = client.connection.add(dn, attributes=attributes)
response = get_response(client, response_id)
return response
def delete_entry(client, dn):
log.info('Deleting entry with distinguiscet name: {}')
response_id = client.connection.delete(dn)
response = get_response(client, response_id)
return response

View File

@ -1,29 +0,0 @@
# -*- encoding: utf-8 -*-
from bonsai.ldapvaluelist import LDAPValueList
from passlib.hash import (
ldap_sha1,
ldap_bcrypt,
ldap_sha256_crypt,
ldap_sha512_crypt,
ldap_pbkdf2_sha256,
ldap_pbkdf2_sha512,
)
HASH_CALLABLE = {
"sha1": ldap_sha1.hash,
"bcrypt": ldap_bcrypt.hash,
"sha256_crypt": ldap_sha256_crypt.hash,
"sha512_crypt": ldap_sha512_crypt.hash,
"pbkdf2_sha256": ldap_pbkdf2_sha256.hash,
"pbkdf2_sha512": ldap_pbkdf2_sha512.hash,
}
def hash_pass(password, method="sha1"):
return HASH_CALLABLE[method](password)
def handle_password(password, method="sha1"):
if isinstance(password, LDAPValueList):
return password
return hash_pass(password, method)

View File

@ -1,213 +0,0 @@
# -*- encoding: utf-8 -*-
from aiohttp import web
from aiohttp_session import setup
from aiohttp_session.cookie_storage import EncryptedCookieStorage
import click
from pprint import pformat as pp
import yaml
from phi.config import get_config, merge_config, extract_secret
from phi.logging import setup_logging, get_logger
from phi.api.app import api_app
from phi.web.client_store import ClientStore
from phi.web.login import login
log = get_logger(__name__)
LOGIN_ROUTE = "/login"
COOKIE_NAME = "PHI_COOKIE"
def setup_app(config):
app = web.Application()
setup(app, EncryptedCookieStorage(extract_secret(config), cookie_name=COOKIE_NAME))
app["config"] = config
app["store"] = ClientStore(LOGIN_ROUTE)
app["log"] = log
app.add_routes([web.post(LOGIN_ROUTE, login)])
api = api_app(app["store"])
app.add_subapp("/api", api)
return app
def run_app(app):
web.run_app(
app,
host=app["config"]["core"]["listen"].get("host", "127.0.0.1"),
port=app["config"]["core"]["listen"].get("port", "8080"),
)
@click.command(help="phid is the main application daemon.")
@click.option(
"--config",
"config_path",
type=click.Path(exists=True),
help="Path to a valid config file.",
)
@click.option(
"-H",
"--host",
type=click.STRING,
default="localhost",
help='Address to which the application bounds. Defaults to "localhost".',
)
@click.option(
"-p",
"--port",
type=click.INT,
default=8080,
help="Port to which the application bounds. Defaults to 8080.",
)
@click.option(
"--ldap-host",
"ldap_host",
type=click.STRING,
default="localhost",
help='Address of the LDAP server to connect to. Defaults to "localhost".',
)
@click.option(
"--ldap-port",
"ldap_port",
type=click.INT,
default=389,
help="Port where is exposed the LDAP server to connect to. Defaults to 389.",
)
@click.option(
"--ldap-crypt",
"ldap_crypt",
is_flag=True,
default=True,
help="Connect to the LDAP server using TLSv1.2. Defaults to True.",
)
@click.option(
"--ldap-tls-do-not-validate",
"ldap_tls_validate",
is_flag=True,
default=True,
help="Toggle checking of TLS cert against the provided name. Defaults to True.",
)
@click.option(
"--ldap-tls-ca",
"ldap_tls_ca",
type=click.Path(exists=True),
help="Toggle checking of TLS cert against the provided name. Defaults to True.",
)
@click.option(
"--ldap-base-dn", "ldap_base_dn", type=click.STRING, help="The LDAP base_dn to use."
)
@click.option(
"--ldap-username",
"ldap_username",
type=click.STRING,
help="The username to use to connect to the LDAP server.",
)
@click.option(
"--ldap-password",
"ldap_password",
type=click.STRING,
help="The password to use to connect to the LDAP server. "
"THIS CAN BE READ BY OTHER PROCESSES. NEVER USE IN PRODUCTION!",
)
@click.option(
"--log-conf",
"log_conf",
type=click.Path(exists=True),
help="Path to a yaml configuration for the logger.",
)
@click.option(
"--debug",
"debug",
is_flag=True,
default=False,
help="Set the log level to debug.",
)
def cli(
host,
port,
config_path=None,
ldap_host=None,
ldap_port=None,
ldap_crypt=True,
ldap_tls_validate=True,
ldap_tls_ca=None,
ldap_base_dn=None,
ldap_username=None,
ldap_password=None,
log_conf=None,
debug=False,
):
cli_config = prepare_config_from_cli(
host,
port,
ldap_host,
ldap_port,
ldap_crypt,
ldap_tls_validate,
ldap_tls_ca,
ldap_base_dn,
ldap_username,
ldap_password,
log_conf,
debug,
)
config_file, file_config = get_config(config_path)
log.debug(f"FILE: {pp(file_config)}")
config = merge_config(cli_config, file_config)
if debug:
set_to_debug(config)
# Beware that everything happened until now
# could not possibly get logged.
setup_logging(config.get("logging", {}))
if config_file:
log.debug("Config file found at: %s", config_file)
log.debug("{}".format(pp(file_config)))
log.debug("CLI config:\n{}".format(pp(cli_config)))
log.info("Starting app with config:\n{}".format(pp(config)))
app = setup_app(config)
run_app(app)
def prepare_config_from_cli(
host,
port,
ldap_host=None,
ldap_port=None,
ldap_crypt=True,
ldap_tls_validate=True,
ldap_tls_ca=None,
ldap_base_dn=None,
ldap_username=None,
ldap_password=None,
log_conf=None,
debug=False,
):
_core = {"listen": {"host": host, "port": port}}
_ldap = {
"host": ldap_host,
"port": ldap_port,
"encryption": "TLSv1.2" if ldap_crypt else None,
"validate": ldap_tls_validate,
"ca_certs": ldap_tls_ca,
"username": ldap_username,
"password": ldap_password,
"base_dn": ldap_base_dn,
}
_logging = {}
if log_conf:
with open(log_conf) as log_conf_fd:
_logging = yaml.safe_load(log_conf_fd)
return {"core": _core, "ldap": _ldap, "logging": _logging}
def set_to_debug(conf):
for logger, log_conf in conf["logging"]["loggers"].items():
log_conf["level"] = "DEBUG"
conf["logging"]["loggers"][logger] = log_conf

View File

@ -1,23 +0,0 @@
# -*- encoding: utf-8 -*-
from aiohttp.web import middleware, HTTPFound
from phi.web import app
from phi.async_ldap.model import Hackers, Robots, Roles
@middleware
async def authenticated(request, handler):
try:
store = request.app["store"]
except KeyError:
raise HTTPFound(app.LOGIN_ROUTE)
client = await store.get_client(request)
request.app["ldap_client"] = client
request.app["users"] = Hackers(client)
request.app["services"] = Robots(client)
request.app["groups"] = Roles(client)
resp = await handler(request)
return resp

View File

@ -1,35 +0,0 @@
# -*- encoding: utf-8 -*-
import secrets
from aiohttp.web import HTTPFound
from aiohttp_session import get_session
class ClientStore(dict):
"""
This class is responsible to hold the clients used by the active connections.
"""
def __init__(self, login_route):
self.login_route = login_route
self.store = dict()
async def get_client(self, request):
session = await get_session(request)
client_id = session.get("client_id")
if client_id is None:
raise HTTPFound(self.login_route)
client = self.store.get(client_id)
if client is None:
raise HTTPFound(self.login_route)
return client
async def set_client(self, request, client):
session = await get_session(request)
client_id = secrets.token_hex(16)
self.store[client_id] = client
session["client_id"] = client_id

View File

@ -1,38 +0,0 @@
# -*- encoding: utf-8 -*-
from aiohttp.web import HTTPBadRequest, HTTPOk
from phi.async_ldap.client import AsyncClient
async def login(request):
log = request.app["log"]
log.debug("login")
store = request.app["store"]
config = request.app["config"]
body = await request.json()
tag = body.get("tag", "uid")
ou = body.get("ou")
if ou is not None:
config["ldap"]["ou"] = ou
try:
user = body["user"]
password = body["password"]
except KeyError as e:
text = f"Missing key: {e}"
log.warn(text)
raise HTTPBadRequest(text=text)
client = AsyncClient(
attribute_id=tag, username=user, password=password, **config.get("ldap")
)
log.debug(f"Client: {client}")
await store.set_client(request, client)
raise HTTPOk

130
src/phicli Executable file
View File

@ -0,0 +1,130 @@
#!/usr/bin/env python3
from pprint import pformat as pp
from getpass import getpass
from phi.config import get_config
from phi.logging import setup_logging, get_logger
from phi import cli
import phi.ldap.client
from phi.ldap.user import get_user_by_uid, add_user, delete_user
from phi.ldap.group import get_group_by_cn, get_all_groups, add_group_member
log = get_logger(__name__)
@cli.register('dispaly user fields', ['user identifier'])
def showuser(uid):
user = get_user_by_uid(client, uid)
if user is None:
print('User {} not found'.format(uid))
return
print(pp(user))
@cli.register('add a new user', ['user identifier'])
def adduser(uid):
def ask(prompt, default):
full_prompt = '{} [{}] '.format(prompt, default)
return input(full_prompt) or default
user = get_user_by_uid(client, uid)
if user is not None:
print("User {} already existing".format(uid))
return
cn = ask('Common name:', uid)
sn = ask('Last name:', uid)
mail = ask('Mail:', '{}@localhost'.format(uid))
password = getpass()
pass_check = getpass('Retype password: ')
if password != pass_check:
print('Password not matching')
return
add_user(client, uid, cn, sn, mail, password)
# Check
user = get_user_by_uid(client, uid)
print()
print(pp(user))
@cli.register('delete an user', ['user identifier'])
def deluser(uid):
check = input('Are you sure? [y/N] ') or 'N'
if check.lower() != 'y':
print('Ok then')
return
user = get_user_by_uid(client, uid)
if user is not None:
delete_user(client, user)
print('Done')
else:
print('User {} not found'.format(uid))
@cli.register('show a group', ['group common name'])
def showgroup(cn):
group = get_group_by_cn(client, cn)
if group is None:
print('Group {} not found'.format(gcn))
return
print(pp(group))
@cli.register('list all groups')
def listgroups():
groups = get_all_groups(client)
for group in groups:
print(group['cn'])
@cli.register('add an user to a group',
['user identifier', 'group common name'])
def addtogroup(uid, gcn):
user = get_user_by_uid(client, uid)
group = get_group_by_cn(client, gcn)
if user is None:
print('User {} not found'.format(uid))
return
if group is None:
print('Group {} not found'.format(gcn))
return
if uid in group['members']:
print('User {} is already in group {}'.format(uid, gcn))
return
add_group_member(client, group, user)
if __name__ == '__main__':
cli.add_arg('--config', 'config.yml', 'custom configuration file')
args = cli.get_args()
config_file = args['config']
config_file, config = get_config(config_file)
setup_logging(config.get('logging', {}))
log.info("Using configuration at '{}':\n{}"
.format(config_file, pp(config)))
# TODO: check fields in config
client = phi.ldap.client.Client(**config['ldap'])
log.info('Opening LDAP client')
client.open()
log.info('Arguments: {}'.format(pp(args)))
cli.run(args)
log.info('Closing LDAP client')
client.close()

22
src/phid Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python3
from pprint import pformat as pp
from phi.config import get_config
from phi.logging import setup_logging, get_logger
from phi.app import setup_app, run_app
log = get_logger(__name__)
if __name__ == '__main__':
config_file, config = get_config()
# Beware that everything happened until now
# could not possibly get logged.
setup_logging(config.get('logging', {}))
log.info("Found configuration at '{}':\n{}"
.format(config_file, pp(config)))
app = setup_app(config)
run_app(app)

View File

@ -1,159 +0,0 @@
# -*- coding: utf-8 -*-
import asyncio
from pprint import pprint as pp
from phi.async_ldap.model import (
Hackers,
User,
Robots,
Service,
Group,
Roles,
)
from phi.async_ldap.mixins import build_heritage
from phi.async_ldap.client import AsyncClient
import phi.exceptions as e
async def dlv(h, cls):
return [el async for el in build_heritage(h, cls)]
cl = AsyncClient(
"ldap://localhost",
port=389,
encryption=True,
# validate=True,
ca_cert="/home/leo/Documents/coding/phi/openldap/cert.pem",
username="root",
password="root",
base_dn="dc=unit,dc=macaomilano,dc=org",
attribute_id="cn",
)
async def get_all_children():
h = Hackers(cl)
r = Robots(cl)
g = Roles(cl)
hackers = await dlv(h, User)
robots = await dlv(r, Service)
groups = await dlv(g, Group)
return (hackers, robots, groups)
async def get_members(group):
return [el async for el in group]
async def print_async(label, awaitable):
print(label)
result = await awaitable
pp(result)
async def describe(obj):
return await obj.describe()
async def _await(awaitable):
return await awaitable
def sync_await(awaitable):
return asyncio.run(_await(awaitable))
h = Hackers(cl)
r = Robots(cl)
c = Roles(cl)
# asyncio.run(print_async("hackers:", describe(h)))
# asyncio.run(print_async("conte_mascetti:", describe(User(cl, "conte_mascetti"))))
# asyncio.run(print_async("robots:", describe(r)))
# asyncio.run(print_async("phi:", describe(Service(cl, "phi"))))
# asyncio.run(print_async("congregations:", describe(c)))
# asyncio.run(print_async("GitUsers:", describe(Group(cl, "GitUsers"))))
# asyncio.run(print_async("Hackers members:", get_members(h)))
# asyncio.run(print_async("Robots members:", get_members(r)))
# asyncio.run(print_async("Roles members:", get_members(c)))
#
async def add_new(obj, name, **kw):
try:
_new = obj(cl, name, **kw)
await _new.save()
except e.PhiEntryExists as err:
print(f"Failed add: {repr(err)}")
async def safe_search(group, name):
try:
res = await group.search(name)
print("Search result:", res)
return res
except e.PhiEntryDoesNotExist as err:
print(f"Failed search: {repr(err)}")
async def safe_delete(obj, cascade=None):
try:
if cascade:
obj.delete_cascade = cascade
await obj.delete()
except Exception as err:
print(f"Failed delete: {repr(err)}")
async def add_member(group, member):
await group.add_member(member)
async def remove_member(group, member):
await group.remove_member(member)
# asyncio.run(safe_search(h, "pippo"))
# asyncio.run(
# add_new(User, "pippo", cn="Pippo (Goofy)", sn="Pippo", mail="pippo@unit.info")
# )
# asyncio.run(safe_search(h, "pippo"))
# asyncio.run(
# add_new(User, "pippo", cn="Pippo (Goofy)", sn="Pippo", mail="pippo@unit.net")
# )
# asyncio.run(safe_delete(asyncio.run(safe_search(h, "pippo"))))
# asyncio.run(print_async("Hackers members:", get_members(h)))
# asyncio.run(safe_delete(h))
# asyncio.run(print_async("Hackers members:", get_members(h)))
# asyncio.run(safe_delete(h, True))
# asyncio.run(print_async("Hackers members:", get_members(h)))
# asyncio.run(safe_search(r, "phi"))
# asyncio.run(print_async("Robots members:", get_members(r)))
# asyncio.run(add_new(Service, "db", userPassword="lolpassword"))
# asyncio.run(print_async("Robots members:", get_members(r)))
asyncio.run(safe_search(c, "GitUsers"))
asyncio.run(print_async("Roles members:", get_members(c)))
asyncio.run(
add_new(Group, "naughty", member=[User(cl, "conte_mascetti"), User(cl, "necchi")])
)
asyncio.run(print_async("Roles members:", get_members(c)))
asyncio.run(safe_delete(Group(cl, "naughty")))
asyncio.run(print_async("Roles members:", get_members(c)))
asyncio.run(
add_new(Group, "naughty", member=[User(cl, "conte_mascetti"), User(cl, "necchi")])
)
asyncio.run(print_async("Roles members:", get_members(c)))
print("==> HERE <==")
naughty = sync_await(Group(cl, "naughty").sync())
print("NAUGHTY =>>", [m for m in naughty.get_members()])
asyncio.run(add_member(naughty, User(cl, "perozzi")))
print("NAUGHTY =>>", [m for m in naughty.get_members()])
asyncio.run(remove_member(naughty, User(cl, "conte_mascetti")))
print("NAUGHTY =>>", [m for m in naughty.get_members()])

View File

@ -1,6 +1,6 @@
import pytest
import phi.ldap.client
import phi.api.app
# import phi.api.app
@pytest.fixture
@ -9,26 +9,27 @@ def ldap_client():
host='localhost', port=389,
encryption='TLSv1.2', ciphers='HIGH',
validate=False,
username='uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org',
password='phi',
base_dn='dc=unit,dc=macaomilano,dc=org',
attribute_id='uid', attribute_mail='mail')
# username='uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org',
# password='phi',
username='cn=root,dc=unit,dc=macaomilano,dc=org',
password='root',
base_dn='dc=unit,dc=macaomilano,dc=org')
client.open()
yield client
client.close()
@pytest.fixture
def api_app():
return phi.api.app.api_app({
'ldap': {
'host': 'localhost',
'port': 389,
'encryption': 'TLSv1.2',
'ciphers': 'HIGH',
'validate': 'False',
'username': 'uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org',
'password': 'phi',
'base_dn': 'dc=unit,dc=macaomilano,dc=org',
'attribute_id': 'uid',
'attribute_mail': 'mail'}})
# @pytest.fixture
# def api_app():
# return phi.api.app.api_app({
# 'ldap': {
# 'host': 'localhost',
# 'port': 389,
# 'encryption': 'TLSv1.2',
# 'ciphers': 'HIGH',
# 'validate': 'False',
# 'username': 'uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org',
# 'password': 'phi',
# 'base_dn': 'dc=unit,dc=macaomilano,dc=org',
# 'attribute_id': 'uid',
# 'attribute_mail': 'mail'}})

View File

@ -1,15 +1,15 @@
async def test_user_request_not_valid(test_client, api_app):
client = await test_client(api_app)
# async def test_user_request_not_valid(test_client, api_app):
# client = await test_client(api_app)
resp = await client.get('/user')
assert resp.status == 422
resp = None
# resp = await client.get('/user')
# assert resp.status == 422
# resp = None
resp = await client.get('/user/')
assert resp.status == 422
# resp = await client.get('/user/')
# assert resp.status == 422
async def test_user_not_found(test_client, api_app):
client = await test_client(api_app)
resp = await client.get('/user/nonexistent')
assert resp.status == 404
# async def test_user_not_found(test_client, api_app):
# client = await test_client(api_app)
# resp = await client.get('/user/nonexistent')
# assert resp.status == 404

View File

@ -1,4 +1,7 @@
from phi.ldap.user import get_user_by_uid
from phi.ldap.user import get_user_by_uid, get_all_users, \
add_user, delete_user_by_uid, delete_user
from phi.ldap.group import add_group_member, get_group_by_cn, get_all_groups
def test_connection(ldap_client):
@ -7,6 +10,90 @@ def test_connection(ldap_client):
def test_get_user_by_id(ldap_client):
entry = get_user_by_uid(ldap_client, 'conte_mascetti')
assert entry['uid'] == 'conte_mascetti'
assert entry['mail'] == 'rmascetti@autistici.org'
user = get_user_by_uid(ldap_client, 'conte_mascetti')
assert user['uid'] == 'conte_mascetti'
assert user['mail'] == 'rmascetti@autistici.org'
def test_get_all_users(ldap_client):
users = get_all_users(ldap_client)
# print(users)
assert 'conte_mascetti' in [u['uid'] for u in users]
def test_add_delete_user(ldap_client):
uid = 'rosa_rossi'
cn = 'Rosa'
sn = 'Rossi'
mail = 'foo@autistici.org'
password = 'changeme'
add_user(ldap_client, uid, cn, sn, mail, password)
user = get_user_by_uid(ldap_client, uid)
assert user['uid'] == uid
assert user['mail'] == mail
delete_user(ldap_client, user)
# print(user)
user = get_user_by_uid(ldap_client, uid)
assert user is None
def test_failing_add_user(ldap_client):
uid = 'conte_mascetti'
try:
add_user(ldap_client, uid, 'name', 'surname', 'mail', 'pass')
except: # User alrady existing
pass
else:
assert False
def test_failing_delete_user(ldap_client):
uid = 'rosa_rossi'
try:
delete_user_by_uid(ldap_client, uid)
except: # User already not existing
pass
else:
assert False
def test_get_all_groups(ldap_client):
groups = get_all_groups(ldap_client)
cns = [g['cn'] for g in groups]
assert 'WikiUsers' in cns
def test_add_to_group(ldap_client):
client = ldap_client
group_cn = 'WikiUsers'
member_uid = 'rosa_rossi'
add_user(client, member_uid, 'name', 'surname', 'mail', 'pass')
user = get_user_by_uid(client, member_uid)
# print(user)
group = get_group_by_cn(client, group_cn)
group_members = group['members']
assert len(group_members) == 1
# print(group_members)
add_group_member(client, group, user)
group = get_group_by_cn(client, group_cn)
group_members = group['members']
assert len(group_members) == 2
assert user['uid'] in group_members
# print(group_members)
# print(user)
delete_user(client, user)