Skip to content

Commit 6e3afff

Browse files
committed
Add Attendee membrane users form 2024.ploneconf.org with TOTP login
1 parent 9c76052 commit 6e3afff

28 files changed

+998
-2
lines changed

backend/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ requirements-mxdev.txt: pyproject.toml mx.ini ## Generate constraints file
5454
$(VENV_FOLDER): requirements-mxdev.txt ## Install dependencies
5555
@echo "$(GREEN)==> Install environment$(RESET)"
5656
@uv venv $(VENV_FOLDER)
57-
@uv pip install -r requirements-mxdev.txt
57+
@uv pip install -r requirements-mxdev.txt Products.membrane pyotp
5858

5959
.PHONY: sync
6060
sync: $(VENV_FOLDER) ## Sync project dependencies

backend/pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ test = [
4444
"pytest-cov",
4545
"pytest-plone>=1.0.0a1",
4646
]
47+
users = [
48+
"Products.membrane",
49+
"pyotp",
50+
]
4751

4852
[project.urls]
4953
Homepage = "https://github.com/collective/tech-event"
@@ -184,4 +188,4 @@ branch = true
184188
parallel = true
185189
omit = [
186190
"src/collective/techevent/locales/*.py",
187-
]
191+
]

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: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
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.CMFPlone.utils import safe_encode
8+
from Products.membrane import interfaces as ifaces
9+
from zope.component import adapter
10+
from zope.component import getUtility
11+
from zope.interface import implementer
12+
13+
import base64
14+
import pyotp
15+
16+
17+
ALLOWED_STATES = [
18+
"registered",
19+
]
20+
21+
22+
@adapter(IBaseUser)
23+
@implementer(ifaces.IMembraneUserAuth)
24+
class MembraneUserAuthentication:
25+
def __init__(self, context):
26+
self.user = context
27+
28+
def verifyCredentials(self, credentials):
29+
"""Returns True is password is authenticated, False if not."""
30+
user = self.user
31+
32+
# Initialize _v_totp_guess if not present
33+
if not hasattr(user, "_v_totp_guess"):
34+
user._v_totp_guess = []
35+
36+
# Check if the last 5 wrong trials are within the last minute
37+
now = datetime.now()
38+
user._v_totp_guess = [
39+
ts for ts in user._v_totp_guess if now - ts < timedelta(seconds=60)
40+
]
41+
if len(user._v_totp_guess) >= 5:
42+
return False
43+
44+
manager = getUtility(IKeyManager)
45+
seed = base64.b32encode((user.otp_seed + manager.secret()).encode("utf-8"))
46+
totp = pyotp.TOTP(seed.decode("utf-8"), interval=30)
47+
48+
if not verify_totp_code(totp, credentials.get("password", ""), minutes=10):
49+
# Add current timestamp to _v_totp_guess for failed trials
50+
user._v_totp_guess.append(now)
51+
return False
52+
53+
return True
54+
55+
def authenticateCredentials(self, credentials):
56+
# Should not authenticate when the user is not enabled.
57+
user = self.user
58+
state = api.content.get_state(user)
59+
if state not in ALLOWED_STATES:
60+
return
61+
if self.verifyCredentials(credentials):
62+
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)