Skip to content

Commit 4051d24

Browse files
authored
Merge pull request #105 from pvarki/keycloak
Initial KeyCloak integration
2 parents bf55dff + 88864b7 commit 4051d24

36 files changed

+2284
-1417
lines changed

.bumpversion.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[bumpversion]
2-
current_version = 1.4.0
2+
current_version = 1.5.0
33
commit = False
44
tag = False
55

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
# IDE settings
22
.idea
33

4+
*.ipynb
5+
6+
47
# ci artefacts
58
pytest*.xml
69

docker/container-init.sh

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
set -e
33
# Make sure fakeproduct api endpoint points to correct IP, 127.0.01 is this containers localhost...
44
sed 's/.*localmaeher.*//g' /etc/hosts >/etc/hosts.new && cat /etc/hosts.new >/etc/hosts
5-
echo "$(getent hosts host.docker.internal | awk '{ print $1 }') fake.localmaeher.pvarki.fi" >>/etc/hosts
6-
echo "$(getent hosts host.docker.internal | awk '{ print $1 }') tak.localmaeher.pvarki.fi" >>/etc/hosts
5+
echo "$(getent ahostsv4 host.docker.internal | awk '{ print $1 }') fake.localmaeher.dev.pvarki.fi" >>/etc/hosts
6+
echo "$(getent ahostsv4 host.docker.internal | awk '{ print $1 }') tak.localmaeher.dev.pvarki.fi" >>/etc/hosts
7+
echo "$(getent ahostsv4 host.docker.internal | awk '{ print $1 }') kc.localmaeher.dev.pvarki.fi" >>/etc/hosts
78

89
# Make sure the persistent directories exist
910
test -d /data/persistent/private || ( mkdir -p /data/persistent/private && chmod og-rwx /data/persistent/private )

poetry.lock

Lines changed: 1343 additions & 1061 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "rasenmaeher_api"
3-
version = "1.4.0"
3+
version = "1.5.0"
44
description = "python-rasenmaeher-api"
55
authors = [
66
"Aciid <703382+Aciid@users.noreply.github.com>",
@@ -47,6 +47,7 @@ disallow_untyped_decorators=false
4747
junit_family="xunit2"
4848
addopts="--cov=rasenmaeher_api --cov-fail-under=65 --cov-branch"
4949
asyncio_mode="strict"
50+
asyncio_default_fixture_loop_scope = "session"
5051

5152
[tool.pylint.MASTER]
5253
extension-pkg-allow-list = "pydantic"
@@ -84,12 +85,14 @@ pyopenssl = "^23.1"
8485
libpvarki = { git="https://github.com/pvarki/python-libpvarki.git", tag="1.9.0"}
8586
openapi-readme = "^0.2"
8687
python-multipart = "^0.0.6"
87-
aiohttp = "^3.8"
88+
aiohttp = ">=3.11.10,<4.0"
89+
pyjwt = ">=2.10.1"
8890
aiodns = "^3.0"
8991
brotli = "^1.0"
9092
cchardet = { version="^2.1", python="<=3.10"}
9193
filelock = "^3.12"
9294
gino = "^1.0"
95+
python-keycloak = "^4.2.0"
9396

9497
[tool.poetry.group.dev.dependencies]
9598
pytest = "^7.4"
@@ -100,7 +103,7 @@ black = "^23.7"
100103
bandit = "^1.7"
101104
mypy = "^1.5"
102105
pre-commit = "^3.3"
103-
pytest-asyncio = ">=0.21,<1.0" # caret behaviour on 0.x is to lock to 0.x.*
106+
pytest-asyncio = ">=0.23,<1.0" # caret behaviour on 0.x is to lock to 0.x.*
104107
bump2version = "^1.0"
105108
detect-secrets = "^1.2"
106109
httpx = ">=0.23,<1.0" # caret behaviour on 0.x is to lock to 0.x.*

src/rasenmaeher_api/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
""" python-rasenmaeher-api """
2-
__version__ = "1.4.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly
2+
__version__ = "1.5.0" # NOTE Use `bump2version --config-file patch` to bump versions correctly

src/rasenmaeher_api/db/enrollments.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,27 @@
1010
from sqlalchemy.dialects.postgresql import JSONB
1111
from sqlalchemy.dialects.postgresql import UUID as saUUID
1212
import sqlalchemy as sa
13+
from sqlalchemy.sql import func
1314

1415
from .base import ORMBaseModel, utcnow, db
1516
from .people import Person
1617
from .errors import ForbiddenOperation, CallsignReserved, NotFound, Deleted, PoolInactive
1718
from ..rmsettings import RMSettings
1819

1920
LOGGER = logging.getLogger(__name__)
20-
CODE_CHAR_COUNT = 8 # TODO: Make configurable ??
2121
CODE_ALPHABET = string.ascii_uppercase + string.digits
2222
CODE_MAX_ATTEMPTS = 100
2323

2424

25+
def generate_code() -> str:
26+
"""Generate a code"""
27+
settings = RMSettings.singleton()
28+
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(settings.code_size))
29+
if settings.code_avoid_confusion:
30+
code = code.replace("0", "O").replace("1", "I")
31+
return code
32+
33+
2534
class EnrollmentPool(ORMBaseModel): # pylint: disable=R0903
2635
"""Enrollment pools aka links, pk is UUID and comes from basemodel"""
2736

@@ -81,7 +90,7 @@ async def _generate_unused_code(cls) -> str:
8190
attempt = 0
8291
while True:
8392
attempt += 1
84-
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_CHAR_COUNT))
93+
code = generate_code()
8594
try:
8695
await EnrollmentPool.by_invitecode(code)
8796
except NotFound:
@@ -184,7 +193,7 @@ async def list(cls, by_pool: Optional[EnrollmentPool] = None) -> AsyncGenerator[
184193
@classmethod
185194
async def by_callsign(cls, callsign: str) -> Self:
186195
"""Get by callsign"""
187-
obj = await Enrollment.query.where(Enrollment.callsign == callsign).gino.first()
196+
obj = await Enrollment.query.where(func.lower(Enrollment.callsign) == func.lower(callsign)).gino.first()
188197
if not obj:
189198
raise NotFound()
190199
if obj.deleted:
@@ -227,7 +236,7 @@ async def _generate_unused_code(cls) -> str:
227236
attempt = 0
228237
while True:
229238
attempt += 1
230-
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_CHAR_COUNT))
239+
code = generate_code()
231240
try:
232241
await Enrollment.by_approvecode(code)
233242
except NotFound:

src/rasenmaeher_api/db/logincodes.py

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
import sqlalchemy as sa
99
from multikeyjwt import Issuer
1010

11-
from .base import ORMBaseModel, utcnow
11+
from .base import ORMBaseModel, utcnow, db
1212
from .errors import ForbiddenOperation, NotFound, Deleted, TokenReuse
1313

1414
LOGGER = logging.getLogger(__name__)
@@ -30,19 +30,23 @@ class LoginCode(ORMBaseModel): # pylint: disable=R0903
3030
@classmethod
3131
async def use_code(cls, code: str, auditmeta: Optional[Dict[str, Any]] = None) -> str:
3232
"""Exchange the code for JWT, if it was already used raise error that is also 403, return JWT with the claims"""
33-
obj = await LoginCode.by_code(code)
34-
if obj.used_on:
35-
LOGGER.error("{} was used on {}".format(obj.code, obj.used_on))
36-
raise TokenReuse()
37-
if not auditmeta:
38-
auditmeta = {}
39-
await obj.update(used_on=utcnow, auditmeta=auditmeta).apply()
40-
return Issuer.singleton().issue(obj.claims)
33+
async with db.acquire() as conn:
34+
async with conn.transaction(): # do it in a transaction to avoid data races
35+
obj = await LoginCode.by_code(code)
36+
if obj.used_on:
37+
LOGGER.error("{} was used on {}".format(obj.code, obj.used_on))
38+
raise TokenReuse()
39+
if not auditmeta:
40+
auditmeta = {}
41+
await obj.update(used_on=utcnow, auditmeta=auditmeta).apply()
42+
return Issuer.singleton().issue(obj.claims)
4143

4244
@classmethod
4345
async def by_code(cls, code: str) -> Self:
4446
"""Get by token"""
45-
obj = await LoginCode.query.where(LoginCode.code == code).gino.first()
47+
async with db.acquire() as conn:
48+
async with conn.transaction(): # do it in a transaction to avoid data races
49+
obj = await LoginCode.query.where(LoginCode.code == code).gino.first()
4650
if not obj:
4751
raise NotFound()
4852
if obj.deleted:
@@ -55,20 +59,22 @@ async def create_for_claims(cls, claims: Dict[str, Any], auditmeta: Optional[Dic
5559
"""Create a new one with random code for the given claims"""
5660
# TODO: Do this in a transaction to avoid race conditions
5761
attempt = 0
58-
while True:
59-
attempt += 1
60-
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_CHAR_COUNT))
61-
try:
62-
await LoginCode.by_code(code)
63-
except NotFound:
64-
break
65-
if attempt > CODE_MAX_ATTEMPTS:
66-
raise RuntimeError("Can't find unused code")
67-
if not auditmeta:
68-
auditmeta = {}
69-
dbobj = LoginCode(code=code, claims=claims, auditmeta=auditmeta)
70-
await dbobj.create()
71-
return code
62+
async with db.acquire() as conn:
63+
async with conn.transaction(): # do it in a transaction to avoid data races
64+
while True:
65+
attempt += 1
66+
code = "".join(secrets.choice(CODE_ALPHABET) for _ in range(CODE_CHAR_COUNT))
67+
try:
68+
await LoginCode.by_code(code)
69+
except NotFound:
70+
break
71+
if attempt > CODE_MAX_ATTEMPTS:
72+
raise RuntimeError("Can't find unused code")
73+
if not auditmeta:
74+
auditmeta = {}
75+
dbobj = LoginCode(code=code, claims=claims, auditmeta=auditmeta)
76+
await dbobj.create()
77+
return code
7278

7379
async def delete(self) -> bool:
7480
"""Deletion of enrollments is not allowed"""

src/rasenmaeher_api/db/middleware.py

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,5 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
102102
return
103103

104104
# Get and release connection
105-
scope["connection"] = await self.gino.acquire(lazy=True)
106-
try:
105+
async with self.gino.acquire(lazy=True):
107106
await self.app(scope, receive, send)
108-
finally:
109-
conn = scope.pop("connection", None)
110-
if conn is not None:
111-
await conn.release()

0 commit comments

Comments
 (0)