Compare commits

..

10 commits

14 changed files with 129 additions and 9 deletions

View file

@ -24,6 +24,9 @@ dbpassword = getenv('DBPASSWORD', 'secret')
dbschema = getenv('DBSCHEMA', 'demo') dbschema = getenv('DBSCHEMA', 'demo')
# OpenID Connect (OIDC, e.g. via zitadel) authentication settings # OpenID Connect (OIDC, e.g. via zitadel) authentication settings
authentication_method = 'cookie' # 'legacy'|'select'|'oidc'|'cookie'
# OpenID Connect (OIDC) authentication settings
oidc_provider = getenv('OIDC_PROVIDER', '') #'https://instance1-abcdef.zitadel.cloud') oidc_provider = getenv('OIDC_PROVIDER', '') #'https://instance1-abcdef.zitadel.cloud')
oidc_client_id = getenv('OIDC_CLIENT_ID', '12345') oidc_client_id = getenv('OIDC_CLIENT_ID', '12345')
oidc_params = dict( oidc_params = dict(

View file

@ -1,4 +1,27 @@
<configure xmlns="http://namespaces.zope.org/zope"> <configure xmlns="http://namespaces.zope.org/zope"
xmlns:browser="http://namespaces.zope.org/browser">
<browser:page
for="zope.security.interfaces.IUnauthorized"
name="index.html"
class="loops.server.auth.Unauthorized"
permission="zope.Public" />
<!--<browser:page
for="zope.publisher.interfaces.INotFound"
name="index.html"
class="loops.browser.common.NotFound"
permission="zope.Public" />
<browser:page
for="zope.interface.common.interfaces.IException"
name="index.html"
class="loops.browser.common.SystemErrorView"
permission="zope.Public" />-->
<!--<browser:defaultSkin name="Loops" />-->
<include package="loops.server" file="loginform.zcml" />
</configure> </configure>

View file

@ -40,3 +40,24 @@
</form> </form>
</div> </div>
</metal:login> </metal:login>
<metal:loggedout define-macro="loggedout"
i18n:domain="loops"
tal:define="principal request/principal/id">
<h2 i18n:translate="">Logged out</h2>
<div>
<p i18n:translate=""
tal:condition="python: principal == 'zope.anybody'">Your have been successfully logged out from this application. Please login again if needed or close this browser tab</p>
<p i18n:translate=""
tal:condition="python: principal != 'zope.anybody'">
You are not authorized to perform this action. However, you may login as a
different user who is authorized.</p>
<div class="row">
<button i18n:translate="">
<a i18n:translate=""
tal:attributes="href view/baseUrl">Log in again</a></button>
</div>
</div>
</metal:loggedout>

View file

@ -57,6 +57,24 @@ class Logout(object):
return nx return nx
class Loggedout(NodeView):
template = template
@Lazy
def macro(self):
return self.template.macros['loggedout']
@Lazy
def item(self):
return self
@Lazy
def baseUrl(self):
url = self.menu.url
return url
class Unauthorized(ConceptView): class Unauthorized(ConceptView):
isTopLevel = True isTopLevel = True

View file

@ -44,6 +44,11 @@
class="loops.browser.auth.Logout" class="loops.browser.auth.Logout"
permission="zope.View" /> permission="zope.View" />
<page for="loops.interfaces.INode"
name="loggedout.html"
class="loops.browser.auth.Loggedout"
permission="zope.View" />
<!-- see also view/adapter "login.html" in section "query views" --> <!-- see also view/adapter "login.html" in section "query views" -->
<!-- macros --> <!-- macros -->

View file

@ -51,8 +51,8 @@
tal:define="results reportView/results"> tal:define="results reportView/results">
<tr> <tr>
<th style="white-space: nowrap" <th style="white-space: nowrap"
tal:attributes="class col/cssClass"
tal:repeat="col results/displayedColumns"> tal:repeat="col results/displayedColumns">
<span tal:attributes="class col/cssClass">
<a title="tooltip_sort_column" <a title="tooltip_sort_column"
tal:define="colName col/name" tal:define="colName col/name"
tal:omit-tag="python:not item.isSortableColumn(tableName, colName)" tal:omit-tag="python:not item.isSortableColumn(tableName, colName)"
@ -64,7 +64,6 @@
tal:condition="src" tal:condition="src"
tal:attributes="src src" /> tal:attributes="src src" />
</a> </a>
</span>
</th> </th>
</tr> </tr>
<tr tal:repeat="row results" <tr tal:repeat="row results"

View file

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: 3.0.1\n" "Project-Id-Version: 3.0.1\n"
"POT-Creation-Date: 2007-05-22 12:00 CET\n" "POT-Creation-Date: 2007-05-22 12:00 CET\n"
"PO-Revision-Date: 2026-01-23 12:00 CET\n" "PO-Revision-Date: 2026-01-09 12:00 CET\n"
"Last-Translator: Helmut Merz <helmutm@cy55.de>\n" "Last-Translator: Helmut Merz <helmutm@cy55.de>\n"
"Language-Team: loops developers <helmutm@cy55.de>\n" "Language-Team: loops developers <helmutm@cy55.de>\n"
"MIME-Version: 1.0\n" "MIME-Version: 1.0\n"
@ -604,9 +604,18 @@ msgstr "Nicht angemeldet"
msgid "Log in" msgid "Log in"
msgstr "Anmelden" msgstr "Anmelden"
msgid "Log in again"
msgstr "Erneut anmelden"
msgid "Presence" msgid "Presence"
msgstr "Anwesenheit" msgstr "Anwesenheit"
msgid "Logged out"
msgstr "Abgemeldet"
msgid "Your have been successfully logged out from this application. Please login again if needed or close this browser tab"
msgstr "Sie haben sich erfolgreich von dieser Anwendung abgemeldet. Bitte loggen Sie sich bei Bedarf wieder ein oder schließen Sie das Browserfenster"
# general # general
msgid "Actions" msgid "Actions"
@ -1494,7 +1503,7 @@ msgid "authentication-method-legacy"
msgstr "Klassisches Login-Verfahren" msgstr "Klassisches Login-Verfahren"
msgid "authentication-method-oidc" msgid "authentication-method-oidc"
msgstr "Login mit OpenID Connect" msgstr "Immer mit neuem Login-Verfahren anmelden"
msgid "authentication-method-select" msgid "authentication-method-select"
msgstr "Verfahren beim Login auswählen" msgstr "Verfahren beim Login auswählen"

View file

@ -57,6 +57,8 @@ class MediaAsset(MediaAssetFile, ExternalFileAdapter):
data = property(ExternalFileAdapter.getData, setData) data = property(ExternalFileAdapter.getData, setData)
def setExternalAddress(self, addr): def setExternalAddress(self, addr):
if isinstance(addr, bytes):
addr = addr.decode('UTF-8')
ExternalFileAdapter.setExternalAddress(self, addr) ExternalFileAdapter.setExternalAddress(self, addr)
if addr and self.getMimeType().startswith('image/'): if addr and self.getMimeType().startswith('image/'):
self.transform(self.rules) self.transform(self.rules)

View file

@ -178,7 +178,7 @@ dayTo = TrackDateField('dayTo', u'End Day',
executionSteps=['query']) executionSteps=['query'])
day = TrackDateField('day', u'Day', day = TrackDateField('day', u'Day',
description=u'The day the work was done.', description=u'The day the work was done.',
cssClass='center', cssClass='left',
executionSteps=['sort', 'output']) executionSteps=['sort', 'output'])
dayStart = TrackDateField('dayStart', u'Start Day', dayStart = TrackDateField('dayStart', u'Start Day',
description=u'The day the unit of work was started.', description=u'The day the unit of work was started.',
@ -190,15 +190,18 @@ dayEnd = TrackDateField('dayEnd', u'End Day',
executionSteps=['sort', 'output']) executionSteps=['sort', 'output'])
timeStart = TrackTimeField('start', u'Start', timeStart = TrackTimeField('start', u'Start',
description=u'The time the unit of work was started.', description=u'The time the unit of work was started.',
cssClass='noprint',
executionSteps=['sort', 'output']) executionSteps=['sort', 'output'])
timeEnd = TrackTimeField('end', u'End', timeEnd = TrackTimeField('end', u'End',
description=u'The time the unit of work was finished.', description=u'The time the unit of work was finished.',
cssClass='noprint',
executionSteps=['output']) executionSteps=['output'])
task = TargetField('taskId', u'Task', task = TargetField('taskId', u'Task',
description=u'The task to which work items belong.', description=u'The task to which work items belong.',
executionSteps=['sort', 'output']) executionSteps=['sort', 'output'])
party = PartyQueryField('userName', u'Party', party = PartyQueryField('userName', u'Party',
description=u'The party (usually a person) who did the work.', description=u'The party (usually a person) who did the work.',
cssClass='noprint',
fieldType='selection', fieldType='selection',
executionSteps=['sort', 'output', 'query']) executionSteps=['sort', 'output', 'query'])
#partyQuery = TargetField('userName', u'Party', #partyQuery = TargetField('userName', u'Party',
@ -229,6 +232,7 @@ partyState = PartyStateField('partyState', u'Party State',
executionSteps=['query', 'output']) executionSteps=['query', 'output'])
activity = ActivityField('activity', u'LA', activity = ActivityField('activity', u'LA',
description=u'The activity assigned to the work item.', description=u'The activity assigned to the work item.',
cssClass='noprint',
fieldType='selection', fieldType='selection',
executionSteps=['query', 'sort', 'output']) executionSteps=['query', 'sort', 'output'])
# process # process

View file

@ -550,6 +550,8 @@ class IndexAttributes(object):
txt = transformToText(actx) txt = transformToText(actx)
if txt is not None: if txt is not None:
return txt return txt
if isinstance(actx.contentType, bytes):
actx.contentType = actx.contentType.decode('UTF-8')
if not actx.contentType.startswith('text'): if not actx.contentType.startswith('text'):
return u'' return u''
data = actx.data data = actx.data
@ -613,6 +615,8 @@ def transformToText(obj, data=None, contentType=None):
data = obj.data data = obj.data
if contentType is None: if contentType is None:
contentType = obj.contentType contentType = obj.contentType
if type(contentType) == bytes:
contentType = contentType.decode('UTF-8')
transform = component.queryAdapter(obj, ITextTransform, name=contentType) transform = component.queryAdapter(obj, ITextTransform, name=contentType)
if transform is not None: if transform is not None:
#rfa = component.queryAdapter(IReadFile, obj) #rfa = component.queryAdapter(IReadFile, obj)

View file

@ -4,8 +4,9 @@
# and other authentication and authorization stuff. # and other authentication and authorization stuff.
from scopes.web.auth import oidc from scopes.web.auth import oidc
from zope.authentication.interfaces import IAuthentication from zope.authentication.interfaces import IAuthentication, IUnauthenticatedPrincipal
from zope.browserpage import ViewPageTemplateFile from zope.browserpage import ViewPageTemplateFile
from zope.cachedescriptors.property import Lazy
from zope.component import provideAdapter, getUtility, provideUtility from zope.component import provideAdapter, getUtility, provideUtility
from zope.interface import implementer, Interface from zope.interface import implementer, Interface
from zope.publisher.interfaces.browser import IBrowserRequest, IBrowserPage from zope.publisher.interfaces.browser import IBrowserRequest, IBrowserPage
@ -14,6 +15,9 @@ from zope.security.proxy import removeSecurityProxy
import config import config
from logging import getLogger
logger = getLogger("loops.server.auth")
def registerAuthUtility(config): def registerAuthUtility(config):
baseAuth = getUtility(IAuthentication) baseAuth = getUtility(IAuthentication)
print('*** registerAuthUtility, baseAuth:', baseAuth) print('*** registerAuthUtility, baseAuth:', baseAuth)
@ -38,6 +42,10 @@ class LoginPage:
return self.authOidc() return self.authOidc()
return self.index() return self.index()
@Lazy
def isAnonymous(self):
return IUnauthenticatedPrincipal.providedBy(self.request.principal)
def authOidc(self): def authOidc(self):
oidc.Authenticator(self.request).login() oidc.Authenticator(self.request).login()
return '' return ''
@ -49,12 +57,33 @@ class LoginPageSelect(LoginPage):
def showSelection(self): def showSelection(self):
return getConfigAuthMethod() == 'cookie' return getConfigAuthMethod() == 'cookie'
def authMethodCookieString(self):
domain = getattr(config, 'authentication_method_cookie_domain', None)
return 'document.cookie=`loops_auth_method=${this.value}; path=/; expires=Sun, 31 Jan 2027 12:00:00 UTC%s`' % (domain and f'; domain={domain}' or '')
class Unauthorized(LoginPage):
def __call__(self):
response = self.request.response
# 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')
logger.warn(f'unauthorized: user={self.request.principal.id}, authMethod={self.authMethod}')
if self.isAnonymous:
return super(Unauthorized, self).__call__() # open or redirect to login page
else:
response.setStatus(403)
return 'Unauthorized: You are not allowed to access this ressource.'
def getConfigAuthMethod(): def getConfigAuthMethod():
return getattr(config, 'authentication_method', 'legacy') return getattr(config, 'authentication_method', 'legacy')
def getAuthMethodCookieValue(request): def getAuthMethodCookieValue(request):
return request.cookies.get('loops_auth_method') or 'legacy' default = getattr(config, 'authentication_method_cookie_default', 'legacy')
return request.cookies.get('loops_auth_method') or default
# OIDC authentication # OIDC authentication

View file

@ -31,7 +31,8 @@
<select name="auth_method" <select name="auth_method"
onchange="document.cookie=`loops_auth_method=${this.value}; path=/; expires=Sun, 31 Jan 2027 12:00:00 UTC`" onchange="document.cookie=`loops_auth_method=${this.value}; path=/; expires=Sun, 31 Jan 2027 12:00:00 UTC`"
tal:define="meth view/authMethod" tal:define="meth view/authMethod"
tal:attributes="value meth"> tal:attributes="value meth;
onchange view/authMethodCookieString">
<option value="legacy" i18n:translate="authentication-method-legacy" <option value="legacy" i18n:translate="authentication-method-legacy"
tal:attributes="selected python:meth=='legacy'">Legacy</option> tal:attributes="selected python:meth=='legacy'">Legacy</option>
<option value="oidc" i18n:translate="authentication-method-oidc" <option value="oidc" i18n:translate="authentication-method-oidc"

View file

@ -11,6 +11,8 @@ license = {text = "MIT"}
keywords = ["loops"] keywords = ["loops"]
authors = [{name = "Helmut Merz", email = "helmutm@cy55.de"}] authors = [{name = "Helmut Merz", email = "helmutm@cy55.de"}]
# zope.publisher: v5.2.1 needed for handling of :records fields,
# should be replaced by patched zope.publisher fork (with multipart monkey patch)
dependencies = [ dependencies = [
"cybertools", "cybertools",
"py-scopes", "py-scopes",