Compare commits

...

83 Commits

Author SHA1 Message Date
b376d1f270
Add phiadm 2022-02-03 22:20:28 +01:00
fa7b633a16
Update click 2022-02-03 21:31:10 +01:00
d51acc6999
Add jinja2 2022-02-03 15:23:16 +01:00
d414634f42
Change cookie name 2022-02-03 15:18:51 +01:00
a222fe9560
Fix: missing __init__.py in phi.web 2022-02-03 13:04:04 +01:00
7224cd73f2 Merge pull request 'Aggiungere il middleware di autenticazione' (#8) from feat/auth_middleware_and_state into develop
Reviewed-on: #8
2022-02-03 13:00:34 +01:00
9fdef6f2e5
Refactor web-related modules in subpackage 2022-02-03 12:59:52 +01:00
593255e853
Rename Congregations to more meaningful Roles 2022-02-03 12:16:42 +01:00
ef08fe5997
Add session store and login endpoint and auth middleware 2022-02-03 00:46:45 +01:00
714f3221f9
Add aiohttp_session 2022-02-03 00:32:26 +01:00
7201d9a529
Complete CRUD UserView 2022-02-01 23:30:49 +01:00
297b31ab90
Avoid unhandled exceptions 2022-02-01 23:30:26 +01:00
b704ea4d10
Clean unused code and improve logging 2022-02-01 23:29:48 +01:00
b0d2f759ea
Remove ldap3 from dependencies 2022-02-01 23:27:19 +01:00
d6aac65bba
fixup! Minor fixes 2022-01-26 16:20:30 +01:00
c0d835d8d1
fixup! Throw away previous async model implementation 2022-01-26 16:17:45 +01:00
2932957afb
Dependency update 2022-01-25 23:51:10 +01:00
077c53c5be
Minor fixes 2022-01-25 23:46:45 +01:00
e6cee09429
Throw away previous async model implementation 2022-01-25 23:46:24 +01:00
c5a4b86349
Tons of new tests and new_model refinement 2020-12-27 19:48:57 +01:00
b0e5d00994
Update dev-related files 2020-12-27 19:48:25 +01:00
401f01f110
Minor fix 2020-12-27 19:48:02 +01:00
30c46c059a
Rename test module 2020-12-27 19:47:28 +01:00
b400127cc6
Fix bugs (thanks to integration tests) 2020-11-30 19:16:36 +01:00
6bd1beba9e
Add some integration tests 2020-11-30 19:16:10 +01:00
e202d54a7d
Add pytest-integration 2020-11-27 19:42:00 +01:00
67c7830ba4
Add and improve new_model unit tests 2020-11-27 19:41:45 +01:00
95bc52ebd8
Add docs and clarify 2020-11-27 19:41:09 +01:00
ac0ab02d6e
Massive code update 2020-11-11 22:25:02 +01:00
d024232d66
Add logs target in openldap Makefile 2020-11-11 22:24:11 +01:00
166b542846
Set 88 chars per line 2020-11-11 22:23:30 +01:00
d87c89a075
Update Pipfile 2020-11-11 22:23:10 +01:00
b7316ff513
Refactor helper script 2020-08-31 11:02:03 +02:00
67c83975d1
Switch to mixins to build async_ldap model classes 2020-08-31 11:01:46 +02:00
205c87dc49
Update setup.py and Pipfile 2020-08-31 10:11:20 +02:00
c05b023bdb
Use async model in api 2020-08-29 20:15:02 +02:00
1b97d6d7ca
Refactor describe func in model 2020-08-29 20:14:20 +02:00
b979002f78
Black'd 2020-08-29 20:13:14 +02:00
e41e03f464
Move singleton logic in function 2020-08-29 20:10:56 +02:00
d9a6db63d7
Add description 2020-08-29 20:07:56 +02:00
6fea75022f
Refactor config logic for bools
If a config paramenter is a boolean, the resulting configuration is the
logic and of all those given (including defaults). BEWARE in future
config implementations!
2020-08-29 20:06:08 +02:00
12f37b3a55
Move custom exceptions in own module 2020-08-29 20:05:15 +02:00
a0cf7e9603
Silence mypy 2020-08-29 20:03:59 +02:00
8779db9ca0
Stub methods in async client 2020-08-29 19:42:24 +02:00
e454fbd84a
Add sync method and method stubs to Service 2020-08-29 19:41:34 +02:00
66885641c4
Add test and stub test 2020-08-29 19:40:06 +02:00
a22f459915
Clean harder 2020-08-23 21:32:20 +02:00
08c45b54f2 Do not verify cert in dev container 2020-08-23 21:31:23 +02:00
2bc7f0b75f
Improve error verification in async_model tests. 2019-07-19 14:29:46 +02:00
d9e8eb23e3
Tests for Service create method. 2019-07-18 18:15:43 +02:00
ce4c085e3f
User modify_password and verify_password. Failing Service tests. 2019-07-13 16:13:50 +02:00
7634f0f530
Better logging style and improved tests. 2019-07-13 11:40:35 +02:00
79f682cbb7
Password now hashed in app. 2019-07-13 11:39:06 +02:00
2b46eb0353
Renamed exceptions. 2019-07-13 11:35:03 +02:00
fed48022af
Improve tests and added tests for sync, modify and remove. 2019-07-06 21:27:00 +02:00
3454c194b1
Test for create_new_. 2019-07-06 21:26:21 +02:00
f05fe8a0d5
Improve User remove. 2019-07-06 21:25:23 +02:00
ec472218c4
Manage append and replace User modify. 2019-07-06 21:25:01 +02:00
79d7dcc653
Improve User sync. 2019-07-06 21:16:06 +02:00
fe3450b886
Tests for name property. 2019-07-01 22:48:04 +02:00
f558492975
Improved singletonic models. 2019-07-01 22:37:56 +02:00
706f109faf
Create, sync, modify and remove User. 2019-06-30 21:26:27 +02:00
0f7882a387
Search user by attr and by uid. 2019-06-30 21:24:55 +02:00
1be1aac9d0
Hacker, Robots, Congregations now singletons. 2019-06-30 21:23:11 +02:00
7e6b757e3a
Style. 2019-06-30 21:19:04 +02:00
ed8af40392
Tests on async part moved. 2019-06-30 21:16:48 +02:00
2cf07d6732
Moved ldap async modules to dedicated subpkg. 2019-05-04 17:59:21 +02:00
8dce6566ee
Tests for full coverage of async_model. 2019-05-01 15:34:52 +02:00
b466bf8ed2
Leafs are singletons, bound to the client object. 2019-05-01 15:34:46 +02:00
d0cba75ee0
Entry and OU are async iterators that do not deplete. 2019-05-01 15:34:41 +02:00
fd729170d3
Entry and OU shall not be singletons. 2019-05-01 15:34:36 +02:00
c4046a83ff
Adding auxiliary test module. 2019-05-01 15:34:31 +02:00
4e2cadaa92
Extending model. 2019-05-01 15:34:26 +02:00
b1837f80e4
Adding AsyncClient. 2019-05-01 15:34:21 +02:00
27fc927254
Make the init.ldif more sound to the real use case. 2019-04-28 12:31:52 +02:00
a434ff9b4c
Adding bonsai==1.1.0 2019-04-28 12:31:19 +02:00
422d238fc1
Ignore cpy* venv. 2019-04-28 12:30:17 +02:00
b0f312284d
Change cli bool flag and fix ldap connection. 2019-04-28 12:26:20 +02:00
543416368b
Updating Pipfile and lock. 2019-04-19 16:58:44 +02:00
bdd4a39531
Updating test dependencies. 2019-04-19 16:58:28 +02:00
e67b97b214
Added async_model and tests. 2019-04-19 16:57:54 +02:00
c8eb5c2dd4
Typo. 2019-04-15 20:12:51 +02:00
80fb51f7de
Refactor entrypoint to use click. 2019-04-15 18:27:22 +02:00
47 changed files with 4086 additions and 405 deletions

3
.gitignore vendored
View File

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

25
Pipfile
View File

@ -4,9 +4,30 @@ url = "https://pypi.org/simple"
verify_ssl = true verify_ssl = true
[dev-packages] [dev-packages]
ipython = "*"
pytest = "*"
ipdb = "*"
async-generator = "*"
pytest-cov = "*"
pytest-asyncio = "*"
pytest-aiohttp = "*"
mock = "*"
yarl = {editable = true, path = "."}
pytest-integration = "*"
iniconfig = "*"
[packages] [packages]
phi = {editable = true, path = "."} 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"
[requires] [pipenv]
python_version = "3.7" allow_prereleases = true

1637
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -8,4 +8,4 @@ APIs for the Unit hacklab.
Requirements: Requirements:
* Python >= 3.5 * Python >= 3.9

View File

@ -0,0 +1,117 @@
# -*- 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

@ -0,0 +1,196 @@
# -*- 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,6 +3,8 @@ core:
listen: listen:
host: 127.0.0.1 host: 127.0.0.1
port: 8080 port: 8080
# generated with: openssl rand -hex 16
cookiestore_secret: "e41133b5cfdd8660815b8d5cc2c74843"
ldap: ldap:
@ -11,15 +13,15 @@ ldap:
encryption: TLSv1.2 # Can either be None or TLSv1.2. Default: None encryption: TLSv1.2 # Can either be None or TLSv1.2. Default: None
ciphers: "HIGH" ciphers: "HIGH"
validate: True # Can either be True or False. Default: False validate: False # Can either be True or False. Default: False
ca_certs: openldap/cert.pem ca_certs: openldap/cert.pem
username: uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org # username: uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org
password: phi # password: phi
# username: cn=root,dc=unit,dc=macaomilano,dc=org
# password: root
base_dn: dc=unit,dc=macaomilano,dc=org base_dn: dc=unit,dc=macaomilano,dc=org
attribute_id: uid
attribute_mail: mail
logging: logging:
@ -42,9 +44,14 @@ logging:
phi: phi:
level: DEBUG level: DEBUG
handlers: [console, file] handlers: [console, file]
asyncio:
level: DEBUG
handlers: [console, file]
propagate: yes
aiohttp: aiohttp:
level: DEBUG level: DEBUG
handlers: [console, file] handlers: [console, file]
ldap3: propagate: yes
level: WARNING bonsai:
level: DEBUG
handlers: [console, file] handlers: [console, file]

View File

@ -0,0 +1,361 @@
# -*- 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,5 +1,7 @@
FROM alpine:3.7 FROM alpine:3.7
ENV LDAPTLS_REQCERT=never
RUN apk add --no-cache \ RUN apk add --no-cache \
openldap \ openldap \
openldap-back-mdb \ openldap-back-mdb \

View File

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

View File

@ -11,30 +11,93 @@ objectClass: organizationalUnit
objectClass: top objectClass: top
ou: Hackers ou: Hackers
dn: ou=Services,dc=unit,dc=macaomilano,dc=org dn: ou=Robots,dc=unit,dc=macaomilano,dc=org
objectClass: top objectClass: top
objectClass: organizationalUnit objectClass: organizationalUnit
ou: Services ou: Robots
dn: ou=Groups,dc=unit,dc=macaomilano,dc=org dn: ou=Roles,dc=unit,dc=macaomilano,dc=org
objectClass: top objectClass: top
objectClass: organizationalUnit objectClass: organizationalUnit
ou: Groups ou: Roles
dn: uid=phi,ou=Services,dc=unit,dc=macaomilano,dc=org dn: uid=phi,ou=Robots,dc=unit,dc=macaomilano,dc=org
objectClass: account objectClass: account
objectClass: simpleSecurityObject objectClass: simpleSecurityObject
objectClass: top objectClass: top
uid: phi uid: phi
userPassword: {SHA}REu9CtcqSaA1c5J+sEYlTgg0H+M= 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 dn: uid=conte_mascetti,ou=Hackers,dc=unit,dc=macaomilano,dc=org
objectClass: inetOrgPerson objectClass: inetOrgPerson
objectClass: organizationalPerson objectClass: organizationalPerson
objectClass: person objectClass: person
objectClass: top 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 cn: Raffaello
sn: Mascetti sn: Mascetti
mail: rmascetti@autistici.org mail: rmascetti@autistici.org
uid: conte_mascetti uid: conte_mascetti
userPassword: {SHA}oLY7P6V+DWaMJhix7vbMYGIfA+E= 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
objectClass: groupOfNames
objectClass: top
dn: cn=GitUsers,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
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

3
pyproject.toml Normal file
View File

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

View File

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

View File

@ -1,22 +1,29 @@
from setuptools import setup from setuptools import setup, find_packages
setup( setup(
name='phi', name="phi",
version='0.0.1', version="0.0.1",
description="Post-Human Interface",
description='Post-Human Interface',
# license='', # license='',
url='https://git.abbiamoundominio.org/unit/phi', url="https://git.abbiamoundominio.org/unit/phi",
author="unit",
author='unit', author_email="unit@paranoici.org",
author_email='unit@paranoici.org', package_dir={"": "src"},
packages=find_packages("src"),
package_dir={'': 'src'}, entry_points={
packages=['phi', 'phi.api', 'phi.ldap'], "console_scripts": ["phid=phi.web.app:cli", "phiadm=phi.cli.adm:cli"]
scripts=['src/phid'], },
setup_requires=["pytest-runner"],
setup_requires=['pytest-runner'], install_requires=[
install_requires=['aiohttp==2.3.8', 'pyYAML', 'ldap3'], "aiohttp==3.8.1",
tests_require=['pytest', 'pytest-aiohttp'] "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",
) )

View File

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

View File

@ -1,24 +1,131 @@
from aiohttp.web import json_response, View from aiohttp.web import json_response, View
from aiohttp.web import HTTPNotFound, HTTPUnprocessableEntity from aiohttp.web import (
HTTPNotFound,
HTTPUnprocessableEntity,
HTTPServerError,
HTTPNoContent,
HTTPBadRequest,
HTTPCreated,
)
from phi.logging import get_logger from phi.logging import get_logger
from phi.ldap.user import get_user_by_uid # from phi.api.utils import serialize
from phi.api.utils import serialize from phi.async_ldap.model import User
from phi.exceptions import (
PhiEntryDoesNotExist,
PhiUnexpectedRuntimeValue,
PhiCannotExecute,
)
log = get_logger(__name__) log = get_logger(__name__)
class User(View): 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
async def get(self): async def get(self):
uid = self.request.match_info.get('uid', None) log = self.get_log()
log.debug(f"{self.__class__.__name__}.get")
uid = self.get_uid()
if uid is None: user = await self.find_user(uid)
return HTTPUnprocessableEntity()
client = self.request.app['ldap_client'] result = await user.describe()
user = get_user_by_uid(client, uid) log.debug("Returning result %s", result)
if not user: return json_response(result)
return HTTPNotFound()
return json_response(serialize(user)) 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

View File

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

View File

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

View File

@ -1,22 +0,0 @@
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

@ -0,0 +1,135 @@
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

@ -0,0 +1,344 @@
# -*- 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

140
src/phi/async_ldap/model.py Normal file
View File

@ -0,0 +1,140 @@
# -*- 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

0
src/phi/cli/__init__.py Normal file
View File

119
src/phi/cli/adm.py Normal file
View File

@ -0,0 +1,119 @@
# -*- 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

@ -0,0 +1,23 @@
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

@ -0,0 +1,17 @@
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

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

View File

@ -0,0 +1,14 @@
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

View File

@ -0,0 +1,87 @@
#######################################################################
# 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

25
src/phi/cli/utils.py Normal file
View File

@ -0,0 +1,25 @@
# -*- 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,27 +1,80 @@
import os.path import os.path
import pkg_resources
import yaml import yaml
NAME = "phi"
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"],
},
},
},
}
CONFIG_FILE = 'config.yml' DUMMY_CONFIG = {"core": {}, "ldap": {}, "logging": {}}
CONFIG_PATHS = ['./',
'~/.config/' + NAME + '/', CONFIG_FILE = "config.yml"
'/usr/local/etc/' + NAME + '/', CONFIG_PATHS = [
'/etc/' + NAME + '/'] "./",
CONFIG_FILES = [os.path.join(p, CONFIG_FILE) "~/.config/" + NAME + "/",
for p in CONFIG_PATHS] "/usr/local/etc/" + NAME + "/",
"/etc/" + NAME + "/",
]
CONFIG_FILES = [os.path.join(p, CONFIG_FILE) for p in CONFIG_PATHS]
def get_config(): def get_config(config_path=None):
"""Return the path of the found configuration file and its content """Return the path of the found configuration file and its content
:param config_path: optional path to a config file.
:returns: (path, config) :returns: (path, config)
:rtype: (str, dict) :rtype: (str, dict)
""" """
if config_path:
with open(config_path) as c:
config = yaml.safe_load(c)
return config_path, config
for f in CONFIG_FILES: for f in CONFIG_FILES:
try: try:
with open(f, 'r') as c: with open(f, "r") as c:
config = yaml.safe_load(c) config = yaml.safe_load(c)
return (f, config) return (f, config)
except FileNotFoundError: except FileNotFoundError:
@ -30,6 +83,62 @@ def get_config():
# accessible or if the file is not present at all # accessible or if the file is not present at all
# in any of CONFIG_PATHS. # in any of CONFIG_PATHS.
pass 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: else:
raise FileNotFoundError("Could not find {} in any of {}." if main is not None:
.format(CONFIG_FILE, ', '.join(CONFIG_PATHS))) _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")

54
src/phi/exceptions.py Normal file
View File

@ -0,0 +1,54 @@
# -*- 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})"

View File

@ -1,59 +0,0 @@
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,
attribute_id='uid', attribute_mail='mail'):
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.attribute_id = attribute_id
self.attribute_mail = attribute_mail
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

@ -1,59 +0,0 @@
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()

View File

@ -1,36 +0,0 @@
from ldap3 import ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES
from phi.logging import get_logger
log = get_logger(__name__)
def get_entry_by_uid(client, uid):
log.info("Searching entry with identifier: {}".format(uid))
filter_ = "({}={})".format(client.attribute_id, uid)
log.debug("Search filter: {}".format(filter_))
response_id = client.connection.search(
client.base_dn, filter_,
search_scope='SUBTREE',
attributes=[ALL_ATTRIBUTES, ALL_OPERATIONAL_ATTRIBUTES]
)
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 not response:
return None
if response[1:]:
log.erorr("Looking for exactly one result but server gave {}. "
"Taking the first and ignoring the rest."
.format(len(response)))
return response[0]

View File

@ -1,26 +0,0 @@
from phi.ldap.entry import get_entry_by_uid
from phi.ldap.utils import flatten_attributes
def user_attributes_mapping(client):
return {
client.attribute_id: 'uid',
client.attribute_mail: 'mail',
'createTimestamp': 'created_at',
'modifyTimestamp': 'modified_at'
}
def get_user_by_uid(client, uid):
entry = get_entry_by_uid(client, uid)
if not entry:
return None
mapping = user_attributes_mapping(client)
user = {mapping[k]: v
for k, v in entry['attributes'].items()
if k in mapping.keys()}
return flatten_attributes(user)

View File

@ -1,3 +0,0 @@
def flatten_attributes(d):
return {k: (v[0] if isinstance(v, list) else v)
for k, v in d.items()}

29
src/phi/security.py Normal file
View File

@ -0,0 +1,29 @@
# -*- 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)

0
src/phi/web/__init__.py Normal file
View File

213
src/phi/web/app.py Normal file
View File

@ -0,0 +1,213 @@
# -*- 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

@ -0,0 +1,23 @@
# -*- 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

@ -0,0 +1,35 @@
# -*- 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

38
src/phi/web/login.py Normal file
View File

@ -0,0 +1,38 @@
# -*- 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

View File

@ -1,22 +0,0 @@
#!/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)

159
test/aux_async_model.py Normal file
View File

@ -0,0 +1,159 @@
# -*- 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()])