From c9d4f525e93c847f7cdb81202cf7f500b0f25878 Mon Sep 17 00:00:00 2001 From: Helmut Merz Date: Mon, 30 Sep 2024 10:33:14 +0200 Subject: [PATCH] initial import --- .gitignore | 12 + LICENSE | 21 + README.md | 7 + cco/__init__.py | 1 + cco/member/README.txt | 88 ++++ cco/member/__init__.py | 1 + cco/member/auth.pt | 124 +++++ cco/member/auth.py | 350 ++++++++++++++ cco/member/browser.py | 434 ++++++++++++++++++ cco/member/configure.zcml | 81 ++++ cco/member/interfaces.py | 61 +++ cco/member/locales/cco.member.pot | 16 + .../locales/de/LC_MESSAGES/cco.member.mo | Bin 0 -> 3230 bytes .../locales/de/LC_MESSAGES/cco.member.po | 121 +++++ cco/member/pwpolicy.py | 37 ++ cco/member/testing/user_post.sh | 11 + cco/member/tests.py | 44 ++ cco/member/webapi.py | 14 + pyproject.toml | 23 + runtests.sh | 5 + 20 files changed, 1451 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cco/__init__.py create mode 100644 cco/member/README.txt create mode 100644 cco/member/__init__.py create mode 100644 cco/member/auth.pt create mode 100644 cco/member/auth.py create mode 100644 cco/member/browser.py create mode 100644 cco/member/configure.zcml create mode 100644 cco/member/interfaces.py create mode 100644 cco/member/locales/cco.member.pot create mode 100644 cco/member/locales/de/LC_MESSAGES/cco.member.mo create mode 100644 cco/member/locales/de/LC_MESSAGES/cco.member.po create mode 100644 cco/member/pwpolicy.py create mode 100755 cco/member/testing/user_post.sh create mode 100644 cco/member/tests.py create mode 100644 cco/member/webapi.py create mode 100644 pyproject.toml create mode 100755 runtests.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..436408a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +*.pyc +*.pyo +*.swp +dist/ +var/ +*.egg-info +*.project +*.pydevproject +*.ropeproject +*.sublime-project +*.sublime-workspace +.settings diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1ae14e4 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..122d1f7 --- /dev/null +++ b/README.md @@ -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. diff --git a/cco/__init__.py b/cco/__init__.py new file mode 100644 index 0000000..324a85e --- /dev/null +++ b/cco/__init__.py @@ -0,0 +1 @@ +# loops-ext/cco/member diff --git a/cco/member/README.txt b/cco/member/README.txt new file mode 100644 index 0000000..4054509 --- /dev/null +++ b/cco/member/README.txt @@ -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 diff --git a/cco/member/__init__.py b/cco/member/__init__.py new file mode 100644 index 0000000..27fbd65 --- /dev/null +++ b/cco/member/__init__.py @@ -0,0 +1 @@ +# package cco.member diff --git a/cco/member/auth.pt b/cco/member/auth.pt new file mode 100644 index 0000000..eaa60c1 --- /dev/null +++ b/cco/member/auth.pt @@ -0,0 +1,124 @@ + + + + +

+

Login

+
+

+ Please provide Login Information

+
+ + + + + +
+
User Name
+
+
+

+
+
Password
+
+
+

+
+ + +
+ +
+
+
+
+ + + +

Login: TAN Entry

+ +

+ An E-mail with a TAN has been sent to + . + Please enter digits + A and + B below.

+
+ + + +
+
TAN Digit +
+
+ +
+
+
+
TAN Digit +
+
+ +
+

+
+ +
+
+
+ +
+ + +

Edit

+
+
+ + + + +
+ +
+ + +
+
+ + diff --git a/cco/member/auth.py b/cco/member/auth.py new file mode 100644 index 0000000..f2a2b2c --- /dev/null +++ b/cco/member/auth.py @@ -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)) + + diff --git a/cco/member/browser.py b/cco/member/browser.py new file mode 100644 index 0000000..98643b3 --- /dev/null +++ b/cco/member/browser.py @@ -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 diff --git a/cco/member/configure.zcml b/cco/member/configure.zcml new file mode 100644 index 0000000..dfe7c0d --- /dev/null +++ b/cco/member/configure.zcml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cco/member/interfaces.py b/cco/member/interfaces.py new file mode 100644 index 0000000..bbe5a61 --- /dev/null +++ b/cco/member/interfaces.py @@ -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 diff --git a/cco/member/locales/cco.member.pot b/cco/member/locales/cco.member.pot new file mode 100644 index 0000000..c1539e6 --- /dev/null +++ b/cco/member/locales/cco.member.pot @@ -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 \n" +"Language-Team: cco.member developers \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 "" + diff --git a/cco/member/locales/de/LC_MESSAGES/cco.member.mo b/cco/member/locales/de/LC_MESSAGES/cco.member.mo new file mode 100644 index 0000000000000000000000000000000000000000..c37464fc4c916d97e2e44ce18e1068b4ea9f5b04 GIT binary patch literal 3230 zcmZ{lUuYaf9LGnkt&LjSYSmh+(>8^)b}yH-g>p?(6Pw0hQ$w3p$jdT&JGZyZ?(8u; zn`;hzRuO&ENB@96i6AP15)nLn79SJ@5rw|_CW5bif3tggcS*XCd~Rlbzxn;0nLqaL zc$eWjhQFurcjg|(#=$H1;vc>T?qlpxa5wl6co=*PJPGavFMXYqVEjHv{(J_Ke?Jub zsaXFVJdXLl!6(6e_cL|~JP95HFN24{TOh5!0a9Eafn@IsknH~mJ_7y>9zc%%0!jYA zoyZZmcUSJuL6F8{;21aoPJ%1oDe!IZIQT7C1OEiS0S`f`3jP8f0e9|ZjDuq!`Lh60 zURFS=M+Q>7Z-SJE_X>UjlKrnh^6xwFVer?2cRi0T`v{;|96+?P#98_~^CCWK{q)U0HkK&+@`hz~C(#A^u zQB5cgm@PkwpL&$+!JLhYYJwE9XYogQpqkP5L=J}bvVI>0(E_%|Xo-4t=vz>0A_{#T zB|)gTFN`?Cyv&$Hxpr!3;7&F~-6VQ=(g?+TnbzHUq8g!&T_xLYOQdN#(SE+NbtZ}Y zVHq8^Ojuf#S|_>`=|p^`=mPEPY|c>~^u{ON~SaiP@}>XET==N=H65l#hI>OkqCE9p5yG+`FTq z@W~FmV$h3*#WGC=0+7l`?UIZi2BAsaQ5lOcg1S5El~TR^i?IEz3%MVpdbhPStJh() zkwwuuYqj0JhMQ*IEm=42sET}hJYIWp_ zNR6|sg-RoVj<4|-WfW(IFG}6vQ%yUFpRcczDT)}ndD3PaW@r~3tH_`B#`ayhDz&AY%wHGhPJ9K zG&T3av?(_xI2aORJ%4zn{Bi^5m7e>V3V3S5I7S>QgkQY7;Yn@1xrCT( z{5FPuXdJ~!TiQzJIzF^#6plM`f*mmuHg!CO5O(XkUgL47a7QAyDHmjKMUsp<#piX> z{lKw)z7)Z(m(`o8!OerU0VS+Rz7jT2aGt;(xyY|4*i?7&ttzP9u;(H|zROI6sVQPz zO|+J)sMR&P3;8nLg>ax~jd3o#Kt>{Kp#J@a(R@~_%yjZQa-7dOi*yB)+jl=EUlhBF zcIXE_*fD+CHK|e9RH}F!2-^#sD`z}w}StGyiIQXCw4 zLxxK4=a9ra5ailWPLWxA>cuL(MyblsBbtw4uM@nTq0&@iAFV&*qrj=;`7O#3k3<4Y zg>N|k5FQm3TVYZh^(s9lF~pn0$x(WIv6#^vfHpyEPRp!WRz&y6bz=shi+*#@0TmMpSN{K>P8X; zI7P5rREE-GI`EH-t#-dw3bw3k!sBUzvt#b=B+7wT6X(iW8))TX^StVVN06gydGBq> z3t@XUPYHRK=aoX>jqZl#S\n" +"Language-Team: STEG developers \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:
" +"Passwort zurücksetzen" + +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?" diff --git a/cco/member/pwpolicy.py b/cco/member/pwpolicy.py new file mode 100644 index 0000000..cf82cb4 --- /dev/null +++ b/cco/member/pwpolicy.py @@ -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() diff --git a/cco/member/testing/user_post.sh b/cco/member/testing/user_post.sh new file mode 100755 index 0000000..45442ae --- /dev/null +++ b/cco/member/testing/user_post.sh @@ -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 + diff --git a/cco/member/tests.py b/cco/member/tests.py new file mode 100644 index 0000000..a4417ea --- /dev/null +++ b/cco/member/tests.py @@ -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') diff --git a/cco/member/webapi.py b/cco/member/webapi.py new file mode 100644 index 0000000..c6ba713 --- /dev/null +++ b/cco/member/webapi.py @@ -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() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..5db09df --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/runtests.sh b/runtests.sh new file mode 100755 index 0000000..6e514f2 --- /dev/null +++ b/runtests.sh @@ -0,0 +1,5 @@ +# runtests.sh +# run all unit / doc tests + +zope-testrunner --test-path=. $* +