Skip to content

Commit 215cb7b

Browse files
committed
Add minimal Attendee users support reusing 2024.ploneconf.org implementation, but with TOTP login support
1 parent 966d260 commit 215cb7b

File tree

31 files changed

+1180
-2
lines changed

31 files changed

+1180
-2
lines changed

backend/Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ BIN_FOLDER=$(VENV_FOLDER)/bin
3737
# Environment variables to be exported
3838
export PYTHONWARNINGS := ignore
3939
export DOCKER_BUILDKIT := 1
40+
export ENABLE_PRINTING_MAILHOST := True
4041

4142
all: build
4243

@@ -54,7 +55,7 @@ requirements-mxdev.txt: pyproject.toml mx.ini ## Generate constraints file
5455
$(VENV_FOLDER): requirements-mxdev.txt ## Install dependencies
5556
@echo "$(GREEN)==> Install environment$(RESET)"
5657
@uv venv $(VENV_FOLDER)
57-
@uv pip install -r requirements-mxdev.txt
58+
@uv pip install -r requirements-mxdev.txt Products.membrane pyotp
5859

5960
.PHONY: sync
6061
sync: $(VENV_FOLDER) ## Sync project dependencies

backend/pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,11 @@ test = [
4343
"pytest",
4444
"pytest-cov",
4545
"pytest-plone>=1.0.0a1",
46+
"Products.PrintingMailHost",
47+
]
48+
users = [
49+
"Products.membrane",
50+
"pyotp",
4651
]
4752

4853
[project.urls]
@@ -184,4 +189,4 @@ branch = true
184189
parallel = true
185190
omit = [
186191
"src/collective/techevent/locales/*.py",
187-
]
192+
]

backend/src/collective/techevent/configure.zcml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<configure
22
xmlns="http://namespaces.zope.org/zope"
33
xmlns:i18n="http://namespaces.zope.org/i18n"
4+
xmlns:zcml="http://namespaces.zope.org/zcml"
45
i18n_domain="collective.techevent"
56
>
67

@@ -24,6 +25,10 @@
2425
<include package=".services" />
2526
<include package=".subscribers" />
2627
<include package=".vocabularies" />
28+
<include
29+
package=".users"
30+
zcml:condition="installed Products.membrane"
31+
/>
2732

2833
<!-- -*- extra stuff goes here -*- -->
2934

backend/src/collective/techevent/permissions.zcml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,17 @@
6666
title="collective.techevent: Add Keynote"
6767
/>
6868

69+
<!-- Attendees -->
70+
<permission
71+
id="collective.techevent.AddAttendees"
72+
title="collective.techevent: Add Attendees Database"
73+
/>
74+
75+
<permission
76+
id="collective.techevent.AddAttendee"
77+
title="collective.techevent: Add Attendee"
78+
/>
79+
6980
<!-- -*- extra stuff goes here -*- -->
7081

7182
</configure>

backend/src/collective/techevent/setuphandlers/base.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from collective.techevent.utils import setup_tech_event
2+
from collective.techevent.utils import setup_tech_event_users
23
from plone import api
34
from Products.GenericSetup.tool import SetupTool
45

@@ -7,3 +8,9 @@ def setup_techevent_settings(portal_setup: SetupTool):
78
"""Setup event settings for Plone Site."""
89
portal = api.portal.get()
910
setup_tech_event(portal)
11+
12+
13+
def setup_techevent_users(portal_setup: SetupTool):
14+
"""Setup event attendees for Plone Site."""
15+
portal = api.portal.get()
16+
setup_tech_event_users(portal)

backend/src/collective/techevent/users/__init__.py

Whitespace-only changes.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from collective.techevent.users.content import IBaseUser
2+
from collective.techevent.users.utils import verify_totp_code
3+
from datetime import datetime
4+
from datetime import timedelta
5+
from plone import api
6+
from plone.keyring.interfaces import IKeyManager
7+
from Products.membrane import interfaces as ifaces
8+
from zope.component import adapter
9+
from zope.component import getUtility
10+
from zope.interface import implementer
11+
12+
import base64
13+
import pyotp
14+
15+
16+
ALLOWED_STATES = [
17+
"registered",
18+
]
19+
20+
21+
@adapter(IBaseUser)
22+
@implementer(ifaces.IMembraneUserAuth)
23+
class MembraneUserAuthentication:
24+
def __init__(self, context):
25+
self.user = context
26+
27+
def verifyCredentials(self, credentials):
28+
"""Returns True is password is authenticated, False if not."""
29+
user = self.user
30+
31+
# Initialize _v_totp_guess if not present
32+
if not hasattr(user, "_v_totp_guess"):
33+
user._v_totp_guess = []
34+
35+
# Check if the last 5 wrong trials are within the last minute
36+
now = datetime.now()
37+
user._v_totp_guess = [
38+
ts for ts in user._v_totp_guess if now - ts < timedelta(seconds=60)
39+
]
40+
if len(user._v_totp_guess) >= 5:
41+
return False
42+
43+
manager = getUtility(IKeyManager)
44+
seed = base64.b32encode((user.otp_seed + manager.secret()).encode("utf-8"))
45+
totp = pyotp.TOTP(seed.decode("utf-8"), interval=30)
46+
47+
if not verify_totp_code(totp, credentials.get("password", ""), minutes=10):
48+
# Add current timestamp to _v_totp_guess for failed trials
49+
user._v_totp_guess.append(now)
50+
return False
51+
52+
return True
53+
54+
def authenticateCredentials(self, credentials):
55+
# Should not authenticate when the user is not enabled.
56+
user = self.user
57+
state = api.content.get_state(user)
58+
if state not in ALLOWED_STATES:
59+
return
60+
if self.verifyCredentials(credentials):
61+
return (user.getUserId(), user.getUserName())
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<configure
2+
xmlns="http://namespaces.zope.org/zope"
3+
xmlns:browser="http://namespaces.zope.org/browser"
4+
xmlns:plone="http://namespaces.plone.org/plone"
5+
xmlns:zcml="http://namespaces.zope.org/zcml"
6+
i18n_domain="project.title"
7+
>
8+
9+
<include file="profiles.zcml" />
10+
11+
<adapter factory=".behaviors.MembraneUserAuthentication" />
12+
13+
<zcml:configure package="Products.CMFPlone.browser.login">
14+
<browser:page
15+
name="mail_password_template"
16+
for="*"
17+
class="collective.techevent.users.password_reset.PasswordResetToolView"
18+
template="templates/mail_password_template.pt"
19+
permission="zope.Public"
20+
layer="collective.techevent.interfaces.IBrowserLayer"
21+
/>
22+
</zcml:configure>
23+
24+
<include package=".content" />
25+
26+
</configure>
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
from collective.techevent import _
2+
from collective.techevent.users.utils import validate_unique_email
3+
from plone import api
4+
from plone.autoform import directives
5+
from plone.dexterity.content import Container
6+
from plone.schema import Email
7+
from plone.supermodel import model
8+
from Products.membrane.interfaces import IMembraneUserObject
9+
from z3c.form.interfaces import IEditForm
10+
from zope import schema
11+
from zope.interface import implementer
12+
from zope.interface import Interface
13+
from zope.interface import Invalid
14+
from zope.interface import invariant
15+
16+
17+
class IBaseUser(Interface):
18+
id = schema.ASCIILine(title=_("User ID"), required=True)
19+
directives.order_before(id="*")
20+
21+
title = schema.TextLine(readonly=True)
22+
first_name = schema.TextLine(
23+
title=_("First Name"),
24+
required=True,
25+
)
26+
last_name = schema.TextLine(
27+
title=_("Last Name"),
28+
required=True,
29+
)
30+
email = Email(
31+
title=_("E-mail Address"),
32+
required=True,
33+
)
34+
otp_seed = schema.ASCIILine(
35+
title=_("OTP Shared Secret"),
36+
required=False,
37+
)
38+
model.fieldset("credentials", label=_("Credentials"), fields=["email", "otp_seed"])
39+
40+
directives.read_permission(
41+
id="zope2.View",
42+
email="zope2.View",
43+
text="zope2.View",
44+
otp_seed="zope2.ManageUsers",
45+
)
46+
directives.write_permission(
47+
id="cmf.ReviewPortalContent",
48+
email="cmf.ReviewPortalContent",
49+
text="cmf.ModifyPortalContent",
50+
otp_seed="zope2.ManageUsers",
51+
)
52+
directives.omitted("otp_seed")
53+
directives.no_omit(IEditForm, "otp_seed")
54+
55+
@invariant
56+
def email_unique(data):
57+
"""The email must be unique, as it is the login name (user name).
58+
59+
The tricky thing is to make sure editing a user and keeping
60+
his email the same actually works.
61+
"""
62+
user = data.__context__
63+
if user is not None:
64+
if getattr(user, "email", None) and user.email == data.email:
65+
# No change, fine.
66+
return
67+
error = validate_unique_email(data.email)
68+
if error:
69+
raise Invalid(error)
70+
71+
exclude_from_nav = schema.Bool(
72+
default=True,
73+
required=False,
74+
)
75+
directives.omitted("exclude_from_nav")
76+
77+
78+
@implementer(IBaseUser, IMembraneUserObject)
79+
class BaseUser(Container):
80+
"""A Membrane user."""
81+
82+
@property
83+
def title(self):
84+
return f"{self.first_name} {self.last_name}"
85+
86+
@title.setter
87+
def title(self, value):
88+
# title is not writable
89+
pass
90+
91+
def getUserId(self):
92+
return self.id
93+
94+
def getUserName(self):
95+
return self.email
96+
97+
def get_full_name(self):
98+
return self.title
99+
100+
@property
101+
def user(self):
102+
user = api.user.get(userid=self.id)
103+
if user:
104+
return user

0 commit comments

Comments
 (0)