initial import
This commit is contained in:
commit
c9d4f525e9
20 changed files with 1451 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.swp
|
||||||
|
dist/
|
||||||
|
var/
|
||||||
|
*.egg-info
|
||||||
|
*.project
|
||||||
|
*.pydevproject
|
||||||
|
*.ropeproject
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
.settings
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (C) 2023 cyberconcepts.org team
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
7
README.md
Normal file
7
README.md
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
# Introduction
|
||||||
|
|
||||||
|
This is the main part of the code of the semantic
|
||||||
|
web application platform *loops*, based on
|
||||||
|
Zope 3 / bluebream.
|
||||||
|
|
||||||
|
More information: see https://www.cyberconcepts.org.
|
1
cco/__init__.py
Normal file
1
cco/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# loops-ext/cco/member
|
88
cco/member/README.txt
Normal file
88
cco/member/README.txt
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
======================================================================
|
||||||
|
cco.member - cyberconcepts.org: member registration and authentication
|
||||||
|
======================================================================
|
||||||
|
|
||||||
|
>>> from zope.session.interfaces import ISession
|
||||||
|
>>> from zope.publisher.browser import TestRequest
|
||||||
|
>>> from logging import getLogger
|
||||||
|
>>> log = getLogger('cco.member.auth')
|
||||||
|
|
||||||
|
>>> from loops.setup import addAndConfigureObject, addObject
|
||||||
|
>>> from loops.concept import Concept
|
||||||
|
>>> from loops.common import adapted
|
||||||
|
|
||||||
|
>>> concepts = loopsRoot['concepts']
|
||||||
|
>>> len(list(concepts.keys()))
|
||||||
|
10
|
||||||
|
|
||||||
|
>>> from loops.browser.node import NodeView
|
||||||
|
>>> home = loopsRoot['views']['home']
|
||||||
|
>>> homeView = NodeView(home, TestRequest())
|
||||||
|
|
||||||
|
|
||||||
|
Session Credentials Plug-in with optional 2-factor authentication
|
||||||
|
=================================================================
|
||||||
|
|
||||||
|
>>> from cco.member.auth import SessionCredentialsPlugin
|
||||||
|
>>> scp = SessionCredentialsPlugin()
|
||||||
|
|
||||||
|
When retrieving credentials for a standard request we get the usual
|
||||||
|
login + password dictionary.
|
||||||
|
|
||||||
|
>>> input = dict(login='scott', password='tiger')
|
||||||
|
>>> req = TestRequest(home, form=input)
|
||||||
|
|
||||||
|
>>> scp.extractCredentials(req)
|
||||||
|
{'login': 'scott', 'password': 'tiger'}
|
||||||
|
|
||||||
|
When the URL contains an authentication method reference to the 2-factor
|
||||||
|
authentication the first phase of the authentication (redirection to
|
||||||
|
TAN entry form) is executed.
|
||||||
|
|
||||||
|
>>> sdata = ISession(req).get('zope.pluggableauth.browserplugins')
|
||||||
|
>>> sdata['credentials'] = None
|
||||||
|
|
||||||
|
>>> req.setTraversalStack(['++auth++2factor'])
|
||||||
|
|
||||||
|
>>> scp.extractCredentials(req)
|
||||||
|
'2fa_tan_form.html?a=...&h=...&b=...'
|
||||||
|
|
||||||
|
What if we enter data for authentication phase 2? No authentication
|
||||||
|
because the hashes don't match.
|
||||||
|
|
||||||
|
>>> sdata['credentials'] = None
|
||||||
|
|
||||||
|
>>> input = dict(hash='#dummy#', tan_a='1', tan_b='2')
|
||||||
|
>>> req = TestRequest(home, form=input)
|
||||||
|
>>> req.setTraversalStack(['++auth++2factor'])
|
||||||
|
|
||||||
|
>>> scp.extractCredentials(req)
|
||||||
|
|
||||||
|
|
||||||
|
Password Policy Checking
|
||||||
|
========================
|
||||||
|
|
||||||
|
>>> from cco.member.pwpolicy import checkPassword
|
||||||
|
|
||||||
|
>>> checkPassword(u'Test12.')
|
||||||
|
False
|
||||||
|
>>> checkPassword(u'TestTest')
|
||||||
|
False
|
||||||
|
>>> checkPassword(u'testes.')
|
||||||
|
False
|
||||||
|
>>> checkPassword(u'tesT1234')
|
||||||
|
True
|
||||||
|
>>> checkPassword(u'tesTtes.')
|
||||||
|
True
|
||||||
|
|
||||||
|
|
||||||
|
Password Change Form
|
||||||
|
====================
|
||||||
|
|
||||||
|
>>> from cco.member.browser import PasswordChange
|
||||||
|
|
||||||
|
|
||||||
|
Web API
|
||||||
|
=======
|
||||||
|
|
||||||
|
>>> from cco.member.webapi import Users
|
1
cco/member/__init__.py
Normal file
1
cco/member/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
# package cco.member
|
124
cco/member/auth.pt
Normal file
124
cco/member/auth.pt
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
<html i18n:domain="cco.member">
|
||||||
|
|
||||||
|
|
||||||
|
<metal:login define-macro="login_form"
|
||||||
|
i18n:domain="cco.member"
|
||||||
|
tal:define="principal request/principal/id">
|
||||||
|
<p style="color: Red"
|
||||||
|
tal:condition="request/error_message|nothing"
|
||||||
|
i18n:translate=""
|
||||||
|
tal:content="request/error_message" />
|
||||||
|
<h2 i18n:translate="title_login">Login</h2>
|
||||||
|
<div>
|
||||||
|
<p i18n:translate="description_login"
|
||||||
|
tal:condition="python: principal == 'zope.anybody'">
|
||||||
|
Please provide Login Information</p>
|
||||||
|
<form method="post"
|
||||||
|
tal:define="submitted python:
|
||||||
|
principal != 'zope.anybody' and 'SUBMIT' in request">
|
||||||
|
<tal:redirect condition="submitted">
|
||||||
|
<span tal:define="dummy python:request.response.redirect(
|
||||||
|
request.get('camefrom') or request.URL[-1])" />
|
||||||
|
</tal:redirect>
|
||||||
|
<tal:form condition="not:submitted">
|
||||||
|
<input type="hidden" name="base_url"
|
||||||
|
tal:attributes="value request/URL/-1" />
|
||||||
|
<div class="row">
|
||||||
|
<div class="label" i18n:translate="label_login">User Name</div>
|
||||||
|
<div class="field">
|
||||||
|
<input type="text" name="login"/></div>
|
||||||
|
</div><br />
|
||||||
|
<div class="row">
|
||||||
|
<div class="label" i18n:translate="label_password">Password</div>
|
||||||
|
<div class="field">
|
||||||
|
<input type="password" name="password"/></div>
|
||||||
|
</div><br />
|
||||||
|
<div class="row">
|
||||||
|
<input tal:condition="python:view.authenticationMethod != '2factor'"
|
||||||
|
class="form-element" type="submit"
|
||||||
|
name="SUBMIT" value="Log in"
|
||||||
|
i18n:attributes="value button_login" />
|
||||||
|
<input tal:condition="python:view.authenticationMethod == '2factor'"
|
||||||
|
class="form-element" type="submit"
|
||||||
|
name="SUBMIT" value="Log in"
|
||||||
|
i18n:attributes="value button_login_2factor" />
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="camefrom"
|
||||||
|
tal:attributes="value request/camefrom | nothing">
|
||||||
|
</tal:form>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</metal:login>
|
||||||
|
|
||||||
|
|
||||||
|
<metal:tan define-macro="tan_form"
|
||||||
|
i18n:domain="cco.member"
|
||||||
|
tal:define="principal request/principal/id;
|
||||||
|
a request/a|nothing;
|
||||||
|
b request/b|nothing;
|
||||||
|
email item/sendTanEmail;
|
||||||
|
baseUrl request/URL/-1">
|
||||||
|
<h2 i18n:translate="title_login_tan">Login: TAN Entry</h2>
|
||||||
|
<tal:form condition="python:principal == 'zope.anybody' and a and b and email">
|
||||||
|
<p i18n:translate="message_enter_tan_digits_$email_$a_$b">
|
||||||
|
An E-mail with a TAN has been sent to
|
||||||
|
<span tal:content="email" i18n:name="email" />.
|
||||||
|
Please enter digits
|
||||||
|
<strong tal:content="request/a" i18n:name="a">A</strong> and
|
||||||
|
<strong tal:content="request/b" i18n:name="b">B</strong> below.</p>
|
||||||
|
<form method="post" tal:attributes="action baseUrl">
|
||||||
|
<input type="hidden" name="hash"
|
||||||
|
tal:attributes="value request/h|nothing" />
|
||||||
|
<input type="hidden" name="camefrom"
|
||||||
|
tal:attributes="value request/camefrom|string:">
|
||||||
|
<input type="hidden" name="base_url"
|
||||||
|
tal:attributes="value baseUrl" />
|
||||||
|
<div class="row">
|
||||||
|
<div class="label" i18n:translate="label_tan_$a">TAN Digit
|
||||||
|
<span tal:content="request/a" i18n:name="a" /></div>
|
||||||
|
<div class="field">
|
||||||
|
<input type="text" name="tan_a" size="2" maxlength="1"
|
||||||
|
tal:attributes="value request/tan_a|string:"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="label" i18n:translate="label_tan_$b">TAN Digit
|
||||||
|
<span tal:content="request/b" i18n:name="b" /></div>
|
||||||
|
<div class="field">
|
||||||
|
<input type="text" name="tan_b" size="2" maxlength="1"
|
||||||
|
tal:attributes="value request/tan_b|string:"/>
|
||||||
|
</div>
|
||||||
|
</div><br />
|
||||||
|
<div class="row">
|
||||||
|
<input class="form-element" type="submit"
|
||||||
|
name="SUBMIT" value="Log in"
|
||||||
|
i18n:attributes="value button_login_tan" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</tal:form>
|
||||||
|
<tal:redir condition="python:
|
||||||
|
(principal != 'zope.anybody' or not (a and b and email)) and
|
||||||
|
request.response.redirect(baseUrl + '?error_message=Invalid+login+data!')" />
|
||||||
|
</metal:tan>
|
||||||
|
|
||||||
|
<metal:form define-macro="reset_form">
|
||||||
|
<h1 i18n:translate=""
|
||||||
|
tal:content="view/label|default">Edit</h1>
|
||||||
|
<div>
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" name="action" value="update" />
|
||||||
|
<tal:token condition="request/token|nothing">
|
||||||
|
<input type="hidden" name="token"
|
||||||
|
tal:attributes="value request/token|string:" />
|
||||||
|
</tal:token>
|
||||||
|
<br />
|
||||||
|
<metal:fields use-macro="view/fieldRenderers/fields" />
|
||||||
|
<br />
|
||||||
|
<input type="submit" name="submit" value="Save"
|
||||||
|
i18n:attributes="value"
|
||||||
|
tal:attributes="value view/label_submit|string:Save" />
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</metal:form>
|
||||||
|
|
||||||
|
</html>
|
350
cco/member/auth.py
Normal file
350
cco/member/auth.py
Normal file
|
@ -0,0 +1,350 @@
|
||||||
|
#
|
||||||
|
# Copyright (c) 2023 Helmut Merz helmutm@cy55.de
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Specialized authentication components.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from email.MIMEText import MIMEText
|
||||||
|
from urllib import urlencode
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from zope.app.component import hooks
|
||||||
|
from zope.interface import Interface, implements
|
||||||
|
from zope import component
|
||||||
|
from zope.pluggableauth.interfaces import IAuthenticatedPrincipalFactory
|
||||||
|
from zope.pluggableauth.plugins.session import SessionCredentialsPlugin \
|
||||||
|
as BaseSessionCredentialsPlugin
|
||||||
|
from zope.pluggableauth.plugins.session import SessionCredentials
|
||||||
|
from zope.publisher.interfaces.http import IHTTPRequest
|
||||||
|
from zope.session.interfaces import ISession
|
||||||
|
from zope.traversing.browser import absoluteURL
|
||||||
|
from zope.traversing.namespace import view
|
||||||
|
|
||||||
|
from loops.browser.node import getViewConfiguration
|
||||||
|
from loops.organize.interfaces import IPresence
|
||||||
|
from loops.organize.party import getAuthenticationUtility
|
||||||
|
from loops.util import _
|
||||||
|
|
||||||
|
try:
|
||||||
|
from config import single_sign_on as sso
|
||||||
|
except ImportError:
|
||||||
|
sso = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
from config import jwt_key
|
||||||
|
except ImportError:
|
||||||
|
jwt_key = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
import python_jwt as jwt
|
||||||
|
import jwcrypto.jwk as jwk
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
TIMEOUT = timedelta(minutes=60)
|
||||||
|
if jwt_key is not None:
|
||||||
|
JWT_SECRET = jwk.JWK.from_pem(jwt_key)
|
||||||
|
else:
|
||||||
|
JWT_SECRET = None
|
||||||
|
|
||||||
|
#PRIVKEY = "6LcGPQ4TAAAAABCyA_BCAKPkD6wW--IhUicbAZ11" # for captcha
|
||||||
|
|
||||||
|
log = logging.getLogger('cco.member.auth')
|
||||||
|
|
||||||
|
|
||||||
|
class AuthURLNameSpace(view):
|
||||||
|
|
||||||
|
def traverse(self, name, ignored):
|
||||||
|
self.request.shiftNameToApplication()
|
||||||
|
# ignore, has already been evaluated by credentials plugin
|
||||||
|
return self.context
|
||||||
|
|
||||||
|
|
||||||
|
class TwoFactorSessionCredentials(SessionCredentials):
|
||||||
|
|
||||||
|
def __init__(self, login, password):
|
||||||
|
self.login = login
|
||||||
|
self.password = password
|
||||||
|
self.tan = random.randint(100000, 999999)
|
||||||
|
self.timestamp = datetime.now()
|
||||||
|
rng = range(len(str(self.tan)))
|
||||||
|
t1 = random.choice(rng)
|
||||||
|
rng.remove(t1)
|
||||||
|
t2 = random.choice(rng)
|
||||||
|
self.tanA, self.tanB = sorted((t1, t2))
|
||||||
|
self.hash = (hashlib.
|
||||||
|
sha224("%s:%s:%s" % (login, password, self.tan)).
|
||||||
|
hexdigest())
|
||||||
|
self.validated = False
|
||||||
|
|
||||||
|
|
||||||
|
class SessionCredentialsPlugin(BaseSessionCredentialsPlugin):
|
||||||
|
|
||||||
|
tan_a_field = 'tan_a'
|
||||||
|
tan_b_field = 'tan_b'
|
||||||
|
hash_field = 'hash'
|
||||||
|
tokenField = 'token'
|
||||||
|
secure_cookie_token_ns = 'zope_3_sct'
|
||||||
|
|
||||||
|
def setSecureCookieToken(self, credentials, request):
|
||||||
|
response = request.response
|
||||||
|
options = {}
|
||||||
|
expires = 'Tue, 19 Jan 2038 00:00:00 GMT'
|
||||||
|
options['expires'] = expires
|
||||||
|
options['secure'] = True
|
||||||
|
options['HttpOnly'] = True
|
||||||
|
payload = dict(sub=credentials.login)
|
||||||
|
secret = jwk.JWK.from_pem(jwt_key)
|
||||||
|
token = jwt.generate_jwt(payload, secret, 'PS256', timedelta(days=365))
|
||||||
|
response.setCookie(
|
||||||
|
self.secure_cookie_token_ns, token,
|
||||||
|
path=request.getApplicationURL(path_only=True),
|
||||||
|
**options)
|
||||||
|
|
||||||
|
def validateSecureCookieToken(self, request, login):
|
||||||
|
token = request.getCookies().get(self.secure_cookie_token_ns)
|
||||||
|
if token:
|
||||||
|
try:
|
||||||
|
header, claims = jwt.verify_jwt(token, JWT_SECRET, ['PS256'])
|
||||||
|
if claims.get('sub') == login:
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
log.warn('invalid cookie token %s' % token)
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def extractCredentials(self, request):
|
||||||
|
from cco.member.browser import validateToken
|
||||||
|
if not IHTTPRequest.providedBy(request):
|
||||||
|
return None
|
||||||
|
login = request.get(self.loginfield, None)
|
||||||
|
password = request.get(self.passwordfield, None)
|
||||||
|
token = request.get(self.tokenField)
|
||||||
|
session = ISession(request)
|
||||||
|
sessionData = session.get('zope.pluggableauth.browserplugins')
|
||||||
|
traversalStack = request.getTraversalStack()
|
||||||
|
authMethod = 'standard'
|
||||||
|
credentials = None
|
||||||
|
if not token or (token and not validateToken(token)):
|
||||||
|
if sessionData:
|
||||||
|
credentials = sessionData.get('credentials')
|
||||||
|
if isinstance(credentials, TwoFactorSessionCredentials):
|
||||||
|
authMethod = '2factor'
|
||||||
|
if (authMethod == 'standard' and
|
||||||
|
traversalStack and traversalStack[-1].startswith('++auth++')):
|
||||||
|
### SSO: do not switch to 2factor if logged-in via sso
|
||||||
|
#if not getattr(credentials, 'sso_source', None):
|
||||||
|
if not credentials and (not token or not validateToken(token)):
|
||||||
|
authMethod = traversalStack[-1][8:]
|
||||||
|
viewAnnotations = request.annotations.setdefault('loops.view', {})
|
||||||
|
viewAnnotations['auth_method'] = authMethod
|
||||||
|
#log.info('authentication method: %s.' % authMethod)
|
||||||
|
if authMethod == 'standard' or self.validateSecureCookieToken(request, login):
|
||||||
|
return self.extractStandardCredentials(
|
||||||
|
request, login, password, session, credentials)
|
||||||
|
elif authMethod == '2factor':
|
||||||
|
return self.extract2FactorCredentials(
|
||||||
|
request, login, password, session, credentials)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extractStandardCredentials(self, request, login, password,
|
||||||
|
session, credentials):
|
||||||
|
sso_source = request.get('sso_source', None)
|
||||||
|
if login and password:
|
||||||
|
credentials = SessionCredentials(login, password)
|
||||||
|
### SSO: send login request to sso.targets
|
||||||
|
if sso_source:
|
||||||
|
credentials.sso_source = sso_source
|
||||||
|
else:
|
||||||
|
sso_send_login(login, password, request)
|
||||||
|
if credentials:
|
||||||
|
sessionData = session['zope.pluggableauth.browserplugins']
|
||||||
|
### SSO: do not overwrite existing credentials on sso login
|
||||||
|
if not sessionData.get('credentials') or not sso_source:
|
||||||
|
if credentials != sessionData.get('credentials'):
|
||||||
|
sessionData['credentials'] = credentials
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
login = credentials.getLogin()
|
||||||
|
password = credentials.getPassword()
|
||||||
|
return dict(login=login, password=password)
|
||||||
|
|
||||||
|
def extract2FactorCredentials(self, request, login, password,
|
||||||
|
session, credentials):
|
||||||
|
tan_a = request.get(self.tan_a_field, None)
|
||||||
|
tan_b = request.get(self.tan_b_field, None)
|
||||||
|
hash = request.get(self.hash_field, None)
|
||||||
|
if (login and password) and not (tan_a or tan_b or hash):
|
||||||
|
sessionData = session.get('zope.pluggableauth.browserplugins')
|
||||||
|
return self.processPhase1(request, session, login, password)
|
||||||
|
if (tan_a and tan_b and hash) and not (login or password):
|
||||||
|
credentials = self.processPhase2(request, session, hash, tan_a, tan_b)
|
||||||
|
if credentials and credentials.validated:
|
||||||
|
login = credentials.getLogin()
|
||||||
|
password = credentials.getPassword()
|
||||||
|
### SSO: send login request to sso.targets
|
||||||
|
if tan_a and tan_b:
|
||||||
|
sso_send_login(login, password, request)
|
||||||
|
return dict(login=login, password=password)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def processPhase1(self, request, session, login, password, msg=None):
|
||||||
|
sessionData = session['zope.pluggableauth.browserplugins']
|
||||||
|
credentials = TwoFactorSessionCredentials(login, password)
|
||||||
|
sessionData['credentials'] = credentials
|
||||||
|
# send email
|
||||||
|
log.info("Processing phase 1, TAN: %s. " % credentials.tan)
|
||||||
|
params = dict(h=credentials.hash,
|
||||||
|
a=credentials.tanA+1,
|
||||||
|
b=credentials.tanB+1,
|
||||||
|
skip2factor=request.form.get('skip2factor'))
|
||||||
|
if msg:
|
||||||
|
params['loops.message'] = msg
|
||||||
|
url = self.getUrl(request, '2fa_tan_form.html', params)
|
||||||
|
return request.response.redirect(url, trusted=True)
|
||||||
|
|
||||||
|
def processPhase2(self, request, session, hash, tan_a, tan_b):
|
||||||
|
def _validate_tans(a, b, creds):
|
||||||
|
tan = str(creds.tan)
|
||||||
|
return tan[creds.tanA] == a and tan[creds.tanB] == b
|
||||||
|
sessionData = session['zope.pluggableauth.browserplugins']
|
||||||
|
credentials = sessionData.get('credentials')
|
||||||
|
if not credentials:
|
||||||
|
msg = 'Missing credentials'
|
||||||
|
return log.warn(msg)
|
||||||
|
log.info("Processing phase 2, TAN: %s. " % credentials.tan)
|
||||||
|
if credentials.hash != hash:
|
||||||
|
msg = 'Illegal hash.'
|
||||||
|
return log.warn(msg)
|
||||||
|
if credentials.timestamp < datetime.now() - TIMEOUT:
|
||||||
|
msg = 'Timeout exceeded.'
|
||||||
|
request.form['loops.message'] = msg
|
||||||
|
return log.warn(msg)
|
||||||
|
if not _validate_tans(tan_a, tan_b, credentials):
|
||||||
|
msg = 'TAN digits not correct.'
|
||||||
|
log.warn(msg)
|
||||||
|
params = dict(h=credentials.hash,
|
||||||
|
a=credentials.tanA+1,
|
||||||
|
b=credentials.tanB+1,
|
||||||
|
skip2factor=request.form.get('skip2factor'))
|
||||||
|
params['loops.message'] = msg
|
||||||
|
url = self.getUrl(request, '2fa_tan_form.html', params)
|
||||||
|
request.response.redirect(url, trusted=True)
|
||||||
|
return None
|
||||||
|
credentials.validated = True
|
||||||
|
log.info('Credentials valid.')
|
||||||
|
# TODO: only overwrite if changed:
|
||||||
|
sessionData['credentials'] = credentials
|
||||||
|
if request.form.get('skip2factor'):
|
||||||
|
self.setSecureCookieToken(credentials, request)
|
||||||
|
if request.get('camefrom'):
|
||||||
|
request.response.redirect(request['camefrom'], trusted=True)
|
||||||
|
return credentials
|
||||||
|
|
||||||
|
def getUrl(self, request, action, params):
|
||||||
|
if request.get('camefrom'):
|
||||||
|
params['camefrom'] = request['camefrom']
|
||||||
|
baseUrl = request.get('base_url') or ''
|
||||||
|
if baseUrl and not baseUrl.endswith('/'):
|
||||||
|
baseUrl += '/'
|
||||||
|
return '%s%s?%s' % (baseUrl, action, urlencode(params))
|
||||||
|
|
||||||
|
def challenge(self, request):
|
||||||
|
if not IHTTPRequest.providedBy(request):
|
||||||
|
return False
|
||||||
|
site = hooks.getSite()
|
||||||
|
path = request['PATH_INFO'].split('/++/')[-1] # strip virtual host stuff
|
||||||
|
if not path.startswith('/'):
|
||||||
|
path = '/' + path
|
||||||
|
camefrom = request.getApplicationURL() + path
|
||||||
|
if 'login' in camefrom:
|
||||||
|
camefrom = '/'.join(camefrom.split('/')[:-1])
|
||||||
|
url = '%s/@@%s?%s' % (absoluteURL(site, request),
|
||||||
|
self.loginpagename,
|
||||||
|
urlencode({'camefrom': camefrom}))
|
||||||
|
request.response.redirect(url)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def logout(self, request):
|
||||||
|
presence = component.getUtility(IPresence)
|
||||||
|
presence.removePresentUser(request.principal.id)
|
||||||
|
super(SessionCredentialsPlugin, self).logout(request)
|
||||||
|
|
||||||
|
|
||||||
|
def getCredentials(request):
|
||||||
|
session = ISession(request)
|
||||||
|
sessionData = session.get('zope.pluggableauth.browserplugins')
|
||||||
|
if not sessionData:
|
||||||
|
return None
|
||||||
|
return sessionData.get('credentials')
|
||||||
|
|
||||||
|
|
||||||
|
def getPrincipalFromCredentials(context, request, credentials):
|
||||||
|
if not credentials:
|
||||||
|
return None
|
||||||
|
cred = dict(login=credentials.getLogin(),
|
||||||
|
password=credentials.getPassword())
|
||||||
|
auth = getAuthenticationUtility(context)
|
||||||
|
authenticatorPlugins = [p for n, p in auth.getAuthenticatorPlugins()]
|
||||||
|
for authplugin in authenticatorPlugins:
|
||||||
|
if authplugin is None:
|
||||||
|
continue
|
||||||
|
info = authplugin.authenticateCredentials(cred)
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
info.authenticatorPlugin = authplugin
|
||||||
|
principal = component.getMultiAdapter((info, request),
|
||||||
|
IAuthenticatedPrincipalFactory)(auth)
|
||||||
|
principal.id = auth.prefix + info.id
|
||||||
|
return principal
|
||||||
|
|
||||||
|
def getPrincipalForUsername(username, context, request):
|
||||||
|
auth = getAuthenticationUtility(context)
|
||||||
|
authenticatorPlugins = [p for n, p in auth.getAuthenticatorPlugins()]
|
||||||
|
for authplugin in authenticatorPlugins:
|
||||||
|
if authplugin is None:
|
||||||
|
continue
|
||||||
|
info = authplugin.get(username)
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
info.authenticatorPlugin = authplugin
|
||||||
|
principal = info
|
||||||
|
#principal = component.getMultiAdapter((info, request),
|
||||||
|
# IAuthenticatedPrincipalFactory)(auth)
|
||||||
|
principal.id = authplugin.prefix + info.login
|
||||||
|
return principal
|
||||||
|
|
||||||
|
def sso_send_login(login, password, request):
|
||||||
|
if not sso:
|
||||||
|
return
|
||||||
|
data = dict(login=login, password=password,
|
||||||
|
sso_source=sso.get('source', ''))
|
||||||
|
for url in sso['targets']:
|
||||||
|
resp = requests.post(url, data, cookies=dict(request.cookies),
|
||||||
|
allow_redirects=False)
|
||||||
|
log.info('sso_login - url: %s, login: %s -> %s.' % (
|
||||||
|
url, login, resp.status_code))
|
||||||
|
|
||||||
|
|
434
cco/member/browser.py
Normal file
434
cco/member/browser.py
Normal file
|
@ -0,0 +1,434 @@
|
||||||
|
#
|
||||||
|
# Copyright (c) 2016 Helmut Merz helmutm@cy55.de
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Login, logout, unauthorized stuff.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
import python_jwt as jwt
|
||||||
|
import jwcrypto.jws as jws
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
from datetime import timedelta
|
||||||
|
from email.MIMEText import MIMEText
|
||||||
|
import logging
|
||||||
|
from zope.app.exception.browser.unauthorized import Unauthorized as DefaultUnauth
|
||||||
|
from zope.app.pagetemplate import ViewPageTemplateFile
|
||||||
|
from zope.app.security.interfaces import IAuthentication
|
||||||
|
from zope.app.security.interfaces import ILogout, IUnauthenticatedPrincipal
|
||||||
|
from zope.cachedescriptors.property import Lazy
|
||||||
|
from zope import component
|
||||||
|
from zope.i18n import translate
|
||||||
|
from zope.i18nmessageid import MessageFactory
|
||||||
|
from zope.interface import implements
|
||||||
|
from zope.publisher.interfaces.http import IHTTPRequest
|
||||||
|
from zope.sendmail.interfaces import IMailDelivery
|
||||||
|
|
||||||
|
from cco.member.auth import getCredentials, getPrincipalFromCredentials,\
|
||||||
|
getPrincipalForUsername, JWT_SECRET
|
||||||
|
from cco.member.interfaces import IPasswordChange, IPasswordReset
|
||||||
|
from cco.member.pwpolicy import checkPassword
|
||||||
|
from cybertools.composer.schema.browser.common import schema_macros
|
||||||
|
from cybertools.composer.schema.browser.form import Form
|
||||||
|
from cybertools.composer.schema.schema import FormState, FormError
|
||||||
|
from loops.browser.concept import ConceptView
|
||||||
|
from loops.browser.node import NodeView, getViewConfiguration
|
||||||
|
from loops.common import adapted
|
||||||
|
from loops.organize.interfaces import IMemberRegistrationManager
|
||||||
|
from loops.organize.party import getPersonForUser
|
||||||
|
from loops.organize.util import getPrincipalForUserId, getPrincipalFolder
|
||||||
|
|
||||||
|
try:
|
||||||
|
import config
|
||||||
|
except ImportError:
|
||||||
|
config = None
|
||||||
|
|
||||||
|
log = logging.getLogger('cco.member.browser')
|
||||||
|
|
||||||
|
_ = MessageFactory('cco.member')
|
||||||
|
|
||||||
|
template = ViewPageTemplateFile('auth.pt')
|
||||||
|
|
||||||
|
|
||||||
|
def validateToken(token, secret=None):
|
||||||
|
if not secret:
|
||||||
|
secret = JWT_SECRET
|
||||||
|
try:
|
||||||
|
header, claims = jwt.verify_jwt(token, secret, ['PS256'])
|
||||||
|
except (jwt._JWTError, jws.InvalidJWSSignature,
|
||||||
|
jws.InvalidJWSObject, ValueError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class LoginConcept(ConceptView):
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def macro(self):
|
||||||
|
return template.macros['login_form']
|
||||||
|
|
||||||
|
|
||||||
|
class LoginForm(NodeView):
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def macro(self):
|
||||||
|
return template.macros['login_form']
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def item(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def isVisible(self):
|
||||||
|
return self.isAnonymous
|
||||||
|
|
||||||
|
def update(self, topLevel=True):
|
||||||
|
if 'SUBMIT' in self.request.form and not self.isAnonymous:
|
||||||
|
self.request.response.redirect(self.topMenu.url)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class TanForm(LoginForm):
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def macro(self):
|
||||||
|
return template.macros['tan_form']
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def credentials(self):
|
||||||
|
return getCredentials(self.request)
|
||||||
|
|
||||||
|
def sendTanEmail(self):
|
||||||
|
if self.credentials is None:
|
||||||
|
log.warn('credentials missing')
|
||||||
|
return None
|
||||||
|
person = None
|
||||||
|
cred = self.credentials
|
||||||
|
principal = getPrincipalFromCredentials(
|
||||||
|
self.context, self.request, cred)
|
||||||
|
if principal is not None:
|
||||||
|
person = adapted(getPersonForUser(
|
||||||
|
self.context, self.request, principal))
|
||||||
|
if person is None: # invalid credentials
|
||||||
|
log.warn('invalid credentials: %s, %s' % (cred.login, cred.tan))
|
||||||
|
# TODO: display message
|
||||||
|
return None
|
||||||
|
tan = self.credentials.tan
|
||||||
|
recipient = getattr(person, 'tan_email', None) or person.email
|
||||||
|
recipients = [recipient]
|
||||||
|
lang = self.languageInfo.language
|
||||||
|
subject = translate(_(u'tan_mail_subject'), target_language=lang)
|
||||||
|
message = translate(_(u'tan_mail_text_$tan', mapping=dict(tan=tan)),
|
||||||
|
target_language=lang)
|
||||||
|
senderInfo = self.globalOptions('email.sender')
|
||||||
|
sender = senderInfo and senderInfo[0] or 'info@loops.cy55.de'
|
||||||
|
sender = sender.encode('UTF-8')
|
||||||
|
msg = MIMEText(message.encode('UTF-8'), 'plain', 'UTF-8')
|
||||||
|
msg['Subject'] = subject.encode('UTF-8')
|
||||||
|
msg['From'] = sender
|
||||||
|
msg['To'] = ', '.join(recipients)
|
||||||
|
mailhost = component.getUtility(IMailDelivery, 'Mail')
|
||||||
|
mailhost.send(sender, recipients, msg.as_string())
|
||||||
|
return recipient
|
||||||
|
|
||||||
|
|
||||||
|
class Logout(object):
|
||||||
|
|
||||||
|
implements(ILogout)
|
||||||
|
|
||||||
|
def __init__(self, context, request):
|
||||||
|
self.context = context
|
||||||
|
self.request = request
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
nextUrl = self.request.get('nextURL') or self.request.URL[-1]
|
||||||
|
if not IUnauthenticatedPrincipal.providedBy(self.request.principal):
|
||||||
|
auth = component.getUtility(IAuthentication)
|
||||||
|
ILogout(auth).logout(self.request)
|
||||||
|
return self.request.response.redirect(nextUrl)
|
||||||
|
|
||||||
|
|
||||||
|
class LogoutView(NodeView):
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def body(self):
|
||||||
|
nextUrl = self.topMenu.url
|
||||||
|
if not IUnauthenticatedPrincipal.providedBy(self.request.principal):
|
||||||
|
auth = component.getUtility(IAuthentication)
|
||||||
|
ILogout(auth).logout(self.request)
|
||||||
|
return self.request.response.redirect(nextUrl)
|
||||||
|
|
||||||
|
|
||||||
|
class Unauthorized(ConceptView):
|
||||||
|
|
||||||
|
isTopLevel = True
|
||||||
|
|
||||||
|
def __init__(self, context, request):
|
||||||
|
self.context = context
|
||||||
|
self.request = request
|
||||||
|
|
||||||
|
def __call__(self):
|
||||||
|
response = self.request.response
|
||||||
|
response.setStatus(403)
|
||||||
|
# make sure that squid does not keep the response in the cache
|
||||||
|
response.setHeader('Expires', 'Mon, 26 Jul 1997 05:00:00 GMT')
|
||||||
|
response.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate')
|
||||||
|
response.setHeader('Pragma', 'no-cache')
|
||||||
|
if self.nodeView is None:
|
||||||
|
v = DefaultUnauth(self.context, self.request)
|
||||||
|
return v()
|
||||||
|
url = self.nodeView.topMenu.url
|
||||||
|
if self.isAnonymous:
|
||||||
|
response.redirect(url)
|
||||||
|
else:
|
||||||
|
response.redirect(url + '/unauthorized')
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChange(NodeView, Form):
|
||||||
|
|
||||||
|
interface = IPasswordChange
|
||||||
|
message = _(u'message_password_changed')
|
||||||
|
|
||||||
|
formErrors = dict(
|
||||||
|
confirm_nomatch=FormError(_(u'error_password_confirm_nomatch')),
|
||||||
|
wrong_oldpw=FormError(_(u'error_password_wrong_oldpw')),
|
||||||
|
invalid_pw=FormError(_(u'error_password_invalid_pw')),
|
||||||
|
)
|
||||||
|
|
||||||
|
label = label_submit = _(u'label_change_password')
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def macro(self):
|
||||||
|
return schema_macros.macros['form']
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def item(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def data(self):
|
||||||
|
return dict(oldPassword=u'', password=u'', passwordConfirm=u'')
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
form = self.request.form
|
||||||
|
if not form.get('action'):
|
||||||
|
return True
|
||||||
|
formState = self.formState = self.validate(form)
|
||||||
|
if formState.severity > 0:
|
||||||
|
return True
|
||||||
|
pw = form.get('password')
|
||||||
|
if not checkPassword(pw):
|
||||||
|
fi = formState.fieldInstances['password']
|
||||||
|
fi.setError('invalid_pw', self.formErrors)
|
||||||
|
formState.severity = max(formState.severity, fi.severity)
|
||||||
|
return True
|
||||||
|
pwConfirm = form.get('passwordConfirm')
|
||||||
|
if pw != pwConfirm:
|
||||||
|
fi = formState.fieldInstances['password']
|
||||||
|
fi.setError('confirm_nomatch', self.formErrors)
|
||||||
|
formState.severity = max(formState.severity, fi.severity)
|
||||||
|
return True
|
||||||
|
oldPw = form.get('oldPassword')
|
||||||
|
regMan = IMemberRegistrationManager(self.loopsRoot)
|
||||||
|
principal = self.request.principal
|
||||||
|
result = regMan.changePassword(principal, oldPw, pw)
|
||||||
|
if not result:
|
||||||
|
fi = formState.fieldInstances['oldPassword']
|
||||||
|
fi.setError('wrong_oldpw', self.formErrors)
|
||||||
|
formState.severity = max(formState.severity, fi.severity)
|
||||||
|
return True
|
||||||
|
url = '%s?error_message=%s' % (self.url, self.message)
|
||||||
|
self.request.response.redirect(url)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
formState = FormState()
|
||||||
|
for f in self.schema.fields:
|
||||||
|
fi = f.getFieldInstance()
|
||||||
|
value = data.get(f.name)
|
||||||
|
fi.validate(value, data)
|
||||||
|
formState.fieldInstances.append(fi)
|
||||||
|
formState.severity = max(formState.severity, fi.severity)
|
||||||
|
return formState
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordReset(PasswordChange):
|
||||||
|
|
||||||
|
interface = IPasswordReset
|
||||||
|
message = _(u'message_password_reset_successfully')
|
||||||
|
reset_mail_message = _(u'message_password_reset_mail')
|
||||||
|
|
||||||
|
formErrors = dict(
|
||||||
|
invalid_pw=FormError(_(u'error_password_invalid_pw')),
|
||||||
|
invalid_token=FormError(_(u'error_reset_token_invalid')),
|
||||||
|
invalid_username=FormError(_(u'error_username_invalid')),
|
||||||
|
)
|
||||||
|
|
||||||
|
label = label_submit = _(u'label_reset_password')
|
||||||
|
password_reset_period = 15
|
||||||
|
ignoreRedirect = False
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def macro(self):
|
||||||
|
return template.macros['reset_form']
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def item(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def data(self):
|
||||||
|
return dict(password=u'')
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def token(self):
|
||||||
|
return self.request.form.get('token')
|
||||||
|
|
||||||
|
@Lazy
|
||||||
|
def fields(self):
|
||||||
|
result = super(PasswordReset, self).fields
|
||||||
|
if self.token and validateToken(self.token):
|
||||||
|
result = [r for r in result if r.name == 'password']
|
||||||
|
else:
|
||||||
|
result = [r for r in result if r.name == 'username']
|
||||||
|
return result
|
||||||
|
|
||||||
|
def getSubject(self, lang=None, domain=None):
|
||||||
|
if not lang:
|
||||||
|
lang = self.languageInfo.language
|
||||||
|
if not domain:
|
||||||
|
try:
|
||||||
|
domain = config.baseDomain
|
||||||
|
except:
|
||||||
|
domain = self.request.getHeader('HTTP_HOST')
|
||||||
|
result = translate(_(u'pw_reset_mail_subject_$domain',
|
||||||
|
mapping=dict(domain=domain)),
|
||||||
|
target_language=lang)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def getMessage(self, token, lang=None):
|
||||||
|
if not lang:
|
||||||
|
lang = self.languageInfo.language
|
||||||
|
reset_url = '%s?token=%s' % (self.request.getURL(), token)
|
||||||
|
message = translate(_(u'pw_reset_mail_text_$link',
|
||||||
|
mapping=dict(link=reset_url)),
|
||||||
|
target_language=lang)
|
||||||
|
return message
|
||||||
|
|
||||||
|
def sendPasswordResetMail(self, sender, recipients=[], subject='',
|
||||||
|
message=''):
|
||||||
|
msg = MIMEText(message.encode('UTF-8'), 'html', 'UTF-8')
|
||||||
|
msg['Subject'] = subject.encode('UTF-8')
|
||||||
|
msg['From'] = sender
|
||||||
|
msg['To'] = ', '.join(recipients)
|
||||||
|
mailhost = component.getUtility(IMailDelivery, 'Mail')
|
||||||
|
mailhost.send(sender, recipients, msg.as_string())
|
||||||
|
|
||||||
|
def validateToken(self, token, secret=None):
|
||||||
|
return validateToken(token, secret)
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
form = self.request.form
|
||||||
|
if not form.get('action'):
|
||||||
|
return True
|
||||||
|
principal = self.request.principal
|
||||||
|
if principal and principal.id != 'zope.anybody':
|
||||||
|
return True
|
||||||
|
formState = self.formState = self.validate(form)
|
||||||
|
if formState.severity > 0:
|
||||||
|
return True
|
||||||
|
token = form.get('token')
|
||||||
|
secret = JWT_SECRET
|
||||||
|
if token:
|
||||||
|
if not validateToken(token):
|
||||||
|
fi = formState.fieldInstances['password']
|
||||||
|
fi.setError('invalid_token', self.formErrors)
|
||||||
|
formState.severity = max(formState.severity, fi.severity)
|
||||||
|
return True
|
||||||
|
header, claims = jwt.verify_jwt(token, secret, ['PS256'])
|
||||||
|
username = claims.get('username')
|
||||||
|
principal = getPrincipalForUsername(username, self.context,
|
||||||
|
self.request)
|
||||||
|
if not principal:
|
||||||
|
fi = formState.fieldInstances['password']
|
||||||
|
fi.setError('invalid_username', self.formErrors)
|
||||||
|
formState.severity = max(formState.severity, fi.severity)
|
||||||
|
return True
|
||||||
|
pw = form.get('password')
|
||||||
|
if not checkPassword(pw):
|
||||||
|
fi = formState.fieldInstances['password']
|
||||||
|
fi.setError('invalid_pw', self.formErrors)
|
||||||
|
formState.severity = max(formState.severity, fi.severity)
|
||||||
|
return True
|
||||||
|
principal.setPassword(pw)
|
||||||
|
else:
|
||||||
|
username = form.get('username')
|
||||||
|
principal = getPrincipalForUsername(username, self.context,
|
||||||
|
self.request)
|
||||||
|
person = getPersonForUser(self.context, self.request, principal)
|
||||||
|
if not person:
|
||||||
|
fi = formState.fieldInstances['username']
|
||||||
|
fi.setError('invalid_username', self.formErrors)
|
||||||
|
formState.severity = max(formState.severity, fi.severity)
|
||||||
|
return True
|
||||||
|
person = adapted(person)
|
||||||
|
payload = dict(username=username)
|
||||||
|
token = jwt.generate_jwt(payload, secret, 'PS256',
|
||||||
|
timedelta(minutes=getattr(
|
||||||
|
self, 'password_reset_period', 15)))
|
||||||
|
addr = self.getMailAddress(person)
|
||||||
|
if addr:
|
||||||
|
recipients = [addr]
|
||||||
|
else:
|
||||||
|
fi = formState.fieldInstances['username']
|
||||||
|
fi.setError('invalid_username', self.formErrors)
|
||||||
|
formState.severity = max(formState.severity, fi.severity)
|
||||||
|
return True
|
||||||
|
lang = self.languageInfo.language
|
||||||
|
try:
|
||||||
|
domain = config.baseDomain
|
||||||
|
except:
|
||||||
|
domain = self.request.getHeader('HTTP_HOST')
|
||||||
|
senderInfo = self.globalOptions('email.sender')
|
||||||
|
sender = senderInfo and senderInfo[0] or 'info@loops.cy55.de'
|
||||||
|
sender = sender.encode('UTF-8')
|
||||||
|
self.sendPasswordResetMail(sender, recipients, self.getSubject(),
|
||||||
|
self.getMessage(token))
|
||||||
|
url = '%s?error_message=%s' % (self.url, self.reset_mail_message)
|
||||||
|
if not self.ignoreRedirect:
|
||||||
|
self.request.response.redirect(url)
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = '%s?error_message=%s' % (self.url, self.message)
|
||||||
|
self.request.response.redirect(url)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def getMailAddress(self, person):
|
||||||
|
return person.email
|
||||||
|
|
||||||
|
def validate(self, data):
|
||||||
|
formState = FormState()
|
||||||
|
for f in self.schema.fields:
|
||||||
|
fi = f.getFieldInstance()
|
||||||
|
value = data.get(f.name)
|
||||||
|
fi.validate(value, data)
|
||||||
|
formState.fieldInstances.append(fi)
|
||||||
|
formState.severity = max(formState.severity, fi.severity)
|
||||||
|
return formState
|
81
cco/member/configure.zcml
Normal file
81
cco/member/configure.zcml
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
<configure
|
||||||
|
xmlns:zope="http://namespaces.zope.org/zope"
|
||||||
|
xmlns:browser="http://namespaces.zope.org/browser"
|
||||||
|
xmlns:i18n="http://namespaces.zope.org/i18n"
|
||||||
|
i18n_domain="cco.member">
|
||||||
|
|
||||||
|
<i18n:registerTranslations directory="locales" />
|
||||||
|
|
||||||
|
<!-- authentication -->
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="auth"
|
||||||
|
for="* zope.publisher.interfaces.IRequest"
|
||||||
|
provides="zope.traversing.interfaces.ITraversable"
|
||||||
|
factory="cco.member.auth.AuthURLNameSpace" />
|
||||||
|
|
||||||
|
<zope:utility
|
||||||
|
name="cco.member Session Credentials"
|
||||||
|
provides="zope.app.authentication.interfaces.ICredentialsPlugin"
|
||||||
|
factory="cco.member.auth.SessionCredentialsPlugin" />
|
||||||
|
|
||||||
|
<!--<zope:class class="cco.member.auth.SessionCredentialsPlugin">
|
||||||
|
<require
|
||||||
|
permission="zope.ManageServices"
|
||||||
|
interface="zope.app.authentication.session.IBrowserFormChallenger"
|
||||||
|
set_schema="zope.app.authentication.session.IBrowserFormChallenger" />
|
||||||
|
</zope:class>-->
|
||||||
|
|
||||||
|
<!-- views -->
|
||||||
|
|
||||||
|
<browser:page for="loops.interfaces.INode"
|
||||||
|
name="login.html"
|
||||||
|
class="cco.member.browser.LoginForm"
|
||||||
|
permission="zope.View" />
|
||||||
|
|
||||||
|
<browser:page for="loops.interfaces.INode"
|
||||||
|
name="2fa_tan_form.html"
|
||||||
|
class="cco.member.browser.TanForm"
|
||||||
|
permission="zope.View" />
|
||||||
|
|
||||||
|
<!-- <browser:page for="loops.interfaces.INode"
|
||||||
|
name="logout.html"
|
||||||
|
class="cco.member.browser.Logout"
|
||||||
|
permission="zope.View" />-->
|
||||||
|
|
||||||
|
<browser:page for="loops.interfaces.INode"
|
||||||
|
name="logout_view"
|
||||||
|
class="cco.member.browser.LogoutView"
|
||||||
|
permission="zope.View" />
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="login.html"
|
||||||
|
for="loops.interfaces.IConcept
|
||||||
|
zope.publisher.interfaces.browser.IBrowserRequest"
|
||||||
|
provides="zope.interface.Interface"
|
||||||
|
factory="cco.member.browser.LoginConcept"
|
||||||
|
permission="zope.View" />
|
||||||
|
|
||||||
|
<browser:page
|
||||||
|
for="loops.interfaces.INode"
|
||||||
|
name="cco_change_password.html"
|
||||||
|
class="cco.member.browser.PasswordChange"
|
||||||
|
permission="zope.View" />
|
||||||
|
|
||||||
|
<browser:page
|
||||||
|
for="loops.interfaces.INode"
|
||||||
|
name="cco_reset_password.html"
|
||||||
|
class="cco.member.browser.PasswordReset"
|
||||||
|
permission="zope.View" />
|
||||||
|
|
||||||
|
<!-- webapi -->
|
||||||
|
|
||||||
|
<zope:adapter
|
||||||
|
name="member_api_users"
|
||||||
|
for="loops.interfaces.ITypeConcept
|
||||||
|
zope.publisher.interfaces.http.IHTTPRequest"
|
||||||
|
provides="zope.interface.Interface"
|
||||||
|
factory="cco.member.webapi.Users"
|
||||||
|
permission="cco.webapi.Post" />
|
||||||
|
|
||||||
|
</configure>
|
61
cco/member/interfaces.py
Normal file
61
cco/member/interfaces.py
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
#
|
||||||
|
# Copyright (c) 2016 Helmut Merz helmutm@cy55.de
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Interfaces for member registration, password change, etc.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from zope.i18nmessageid import MessageFactory
|
||||||
|
from zope.interface import Interface
|
||||||
|
from zope import schema
|
||||||
|
|
||||||
|
|
||||||
|
_ = MessageFactory('cco.member')
|
||||||
|
|
||||||
|
|
||||||
|
class IPasswordChange(Interface):
|
||||||
|
|
||||||
|
oldPassword = schema.Password(title=_(u'label_old_password'),
|
||||||
|
description=_(u'desc_old_password'),
|
||||||
|
required=True,)
|
||||||
|
|
||||||
|
password = schema.Password(title=_(u'label_new_password'),
|
||||||
|
description=_(u'desc_new_password'),
|
||||||
|
required=True,)
|
||||||
|
|
||||||
|
passwordConfirm = schema.Password(title=_(u'label_confirm_new_password'),
|
||||||
|
description=_(u'desc_confirm_new_password'),
|
||||||
|
required=True,)
|
||||||
|
|
||||||
|
oldPassword.nostore = True
|
||||||
|
password.nostore = True
|
||||||
|
passwordConfirm.nostore = True
|
||||||
|
|
||||||
|
|
||||||
|
class IPasswordReset(Interface):
|
||||||
|
|
||||||
|
username = schema.TextLine(title=_(u'label_username'),
|
||||||
|
description=_(u'desc_usernam'),
|
||||||
|
required=False,)
|
||||||
|
|
||||||
|
password = schema.Password(title=_(u'label_new_password'),
|
||||||
|
description=_(u'desc_new_password'),
|
||||||
|
required=False,)
|
||||||
|
|
||||||
|
username.nostore = True
|
||||||
|
password.nostore = True
|
16
cco/member/locales/cco.member.pot
Normal file
16
cco/member/locales/cco.member.pot
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
"Project-Id-Version: 1.0\n"
|
||||||
|
"POT-Creation-Date: 2015-11-23 12:00 CET\n"
|
||||||
|
"PO-Revision-Date: 2015-11-23 12:00 CET\n"
|
||||||
|
"Last-Translator: Helmut Merz <helmutm@cy55.de>\n"
|
||||||
|
"Language-Team: cco.member developers <helmutm@cy55.de>\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Generated-By: kwrite\n"
|
||||||
|
|
||||||
|
msgid "Login"
|
||||||
|
msgstr ""
|
||||||
|
|
BIN
cco/member/locales/de/LC_MESSAGES/cco.member.mo
Normal file
BIN
cco/member/locales/de/LC_MESSAGES/cco.member.mo
Normal file
Binary file not shown.
121
cco/member/locales/de/LC_MESSAGES/cco.member.po
Normal file
121
cco/member/locales/de/LC_MESSAGES/cco.member.po
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
msgid ""
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
"Project-Id-Version: 1.0\n"
|
||||||
|
"POT-Creation-Date: 2015-11-23 12:00 CET\n"
|
||||||
|
"PO-Revision-Date: 2020-03-24 12:00+0100\n"
|
||||||
|
"Last-Translator: Helmut Merz <helmutm@cy55.de>\n"
|
||||||
|
"Language-Team: STEG developers <helmutm@cy55.de>\n"
|
||||||
|
"MIME-Version: 1.0\n"
|
||||||
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
"Generated-By: sublime_text\n"
|
||||||
|
|
||||||
|
# authentication
|
||||||
|
|
||||||
|
msgid "title_login"
|
||||||
|
msgstr "Anmeldung"
|
||||||
|
|
||||||
|
msgid "description_login"
|
||||||
|
msgstr "Bitte geben Sie ihre Anmeldedaten ein"
|
||||||
|
|
||||||
|
msgid "label_login"
|
||||||
|
msgstr "Benutzername"
|
||||||
|
|
||||||
|
msgid "label_password"
|
||||||
|
msgstr "Passwort"
|
||||||
|
|
||||||
|
msgid "button_login"
|
||||||
|
msgstr "Anmelden"
|
||||||
|
|
||||||
|
msgid "button_login_2factor"
|
||||||
|
msgstr "Daten eingeben"
|
||||||
|
|
||||||
|
msgid "title_login_tan"
|
||||||
|
msgstr "Anmeldung: TAN-Eingabe"
|
||||||
|
|
||||||
|
msgid "message_enter_tan_digits_$email_$a_$b"
|
||||||
|
msgstr "Ein E-Mail mit einer Transaktionsnummer (TAN) wurde an die Adresse $email gesendet. Bitte geben Sie die $a. und die $b. Ziffer dieser TAN in die untenstehenden Felder ein."
|
||||||
|
|
||||||
|
msgid "tan_mail_subject"
|
||||||
|
msgstr "Ihre TAN für den Zugang"
|
||||||
|
|
||||||
|
msgid "tan_mail_text_$tan"
|
||||||
|
msgstr "TAN: $tan"
|
||||||
|
|
||||||
|
msgid "label_tan_$a"
|
||||||
|
msgstr "TAN Ziffer $a"
|
||||||
|
|
||||||
|
msgid "label_tan_$b"
|
||||||
|
msgstr "TAN Ziffer $b"
|
||||||
|
|
||||||
|
msgid "button_login_tan"
|
||||||
|
msgstr "Anmelden"
|
||||||
|
|
||||||
|
msgid "Invalid login data!"
|
||||||
|
msgstr "Ungültige Anmeldedaten!"
|
||||||
|
|
||||||
|
# change password
|
||||||
|
|
||||||
|
msgid "label_change_password"
|
||||||
|
msgstr "Passwort ändern"
|
||||||
|
|
||||||
|
msgid "label_old_password"
|
||||||
|
msgstr "Altes Passwort"
|
||||||
|
|
||||||
|
msgid "desc_old_password"
|
||||||
|
msgstr "Bitte geben Sie Ihr bisheriges Passwort ein."
|
||||||
|
|
||||||
|
msgid "label_new_password"
|
||||||
|
msgstr "Neues Passwort"
|
||||||
|
|
||||||
|
msgid "desc_new_password"
|
||||||
|
msgstr "Bitte geben Sie das gewünschte neue Passwort ein."
|
||||||
|
|
||||||
|
msgid "label_confirm_new_password"
|
||||||
|
msgstr "Neues Passwort wiederholen"
|
||||||
|
|
||||||
|
msgid "desc_confirm_new_password"
|
||||||
|
msgstr "Bitte wiederholen Sie das neue Passwort."
|
||||||
|
|
||||||
|
msgid "message_password_changed"
|
||||||
|
msgstr "Ihr Passwort wurde geändert. Bitte melden Sie sich mit Ihrem neuen Passwort an."
|
||||||
|
|
||||||
|
msgid "error_password_wrong_oldpw"
|
||||||
|
msgstr "Das eingegebene alte Passwort ist nicht korrekt."
|
||||||
|
|
||||||
|
msgid "error_password_confirm_nomatch"
|
||||||
|
msgstr "Passwort und Passwort-Wiederholung stimmen nicht überein."
|
||||||
|
|
||||||
|
msgid "error_password_invalid_pw"
|
||||||
|
msgstr "Das eingegebene Passwort entspricht nicht den Anforderungen an ein sicheres Passwort: mindestens acht Zeichen, Groß- und Kleinbuchstaben, mindestens eine Ziffer oder ein Sonderzeichen."
|
||||||
|
|
||||||
|
# password reset
|
||||||
|
msgid "pw_reset_mail_text_$link"
|
||||||
|
msgstr ""
|
||||||
|
"Bitte nutzen Sie folgenden Link für um Ihr Passwort zurückzusetzen:<br />"
|
||||||
|
"<a href=\"$link\">Passwort zurücksetzen</a>"
|
||||||
|
|
||||||
|
msgid "pw_reset_mail_subject_$domain"
|
||||||
|
msgstr "Ihre Passwort zurücksetzen Anforderung auf $domain"
|
||||||
|
|
||||||
|
msgid "message_password_reset_successfully"
|
||||||
|
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt. Sie können sich ab sofort mit Ihrem neuen Passwort einloggen."
|
||||||
|
|
||||||
|
msgid "message_password_reset_mail"
|
||||||
|
msgstr "Sie erhalten zeitnah eine E-Mail mit dem Passwort Reset Link."
|
||||||
|
|
||||||
|
msgid "label_reset_password"
|
||||||
|
msgstr "Passwort (zurück)setzen"
|
||||||
|
|
||||||
|
msgid "error_username_invalid"
|
||||||
|
msgstr "Ungültiger Benutzername/ E-Mail"
|
||||||
|
|
||||||
|
msgid "error_reset_token_invalid"
|
||||||
|
msgstr "Reset Token ungültig/ abgelaufen"
|
||||||
|
|
||||||
|
msgid "label_username"
|
||||||
|
msgstr "Benutzername/ E-Mail"
|
||||||
|
|
||||||
|
msgid "label_forgot_password"
|
||||||
|
msgstr "Passwort vergessen?"
|
37
cco/member/pwpolicy.py
Normal file
37
cco/member/pwpolicy.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
#
|
||||||
|
# Copyright (c) 2016 Helmut Merz helmutm@cy55.de
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation; either version 2 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||||
|
#
|
||||||
|
|
||||||
|
"""
|
||||||
|
Check passwords for conformance to password policy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def checkPassword(pw):
|
||||||
|
if len(pw) < 8:
|
||||||
|
return False
|
||||||
|
safety = dict(upper=False, lower=False, nonalpha=False)
|
||||||
|
for c in pw:
|
||||||
|
if ord(c) > 128:
|
||||||
|
return False
|
||||||
|
if c.isupper():
|
||||||
|
safety['upper'] = True
|
||||||
|
elif c.islower():
|
||||||
|
safety['lower'] = True
|
||||||
|
else:
|
||||||
|
safety['nonalpha'] = True
|
||||||
|
return False not in safety.values()
|
11
cco/member/testing/user_post.sh
Executable file
11
cco/member/testing/user_post.sh
Executable file
|
@ -0,0 +1,11 @@
|
||||||
|
#user_post.sh
|
||||||
|
|
||||||
|
url="http://localhost:11080/sites/steg/views/webapi/users" # local dev
|
||||||
|
#url="http://fms.steg.cy55.de/api/users" # STEG internal
|
||||||
|
login="api"
|
||||||
|
password="dummy"
|
||||||
|
data='{"name": "steg.test77"}'
|
||||||
|
header_ct="Content-Type: application/json"
|
||||||
|
|
||||||
|
curl -v -u $login:$password --data-raw "$data" -H "$header_ct" $url
|
||||||
|
|
44
cco/member/tests.py
Normal file
44
cco/member/tests.py
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
# cco.member.tests
|
||||||
|
|
||||||
|
""" Tests for the 'cco.member' package.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest, doctest
|
||||||
|
from zope import component
|
||||||
|
from zope.app.testing.setup import placefulSetUp, placefulTearDown
|
||||||
|
from zope.publisher.browser import TestRequest
|
||||||
|
|
||||||
|
from loops.interfaces import IConceptManager
|
||||||
|
from loops.tests.setup import TestSite
|
||||||
|
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
site = placefulSetUp(True)
|
||||||
|
t = TestSite(site)
|
||||||
|
concepts, resources, views = t.setup()
|
||||||
|
loopsRoot = site['loops']
|
||||||
|
self.globs['loopsRoot'] = loopsRoot
|
||||||
|
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
placefulTearDown()
|
||||||
|
|
||||||
|
|
||||||
|
class Test(unittest.TestCase):
|
||||||
|
"Basic tests."
|
||||||
|
|
||||||
|
def testBasicStuff(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_suite():
|
||||||
|
flags = doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS
|
||||||
|
return unittest.TestSuite((
|
||||||
|
unittest.makeSuite(Test),
|
||||||
|
doctest.DocFileSuite('README.txt', optionflags=flags,
|
||||||
|
setUp=setUp, tearDown=tearDown),
|
||||||
|
))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main(defaultTest='test_suite')
|
14
cco/member/webapi.py
Normal file
14
cco/member/webapi.py
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
#
|
||||||
|
# cco.member.webapi
|
||||||
|
#
|
||||||
|
|
||||||
|
from cco.webapi.server import TypeHandler
|
||||||
|
|
||||||
|
|
||||||
|
class Users(TypeHandler):
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
data = self.getInputData()
|
||||||
|
print '***', data
|
||||||
|
#create_or_update_object(self.loopsRoot, 'person', data)
|
||||||
|
return self.success()
|
23
pyproject.toml
Normal file
23
pyproject.toml
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
[build-system]
|
||||||
|
requires = ["setuptools"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "loops-ext"
|
||||||
|
version = "3.0.1"
|
||||||
|
description = "extensions for the loops web application platform"
|
||||||
|
readme = "README.md"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
keywords = ["loops"]
|
||||||
|
authors = [{name = "Helmut Merz", email = "helmutm@cy55.de"}]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"loops"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
|
||||||
|
test = ["zope.testrunner"]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["cco"]
|
5
runtests.sh
Executable file
5
runtests.sh
Executable file
|
@ -0,0 +1,5 @@
|
||||||
|
# runtests.sh
|
||||||
|
# run all unit / doc tests
|
||||||
|
|
||||||
|
zope-testrunner --test-path=. $*
|
||||||
|
|
Loading…
Add table
Reference in a new issue