├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── debian ├── .gitignore ├── compat ├── control ├── copyright ├── rules ├── source │ └── format ├── xcauth.postinst ├── xcauth.postrm ├── xcauth.preinst ├── xcauth.prerm └── xcauth.substvars ├── doc ├── Cache.md ├── Database.md ├── Installation.md ├── Protocol.md ├── QuickInstallEjabberd.md ├── QuickInstallProsody.md ├── SystemDiagram.svg ├── Systemd.md ├── Testing.md └── codecov.svg ├── prosody-modules ├── mod_auth_external.lua ├── mod_auth_socket.lua └── pseudolpty.lib.lua ├── setup.cfg ├── systemd ├── xcauth.service ├── xcejabberd.socket ├── xcpostfix.socket ├── xcprosody.socket └── xcsaslauth.socket ├── tests ├── Coverage.md ├── run-online-ejabberd.pl ├── run-online-postfix-connection-error.pl ├── run-online-postfix.pl ├── run-online-prosody.pl ├── run-online-saslauthd.pl ├── run-online.pl ├── run-signal-tests.sh └── xcauth.accounts.sample ├── tools ├── README.md ├── dhparams.pem.md ├── ejabberd.yml ├── xcauth.logrotate ├── xcauth.sudo ├── xcdelgroup.sh ├── xcdelhost.sh ├── xcdeluser.sh ├── xcejabberdctl.sh ├── xcrefreshroster.sh └── xcrestart.sh ├── xcauth.conf ├── xcauth.py ├── xcdbm.py └── xclib ├── .gitignore ├── __init__.py ├── auth.py ├── authops.py ├── check.py ├── configuration.py ├── db.py ├── dbmops.py ├── ejabberd_io.py ├── ejabberdctl.py ├── isuser.py ├── postfix_io.py ├── prosody_io.py ├── roster.py ├── roster_thread.py ├── saslauthd_io.py ├── sigcloud.py ├── sockact.py ├── tests ├── .gitignore ├── 00_check_test.py ├── 10_utf8_test.py ├── 11_config_test.py ├── 12_dbm_test.py ├── 13_sockact_test.py ├── 14_db_migration_test.py ├── 20_prosody_test.py ├── 21_ejabberd_test.py ├── 22_saslauthd_test.py ├── 23_postfix_test.py ├── 30_isuser_stub_test.py ├── 31_isuser_request_test.py ├── 32_auth_stub_test.py ├── 40_ejabberdctl_test.py ├── 41_roster_try_test.py ├── 42_roster_request_test.py ├── 50_online_test.py ├── 51_online_postfix_failure_test.py ├── README.md ├── __init__.py ├── generateTimeLimitedToken └── iostub.py ├── utf8.py └── version.py /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | coverage.xml 3 | xcauth_*.tar.gz 4 | *~ 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | dist: trusty 4 | sudo: required 5 | 6 | python: 7 | - "3.5" 8 | 9 | install: 10 | - sudo apt install libdb5.3-dev 11 | - pip install requests configargparse bcrypt bsddb3 12 | - pip install coverage nose rednose 13 | 14 | script: 15 | - nosetests --with-coverage --cover-xml --cover-package=xclib 16 | 17 | after_success: 18 | - bash <(curl -s https://codecov.io/bash) 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017,2018 JavaScript XMPP Client 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | RELEASE = buster 2 | MINOR = 1 3 | MODULE = xcauth 4 | LIBNAME = xclib 5 | CUSER = ${MODULE} 6 | PREFIX = /usr 7 | SBINDIR = ${PREFIX}/sbin 8 | LIBDIR = ${PREFIX}/lib/python3/dist-packages/${LIBNAME} 9 | DOCDIR = ${PREFIX}/share/doc/${MODULE} 10 | MODDIR = ${PREFIX}/lib/prosody/modules/${MODULE} 11 | DATAPREFIX = /var 12 | LOGDIR = ${DATAPREFIX}/log/${MODULE} 13 | DBDIR = ${DATAPREFIX}/lib/${MODULE} 14 | ETCDIR = /etc 15 | LRTDIR = ${ETCDIR}/logrotate.d 16 | SDSDIR = ${ETCDIR}/systemd/system 17 | SU_DIR = ${ETCDIR}/sudoers.d 18 | JABDIR = ${ETCDIR}/ejabberd 19 | DESTDIR = 20 | 21 | # Automatic 22 | VERSION = $(shell git describe | sed 's/^v//') 23 | 24 | ######################################################## 25 | # Compiling 26 | ######################################################## 27 | all: 28 | @echo 'INFO: Nothing to build. Continue with "make test" or "make install".' 29 | 30 | ######################################################## 31 | # Testing 32 | ######################################################## 33 | test testing: tests 34 | tests: nosetests perltests loggingtests signaltests 35 | moretests: tests perltests-all 36 | 37 | nosetests: 38 | @if [ ! -r /etc/xcauth.accounts ]; then \ 39 | echo 'INFO: Read tests/Coverage.md for more thorough tests'; \ 40 | fi 41 | nosetests3 42 | 43 | perltests: perltests-direct perltests-subprocess perltests-socket1366x 44 | perltests-all: perltests perltests-socket2366x 45 | 46 | perltests-direct: 47 | tests/run-online.pl 48 | perltests-subprocess: 49 | for i in tests/run-online-*.pl; do \ 50 | echo ""; \ 51 | echo ===============================; \ 52 | echo == $$i; \ 53 | $$i subprocess || exit 1; \ 54 | done 55 | perltests-socket1366x: 56 | for i in tests/run-online-*.pl; do \ 57 | echo ""; \ 58 | echo ===============================; \ 59 | echo == $$i; \ 60 | $$i socket1366x || exit 1; \ 61 | done 62 | perltests-socket2366x: 63 | for i in tests/run-online-*.pl; do \ 64 | echo ""; \ 65 | echo ===============================; \ 66 | echo == $$i; \ 67 | $$i socket2366x || exit 1; \ 68 | done 69 | 70 | loggingtests: 71 | @echo ""; echo "### Expect warnings, but no aborts" 72 | # Permission denied 73 | echo isuser:john.doe:example.org | ./xcauth.py -t generic -l /etc 74 | # No such file or directory 75 | echo isuser:john.doe:example.org | ./xcauth.py -t generic -l /no/such/file/or/directory 76 | 77 | signaltests: 78 | tests/run-signal-tests.sh 79 | 80 | ######################################################## 81 | # Installation 82 | ######################################################## 83 | install: install_users 84 | ${MAKE} install_dirs install_files compile_python 85 | 86 | debinstall: install_dirs install_files 87 | 88 | install_users: 89 | if ! groups xcauth > /dev/null 2>&1; then \ 90 | adduser --system --group --home ${DBDIR} --gecos "XMPP Cloud Authentication" ${CUSER}; \ 91 | fi 92 | # These group additions are no longer necessary for systemd mode, 93 | # but still if someone wants to run xcauth the old (subprocess) mode. 94 | # User exists, but not group of xcauth -> add group 95 | if [ `groups prosody 2> /dev/null | grep -v xcauth | wc -l` -gt 0 ]; then \ 96 | adduser prosody xcauth; \ 97 | fi 98 | if [ `groups ejabberd 2> /dev/null | grep -v xcauth | wc -l` -gt 0 ]; then \ 99 | adduser ejabberd xcauth; \ 100 | fi 101 | 102 | install_dirs: 103 | mkdir -p ${DESTDIR}${SBINDIR} ${DESTDIR}${LIBDIR} 104 | mkdir -p ${DESTDIR}${ETCDIR} ${DESTDIR}${LRTDIR} 105 | mkdir -p ${DESTDIR}${DOCDIR} ${DESTDIR}${SDSDIR} 106 | mkdir -p ${DESTDIR}${LOGDIR} ${DESTDIR}${DBDIR} 107 | mkdir -p ${DESTDIR}${SU_DIR} ${DESTDIR}${MODDIR} 108 | mkdir -p ${DESTDIR}${JABDIR} 109 | chmod 750 ${DESTDIR}${LOGDIR} ${DESTDIR}${DBDIR} 110 | if group ${CUSER} > /dev/null 2>&1; then \ 111 | chown ${CUSER}:${CUSER} ${DESTDIR}${LOGDIR} ${DESTDIR}${DBDIR}; \ 112 | fi 113 | 114 | install_files: install_dirs 115 | install -C -m 755 -T xcauth.py ${DESTDIR}${SBINDIR}/${MODULE} 116 | install -C -m 755 -T tools/xcrestart.sh ${DESTDIR}${SBINDIR}/xcrestart 117 | install -C -m 755 -T tools/xcrefreshroster.sh ${DESTDIR}${SBINDIR}/xcrefreshroster 118 | install -C -m 755 -T tools/xcdeluser.sh ${DESTDIR}${SBINDIR}/xcdeluser 119 | install -C -m 755 -T tools/xcdelgroup.sh ${DESTDIR}${SBINDIR}/xcdelgroup 120 | install -C -m 755 -T tools/xcdelhost.sh ${DESTDIR}${SBINDIR}/xcdelhost 121 | install -C -m 755 -T tools/xcejabberdctl.sh ${DESTDIR}${SBINDIR}/xcejabberdctl 122 | install -C -m 440 -T tools/xcauth.sudo ${DESTDIR}${SU_DIR}/xcauth 123 | install -C -m 644 -T tools/xcauth.logrotate ${DESTDIR}${LRTDIR}/${MODULE} 124 | install -C -m 644 -T prosody-modules/mod_auth_external.lua ${DESTDIR}${MODDIR}/mod_auth_external.lua-xcauth-version 125 | install -C -m 644 -T prosody-modules/mod_auth_socket.lua ${DESTDIR}${MODDIR}/mod_auth_socket.lua 126 | install -C -m 644 -T prosody-modules/pseudolpty.lib.lua ${DESTDIR}${MODDIR}/pseudolpty.lib.lua 127 | install -C -m 644 -T tools/ejabberd.yml ${DESTDIR}${JABDIR}/ejabberd.yml-xcauth-example 128 | install -C -m 644 -T tools/dhparams.pem.md ${DESTDIR}${JABDIR}/dhparams.pem-xcauth-example 129 | install -C -m 644 -t ${DESTDIR}${LIBDIR} xclib/*.py 130 | install -C -m 644 -t ${DESTDIR}${DOCDIR} *.md LICENSE 131 | install -C -m 644 -t ${DESTDIR}${DOCDIR} doc/*.md doc/SystemDiagram.svg 132 | if group ${CUSER} > /dev/null 2>&1; then \ 133 | install -C -m 640 -o ${CUSER} -g ${CUSER} xcauth.conf ${DESTDIR}${ETCDIR}; \ 134 | else \ 135 | install -C -m 640 xcauth.conf ${DESTDIR}${ETCDIR}; \ 136 | fi 137 | install -C -m 644 -t ${DESTDIR}${SDSDIR} systemd/*.service systemd/*.socket 138 | 139 | compile_python: install_files 140 | python3 -m compileall ${DESTDIR}${LIBDIR} 141 | 142 | ######################################################## 143 | # Packaging 144 | ######################################################## 145 | package: deb tar sdeb 146 | update_version: 147 | (echo "xcauth (${VERSION}-${MINOR}) ${RELEASE}; urgency=medium"; \ 148 | echo ""; \ 149 | echo " * Direct packaging"; \ 150 | echo ""; \ 151 | echo -n " -- Marcel Waldvogel "; date -R) > debian/changelog 152 | deb: update_version 153 | dpkg-buildpackage -us -uc -b 154 | nightly:deb 155 | reprepro -b ../dl.jsxc.org includedeb nightly ../xcauth_${VERSION}-0~20.100_all.deb 156 | nightly-push: nightly 157 | (cd ../dl.jsxc.org && git add pool/*/*/*/* && git commit -a -m "Nightly" && git push) 158 | stable: nightly 159 | reprepro -b ../dl.jsxc.org includedeb stable ../xcauth_${VERSION}-0~20.100_all.deb 160 | stable-push: stable 161 | (cd ../dl.jsxc.org && git add pool/*/*/*/* && git commit -a -m "Stable" && git push) 162 | 163 | tar: 164 | tar cfa ../xcauth_${VERSION}.orig.tar.gz \ 165 | --owner=${USER} --group=${USER} --mode=ugo+rX,u+w,go-w \ 166 | --exclude-backups --exclude-vcs --exclude-vcs-ignores \ 167 | --transform='s,^[.],xcauth_${VERSION}.orig,' --sort=name . 168 | 169 | sdeb: tar update_version 170 | debuild -S -i'(^[.]git|^[.]|/[.]|/__pycache__)' 171 | 172 | ######################################################## 173 | # Cleanup 174 | ######################################################## 175 | clean: 176 | ${RM} xcauth_*.tar.gz 177 | ${RM} -r xclib/__pycache__ xclib/tests/__pycache__ 178 | ${RM} -r debian/xcauth 179 | ${RM} -r debian/.debhelper 180 | 181 | .PHONY: all install test testing clean package tar deb sdeb 182 | .PHONY: tests moretests nosetests perltests perltests-all perltests-direct 183 | .PHONY: perltests-subprocess perltests-socket1366x perltests-socket2366x 184 | .PHONY: loggingtests huptests install_dirs install_files install_users 185 | .PHONY: compile_python 186 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `xmpp-cloud-auth`: Your authentication hub for Nextcloud, Instant Messaging, and mail 2 | 3 | `xmpp-cloud-auth` (aka `xcauth`) started as a simple program to free [JSXC – The Open Chat](https://www.jsxc.org) for [Nextcloud](https://www.nextcloud.com) from having to remember and reuse passwords: The password entered for Nextcloud login would need to be captured and stored in the browser in such a way that malware could also access it. 4 | 5 | In the meantime, it has grown to a full authentication hub: 6 | 7 | [![Authentication Hub: System Diagram](./doc/SystemDiagram.svg)](./doc/SystemDiagram.svg) 8 | 9 | For installation and configuration instructions, see [doc/Installation.md](doc/Installation.md). :warning: Especially if you plan to [use it on *Prosody*](doc/Installation.md#prosody), as their `mod_auth_external.lua` does not work around a bug in `lpty`. 10 | 11 | # Code quality 12 | 13 | * Build status: [![Build Status](https://travis-ci.org/jsxc/xmpp-cloud-auth.svg?branch=master)](https://travis-ci.org/jsxc/xmpp-cloud-auth) 14 | * Code coverage (offline-only): [![codecov](https://codecov.io/gh/jsxc/xmpp-cloud-auth/branch/master/graph/badge.svg)](https://codecov.io/gh/jsxc/xmpp-cloud-auth) (codecov.io unfortunately can't do online tests) 15 | * Code coverage (offline and online tests): [![99%](./doc/codecov.svg)](tests/Coverage.md) (manually updated every few commits) 16 | 17 | # Binary repository 18 | 19 | To use our binary `deb` repository, create `/etc/apt/sources.list.d/jsxc.list` with the following contents: 20 | 21 | ```deb 22 | deb https://dl.jsxc.org stable main 23 | ``` 24 | -------------------------------------------------------------------------------- /debian/.gitignore: -------------------------------------------------------------------------------- 1 | changelog 2 | xcauth 3 | files 4 | *debhelper* 5 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: xcauth 2 | Section: utils 3 | Priority: optional 4 | Maintainer: Marcel Waldvogel 5 | Build-Depends: debhelper (>= 10), lsb-release, git, socket, 6 | python3-nose, python3-rednose, python3-nose-cov 7 | Standards-Version: 3.9.7 8 | Homepage: https://www.jsxc.org 9 | Vcs-Browser: https://github.com/jsxc/xmpp-cloud-auth/ 10 | Vcs-Git: https://github.com/jsxc/xmpp-cloud-auth.git 11 | 12 | Package: xcauth 13 | Architecture: all 14 | Description: Nextcloud+JSXC authentication hub for XMPP 15 | Pass on Nextcloud credentials, groups, application-specific passwords, 16 | and time-limited tokens to ejabberd, Prosody, saslauthd, and postfix. 17 | Depends: ${misc:Depends}, ${shlibs:Depends}, 18 | python3, python3-requests, python3-configargparse, python3-bcrypt, 19 | python3-bsddb3, python3-systemd, adduser 20 | Suggests: ejabberd (>= 18.06.0), prosody, lua-lpty, postfix, python3-bsddb3 21 | Recommends: socket, sudo (>= 1.7.2p1-1) 22 | Enhances: ejabberd, prosody, postfix 23 | # Should "Break: sasl2-bin", as it cannot work together with saslauthd. 24 | # However, this will also disable other SASL-related tools. 25 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: xmpp-cloud-auth 3 | Source: https://github.com/jsxc/xmpp-cloud-auth 4 | 5 | Files: * 6 | Copyright: 2017,2018 JavaScript XMPP Chat 7 | 2017,2018 Klaus Herberth 8 | 2017,2018 Marcel Waldvogel 9 | License: MIT 10 | 11 | Files: debian/* 12 | Copyright: 2018 Marcel Waldvogel 13 | License: MIT 14 | 15 | License: MIT 16 | Permission is hereby granted, free of charge, to any person obtaining a 17 | copy of this software and associated documentation files (the "Software"), 18 | to deal in the Software without restriction, including without limitation 19 | the rights to use, copy, modify, merge, publish, distribute, sublicense, 20 | and/or sell copies of the Software, and to permit persons to whom the 21 | Software is furnished to do so, subject to the following conditions: 22 | . 23 | The above copyright notice and this permission notice shall be included 24 | in all copies or substantial portions of the Software. 25 | . 26 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 27 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 28 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 29 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 30 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 31 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 32 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 33 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | DISTRIBUTION = $(shell lsb_release -sr) 3 | VERSION = $(shell git describe | sed 's/^v//') 4 | PACKAGEVERSION = $(VERSION)-0~$(DISTRIBUTION)0 5 | 6 | # Do not use --with systemd, as this only works with 7 | # a single socket or service. Using it in a mixed setup 8 | # would complicate the install process. 9 | %: 10 | dh $@ 11 | 12 | override_dh_auto_clean: 13 | override_dh_auto_test: 14 | override_dh_auto_build: 15 | override_dh_auto_install: 16 | make debinstall DESTDIR=debian/xcauth 17 | 18 | override_dh_gencontrol: 19 | dh_gencontrol -- -v$(PACKAGEVERSION) 20 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) 2 | -------------------------------------------------------------------------------- /debian/xcauth.postinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Copy of default debhelper code (see debian/rules) 5 | if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then 6 | # This will only remove masks created by d-s-h on package removal. 7 | deb-systemd-helper unmask 'xcauth.service' >/dev/null || true 8 | 9 | # was-enabled defaults to true, so new installations run enable. 10 | if deb-systemd-helper --quiet was-enabled 'xcauth.service'; then 11 | # Enables the unit on first installation, creates new 12 | # symlinks on upgrades if the unit file has changed. 13 | deb-systemd-helper enable 'xcauth.service' >/dev/null || true 14 | else 15 | # Update the statefile to add new symlinks (if any), which need to be 16 | # cleaned up on purge. Also remove old symlinks. 17 | deb-systemd-helper update-state 'xcauth.service' >/dev/null || true 18 | fi 19 | fi 20 | 21 | # Multi-socket generalization of what debhelper would add 22 | if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then 23 | if [ -d /run/systemd/system ]; then 24 | systemctl --system daemon-reload >/dev/null || true 25 | if [ -n "$2" ]; then 26 | _dh_action=restart 27 | else 28 | _dh_action=start 29 | fi 30 | deb-systemd-invoke enable xcauth.service xcejabberd.socket xcpostfix.socket xcprosody.socket xcsaslauth.socket >/dev/null || true 31 | deb-systemd-invoke $_dh_action xcauth.service xcejabberd.socket xcpostfix.socket xcprosody.socket xcsaslauth.socket >/dev/null || true 32 | fi 33 | fi 34 | 35 | # Copy of default debhelper code (see debian/rules) 36 | if [ "$1" = "configure" ] || [ "$1" = "abort-upgrade" ] || [ "$1" = "abort-deconfigure" ] || [ "$1" = "abort-remove" ] ; then 37 | if [ -x "/etc/init.d/xcauth" ]; then 38 | update-rc.d xcauth defaults >/dev/null 39 | if [ -n "$2" ]; then 40 | _dh_action=restart 41 | else 42 | _dh_action=start 43 | fi 44 | invoke-rc.d xcauth $_dh_action || exit 1 45 | fi 46 | fi 47 | 48 | # Fix permissions which were not possible in fakeroot 49 | chown xcauth:xcauth /var/log/xcauth /var/lib/xcauth /etc/xcauth.conf 50 | 51 | # Compile Python package 52 | python3 -m compileall /usr/lib/python3/dist-packages/xclib 53 | 54 | #DEBHELPER# 55 | -------------------------------------------------------------------------------- /debian/xcauth.postrm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Copy of default debhelper code (see debian/rules) 5 | if [ "$1" = "purge" ] ; then 6 | update-rc.d xcauth remove >/dev/null 7 | fi 8 | 9 | # Copy of default debhelper code (see debian/rules) 10 | if [ -d /run/systemd/system ]; then 11 | systemctl --system daemon-reload >/dev/null || true 12 | fi 13 | 14 | # Copy of default debhelper code (see debian/rules) 15 | if [ "$1" = "remove" ]; then 16 | if [ -x "/usr/bin/deb-systemd-helper" ]; then 17 | deb-systemd-helper mask 'xcauth.service' >/dev/null || true 18 | fi 19 | fi 20 | 21 | # Copy of default debhelper code (see debian/rules) 22 | if [ "$1" = "purge" ]; then 23 | if [ -x "/usr/bin/deb-systemd-helper" ]; then 24 | deb-systemd-helper purge 'xcauth.service' >/dev/null || true 25 | deb-systemd-helper unmask 'xcauth.service' >/dev/null || true 26 | fi 27 | fi 28 | 29 | #DEBHELPER# 30 | -------------------------------------------------------------------------------- /debian/xcauth.preinst: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Create users, groups if needed 5 | if ! groups xcauth > /dev/null 2>&1 6 | then 7 | adduser --system --disabled-password --disabled-login --group --home /var/lib/xcauth --gecos "XMPP Cloud Authentication" xcauth 8 | fi 9 | 10 | # These group additions are no longer necessary for systemd mode, 11 | # but still if someone wants to run xcauth the old (subprocess) mode. 12 | 13 | # User exists, but not group of xcauth -> add group 14 | if [ `groups prosody 2> /dev/null | grep -v xcauth | wc -l` -gt 0 ] 15 | then 16 | adduser prosody xcauth 17 | fi 18 | 19 | if [ `groups ejabberd 2> /dev/null | grep -v xcauth | wc -l` -gt 0 ] 20 | then 21 | adduser ejabberd xcauth 22 | fi 23 | 24 | #DEBHELPER# 25 | -------------------------------------------------------------------------------- /debian/xcauth.prerm: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | # Multi-socket generalization of what debhelper would add 5 | if [ -d /run/systemd/system ] && [ "$1" = remove ]; then 6 | deb-systemd-invoke stop xcauth.service xcejabberd.socket xcpostfix.socket xcprosody.socket xcsaslauth.socket >/dev/null || true 7 | deb-systemd-invoke disable xcauth.service xcejabberd.socket xcpostfix.socket xcprosody.socket xcsaslauth.socket >/dev/null || true 8 | fi 9 | 10 | # Copy of default debhelper code (see debian/rules) 11 | if [ -x "/etc/init.d/xcauth" ] && [ "$1" = remove ]; then 12 | invoke-rc.d xcauth stop || exit 1 13 | fi 14 | 15 | # Copy of default debhelper code (see debian/rules) 16 | if [ -d /run/systemd/system ] && [ "$1" = remove ]; then 17 | deb-systemd-invoke stop xcauth.service >/dev/null || true 18 | fi 19 | 20 | # Remove compilation results 21 | rm -rf /usr/lib/python3/dist-packages/xclib/__pycache__ 22 | 23 | #DEBHELPER# 24 | -------------------------------------------------------------------------------- /debian/xcauth.substvars: -------------------------------------------------------------------------------- 1 | misc:Depends= 2 | misc:Pre-Depends= 3 | -------------------------------------------------------------------------------- /doc/Cache.md: -------------------------------------------------------------------------------- 1 | # User caching 2 | 3 | To reduce the impact of short outages of the backend authentication servers and reduce the load, username/password caching has been added. 4 | 5 | The cache is consulted after token verification fails (there is no use in caching the tokens, as they are dynamic and can be verified locally). 6 | The passwords are hashed with `bcrypt` before storage, so gaining access to the cache should have no security impact. 7 | 8 | To invalidate the cache, authenticate with a different password. 9 | The first login with the new password after changing passwords will thus automatically invalidate the old password and prevent anyone knowing the old password from successfully authenticating. 10 | 11 | ## Cache behavior 12 | 13 | 1. If a valid token is passed, succeed immediately 14 | and do not touch the cache. 15 | 1. If the last authentication query was less than `cache-query-ttl` ago, 16 | **and** the last verification against the backend was less than 17 | `cache-verification-ttl` ago, **and** the password matches the 18 | stored one: Record the current query time and succeed. 19 | 1. Query the backend database 20 | - If it is successful, update the cache 21 | - If the request failed for a reason other than "password incorrect", 22 | **and** the last verification against the backend was less than 23 | `cache-unreachable-ttl` ago (independent of the other TTLs), **and** 24 | the password matches, succeed. 25 | 26 | ## Cache format 27 | 28 | - Key: JID in IDN format 29 | - Value: Tab-separated tuple of 30 | - `bcrypt()`ed password, 31 | - Unix timestamp of first successful login, 32 | - Unix timestamp of most recent successful verification, and 33 | - Unix timestamp of most recent successful authentication query. 34 | 35 | # Shared roster caching 36 | 37 | To speed up *ejabberd* shared roster updates, another cache is used. Multiple data sets are stored in the same cache, distinguished by their key spaces: 38 | 39 | ## Roster Hash Cache format 40 | 41 | Used to determine whether a roster update thread should be started at all. 42 | 43 | - Key: 'RHC:' + JID 44 | - Value: SHA-256 hash of the body returned for the last processed roster request 45 | 46 | ## Full Name Cache format 47 | 48 | Used to determine whether a 'get_vcard' command needs to be sent to *ejabberdctl*. 49 | 50 | - Key: 'FNC:' + JID 51 | - Value: Full name 52 | 53 | ## Roster Group Cache format 54 | 55 | Used to determine whether a 'srg_create' command needs to be sent to *ejabberdctl*. 56 | 57 | - Key: 'RGC:' + Group + ':' + Domain 58 | - Value: Tab-separated group members 59 | 60 | ## Login In Group format 61 | 62 | Used to record which groups a user (more precisely, the user currently logging in) belongs to. 63 | 64 | This is not a cache in the narrow sense, as erasing this entry will not slow down 65 | operation, but will slow down the removal from groups. 66 | 67 | If 'Login In Groups' is persistent, users having been removed from a group will be thrown out of that group *when the first still-member of that group logs in*. 68 | 69 | If 'Login In Groups' is not persistent, a user will also be removed from the group *when he logs in himself next*. 70 | 71 | So this one results in sooner group membership removal. 72 | 73 | - Key: 'LIG:' + JID 74 | - Value: Tab-separated groups this user is member of (when last recorded) 75 | -------------------------------------------------------------------------------- /doc/Database.md: -------------------------------------------------------------------------------- 1 | # New `sqlite` database format 2 | 3 | Basic design rationale: All the tables can be extended with 4 | additional fields by the user; code will only ever look at 5 | and modify the fields it needs. 6 | 7 | An example for this are the `reg*` fields in the `domains` 8 | table, which are not used by `xcauth`, but could be useful 9 | for multi-domain servers (and are in fact used by our 10 | [Managed server](https://jsxc.org/managed.html)). 11 | 12 | ## Domains 13 | ```sql 14 | CREATE TABLE domains (xmppdomain TEXT PRIMARY KEY, 15 | authsecret TEXT, 16 | authurl TEXT, 17 | authdomain TEXT, 18 | regcontact TEXT, 19 | regfirst TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 20 | reglatest TIMESTAMP DEFAULT CURRENT_TIMESTAMP); 21 | ``` 22 | The `xmppdomain` is the part behind the `@` in the XMPP JID 23 | (aka "the XMPP address"). The `authurl` is then contacted with 24 | the users "local part" (the part in front of the `@` and the `authdomain`) 25 | to obtain information about this user. This request is authenticated based 26 | on the `authsecret`. 27 | 28 | If your email address on the Nextcloud server is the same as your XMPP domain, 29 | then `xmppdomain` and `authdomain` will match. 30 | 31 | `regcontact` (registration contact), `regfirst` (first registration), and 32 | `reglatest` (latest registration) are currently not used by `xcauth` itself. 33 | They may be freely used by configuration-related tools. 34 | 35 | ## Account cache 36 | 37 | ```sql 38 | CREATE TABLE authcache (jid TEXT PRIMARY KEY, 39 | pwhash TEXT, 40 | firstauth TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 41 | remoteauth TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 42 | anyauth TIMESTAMP DEFAULT CURRENT_TIMESTAMP); 43 | ``` 44 | 45 | The authentication cache can reduce the load on the remote Nextcloud 46 | server and will gloss over short-term unreachability of that server. 47 | However, it uses more computation power locally to securely encrypt 48 | the password as a `pwhash`. If the Nextcloud server is local and 49 | reliable, it is recommended not to enable the authentication cache. 50 | 51 | The lookup is based on the `jid` (the XMPP ID) and stores 52 | - the first time the user authenticated (`firstauth`), 53 | - the most recent authentication accepted by the Nextcloud server 54 | (`remoteauth`), and 55 | - the most recent authentication by the user, either against the cache 56 | or against the Nextcloud server (`anyauth`). 57 | 58 | ## Shared roster state 59 | 60 | ```sql 61 | CREATE TABLE rosterinfo (jid TEXT PRIMARY KEY, 62 | fullname TEXT, 63 | grouplist TEXT, 64 | responsehash TEXT, 65 | last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP); 66 | CREATE TABLE rostergroups (groupname TEXT PRIMARY KEY, 67 | userlist TEXT); 68 | ``` 69 | 70 | The `rosterinfo` table contains information about a user 71 | (represented by its `jid`): the full name (`fullname`), 72 | a tab-separated list of groups (`grouplist`), 73 | and the SHA-256 hash of the roster response body (`responsehash`). 74 | 75 | The `rostergroups` table contains the tab-separated list of users 76 | (`userlist`) that have been added to a group (identified by `groupname`). 77 | 78 | Memberships in `rosterinfo` and `rostergroups` do not necessarily need 79 | to be reciprocal: A user's `grouplist` is only ever updated on that user's 80 | login, but a group's `userlist` is updated on every login of one of its 81 | members. This might be considered a bug and should be addressed. 82 | 83 | ## Automatic conversion 84 | When a version of `xcauth` with sqlite support is run, and the `sqlite` 85 | database file does not exist, the database is created and the current values 86 | of the legacy files pointed to by `--domain-db`, `--cache-db`, 87 | and `--shared-roster-db` are imported. 88 | 89 | 90 | # Legacy database formats (up to v1.1.x) 91 | 92 | ## Domain DB 93 | Location specified by `--domain-db`, this database is an `andybm`-based key/value storage. 94 | 95 | - key: Domainpart of JID 96 | - value: A tab-separated tuple 97 | 1. API secret 98 | 2. API URL 99 | 3. Authentication domain (the one JSXC should check against) 100 | 4. Unused value, can be used by other users of the database (must not contain a tab, as this will interfere with future database field updates) 101 | 102 | ## User Cache 103 | Location specified by `--cache-db`, this database is an `andydbm`-based key/value storage. 104 | 105 | - key: username`:`domain (a bare JID with ':' instead of '@') 106 | - value: A tab-separated tuple 107 | 1. Password hash (for local verification) 108 | 2. Timestamp of first authentication 109 | 3. Timestamp of last successful authentication (possibly local-only) 110 | 4. Timestamp of last successful authentication against the server 111 | 5. Unused value, can be used by other users of the database (must not contain a tab, as this will interfere with future database field updates) 112 | 113 | ## Shared Roster 114 | Location specified by `--shared-roster-db`, this database is an `anydbm`-based key/value storage. 115 | 116 | It contains multiple types of key/value pairs, prefixed by: 117 | - `FNC:`user`:`domain: Full-name cache. Value: full name 118 | - `RH:`user`:`domain: Response Hash. Value: SHA-256 hash of the `shared_roster` response body 119 | (to shortcircuit the next two lookups/processes) 120 | - `LIG:`user`:`domain: Login In Group. Value: tab-separated lists of groups 121 | the user was in at last login 122 | - `RGC:`group`:`domain: Reverse Group Cache. Value: tab-separated lists of users in that group 123 | 124 | ### User memberships 125 | 126 | This is needed for removing a user from a group (s)he was in earlier. They should not be 127 | deleted unless they become empty 128 | 129 | - key: username`:`domain (a bare JID with ':' instead of '@') 130 | - value: A tab-separated list of shared roster group IDs the user was in on the most recent login 131 | 132 | ### Response cache 133 | 134 | This is used to avoid the many expensive `ejabberdctl` calls required otherwise. 135 | They can be deleted without affecting functionality, but affecting performance. 136 | 137 | - key: `CACHE:`username`:`domain (a bare JID with ':' instead of '@', prefixed with 'CACHE:') 138 | - value: SHA256(`response body`) 139 | -------------------------------------------------------------------------------- /doc/Protocol.md: -------------------------------------------------------------------------------- 1 | # Protocol descriptions 2 | 3 | Protocols can be separated into two categories: 4 | - Frontend protocols, requests coming in from the XMPP (or other) server: [prosody aka generic](#prosody), [ejabberd](#ejabberd), and [saslauthd](#saslauthd). 5 | - Backend protocols, requests going out to a [JSXC-enabled Nextcloud](#jsxc) 6 | 7 | # Frontend protocols 8 | 9 | ## Prosody 10 | 11 | [A line-based, colon-separated protocol](https://modules.prosody.im/mod_auth_external.html#protocol). Requests are newline-terminated, fields in the request colon-separated (variables are in all-caps): 12 | 13 | - `isuser:USER:DOMAIN`: Does USER exist at DOMAIN? 14 | - `auth:USER:DOMAIN:PASSWORD`: Is PASSWORD (may include colons!) correct for USER@DOMAIN? 15 | - Any other request receives a failure response 16 | 17 | Responses are (newline-terminated): 18 | 19 | - `1`: success 20 | - `0`: failure 21 | 22 | ## ejabberd 23 | 24 | A length-prefixed, colon-separated protocol. Requests are prefixed by a two-byte network byte order (big endian) length, fields are colon-separated. 25 | 26 | Requests contents are the same as for [Prosody](#prosody) above. 27 | 28 | Responses are again length-prefixed, but are themselves a network byte order two-byte value ("short") of 29 | 30 | - 1: success 31 | - 0: failure 32 | 33 | ## saslauthd 34 | 35 | The request consists of four fields (USER, PASSWORD, SERVICE, REALM), each prefixed by a two-byte network byte order length. There is no request type, it always corresponds to `auth` above, ignoring SERVICE. 36 | 37 | Responses are length-prefixed strings: 38 | 39 | - `OK ` + (optional) reason 40 | - `NO ` + (optional) reason 41 | 42 | ## Postfix 43 | 44 | Similar to SMTP. [A line-based, space-separated protocol](http://www.postfix.org/tcp_table.5.html). Requests are of the format `get @`, newline-terminated. This maps to the `isuser` command; the other commands are not supported. 45 | 46 | Responses start with `200 ` (user exists), `400 ` (problem processing the request, try again), or `500 ` (user does not exist), followed by a newline-terminated human-readable explanation. 47 | 48 | # Backend protocol 49 | 50 | ## JSXC 51 | Your XMPP server sends the authentication data in a [special format](https://www.ejabberd.im/files/doc/dev.html#htoc9) on the standard input to the authentication script, length-prefixed (`-t ejabberd`) for *ejabberd*, newline-terminated (`-t prosody` aka `-t generic`) for *Prosody* (and maybe others). The script will first try to verify the given password as time-limited token and if this fails, it will send a HTTP request to your cloud installation to verify this data. To protect your Nextcloud/Owncloud against different attacks, every request has a signature similar to the [github webhook signature]( https://developer.github.com/webhooks/securing/). 52 | 53 | ### Time-limited token 54 | The time-limited token has the following structure: 55 | ``` 56 | # Definitions 57 | version := 'protocol version' 58 | id := 'key id' 59 | expiry := 'end of lifetime as unix timestamp' 60 | user := 'user identifier' 61 | secret := 'shared secret' 62 | name[x] := 'first x bit of name' 63 | . := 'concatenation' 64 | 65 | # Calculation 66 | version = hexToBin(0x00) 67 | id = sha256(secret) 68 | challenge = version[8] . id[16] . expiry[32] . user 69 | mac = sha256_mac(challenge, secret) 70 | token = version[8] . mac[128] . id[16] . expiry[32] 71 | 72 | # Improve readability 73 | token = base64_encode(token) 74 | token = replace('=', '', token) 75 | token = replace('O', '-', token) 76 | token = replace('I', '$', token) 77 | token = replace('l', '%', token) 78 | ``` 79 | 80 | ### Request signature 81 | Every request to the API URL needs a `HTTP_X_JSXC_SIGNATURE` header: 82 | ``` 83 | body := 'request body' 84 | secret := 'shared secret' 85 | 86 | MAC = sha1_hmac(body, secret) 87 | 88 | HTTP_X_JSXC_SIGNATURE: sha1=MAC 89 | ``` 90 | -------------------------------------------------------------------------------- /doc/QuickInstallEjabberd.md: -------------------------------------------------------------------------------- 1 | # Quick installation for *ejabberd* 2 | 3 | This documentation assumes that you: 4 | 1. already have a working *ejabberd* configuration, 5 | 1. it works with together *JSXC*, and 6 | 1. you have a simple, single-domain, single-purpose installation. 7 | 8 | If you need to start from scratch, look at our 9 | [step-by-step tutorial](https://github.com/jsxc/xmpp-cloud-auth/wiki/raspberry-pi-en). 10 | If you have advanced requirements, would like to know background, 11 | or run into trouble with this setup, read the 12 | [full installation instructions](./Installation.md). 13 | 14 | ## Software installation 15 | 16 | Currently, only nightlies are supported, which you get by 17 | ``` 18 | sudo -s 19 | echo deb https://dl.jsxc.org stable main > /etc/apt/sources.list.d/jsxc.list 20 | wget -qO - https://dl.jsxc.org/archive.key | apt-key add - 21 | apt update 22 | apt install xcauth 23 | ``` 24 | 25 | ## `xcauth` configuration 26 | 27 | :warning: The API secret must not fall into the wrong hands! 28 | Anyone knowing it can authenticate as any user to the XMPP server 29 | (and create arbitrary new users on the XMPP server). 30 | 31 | ```sh 32 | cp xcauth.conf /etc 33 | chown xcauth:xcauth /etc/xcauth.conf 34 | chmod 660 /etc/xcauth.conf 35 | ``` 36 | Modify `/etc/xcauth.conf` according to your environment. The values for 37 | API URL and API SECRET can be found in your Nextcloud JSXC admin page. 38 | 39 | ### Adapt *ejabberd* configuration to use `xcauth` 40 | 41 | Add the following to your *ejabberd* config (probably `/etc/ejabberd/ejabberd.yml`): 42 | ```YaML 43 | auth_method: external 44 | extauth_program: "/opt/xmpp-cloud-auth/xcauth.py" 45 | auth_use_cache: false 46 | ``` 47 | 48 | Ready! 49 | -------------------------------------------------------------------------------- /doc/QuickInstallProsody.md: -------------------------------------------------------------------------------- 1 | # Quick installation for *Prosody* 2 | 3 | This documentation assumes that you: 4 | 1. already have a working *Prosody* configuration, 5 | 1. it works with together *JSXC*, and 6 | 1. you have a simple, single-domain, single-purpose installation. 7 | 8 | If you need to start from scratch, look at our 9 | [step-by-step tutorial](https://github.com/jsxc/xmpp-cloud-auth/wiki/raspberry-pi-en). 10 | If you have advanced requirements, would like to know background, 11 | or run into trouble with this setup, read the 12 | [full installation instructions](./Installation.md). 13 | 14 | ## Software installation 15 | 16 | Currently, only nightlies are supported, which you get by 17 | ``` 18 | sudo -s 19 | echo deb https://dl.jsxc.org stable main > /etc/apt/sources.list.d/jsxc.list 20 | wget -qO - https://dl.jsxc.org/archive.key | apt-key add - 21 | apt update 22 | apt install xcauth 23 | ``` 24 | 25 | ## `xcauth` configuration 26 | 27 | :warning: The API secret must not fall into the wrong hands! 28 | Anyone knowing it can authenticate as any user to the XMPP server 29 | (and create arbitrary new users on the XMPP server). 30 | 31 | ```sh 32 | cp xcauth.conf /etc 33 | chown xcauth:xcauth /etc/xcauth.conf 34 | chmod 660 /etc/xcauth.conf 35 | ``` 36 | Modify `/etc/xcauth.conf` according to your environment. The values for 37 | API URL and API SECRET can be found in your Nextcloud JSXC admin page. 38 | 39 | ## Adapt *Prosody* configuration to use `xcauth` 40 | 41 | Add the following to your *Prosody* config (probably `/etc/prosody/prosody.cfg.lua`): 42 | ```lua 43 | authentication = "external" 44 | external_auth_command = "/opt/xmpp-cloud-auth/xcauth.py" 45 | plugin_paths = { "/opt/xmpp-cloud-auth/prosody-modules/" } 46 | ``` 47 | 48 | Ready! 49 | -------------------------------------------------------------------------------- /doc/Systemd.md: -------------------------------------------------------------------------------- 1 | # *systemd* integration 2 | 3 | `xcauth` can also be started from *systemd*. Three modes are supported: 4 | 5 | 1. Starting in *inetd* compatibility mode: For each connection to that socket, a new `xcauth` process is started. `xcauth` reads from stdin/stdout (DEPRECATED). 6 | 1. Using *systemd* [socket activation](http://0pointer.net/blog/projects/socket-activation.html), single protocol per configuration file: On the first connection, the single `xcauth` process is started for this protocol/port. For each incoming connection, only a thread is spawned. This is more efficient if a new connection is opened for every request (common for *saslauthd* and *postfix* modes, but depends on the requesting application). 7 | 1. Using *systemd* socket activation, multiple protocols per configuration file: Similar to the one above, but only a single `xcauth` process is ever started. All protocols are determined by information passed by *systemd* on process start (RECOMMENDED). 8 | 9 | The following ports are used by default: 10 | - TCP port 23662: *ejabberd* protocol support 11 | - TCP port 23663: *prosody* protocol support 12 | - TCP port 23665: *postfix* protocol support 13 | - TCP port 23666 and, optionally, `/var/run/saslauthd/mux` (stream-based Unix domain socket): *saslauthd* protocol support 14 | `/var/run/saslauthd/mux` is not activated by default, as it would conflict with a running `/usr/sbin/saslauthd` from *sasl2-bin*. You need to manually enable it in `/etc/systemd/system/xcsaslauth.socket` and then reload the configuration. 15 | 16 | ## XMPP authentication over *systemd* socket 17 | 18 | For some environments, it might be advantageous to use *xcauth* over a network socket. Here is a set of sample *systemd* configuration files, accepting the network connections described above. 19 | 20 | ### Installation (as root) 21 | 22 | 1. Perform the *xcauth* installation as explained in the [parent README](../README.md) or the [installation wiki](https://github.com/jsxc/xcauth/wiki). Especially copy `./xcauth.py` as `/usr/sbin/xcauth` and put the configuration in `/etc/xcauth.conf`. 23 | 1. `make install` 24 | 1. Activate the service: 25 | ```sh 26 | systemctl enable xcauth.service 27 | for i in xc*.socket; do 28 | systemctl start $i 29 | done 30 | systemctl start xcauth.service 31 | ``` 32 | 33 | :warning: If you do not want to replace an existing *saslauthd* on your system, 34 | do remove `/etc/systemd/system/xcsaslauthd.socket` after the installation 35 | or do not start `xcsaslauth.socket`. 36 | 37 | ### Testing 38 | 39 | Trye the following (`$` indicates the command line prompt, `<` is data received and `>` data sent): 40 | 41 | ``` 42 | $ telnet localhost 23663 43 | < Trying ::1... 44 | < Connected to localhost. 45 | < Escape character is '^]'. 46 | > isuser:admin:example.org 47 | < 1 48 | > auth:admin:example.org:good_password 49 | < 1 50 | > auth:admin:example.org:incorrect_password 51 | < 0 52 | > quit 53 | < Connection closed by foreign host. 54 | $ 55 | ``` 56 | 57 | ## `saslauthd` mode (authentication) 58 | 59 | To use *xcauth.py* as an authentication backend for e.g. mail servers 60 | (successfully tested with *Postfix* and *Cyrus IMAP*), you can activate 61 | that software's authentication against *saslauthd* (see their 62 | respective documentation for how to do this). Then, run the following 63 | commands to have *xcauth.py* pose as *saslauthd*: 64 | 65 | 1. Install *xcauth* as described above. 66 | 1. Copy `xcsaslauth.service` and `xcsaslauth.socket` to `/etc/systemd/system` (see above for symlink issues) 67 | 1. Disable "normal" *saslauthd*: `systemctl disable saslauthd` 68 | 1. Enable *xcauth.py* in *saslauthd* mode: `systemctl enable xcsaslauth.socket` and `systemctl start xcsaslauth.socket` 69 | 70 | Note that the *xcsaslauth* service listens on the Unix domain socket 71 | `/var/run/saslauthd/mux`. This should be default on Ubuntu, even though 72 | the software configuration files might only mention `/var/run/saslauthd`, 73 | the `/mux` suffix is added internally by the *SASL* library. 74 | 75 | ## `postfix` mode (existence check) 76 | 77 | When a *Postfix* mail server serves multiple realms (=domains), it 78 | needs some way to know whether a 79 | [mailbox](http://www.postfix.org/VIRTUAL_README.html#virtual_mailbox) 80 | exists 81 | ([`virtual_mailbox_maps`](http://www.postfix.org/postconf.5.html#virtual_mailbox_maps) 82 | *Postfix* variable). Using *saslauthd* mode alone would require 83 | manually maintaining a *virtual mailbox map*, listing each user/mailbox. 84 | 85 | *xcauth* already can provide the information whether a user exists, so 86 | `postfix` mode provides a *Postfix* 87 | [`tcp_table`](http://www.postfix.org/tcp_table.5.html) compatible interface 88 | to query the existance of a user and thereby confirm the availability of the 89 | user's mailbox. 90 | 91 | 1. Ensure that all *Nextcloud* logins are in `user@domain` format 92 | (any login name without an `@domain` part would be a valid account 93 | in all realms/domains, which might not be desirable) 94 | 1. Maintain a list of all *realms* in `/etc/postfix/vmdomains`, as a tuple 95 | with the domain as the left-hand side, and an arbitrary right-hand side 96 | ("OK" is a common value). 97 | 98 | The users can be determined easily, but not the domains. It is assumed 99 | that the domain list will not change frequently. (You will need to run 100 | `postmap /etc/postfix/vmdomains` after every change to that file.) 101 | 1. Add the line 102 | ```Postfix 103 | virtual_mailbox_maps = hash:/etc/postfix/vmdomains, tcp:localhost:23665 104 | ``` 105 | to `/etc/postfix/main.cf`. Integrate any existing assignment to 106 | `virtual_mailbox_maps`. 107 | 1. Install *xcauth* as described above. 108 | 1. Copy `xcpostfix.service` and `xcpostfix.socket` to `/etc/systemd/system` (see above for symlink issues) 109 | 1. Enable *xcauth.py* in *postfix* mode: `systemctl enable xcpostfix.socket` and `systemctl start xcpostfix.socket` 110 | 111 | ## Security considerations 112 | 113 | :warning: For security reasons, you might want to limit who can use this service over the network. Also, as `xcauth` is meant for local use, it does not support encryption (and therefore, confidentiality) of the commands (including passwords!) and authentication of return values. Therefore, please use it over the *loopback* interface only. If you must use a network connection, wrap it in `stunnel` or similar. 114 | -------------------------------------------------------------------------------- /doc/Testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | For testing, we use `nose`. This probably can be installed using 4 | ```sh 5 | apt install python-nose 6 | ``` 7 | or 8 | ```sh 9 | pip install nose 10 | ``` 11 | on your system. To run the tests, type 12 | ```sh 13 | nosetests3 14 | ``` 15 | or (if you also want coverage information), 16 | ```sh 17 | nosetests3 --with-coverage --cover-package=xclib 18 | ``` 19 | in this directory. Some of the tests will only run, 20 | if `/etc/xcauth.accounts` exists. Then, it will read 21 | tab-separated username/password pairs from that file 22 | and use the settings in `/etc/xcauth.conf` to verify 23 | those passwords. Additionally, it will assume that 24 | a user `nosuchuser` **does not exist**. Please make 25 | sure that only authorized users can read these 26 | configuration files. 27 | 28 | The full command-line tool can be tested when typing 29 | ```sh 30 | tests/run-online.pl /etc/xcauth.accounts 31 | ``` 32 | in the current directory. 33 | This will also use `/etc/xcauth.accounts`, so this 34 | needs to exist together with your valid `/etc/xcauth.conf`. 35 | 36 | ## Format of `/etc/xcauth.accounts` 37 | Line-based, tab-separated file with the following fields: 38 | 39 | ### Lines not starting with a tab 40 | 1. login 41 | 2. domain (typically your default domain) 42 | 3. password 43 | 44 | ### Lines starting with a tab 45 | 1. (empty, before the first tab) 46 | 2. command: isuser, auth, roster 47 | 3. expected response 48 | 49 | ### Lines starting with '#' and empty lines will be ignored 50 | 51 | ### Example file 52 | The table column separators will be single tabs in the real file. 53 | 54 | | Field 1 | Field 2 | Field 3 | 55 | | --------- | ---------------- | ----------------------------------------------- | 56 | | user1 | example.ch | p4ssw0rd | 57 | | | isuser | True | 58 | | | auth | True | 59 | | | roster | {"result":"success","data":{"sharedRoster":[]}} | 60 | | user1 | example.ch | wr0ng-p4ssw05d | 61 | | | isuser | True | 62 | | | auth | False | 63 | | nosuchuser| example.ch | dontcare | 64 | | | isuser | False | 65 | -------------------------------------------------------------------------------- /doc/codecov.svg: -------------------------------------------------------------------------------- 1 | 2 | 16 | 18 | 19 | 21 | image/svg+xml 22 | 24 | 25 | 26 | 27 | 28 | 30 | 54 | 62 | 67 | 71 | 72 | 81 | 90 | 95 | 102 | code coverage 107 | code coverage 112 | 99% 117 | 99% 122 | 123 | -------------------------------------------------------------------------------- /prosody-modules/mod_auth_external.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- Prosody IM 3 | -- Copyright (C) 2010 Waqas Hussain 4 | -- Copyright (C) 2010 Jeff Mitchell 5 | -- Copyright (C) 2013 Mikael Nordfeldth 6 | -- Copyright (C) 2013 Matthew Wild, finally came to fix it all 7 | -- Copyright (C) 2017-2018 Marcel Waldvogel 8 | -- 9 | -- This project is MIT/X11 licensed. Please see the 10 | -- COPYING file in the source package for more information. 11 | -- 12 | 13 | local usermanager = require "core.usermanager"; 14 | local new_sasl = require "util.sasl".new; 15 | local server = require "net.server"; 16 | local have_async, async = pcall(require, "util.async"); 17 | 18 | local log = module._log; 19 | local host = module.host; 20 | 21 | local script_type = module:get_option_string("external_auth_protocol", "generic"); 22 | local command = module:get_option_string("external_auth_command", ""); 23 | local read_timeout = module:get_option_number("external_auth_timeout", 5); 24 | local auth_processes = module:get_option_number("external_auth_processes", 1); 25 | 26 | local lpty, pty_options; 27 | if command:sub(1,1) == "@" then 28 | -- Use a socket connection 29 | lpty = module:require "pseudolpty"; 30 | log("info", "External auth with pseudolpty socket to %s", command:sub(2)); 31 | pty_options = { log = log }; 32 | else 33 | lpty = assert(require "lpty", "mod_auth_external requires lpty: https://modules.prosody.im/mod_auth_external.html#installation"); 34 | log("info", "External auth with pty command %s", command); 35 | pty_options = { throw_errors = false, no_local_echo = true, use_path = false }; 36 | end 37 | local blocking = module:get_option_boolean("external_auth_blocking", not(have_async and server.event and lpty.getfd)); 38 | assert(script_type == "ejabberd" or script_type == "generic", 39 | "Config error: external_auth_protocol must be 'ejabberd' or 'generic'"); 40 | assert(not host:find(":"), "Invalid hostname"); 41 | 42 | 43 | if not blocking then 44 | log("debug", "External auth in non-blocking mode, yay!") 45 | waiter, guard = async.waiter, async.guarder(); 46 | elseif auth_processes > 1 then 47 | log("warn", "external_auth_processes is greater than 1, but we are in blocking mode - reducing to 1"); 48 | auth_processes = 1; 49 | end 50 | 51 | local ptys = {}; 52 | 53 | for i = 1, auth_processes do 54 | ptys[i] = lpty.new(pty_options); 55 | end 56 | 57 | function module.unload() 58 | for i = 1, auth_processes do 59 | ptys[i]:endproc(); 60 | end 61 | end 62 | 63 | module:hook_global("server-cleanup", module.unload); 64 | 65 | local curr_process = 0; 66 | function send_query(text) 67 | curr_process = (curr_process%auth_processes)+1; 68 | local pty = ptys[curr_process]; 69 | 70 | local finished_with_pty 71 | if not blocking then 72 | finished_with_pty = guard(pty); -- Prevent others from crossing this line while we're busy 73 | end 74 | if not pty:hasproc() then 75 | local status, ret = pty:exitstatus(); 76 | if status and (status ~= "exit" or ret ~= 0) then 77 | log("warn", "Auth process exited unexpectedly with %s %d, restarting", status, ret or 0); 78 | return nil; 79 | end 80 | local ok, err = pty:startproc(command); 81 | if not ok then 82 | log("error", "Failed to start auth process '%s': %s", command, err); 83 | return nil; 84 | end 85 | log("debug", "Started auth process"); 86 | end 87 | 88 | pty:send(text); 89 | pty:flush("i"); 90 | if blocking then 91 | local response; 92 | response = pty:read(read_timeout); 93 | if response == text then 94 | response = pty:read(read_timeout); 95 | end 96 | return response; 97 | else 98 | local response; 99 | local wait, done = waiter(); 100 | server.addevent(pty:getfd(), server.event.EV_READ, function () 101 | response = pty:read(); 102 | if not response == text then 103 | done(); 104 | end 105 | return -1; 106 | end); 107 | wait(); 108 | finished_with_pty(); 109 | return response; 110 | end 111 | end 112 | 113 | function do_query(kind, username, password) 114 | if not username then return nil, "not-acceptable"; end 115 | 116 | local query = (password and "%s:%s:%s:%s" or "%s:%s:%s"):format(kind, username, host, password); 117 | local len = #query 118 | if len > 1000 then return nil, "policy-violation"; end 119 | 120 | if script_type == "ejabberd" then 121 | local lo = len % 256; 122 | local hi = (len - lo) / 256; 123 | query = string.char(hi, lo)..query; 124 | elseif script_type == "generic" then 125 | query = query..'\n'; 126 | end 127 | 128 | local response, err = send_query(query); 129 | if response then log("debug", "Response: %s", response ); end 130 | if not response then 131 | log("warn", "Error while waiting for result from auth process: %s", err or "unknown error"); 132 | elseif (script_type == "ejabberd" and response == "\0\2\0\0") or 133 | (script_type == "generic" and response:gsub("\r?\n$", "") == "0") then 134 | return nil, "not-authorized"; 135 | elseif (script_type == "ejabberd" and response == "\0\2\0\1") or 136 | (script_type == "generic" and response:gsub("\r?\n$", "") == "1") then 137 | return true; 138 | else 139 | log("warn", "Unable to interpret data from auth process, %s", 140 | (response:match("^error:") and response) or ("["..#response.." bytes]")); 141 | return nil, "internal-server-error"; 142 | end 143 | end 144 | 145 | local provider = {}; 146 | 147 | function provider.test_password(username, password) 148 | return do_query("auth", username, password); 149 | end 150 | 151 | function provider.set_password(username, password) 152 | return do_query("setpass", username, password); 153 | end 154 | 155 | function provider.user_exists(username) 156 | return do_query("isuser", username); 157 | end 158 | 159 | function provider.create_user(username, password) -- luacheck: ignore 212 160 | return nil, "Account creation/modification not available."; 161 | end 162 | 163 | function provider.get_sasl_handler() 164 | local testpass_authentication_profile = { 165 | plain_test = function(sasl, username, password, realm) 166 | return usermanager.test_password(username, realm, password), true; 167 | end, 168 | }; 169 | return new_sasl(host, testpass_authentication_profile); 170 | end 171 | 172 | module:provides("auth", provider); 173 | -------------------------------------------------------------------------------- /prosody-modules/mod_auth_socket.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- Prosody IM 3 | -- Copyright (C) 2010 Waqas Hussain 4 | -- Copyright (C) 2010 Jeff Mitchell 5 | -- Copyright (C) 2013 Mikael Nordfeldth 6 | -- Copyright (C) 2013 Matthew Wild, finally came to fix it all 7 | -- Copyright (C) 2017-2020 Marcel Waldvogel (this file only) 8 | -- 9 | -- This project is MIT/X11 licensed. Please see the 10 | -- COPYING file in the source package for more information. 11 | -- 12 | 13 | local usermanager = require "core.usermanager"; 14 | local new_sasl = require "util.sasl".new; 15 | local server = require "net.server"; 16 | local have_async, async = pcall(require, "util.async"); 17 | 18 | local log = module._log; 19 | local host = module.host; 20 | 21 | local script_type = module:get_option_string("socket_auth_protocol", "generic"); 22 | local command = module:get_option_string("socket_auth_connect", "@localhost:23663"); 23 | local read_timeout = module:get_option_number("socket_auth_timeout", 5); 24 | local auth_processes = module:get_option_number("socket_auth_processes", 1); 25 | 26 | local lpty, pty_options; 27 | assert(command:sub(1,1) == "@", "mod_auth_socket requires a socket connection starting with @") 28 | lpty = module:require "pseudolpty"; 29 | log("info", "Socket auth with pseudolpty socket to %s", command:sub(2)); 30 | pty_options = { log = log }; 31 | 32 | local blocking = module:get_option_boolean("socket_auth_blocking", not(have_async and server.event and lpty.getfd)); 33 | assert(script_type == "ejabberd" or script_type == "generic", 34 | "Config error: socket_auth_protocol must be 'ejabberd' or 'generic'"); 35 | assert(not host:find(":"), "Invalid hostname"); 36 | 37 | 38 | if not blocking then 39 | log("debug", "Socket auth in non-blocking mode, yay!") 40 | waiter, guard = async.waiter, async.guarder(); 41 | elseif auth_processes > 1 then 42 | log("warn", "socket_auth_processes is greater than 1, but we are in blocking mode - reducing to 1"); 43 | auth_processes = 1; 44 | end 45 | 46 | local ptys = {}; 47 | 48 | for i = 1, auth_processes do 49 | ptys[i] = lpty.new(pty_options); 50 | end 51 | 52 | function module.unload() 53 | for i = 1, auth_processes do 54 | ptys[i]:endproc(); 55 | end 56 | end 57 | 58 | module:hook_global("server-cleanup", module.unload); 59 | 60 | local curr_process = 0; 61 | function send_query(text) 62 | curr_process = (curr_process%auth_processes)+1; 63 | local pty = ptys[curr_process]; 64 | 65 | local finished_with_pty 66 | if not blocking then 67 | finished_with_pty = guard(pty); -- Prevent others from crossing this line while we're busy 68 | end 69 | if not pty:hasproc() then 70 | local status, ret = pty:exitstatus(); 71 | if status and (status ~= "exit" or ret ~= 0) then 72 | log("warn", "Auth process exited unexpectedly with %s %d, restarting", status, ret or 0); 73 | return nil; 74 | end 75 | local ok, err = pty:startproc(command); 76 | if not ok then 77 | log("error", "Failed to start auth process '%s': %s", command, err); 78 | return nil; 79 | end 80 | log("debug", "Started auth process"); 81 | end 82 | 83 | pty:send(text); 84 | pty:flush("i"); 85 | if blocking then 86 | local response; 87 | response = pty:read(read_timeout); 88 | if response == text then 89 | response = pty:read(read_timeout); 90 | end 91 | return response; 92 | else 93 | local response; 94 | local wait, done = waiter(); 95 | server.addevent(pty:getfd(), server.event.EV_READ, function () 96 | response = pty:read(); 97 | if not response == text then 98 | done(); 99 | end 100 | return -1; 101 | end); 102 | wait(); 103 | finished_with_pty(); 104 | return response; 105 | end 106 | end 107 | 108 | function do_query(kind, username, password) 109 | if not username then return nil, "not-acceptable"; end 110 | 111 | local query = (password and "%s:%s:%s:%s" or "%s:%s:%s"):format(kind, username, host, password); 112 | local len = #query 113 | if len > 1000 then return nil, "policy-violation"; end 114 | 115 | if script_type == "ejabberd" then 116 | local lo = len % 256; 117 | local hi = (len - lo) / 256; 118 | query = string.char(hi, lo)..query; 119 | elseif script_type == "generic" then 120 | query = query..'\n'; 121 | end 122 | 123 | local response, err = send_query(query); 124 | if response then log("debug", "Response: %s", response ); end 125 | if not response then 126 | log("warn", "Error while waiting for result from auth process: %s", err or "unknown error"); 127 | elseif (script_type == "ejabberd" and response == "\0\2\0\0") or 128 | (script_type == "generic" and response:gsub("\r?\n$", "") == "0") then 129 | return nil, "not-authorized"; 130 | elseif (script_type == "ejabberd" and response == "\0\2\0\1") or 131 | (script_type == "generic" and response:gsub("\r?\n$", "") == "1") then 132 | return true; 133 | else 134 | log("warn", "Unable to interpret data from auth process, %s", 135 | (response:match("^error:") and response) or ("["..#response.." bytes]")); 136 | return nil, "internal-server-error"; 137 | end 138 | end 139 | 140 | local provider = {}; 141 | 142 | function provider.test_password(username, password) 143 | return do_query("auth", username, password); 144 | end 145 | 146 | function provider.set_password(username, password) 147 | return do_query("setpass", username, password); 148 | end 149 | 150 | function provider.user_exists(username) 151 | return do_query("isuser", username); 152 | end 153 | 154 | function provider.create_user(username, password) -- luacheck: ignore 212 155 | return nil, "Account creation/modification not available."; 156 | end 157 | 158 | function provider.get_sasl_handler() 159 | local testpass_authentication_profile = { 160 | plain_test = function(sasl, username, password, realm) 161 | return usermanager.test_password(username, realm, password), true; 162 | end, 163 | }; 164 | return new_sasl(host, testpass_authentication_profile); 165 | end 166 | 167 | module:provides("auth", provider); 168 | -------------------------------------------------------------------------------- /prosody-modules/pseudolpty.lib.lua: -------------------------------------------------------------------------------- 1 | -- 2 | -- Prosody IM 3 | -- Copyright (C) 2017 Marcel Waldvogel 4 | -- 5 | -- This project is MIT/X11 licensed. Please see the 6 | -- COPYING file in the source package for more information. 7 | -- 8 | 9 | -- 10 | -- Behaves as if it were an lpty (Lua Pseudo TTY) object, 11 | -- but connects through a socket. So it is a Pseudo-LPTY (pun intended). 12 | -- 13 | -- To maximize fail-safety (and minimize modifications to the caller), 14 | -- it auto-reconnects transparently. 15 | -- 16 | -- Only provides the functions necessary for mod_auth_external.lua. 17 | -- It follows a delegation pattern to the TCP socket in order to 18 | -- simplify (re)connection. 19 | -- 20 | 21 | local _M = {}; 22 | local plpty = {}; 23 | 24 | function _M.new(opts) 25 | -- create a new empty plpty object ignoring opts 26 | o = {} 27 | setmetatable(o, {__index = plpty}); 28 | o.log = opts.log or print; 29 | return o; 30 | end 31 | 32 | function plpty:startproc(command) 33 | -- command may be of the form [@]: or 34 | local host, port = string.match(command, "@?([^:]+):([^:]+)"); 35 | log("debug", "local host=%s, port=%s", host, port); 36 | self.host = host or "localhost"; 37 | self.port = port or command; 38 | log("debug", "self host=%s, port=%s", self.host, self.port); 39 | return self:reconnect(); 40 | end 41 | 42 | function plpty:hasproc() 43 | return self.connected; 44 | end 45 | 46 | function plpty:disconnect() 47 | if self.sock then 48 | self.sock:close(); 49 | end 50 | self.connected = nil; 51 | end 52 | 53 | function plpty:reconnect() 54 | self:disconnect(); 55 | self.sock = assert(require("socket").tcp(), "Cannot create TCP LuaSocket"); 56 | self.connected, self.exitstr = self.sock:connect(self.host, self.port); 57 | if self.connected then 58 | self.log("info", "plpty:reconnect succeeded"); 59 | else 60 | self.log("error", "plpty:reconnect failed: %s", self.exitstr); 61 | end 62 | return self.connected, self.exitstr; 63 | end 64 | 65 | function plpty:exitstatus() 66 | return self.exitstr, self.exitint or 0; 67 | end 68 | 69 | function plpty:send(text) 70 | self.log("debug", "plpty:send(%s)", text); 71 | 72 | -- sock:send() will not check for closedness 73 | local ok, err = self:read(0); 74 | if not ok and not (err ~= "timeout") then 75 | self:reconnect(); 76 | end 77 | 78 | local bytes, err = self.sock:send(text); 79 | self.log("debug", "bytes=%s, err=%s", bytes or "nil", err or "---"); 80 | if err then 81 | self.log("info", "plpty:send: socket to %s:%s %s", self.host, self.port, err); 82 | if self:reconnect() then 83 | -- retransmit 84 | bytes, err = self.sock:send(text); 85 | end 86 | end 87 | end 88 | 89 | function plpty:flush(mode) 90 | -- noop 91 | end 92 | 93 | function plpty:getfd() 94 | return self.sock:getfd(); 95 | end 96 | 97 | function plpty:read(timeout) 98 | self.sock:settimeout(timeout); 99 | local ok, err = self.sock:receive(); 100 | self.log("debug", "plpty:read(%d) -> %s, %s", timeout, ok or "---", err or "---"); 101 | if err then 102 | self.log("info", "plpty:read: socket to %s:%s %s", self.host, self.port, err); 103 | self:disconnect(); 104 | end 105 | return ok; 106 | end 107 | 108 | return _M; 109 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # Configuration file for nosetests 2 | [nosetests] 3 | with-coverage=1 4 | cover-package=xclib 5 | #stop=1 6 | rednose=1 7 | -------------------------------------------------------------------------------- /systemd/xcauth.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=XMPP to Nextcloud+JSXC authentication bridge 3 | 4 | [Service] 5 | ExecStart=/usr/sbin/xcauth 6 | User=xcauth 7 | Sockets=xcauth.socket xcejabberd.socket xcpostfix.socket xcprosody.socket xcsaslauth.socket 8 | # Should be doable in `xcsaslauth.socket` according to 9 | # https://www.freedesktop.org/software/systemd/man/systemd.socket.html#ExecStartPre= 10 | # but doesn't for me in Ubuntu 18.04. So I moved it here. 11 | # 12 | # The downside of this setup: saslauth connection may not occur before 13 | # manual `systemctl start xcauth.service` or connection to one of the 14 | # other sockets. 15 | # 16 | # Do not fail if /var/run/saslauthd does not exist 17 | ExecStartPre=-+/bin/chgrp sasl /var/run/saslauthd/ 18 | 19 | [Install] 20 | WantedBy=multi-user.target 21 | -------------------------------------------------------------------------------- /systemd/xcejabberd.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=XMPP to Nextcloud+JSXC authentication bridge for ejabberd 3 | 4 | [Socket] 5 | ListenStream=[::1]:23662 6 | ListenStream=127.0.0.1:23662 7 | Accept=false 8 | FileDescriptorName=ejabberd 9 | Service=xcauth.service 10 | 11 | [Install] 12 | WantedBy=xcauth.service 13 | -------------------------------------------------------------------------------- /systemd/xcpostfix.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Postfix virtual mailbox to Nextcloud+JSXC bridge 3 | 4 | [Socket] 5 | ListenStream=[::1]:23665 6 | ListenStream=127.0.0.1:23665 7 | Accept=false 8 | FileDescriptorName=postfix 9 | Service=xcauth.service 10 | 11 | [Install] 12 | WantedBy=xcauth.service 13 | -------------------------------------------------------------------------------- /systemd/xcprosody.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=XMPP to Nextcloud+JSXC authentication bridge for Prosody 3 | 4 | [Socket] 5 | ListenStream=[::1]:23663 6 | ListenStream=127.0.0.1:23663 7 | Accept=false 8 | FileDescriptorName=prosody 9 | Service=xcauth.service 10 | 11 | [Install] 12 | WantedBy=xcauth.service 13 | -------------------------------------------------------------------------------- /systemd/xcsaslauth.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SASL to Nextcloud+JSXC authentication bridge 3 | 4 | [Socket] 5 | ListenStream=[::1]:23666 6 | ListenStream=127.0.0.1:23666 7 | 8 | ## Only enable the following block if you are not running 9 | ## `/usr/sbin/saslauthd` from *sasl2-bin* 10 | #ListenStream=/var/run/saslauthd/mux 11 | #SocketUser=root 12 | #SocketGroup=sasl 13 | #SocketMode=660 14 | #DirectoryMode=710 15 | 16 | # Does not work for me yet, despite the documentation in 17 | # https://www.freedesktop.org/software/systemd/man/systemd.socket.html#ExecStartPre= 18 | #ExecStartPost=/bin/chgrp sasl /var/run/saslauthd/ 19 | 20 | Accept=false 21 | FileDescriptorName=saslauthd 22 | Service=xcauth.service 23 | 24 | [Install] 25 | WantedBy=xcauth.service 26 | -------------------------------------------------------------------------------- /tests/Coverage.md: -------------------------------------------------------------------------------- 1 | # Code coverage 2 | 3 | Unit tests only result in a code coverage of 4 | [![codecov](https://codecov.io/gh/jsxc/xmpp-cloud-auth/branch/master/graph/badge.svg)](https://codecov.io/gh/jsxc/xmpp-cloud-auth). 5 | 6 | However, with system tests (which require `/etc/xcauth.accounts` based on [xcauth.accounts.sample](./xcauth.accounts.sample) and a running Nextcloud+JSXC instance), code coverage as of [`d98186869`](https://github.com/jsxc/xmpp-cloud-auth/commit/d98186869) achieves a stunning 99%. ![Code coverage](../doc/codecov.svg) 7 | 8 | This is the output of `nosetests3` in the top-level directory (with online tests, based on the above version). 9 | 10 | ``` 11 | ...............................--............................................ 12 | ....................... 13 | Name Stmts Miss Cover Missing 14 | ------------------------------------------------------ 15 | xclib/__init__.py 28 0 100% 16 | xclib/auth.py 97 0 100% 17 | xclib/authops.py 135 1 99% 116 18 | xclib/check.py 8 0 100% 19 | xclib/configuration.py 70 1 99% 152 20 | xclib/db.py 132 1 99% 156 21 | xclib/dbmops.py 19 0 100% 22 | xclib/ejabberd_io.py 34 2 94% 33-34 23 | xclib/ejabberdctl.py 31 0 100% 24 | xclib/isuser.py 25 0 100% 25 | xclib/postfix_io.py 25 0 100% 26 | xclib/prosody_io.py 17 0 100% 27 | xclib/roster.py 56 0 100% 28 | xclib/roster_thread.py 88 0 100% 29 | xclib/saslauthd_io.py 36 2 94% 35-36 30 | xclib/sigcloud.py 49 1 98% 76 31 | xclib/sockact.py 27 0 100% 32 | xclib/utf8.py 18 0 100% 33 | xclib/version.py 1 0 100% 34 | ------------------------------------------------------ 35 | TOTAL 896 8 99% 36 | 37 | ----------------------------------------------------------------------------- 38 | 100 tests run in 2.9 seconds. 39 | 2 skipped (98 tests passed) 40 | ``` 41 | -------------------------------------------------------------------------------- /tests/run-online-ejabberd.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | use IPC::Open2; 3 | if ( ! -r "/etc/xcauth.accounts" ) { 4 | print STDERR "/etc/xcauth.accounts must exist and be readable\n"; 5 | exit(1); 6 | } 7 | $| = 1; # Autoflush on 8 | open STDIN, ") { 42 | chomp; 43 | next if length($_) == 0 || substr($_, 0, 1) eq '#'; 44 | @fields = split(/\t/, $_, -1); 45 | if ($#fields != 2) { 46 | print STDERR "Need 3 fields per line: $_\n"; 47 | exit(1); 48 | } 49 | if ($fields[0] eq '') { 50 | if ($fields[1] eq 'auth') { 51 | $cmd = "auth:$u:$d:$p"; 52 | print COMMAND pack('n', length($cmd)) . $cmd; 53 | } elsif ($fields[1] eq 'isuser') { 54 | $cmd = "isuser:$u:$d"; 55 | print COMMAND pack('n', length($cmd)) . $cmd; 56 | } elsif ($fields[1] eq 'roster') { 57 | print STDERR "Not testing roster command in ejabberd mode\n"; 58 | next; 59 | } else { 60 | print STDERR "Invalid command $fields[1]\n"; 61 | exit(1); 62 | } 63 | sysread PROG, $data, 4; 64 | # Normalization 65 | if ($data eq pack("nn", 2, 0)) { 66 | $data = "False"; 67 | } elsif ($data eq pack("nn", 2, 1)) { 68 | $data = "True"; 69 | } 70 | 71 | if ($data ne $fields[2]) { 72 | print STDERR "*** Test " . join(' ', @fields[1,2]) . " failed ($u/$d/$p: $data != $fields[2])\n"; 73 | exit(1); 74 | } else { 75 | print STDERR "*** Test " . join(' ', @fields[1,2]) . " succeeded\n\n"; 76 | } 77 | } else { 78 | ($u, $d, $p) = @fields; 79 | } 80 | } 81 | if ($child > 0) { 82 | kill('TERM', $child); 83 | } 84 | if ($pid > 0) { 85 | kill('TERM', $pid); 86 | } 87 | -------------------------------------------------------------------------------- /tests/run-online-postfix-connection-error.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | use IPC::Open2; 3 | $| = 1; # Autoflush on 4 | my $child = -1; 5 | my $pid = -1; 6 | my $opt = shift; 7 | if ($opt eq "socket1366x") { 8 | # Start our own service on a special port, because we need to fail 9 | $child = fork(); 10 | if ($child < 0) { 11 | die "fork: $!"; 12 | } elsif ($child == 0) { 13 | exec 'systemd-socket-activate', '-l', '12561', './xcauth.py', '-t', 'postfix', "-u", "https://no-connection.jsxc.org/", "-s", "0"; 14 | die "exec: $!"; 15 | } else { 16 | sleep(1); 17 | $pid = open2(\*PROG, \*COMMAND, "socket", "localhost", "12561") or die "$!"; 18 | } 19 | } elsif ($opt eq "socket2366x") { 20 | # Don't do anything here 21 | exit; 22 | } else { 23 | # Use pipe to child process 24 | $pid = open2(\*PROG, \*COMMAND, "./xcauth.py", "-t", "postfix", "-u", "https://no-connection.jsxc.org/", "-s", "0") or die "$!"; 25 | } 26 | print COMMAND "get user\@example.org\n"; 27 | $data = ; 28 | chomp $data; 29 | if ($data !~ /^400 /) { 30 | print STDERR "**** Test for connection failure failed: $data\n"; 31 | exit(1); 32 | } else { 33 | print STDERR "**** Test for connection failure succeeded\n\n"; 34 | } 35 | if ($child > 0) { 36 | kill('TERM', $child); 37 | } 38 | if ($pid > 0) { 39 | kill('TERM', $pid); 40 | } 41 | -------------------------------------------------------------------------------- /tests/run-online-postfix.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | use IPC::Open2; 3 | if ( ! -r "/etc/xcauth.accounts" ) { 4 | print STDERR "/etc/xcauth.accounts must exist and be readable\n"; 5 | exit(1); 6 | } 7 | $| = 1; # Autoflush on 8 | open STDIN, ") { 42 | chomp; 43 | next if length($_) == 0 || substr($_, 0, 1) eq '#'; 44 | @fields = split(/\t/, $_, -1); 45 | if ($#fields != 2) { 46 | print STDERR "Need 3 fields per line: $_\n"; 47 | exit(1); 48 | } 49 | if ($fields[0] eq '') { 50 | if ($fields[1] eq 'auth') { 51 | print STDERR "Not testing auth command in postfix mode\n"; 52 | next; 53 | } elsif ($fields[1] eq 'isuser') { 54 | $cmd = "get $u\@$d"; 55 | print COMMAND "$cmd\n"; 56 | } elsif ($fields[1] eq 'roster') { 57 | print STDERR "Not testing roster command in postfix mode\n"; 58 | next; 59 | } else { 60 | print STDERR "Invalid command $fields[1]\n"; 61 | exit(1); 62 | } 63 | $data = ; 64 | # Normalization 65 | chomp $data; 66 | if ($data =~ /^200 /) { 67 | $data = "True"; 68 | } elsif ($data =~ /^500 /) { 69 | $data = "False"; 70 | } 71 | 72 | if ($data ne $fields[2]) { 73 | print STDERR "*** Test " . join(' ', @fields[1,2]) . " failed ($u/$d/$p: $data != $fields[2])\n"; 74 | exit(1); 75 | } else { 76 | print STDERR "*** Test " . join(' ', @fields[1,2]) . " succeeded\n\n"; 77 | } 78 | } else { 79 | ($u, $d, $p) = @fields; 80 | } 81 | } 82 | if ($child > 0) { 83 | kill('TERM', $child); 84 | } 85 | if ($pid > 0) { 86 | kill('TERM', $pid); 87 | } 88 | -------------------------------------------------------------------------------- /tests/run-online-prosody.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | use IPC::Open2; 3 | if ( ! -r "/etc/xcauth.accounts" ) { 4 | print STDERR "/etc/xcauth.accounts must exist and be readable\n"; 5 | exit(1); 6 | } 7 | $| = 1; # Autoflush on 8 | open STDIN, ") { 42 | chomp; 43 | next if length($_) == 0 || substr($_, 0, 1) eq '#'; 44 | @fields = split(/\t/, $_, -1); 45 | if ($#fields != 2) { 46 | print STDERR "Need 3 fields per line: $_\n"; 47 | exit(1); 48 | } 49 | if ($fields[0] eq '') { 50 | if ($fields[1] eq 'auth') { 51 | $cmd = "auth:$u:$d:$p"; 52 | print COMMAND "$cmd\n"; 53 | } elsif ($fields[1] eq 'isuser') { 54 | $cmd = "isuser:$u:$d"; 55 | print COMMAND "$cmd\n"; 56 | } elsif ($fields[1] eq 'roster') { 57 | print STDERR "Not testing roster command in prosody mode\n"; 58 | next; 59 | } else { 60 | print STDERR "Invalid command $fields[1]\n"; 61 | exit(1); 62 | } 63 | $data = ; 64 | # Normalization 65 | chomp $data; 66 | if ($data eq "1") { 67 | $data = "True"; 68 | } elsif ($data eq "0") { 69 | $data = "False"; 70 | } 71 | 72 | if ($data ne $fields[2]) { 73 | print STDERR "*** Test " . join(' ', @fields[1,2]) . " failed ($u/$d/$p: $data != $fields[2])\n"; 74 | exit(1); 75 | } else { 76 | print STDERR "*** Test " . join(' ', @fields[1,2]) . " succeeded\n\n"; 77 | } 78 | } else { 79 | ($u, $d, $p) = @fields; 80 | } 81 | } 82 | if ($child > 0) { 83 | kill('TERM', $child); 84 | } 85 | if ($pid > 0) { 86 | kill('TERM', $pid); 87 | } 88 | -------------------------------------------------------------------------------- /tests/run-online-saslauthd.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | use IPC::Open2; 3 | if ( ! -r "/etc/xcauth.accounts" ) { 4 | print STDERR "/etc/xcauth.accounts must exist and be readable\n"; 5 | exit(1); 6 | } 7 | $| = 1; # Autoflush on 8 | open STDIN, ") { 42 | chomp; 43 | next if length($_) == 0 || substr($_, 0, 1) eq '#'; 44 | @fields = split(/\t/, $_, -1); 45 | if ($#fields != 2) { 46 | print STDERR "Need 3 fields per line: $_\n"; 47 | exit(1); 48 | } 49 | if ($fields[0] eq '') { 50 | if ($fields[1] eq 'auth') { 51 | $cmd = lstr($u) . lstr($p) . lstr('test') . lstr($d); 52 | print COMMAND $cmd; 53 | } elsif ($fields[1] eq 'isuser') { 54 | print STDERR "Not testing isuser command in saslauthd mode\n"; 55 | next; 56 | } elsif ($fields[1] eq 'roster') { 57 | print STDERR "Not testing roster command in saslauthd mode\n"; 58 | next; 59 | } else { 60 | print STDERR "Invalid command $fields[1]\n"; 61 | exit(1); 62 | } 63 | sysread PROG, $len, 2; 64 | $len = unpack('n', $len); 65 | sysread PROG, $data, $len; 66 | # Normalization 67 | if ($data =~ /^NO /) { 68 | $data = "False"; 69 | } elsif ($data =~ /^OK /) { 70 | $data = "True"; 71 | } 72 | 73 | if ($data ne $fields[2]) { 74 | print STDERR "*** Test " . join(' ', @fields[1,2]) . " failed ($u/$d/$p: $data != $fields[2])\n"; 75 | exit(1); 76 | } else { 77 | print STDERR "*** Test " . join(' ', @fields[1,2]) . " succeeded\n\n"; 78 | } 79 | } else { 80 | ($u, $d, $p) = @fields; 81 | } 82 | } 83 | if ($child > 0) { 84 | kill('TERM', $child); 85 | } 86 | if ($pid > 0) { 87 | kill('TERM', $pid); 88 | } 89 | 90 | sub lstr { 91 | my $s = shift; 92 | return pack("n", length($s)) . $s; 93 | } 94 | -------------------------------------------------------------------------------- /tests/run-online.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/perl -w 2 | if ( ! -r "/etc/xcauth.accounts" ) { 3 | print STDERR "/etc/xcauth.accounts must exist and be readable\n"; 4 | exit(1); 5 | } 6 | open STDIN, ") { 11 | chomp; 12 | next if length($_) == 0 || substr($_, 0, 1) eq '#'; 13 | @fields = split(/\t/, $_, -1); 14 | if ($#fields != 2) { 15 | print STDERR "Need 3 fields per line: $_\n"; 16 | exit(1); 17 | } 18 | if ($fields[0] eq '') { 19 | if ($fields[1] eq 'auth') { 20 | open PROG, "-|", "./xcauth.py", "-A", $u, $d, $p or die "$!"; 21 | } elsif ($fields[1] eq 'isuser') { 22 | open PROG, "-|", "./xcauth.py", "-I", $u, $d or die "$!"; 23 | } elsif ($fields[1] eq 'roster') { 24 | open PROG, "-|", "./xcauth.py", "-R", $u, $d or die "$!"; 25 | } else { 26 | print STDERR "Invalid command $fields[1]\n"; 27 | exit(1); 28 | } 29 | $data = ; 30 | # Normalization 31 | chomp $data; 32 | if ($data eq '[]' || $data eq '{"result":"success","data":{"sharedRoster":[]}}') { 33 | $data = 'None'; 34 | } 35 | 36 | if ($data ne $fields[2]) { 37 | print STDERR "*** Test " . join(' ', @fields[1,2]) . " failed ($u/$d/$p: $data != $fields[2])\n"; 38 | exit(1); 39 | } else { 40 | print STDERR "*** Test " . join(' ', @fields[1,2]) . " succeeded\n\n"; 41 | } 42 | } else { 43 | ($u, $d, $p) = @fields; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/run-signal-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | dir=`mktemp -d` 3 | # `ConfigArgParse` cannot use /dev/null as the config file 4 | # (bogus 'error: File not found: /dev/null' message) 5 | 6 | touch $dir/xcauth.conf 7 | (sleep 5; echo quit) | ./xcauth.py --config-file $dir/xcauth.conf -l $dir -t generic & 8 | pid=$! 9 | sleep 1 10 | 11 | # Dump threads to error log 12 | kill -USR1 $pid 13 | sleep 1 14 | 15 | # Rotate error log 16 | mv $dir/xcauth.err $dir/xcauth.err.1 17 | kill -HUP $pid 18 | sleep 1 19 | 20 | # Dump threads to new error log 21 | kill -USR1 $pid 22 | 23 | # Test that both have been used 24 | grep MainThread $dir/xcauth.err > /dev/null && \ 25 | grep MainThread $dir/xcauth.err.1 > /dev/null 26 | res=$? 27 | if [ $res -eq 0 ]; then 28 | echo "OK" 29 | rm -rf $dir 30 | exit 0 31 | else 32 | echo "Signal handling/log rotation problem, see $dir" 33 | exit $res 34 | fi 35 | -------------------------------------------------------------------------------- /tests/xcauth.accounts.sample: -------------------------------------------------------------------------------- 1 | # File structure: (each field separated by a single tab) 2 | # 3 | # login domain password 4 | # (nil) command result 5 | # (nil) command result 6 | # 7 | # Beware that too many failing 'auth' commands will 8 | # trigger anti-brute force measures (delays, bans, …) 9 | nosuchuser example.ch - 10 | isuser False 11 | auth False 12 | roster None 13 | tester example.ch p4ssw0rd 14 | isuser True 15 | auth True 16 | roster {"result":"success","data":{"sharedRoster":{"tester":{"name":"Frank Tester","groups":["Test","Test2"],"friend@example.ch":{"name":"My Friend","groups":["Test"]},"another":{"name":"A. N. Other","groups":["Test2"]}}}} 17 | another example.ch wrong-password 18 | isuser True 19 | auth False 20 | roster {"result":"success","data":{"sharedRoster":{"tester":{"name":"Frank Tester","groups":["Test2"],"another":{"name":"A. N. Other","groups":["Test2"]}}}} 21 | -------------------------------------------------------------------------------- /tools/README.md: -------------------------------------------------------------------------------- 1 | # Tools 2 | 3 | ## `xcauth.logrotate` 4 | 5 | To regularily rotate the logs, copy `xcauth.logrotate` to 6 | `/etc/logrotate.d/xcauth`. 7 | 8 | `make install` already does this for you. 9 | 10 | ## `xcauth.sudo` and `xcejabberdctl.sh` 11 | 12 | If you want to enable shared roster groups support for *ejabberd*. 13 | 14 | - Copy `xcauth.sudo` as `/etc/sudoers.d/xcauth`, 15 | - Copy `xcejabberd.sh` as `/usr/sbin/xcejabberdctl` 16 | - Remove the `#` in the `ejabberdctl=/usr/sbin/xcejabberdctl` line 17 | of `/etc/xcauth.conf` 18 | - If your `ejabberdctl` does not live in `/usr/sbin`, please create 19 | a symlink from `/usr/sbin/ejabberdctl`. 20 | 21 | `make install` already does *the first two steps* for you. 22 | 23 | ## `xcrestart.sh` 24 | 25 | Restarts the sockets and services for systemd. The service does 26 | not need to be explicitely started, it will be on the first connection 27 | to one of the sockets. It also fixes the permissions of the database 28 | and log files, if `xcauth` was first manually started as root and 29 | created the files with the wrong permissions. 30 | 31 | `make install` installs this as `/usr/bin/xcrestart` 32 | 33 | ## `xcrefreshroster.sh` 34 | 35 | This will cause the roster groups to be rebuilt on the next login of 36 | one of their users. 37 | Necessary when the XMPP server's state differs from the cache, e.g., 38 | because of manual modifications or due to an error in synchronization, 39 | as it happened e.g. in v2.0.2. 40 | 41 | `make install` installs this as `/usr/bin/xcrefreshroster` 42 | 43 | ## `xcdeluser.sh` 44 | 45 | This will delete most of a user's entries from the database. 46 | Not purged are group memberships, as they will be auto-updated on the 47 | next login of another member of that group (and cannot easily be dealt 48 | with using SQL statements). To make sure it will not reappear, you 49 | also have to delete it from Nextcloud. 50 | 51 | `make install` installs this as `/usr/bin/xcdeluser` 52 | 53 | ## `xcdelgroup.sh` 54 | 55 | This will delete a particular group from the cache. To make sure it will 56 | not reappear, you also have to delete it from Nextcloud. 57 | 58 | `make install` installs this as `/usr/bin/xcdelgroup` 59 | 60 | ## `xcdelhost.sh` 61 | 62 | This will delete all entries relating to a host from `xcauth`. 63 | *ejabberd* maitains its own storage. 64 | 65 | `make install` installs this as `/usr/bin/xcdelhost` 66 | 67 | ## `dhparam.pem` and `ejabberd.yml` 68 | 69 | These are the sample configuration files from our 70 | [Debian and/or Raspberry Pi setup](https://github.com/jsxc/xmpp-cloud-auth/wiki/raspberry-pi-en). 71 | 72 | `make install` installs them in `/etc/ejabberd` as `*-xcauth-example`. 73 | -------------------------------------------------------------------------------- /tools/dhparams.pem.md: -------------------------------------------------------------------------------- 1 | # Example `dhparams.pem` 2 | 3 | If [generating `dhparams.pem` on your machine is too slow](https://github.com/jsxc/xmpp-cloud-auth/wiki/raspberry-pi-en), run it on a faster machine. If you still think it is too slow, use the contents of this file. [I have generated them in good faith, but there is no way you can tell.](https://www.cryptologie.net/article/332/openssl-dhparam-2048/) So, whenever possible, generate your own. 4 | 5 | ```PEM 6 | -----BEGIN DH PARAMETERS----- 7 | MIIBCAKCAQEA+RVVp5omu+9PDBRxmsVT3wAvfQLBnwQSYYl/a/TMRkxSz7FPqXHA 8 | 4Hnmd0vr/UmNTT+FlF7pCb5ZdjpMOArUQrjsR9hpwHrMT0n6eqwIWbsl6HRKj8W2 9 | 0S/0K+7EKY0sC5ZmAMJeZJTHwNc/RRqxg7okmvVRlbEwb1Pb2dVTM+97Ypqifyo4 10 | 4dEJo348pbRXHrn6eGoR8jJB4O00yF11G0GSy4z1isijzPgce0mVmRCIP+Es2eSS 11 | isoqp/WycMcVEaS4HAmJq79Nvkx4uzQb0k8GjgMPabJfXpM7kzUy1uxXY2ohhF81 12 | 3Mww06GcXQBp53fStTVXcNOzr39qmgX+OwIBAg== 13 | -----END DH PARAMETERS----- 14 | ``` 15 | -------------------------------------------------------------------------------- /tools/ejabberd.yml: -------------------------------------------------------------------------------- 1 | # This is an example configuration file for ejabberd taken from 2 | # the setup explanations in our Debian and/or Raspberry Pi setup: 3 | # 4 | # - English: https://github.com/jsxc/xmpp-cloud-auth/wiki/raspberry-pi-en 5 | # - German: https://github.com/jsxc/xmpp-cloud-auth/wiki/raspberry-pi 6 | # 7 | # Read those setup manuals for more information, but especially replace all 8 | # occurences of "SERVERNAME" below with your fully-qualified domain name. 9 | 10 | define_macro: 11 | 'CIPHERS': "ECDH:DH:!3DES:!aNULL:!eNULL:!MEDIUM@STRENGTH:!AES128" 12 | 'TLSOPTS': 13 | - "no_sslv3" 14 | # generated with: openssl dhparam -out dhparams.pem 2048 15 | 'DHFILE': "/etc/ejabberd/dhparams.pem" 16 | 17 | certfiles: 18 | - "/etc/letsencrypt/live/*/fullchain.pem" 19 | - "/etc/letsencrypt/live/*/privkey.pem" 20 | s2s_use_starttls: required 21 | s2s_protocol_options: 'TLSOPTS' 22 | s2s_ciphers: 'CIPHERS' 23 | s2s_dhfile: 'DHFILE' 24 | c2s_dhfile: 'DHFILE' 25 | 26 | hosts: 27 | - "SERVERNAME" 28 | 29 | loglevel: 4 30 | log_rotate_size: 10485760 31 | log_rotate_date: "" 32 | log_rotate_count: 1 33 | log_rate_limit: 100 34 | trusted_proxies: 35 | - "127.0.0.1" 36 | - "::1" 37 | - "localhost" 38 | - "::FFFF:127.0.0.1" # This is the one which works here, but others might catch in other environments 39 | auth_method: external 40 | extauth_program: "/usr/bin/socket localhost 23662" 41 | auth_use_cache: false 42 | 43 | shaper: 44 | normal: 1000 45 | fast: 50000 46 | proxy: 1000000 47 | max_fsm_queue: 1000 48 | acl: 49 | admin: 50 | user: 51 | # Replace with the server admins you want 52 | # - "admin1@SERVERNAME" 53 | # - "admin2@SERVERNAME" 54 | local: 55 | user_regexp: "" 56 | loopback: 57 | ip: 58 | - "127.0.0.0/8" 59 | proxy65_access: 60 | local: allow 61 | all: deny 62 | proxy65_shaper: 63 | admin: none 64 | proxy_users: proxyrate 65 | 66 | shaper_rules: 67 | ## Maximum number of simultaneous sessions allowed for a single user: 68 | max_user_sessions: 10 69 | ## Maximum number of offline messages that users can have: 70 | max_user_offline_messages: 71 | - 5000: admin 72 | - 500 73 | ## For C2S connections, all users except admins use the "normal" shaper 74 | c2s_shaper: 75 | - none: admin 76 | - normal 77 | ## All S2S connections use the "fast" shaper 78 | s2s_shaper: fast 79 | access_rules: 80 | ## This rule allows access only for local users: 81 | local: 82 | - allow: local 83 | ## Only non-blocked users can use c2s connections: 84 | c2s: 85 | - deny: blocked 86 | - allow 87 | ## Only admins can send announcement messages: 88 | announce: 89 | - allow: admin 90 | ## Only admins can use the configuration interface: 91 | configure: 92 | - allow: admin 93 | ## Only accounts of the local ejabberd server can create rooms: 94 | muc_create: 95 | - allow: local 96 | ## Only accounts on the local ejabberd server can create Pubsub nodes: 97 | pubsub_createnode: 98 | - allow: local 99 | ## In-band registration allows registration of any possible username. 100 | ## To disable in-band registration, replace 'allow' with 'deny'. 101 | register: 102 | - deny 103 | ## Only allow to register from localhost 104 | trusted_network: 105 | - allow: loopback 106 | api_permissions: 107 | "console commands": 108 | from: 109 | - ejabberd_ctl 110 | who: all 111 | what: "*" 112 | "admin access": 113 | who: 114 | - access: 115 | - allow: 116 | - ip: "127.0.0.1/8" 117 | - acl: admin 118 | - oauth: 119 | - scope: "ejabberd:admin" 120 | - access: 121 | - allow: 122 | - ip: "127.0.0.1/8" 123 | - acl: admin 124 | what: 125 | - "*" 126 | - "!stop" 127 | - "!start" 128 | "public commands": 129 | who: 130 | - ip: "127.0.0.1/8" 131 | what: 132 | - "status" 133 | - "connected_users_number" 134 | language: "en" 135 | 136 | modules: # See manual 137 | # Ad-Hoc Commands (XEP-0050) 138 | mod_adhoc: {} 139 | # Additional ejabberdctl commands 140 | mod_admin_extra: {} 141 | # Send global announcements 142 | mod_announce: # recommends mod_adhoc 143 | access: announce 144 | # Transparently convert between vcard and pubsub avatars 145 | mod_avatar: {} # Requires ejabberd >= 17.09, mod_vcard, mod_vcard_xupdate, mod_pusub 146 | # Simple Communications Blocking (XEP-0191) 147 | mod_blocking: {} # requires mod_privacy 148 | # Exchange entity (client) capabilities, e.g. Jingle (XEP-0115) 149 | mod_caps: {} 150 | # Send messages to all clients of a user (XEP-0280) 151 | mod_carboncopy: {} 152 | # Queue and filter stanzas for inactive clients (improves mobile client battery life, XEP-0352) 153 | mod_client_state: {} 154 | # Server configuration with Ad-Hoc commands 155 | mod_configure: {} # requires mod_adhoc 156 | # Service discovery, e.g. for MUC, Pub/Sub, HTTP Upload (XEP-0030) 157 | # (and, announcing an abuse contact) 158 | mod_disco: 159 | server_info: 160 | - 161 | modules: all 162 | name: "abuse-address" 163 | urls: ["mailto:abuse@SERVERNAME"] 164 | # (XMPP over) BOSH: HTTP tunneling for web clients such as JSXC (XEP-0124, XEP-0206) 165 | mod_bosh: {} 166 | # Last activity (XEP-0012) 167 | mod_last: {} 168 | # Message Archive Management (XEP-0313): Allows clients to catch up 169 | mod_mam: 170 | default: roster 171 | # Queue messages for offline users (XEP-0160) 172 | mod_offline: 173 | access_max_user_messages: max_user_offline_messages 174 | # XMPP Ping and periodic keepalives (XEP-0199) 175 | mod_ping: {} 176 | # Limit status spam (a full presence authorization requires 4 messages) 177 | # See also Anti-Spam Workshop 178 | mod_pres_counter: 179 | count: 50 180 | interval: 600 181 | # Block some senders (XEP-0016) 182 | mod_privacy: {} 183 | # Private XML storage (XEP-0049) 184 | mod_private: {} 185 | # Allow direct file transfer (obsoleted by HTTP upload, but required by the XMPP Compliance Suite) 186 | # Needs restart, not just reload when changing ip/port 187 | mod_proxy65: 188 | host: "proxy.@HOST@" 189 | name: "File Transfer Proxy" 190 | ip: "::" 191 | port: 7777 192 | max_connections: 10 193 | auth_type: plain 194 | access: local 195 | shaper: proxy65_shaper 196 | # Allows clients to request push notifications 197 | mod_push: {} # Requires ejabberd >= 17.08 198 | # The roster. You want this. With versioning. 199 | mod_roster: 200 | versioning: true 201 | store_current_id: true 202 | # If you want to pre-configure rosters for workgroups 203 | mod_shared_roster: {} 204 | # Allow users to create a vcard, visible to authorized peers (XEP-0054) 205 | mod_vcard: 206 | search: false # Privacy 207 | # vcard-based Avatars (XEP-0153) 208 | mod_vcard_xupdate: {} 209 | # Return version information 210 | mod_version: {} 211 | # Stream management (XEP-0198): Continuity after network interruptions 212 | mod_stream_mgmt: {} 213 | # Ask for a dialback, if the certificate does not match (XEP-0220) 214 | mod_s2s_dialback: {} 215 | 216 | # Additional services 217 | 218 | # Publish/subscribe, e.g. for Movim 219 | mod_pubsub: 220 | host: "pubsub.@HOST@" # "hosts:" for multiple pubsub services 221 | access_createnode: local 222 | ignore_pep_from_offline: false 223 | last_item_cache: false 224 | max_items_node: 1000 225 | default_node_config: 226 | max_items: 1000 227 | plugins: 228 | - "flat" 229 | - "pep" # Requires mod_caps. 230 | # Multi-User (group) Chat 231 | mod_muc: 232 | host: "conference.@HOST@" 233 | access: 234 | - allow 235 | access_admin: 236 | - allow: admin 237 | access_create: muc_create 238 | access_persistent: muc_create 239 | # File transfer via HTTP Upload 240 | mod_http_upload: 241 | host: "userdata.@HOST@" 242 | docroot: "/var/www/userdata/" # Or wherever you would like to have them stored 243 | put_url: "https://userdata.@HOST@/ud" 244 | custom_headers: 245 | "Access-Control-Allow-Origin": "*" 246 | "Access-Control-Allow-Methods": "OPTIONS, HEAD, GET, PUT" 247 | "Access-Control-Allow-Headers": "Content-Type" 248 | # Expire files on server after specified period 249 | mod_http_upload_quota: 250 | max_days: 30 251 | 252 | listen: 253 | - 254 | port: 5222 255 | ip: "::" 256 | module: ejabberd_c2s 257 | starttls_required: true 258 | protocol_options: 'TLSOPTS' 259 | dhfile: 'DHFILE' 260 | ciphers: 'CIPHERS' 261 | max_stanza_size: 65536 262 | shaper: c2s_shaper 263 | access: c2s 264 | - 265 | port: 5223 266 | ip: "::" 267 | module: ejabberd_c2s 268 | tls: true 269 | protocol_options: 'TLSOPTS' 270 | dhfile: 'DHFILE' 271 | ciphers: 'CIPHERS' 272 | max_stanza_size: 65536 273 | shaper: c2s_shaper 274 | access: c2s 275 | - 276 | port: 5269 277 | ip: "::" 278 | module: ejabberd_s2s_in 279 | max_stanza_size: 131072 280 | shaper: s2s_shaper 281 | - 282 | port: 5280 283 | ip: "::" 284 | module: ejabberd_http 285 | http_bind: true # Will map to "/http-bind" 286 | - 287 | port: 5288 288 | ip: "::" 289 | module: ejabberd_http 290 | request_handlers: 291 | "": mod_http_upload 292 | -------------------------------------------------------------------------------- /tools/xcauth.logrotate: -------------------------------------------------------------------------------- 1 | /var/log/xcauth/xcauth.log { 2 | daily 3 | missingok 4 | rotate 14 5 | compress 6 | delaycompress 7 | notifempty 8 | create 660 xcauth xcauth 9 | sharedscripts 10 | # Will detect log file changes 11 | } 12 | 13 | /var/log/xcauth/xcauth.err { 14 | weekly 15 | minsize 100k 16 | missingok 17 | rotate 7 18 | compress 19 | delaycompress 20 | notifempty 21 | create 660 xcauth xcauth 22 | sharedscripts 23 | # No automatic detection of stderr redirection change 24 | postrotate 25 | killall -HUP xcauth 26 | endscript 27 | } 28 | -------------------------------------------------------------------------------- /tools/xcauth.sudo: -------------------------------------------------------------------------------- 1 | # Allow user xcauth to run ejabberdctl for shared roster maintenance 2 | # ejabberdctl is typically installed in /usr/sbin (Debian default) 3 | # or /opt/ejabberd*/bin (Process One default). The latter is assumed 4 | # to be symlinked from /opt/ejabberd/bin (requiring adaptation to 5 | # xcejabberdctl) or symlinked from /usr/sbin/ejabberdctl (works out 6 | # of the box with xcauth). 7 | 8 | xcauth ALL=(ejabberd) NOPASSWD: /usr/sbin/ejabberdctl, /opt/ejabberd/bin/ejabberdctl 9 | -------------------------------------------------------------------------------- /tools/xcdelgroup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This will delete a particular group from the cache. To make sure it will 3 | # not reappear, you also have to delete it from Nextcloud. 4 | # If your group name contains spaces or other special characters, it will 5 | # not work. 6 | # 7 | # Usage: xcdelgroup 8 | # Example: xcdelgroup employees@example.org 9 | # 10 | sqlite3 /var/lib/xcauth/xcauth.sqlite3 'DELETE FROM rostergroups WHERE groupname="'"$1"'";' 11 | xcejabberdctl srg_delete `echo $1 | tr @ ' '` 12 | -------------------------------------------------------------------------------- /tools/xcdelhost.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This will delete a particular group from the cache. To make sure it will 3 | # not reappear, you also have to delete it from Nextcloud. 4 | # If your group name contains spaces or other special characters, it will 5 | # not work. 6 | # 7 | # Usage: xcdelhost 8 | # Example: xcdelgroup example.org 9 | # 10 | sqlite3 /var/lib/xcauth/xcauth.sqlite3 'DELETE FROM rostergroups WHERE groupname LIKE "%@'"$1"'";' 11 | sqlite3 /var/lib/xcauth/xcauth.sqlite3 'DELETE FROM rosterinfo WHERE jid LIKE "%@'"$1"'";' 12 | sqlite3 /var/lib/xcauth/xcauth.sqlite3 'DELETE FROM authcache WHERE jid LIKE "%@'"$1"'";' 13 | sqlite3 /var/lib/xcauth/xcauth.sqlite3 'DELETE FROM domains WHERE xmppdomain="'"$1"'";' 14 | -------------------------------------------------------------------------------- /tools/xcdeluser.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This will delete most of a user's entries from the database. 3 | # Not purged are group memberships, as they will be auto-updated on the 4 | # next login of another member of that group (and cannot easily be dealt 5 | # with using SQL statements). 6 | # 7 | # Usage: xcdeluser 8 | # Example: xcdeluser joe@example.org 9 | # 10 | sqlite3 /var/lib/xcauth/xcauth.sqlite3 'DELETE FROM rosterinfo WHERE jid="'"$1"'";' 11 | sqlite3 /var/lib/xcauth/xcauth.sqlite3 'DELETE FROM authcache WHERE jid="'"$1"'";' 12 | -------------------------------------------------------------------------------- /tools/xcejabberdctl.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Without `-H`, `ejabberdctl` will not be able to talk to `ejabberd` 3 | # If your `ejabberdctl` is somewhere else, please do one of the following: 4 | # 5 | # * Change the path in this file (will require you to redo this whenever 6 | # you update *xcauth*) 7 | # * Create a file with the following contents in `/usr/sbin/ejabberdctl` 8 | # and `chmod 755 /usr/sbin/ejabberdctl`: 9 | # ```sh 10 | # #!/bin/sh 11 | # exec /ejabberdctl "$@" 12 | # ``` 13 | # (of course, replace with the real path). 14 | # 15 | # (symlinking your real `ejabberdctl` from `/usr/sbin` does not work, 16 | # as the path of from which it was launched is used to determine the 17 | # *ejabberd* installation directory) 18 | exec sudo -H -u ejabberd /usr/sbin/ejabberdctl "$@" 19 | -------------------------------------------------------------------------------- /tools/xcrefreshroster.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # This will cause the roster groups to be rebuilt on the next login of 3 | # one of their users. 4 | # Necessary when the XMPP server's state differs from the cache, e.g., 5 | # because of manual modifications. 6 | sqlite3 /var/lib/xcauth/xcauth.sqlite3 'UPDATE rosterinfo SET fullname=NULL, grouplist=NULL, responsehash=NULL;' 7 | -------------------------------------------------------------------------------- /tools/xcrestart.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | echo "Fixing permissions (just in case)" 3 | chown -R xcauth:xcauth /etc/xcauth.conf /var/*/xcauth 4 | echo "Stopping xcauth.service, if running" 5 | systemctl -q stop xcauth.service 6 | echo "(Re-)starting listening sockets" 7 | cd /etc/systemd/system && for i in xc*.socket; do 8 | systemctl start $i 9 | done 10 | -------------------------------------------------------------------------------- /xcauth.conf: -------------------------------------------------------------------------------- 1 | # Example xcauth.py configuration file 2 | # 3 | # Preferably put this in /etc, 4 | # and make it readable only for the user the XMPP server is running under 5 | 6 | # Type: ejabberd, prosody (=generic), saslauthd, or postfix 7 | # 8 | #type=ejabberd 9 | #type=prosody 10 | type=generic 11 | 12 | # Secret: API token 13 | # Shown in the Nextcloud JSXC administration settings 14 | # 15 | # :warning: The real secret must not fall into the wrong hands! 16 | # Anyone knowing it can authenticate as any user to the XMPP server. 17 | # 18 | #secret=0123456789ABCDEF 19 | 20 | # URL: Where JSXC for Nextcloud (>=3.2.0) can be queried 21 | # Shown in the Nextcloud JSXC administration settings 22 | # 23 | #url=https://example.org/index.php/apps/ojsxc/ajax/externalApi.php 24 | 25 | # Request timeout 26 | # The timeout to apply for both the connection setup and awaiting 27 | # a response. If set to a tuple, the first value applies to connection 28 | # setup only, the second for waiting for the response. 29 | # 30 | #timeout=5,10 31 | 32 | # Database to use for additional information 33 | # - When serving multiple domains 34 | # - When wishing to cache user information 35 | # (to reduce the load on Nextcloud and continue if it is unavailable shortly) 36 | # (see [Database documentation](./doc/Database.md) for more information). 37 | # 38 | # Can be any path allowed by SQLite3, including the ':memory:' special value. 39 | # 40 | #db=/var/lib/xcauth/xcauth.sqlite3 41 | 42 | # Log: Log directory 43 | # In this directory, xcauth.{log,err} will be created 44 | # 45 | #log=/var/log/ejabberd 46 | #log=/var/log/prosody 47 | log=/var/log/xcauth 48 | 49 | # User caching: Cache database/policy 50 | # Where to store a cache database to avoid queries. 51 | # - 'none' (default): Disable the cache 52 | # - 'memory': Use an in-memory cache 53 | # - 'db': Use the main db specified with `--db` 54 | # (`--db=:memory:` and `--cache-storage=db` does not count as 55 | # an in-memory database for the `--cache-bcrypt` logic below) 56 | # 57 | #cache-storage=none 58 | 59 | # User caching: TTL since last query 60 | # Use cache entry if most recent query is not older than this timespan 61 | # Time is measured in seconds, unless `s`, `m`, `h`, `d`, `w` is used 62 | # as a suffix (seconds, minutes, hours, days, weeks, respectively). 63 | # 64 | #cache-query-ttl=4h 65 | 66 | # User caching: TTL since last verification 67 | # Use cache entry if most recent verification against the backend is 68 | # not older than this and has been queried at least once every 69 | # `cache-query-ttl`. Time is measured as for `cache-query-ttl` 70 | # 71 | #cache-verification-ttl=1d 72 | 73 | # User caching: TTL when backend unreachable 74 | # Use cache entry if the request to the backend files for a reason 75 | # other than "password invalid". Then, independent of the other TTLs above, 76 | # any verification younger than this time will be considered valid. 77 | # Time is measured as for `cache-query-ttl`. 78 | # 79 | #cache-unreachable-ttl=1w 80 | 81 | # User caching: Password hashing complexity 82 | # Hash passwords with 2^cache-bcrypt-rounds before storing (i.e., every 83 | # increasing this parameter results in twice as much computation time, 84 | # both for XMPP cloud auth and an attacker). 85 | # 86 | # If this is a tuple, the first value will be used for persistent, 87 | # the second for in-memory databases (with `cache-storage=memory`). 88 | # 89 | # The current default for bcrypt of Summer 2018 is 12. 90 | # 91 | #cache-bcrypt-rounds=12,4 92 | 93 | # Shared roster: ejabberdctl path 94 | # Which ejabberdctl to use, and whether to use an ejabberctl at all. 95 | # When set, updates *ejabberd* shared roster from the Nextcloud name 96 | # and group information on each login. 97 | # 98 | # `xcejabberdctl` uses `sudo` and `/etc/sudoers.d/xcauth` to 99 | # run as `ejabberdctl` as user `ejabberd`, and is the recommended 100 | # way when run from `systemd`. 101 | # 102 | # Default: none (shared roster update disabled) 103 | # 104 | #ejabberdctl=/usr/sbin/ejabberdctl 105 | #ejabberdctl=/usr/sbin/xcejabberdctl 106 | 107 | # Debug: Log more 108 | # 109 | #debug 110 | -------------------------------------------------------------------------------- /xcauth.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 -tt 2 | 3 | from xclib.configuration import get_args 4 | from xclib.authops import perform 5 | 6 | DEFAULT_LOG_DIR = '/var/log/xcauth' 7 | DESC = '''XMPP server authentication against JSXC>=3.2.0 on Nextcloud. 8 | See https://jsxc.org or https://github.com/jsxc/xmpp-cloud-auth.''' 9 | EPILOG = '''-I, -R, and -A take precedence over -t. One of them is required. 10 | -I, -R, and -A imply -i and -d. 11 | 12 | Signals: SIGHUP reopens the error log, 13 | SIGUSR1 dumps the thread list to the error log.''' 14 | 15 | if __name__ == '__main__': 16 | args = get_args(DEFAULT_LOG_DIR, DESC, EPILOG, 'xcauth') 17 | perform(args) 18 | 19 | # vim: tabstop=8 softtabstop=0 expandtab shiftwidth=4 20 | -------------------------------------------------------------------------------- /xcdbm.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 -tt 2 | 3 | from xclib.configuration import get_args 4 | from xclib.dbmops import perform 5 | 6 | DESC = '''XMPP server authentication against JSXC>=3.2.0 on Nextcloud: Database manipulation. 7 | See https://jsxc.org or https://github.com/jsxc/xmpp-cloud-auth.''' 8 | EPILOG = '''Exactly one of -G, -P, -D, -L, and -U is required.''' 9 | 10 | if __name__ == '__main__': 11 | args = get_args(None, DESC, EPILOG, 'xcdbm') 12 | perform(args) 13 | 14 | # vim: tabstop=8 softtabstop=0 expandtab shiftwidth=4 15 | -------------------------------------------------------------------------------- /xclib/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | -------------------------------------------------------------------------------- /xclib/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import requests 4 | from argparse import Namespace 5 | from xclib.sigcloud import sigcloud 6 | import xclib.isuser 7 | from xclib.utf8 import utf8, unutf8 8 | from xclib.db import connection 9 | 10 | def verify_with_isuser(url, secret, domain, user, timeout, hook=None): 11 | xc = xcauth(default_url=url, default_secret=secret, timeout=timeout) 12 | sc = sigcloud(xc, user, domain) 13 | if hook != None: hook(sc) # For automated testing only 14 | return sc.isuser_verbose() 15 | 16 | class xcauth: 17 | def __init__(self, default_url=None, default_secret=None, 18 | ejabberdctl=None, 19 | sql_db=None, cache_storage='none', 20 | domain_db = None, cache_db = None, shared_roster_db = None, 21 | ttls={'query': 3600, 'verify': 86400, 'unreach': 7*86400}, 22 | bcrypt_rounds=12, timeout=5): 23 | '''Argument notes: 24 | - `sql_db`: Path to SQLite3 db or ':memory:' (`None` is ':memory:') 25 | - `cache_storage`: Whether cache is disabled ('none'), 26 | is in-memory ('memory'), 27 | or in the `sql_db` above ('db', i.e., generally persistent) 28 | - `[domain|cache|shared_roster]_db`: Legacy database for imports into `sql_db` 29 | (`None`, `str` (path to bsddb3), or a `dict` 30 | ''' 31 | self.default_url=default_url 32 | self.default_secret=default_secret 33 | self.ejabberdctl_path=ejabberdctl 34 | self.ttls=ttls 35 | self.timeout=timeout 36 | self.bcrypt_rounds=bcrypt_rounds 37 | self.session=requests.Session() 38 | h = {'db': sql_db or ':memory:', 39 | 'domain_db': domain_db, 40 | 'cache_db': cache_db, 41 | 'shared_roster_db': shared_roster_db, 42 | 'cache_storage': cache_storage} 43 | self.db = connection(Namespace(**h)) 44 | 45 | def per_domain(self, dom): 46 | for row in self.db.conn.execute('SELECT authsecret, authurl, authdomain FROM domains WHERE xmppdomain = ?', (dom,)): 47 | return utf8(row[0]), row[1], row[2] 48 | return utf8(self.default_secret), self.default_url, dom 49 | -------------------------------------------------------------------------------- /xclib/auth.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import hashlib 3 | import hmac 4 | import bcrypt 5 | from datetime import datetime, timedelta 6 | from struct import pack, unpack 7 | from base64 import b64decode 8 | from xclib.utf8 import utf8, unutf8 9 | 10 | usersafe_encoding = str.maketrans('-$%', 'OIl') 11 | 12 | # Is merged into sigcloud 13 | class auth: 14 | # First try if it is a valid token 15 | # Failure may just indicate that we were passed a password 16 | def auth_token(self): 17 | try: 18 | token = b64decode(self.password.translate(usersafe_encoding) + '=======') 19 | except: 20 | logging.debug('Not a token (not base64)') 21 | return False 22 | 23 | jid = self.username + '@' + self.domain 24 | 25 | if len(token) != 23: 26 | logging.debug('Not a token (len: %d != 23)' % len(token)) 27 | return False 28 | 29 | (version, mac, header) = unpack('> B 16s 6s', token) 30 | if version != 0: 31 | logging.debug('Not a token (version: %d != 0)' % version) 32 | return False; 33 | 34 | (secretID, expiry) = unpack('> H I', header) 35 | expiry = datetime.utcfromtimestamp(expiry) 36 | if expiry < self.now: 37 | logging.debug('Token has expired') 38 | return False 39 | 40 | challenge = pack('> B 6s %ds' % len(jid), version, header, utf8(jid)) 41 | response = hmac.new(self.secret, challenge, hashlib.sha256).digest() 42 | if hmac.compare_digest(mac, response[:16]): 43 | return True 44 | else: 45 | logging.warning('Token for %s has invalid signature (possible attack attempt!)' % jid) 46 | return False 47 | 48 | def auth_cloud(self): 49 | response = self.cloud_request({ 50 | 'operation':'auth', 51 | 'username': self.username, 52 | 'domain': self.authDomain, 53 | 'password': self.password 54 | }) 55 | if response and 'result' in response: 56 | return response['result'] # 'error', 'success', 'noauth' 57 | return False 58 | 59 | def checkpw(self, pwhash): 60 | '''Compare self.password with pwhash. 61 | 62 | Try to be resistant to timing attacks and use `checkpw` if available.''' 63 | pw = utf8(self.password) 64 | pwhash = utf8(pwhash) 65 | if 'checkpw' in dir(bcrypt): 66 | return bcrypt.checkpw(pw, pwhash) 67 | else: 68 | ret = bcrypt.hashpw(pw, pwhash) 69 | return ret == pwhash 70 | 71 | def auth_with_cache(self, unreach=False): 72 | if self.ctx.db.cache_storage == 'none': 73 | return False 74 | jid = self.username + '@' + self.domain 75 | now = self.now # For tests 76 | for row in self.ctx.db.cache.execute('SELECT pwhash, remoteauth, anyauth FROM authcache WHERE jid = ?', (jid,)): 77 | (pwhash, tsv, tsa) = row 78 | if ((tsa + timedelta(seconds=self.ctx.ttls['query']) > now 79 | and tsv + timedelta(seconds=self.ctx.ttls['verify']) > now) 80 | or (unreach and tsv + timedelta(seconds=self.ctx.ttls['unreach']) > now)): 81 | if self.checkpw(pwhash): 82 | # Update does not need to be atomic 83 | self.ctx.db.cache.execute('UPDATE authcache SET anyauth = ? WHERE jid = ?', (now, jid)) 84 | return True 85 | return False 86 | 87 | def auth_update_cache(self): 88 | if self.ctx.db.cache_storage == 'none': 89 | return False 90 | jid = self.username + '@' + self.domain 91 | now = self.now 92 | try: 93 | if self.ctx.db.cache_storage == 'memory': 94 | rounds = self.ctx.bcrypt_rounds[1] 95 | else: 96 | rounds = self.ctx.bcrypt_rounds[0] 97 | salt = bcrypt.gensalt(rounds=rounds) 98 | except TypeError: 99 | # Old versions of bcrypt() apparently do not support the rounds option 100 | salt = bcrypt.gensalt() 101 | pwhash = unutf8(bcrypt.hashpw(utf8(self.password), salt)) 102 | # Upsert in SQLite is too new to rely on: 103 | # https://www.sqlite.org/draft/lang_UPSERT.html 104 | # 105 | # INSERT OR REPLACE cannot be used, as it will inherit 106 | # the DEFAULT values instead of the existing values. 107 | self.ctx.db.cache.begin() 108 | self.ctx.db.cache.execute( 109 | '''INSERT OR IGNORE INTO authcache (jid, firstauth) 110 | VALUES (?, ?)''', 111 | (jid, now)) 112 | self.ctx.db.cache.execute( 113 | '''UPDATE authcache 114 | SET pwhash = ?, remoteauth = ?, anyauth = ? 115 | WHERE jid = ?''', (pwhash, now, now, jid)) 116 | self.ctx.db.cache.commit() 117 | 118 | def auth(self): 119 | if self.auth_token(): 120 | logging.info('SUCCESS: Token for %s@%s is valid' 121 | % (self.username, self.domain)) 122 | self.try_roster() 123 | return True 124 | if self.auth_with_cache(unreach=False): 125 | logging.info('SUCCESS: Cache says password for %s@%s is valid' 126 | % (self.username, self.domain)) 127 | self.try_roster() 128 | return True 129 | r = self.auth_cloud() 130 | if not r or r == 'error': # Request did not get through (connect, HTTP, signature check) 131 | cache = self.auth_with_cache(unreach=True) 132 | logging.info('UNREACHABLE: Cache says password for %s@%s is %r' 133 | % (self.username, self.domain, cache)) 134 | # The roster request would be futile 135 | return cache 136 | elif r == 'success': 137 | logging.info('SUCCESS: Cloud says password for %s@%s is valid' 138 | % (self.username, self.domain)) 139 | self.auth_update_cache() 140 | self.try_roster() 141 | return True 142 | else: # 'noauth' 143 | logging.info('FAILURE: Could not authenticate user %s@%s: %s' 144 | % (self.username, self.domain, r)) 145 | return False 146 | -------------------------------------------------------------------------------- /xclib/authops.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import atexit 4 | import bsddb3 5 | import select 6 | import threading 7 | import socket 8 | import io 9 | import signal 10 | from xclib import xcauth 11 | from xclib.sigcloud import sigcloud 12 | from xclib.version import VERSION 13 | from xclib.sockact import listen_fds_with_names 14 | 15 | def rebind_stderr(signum, stackframe): 16 | try: 17 | # redirect stderr; line buffering 18 | sys.stderr = open(errfile, 'a+', buffering=1) 19 | except OSError as e: 20 | logging.warning('Cannot redirect stderr to %s: %s' % (errfile, str(e))) 21 | 22 | def log_info(signum, stackframe): 23 | sys.stderr.write('Current thread list:\n') 24 | for t in threading.enumerate(): 25 | sys.stderr.write(str(t)+'\n') 26 | sys.stderr.write('(Ends)\n') 27 | 28 | def perform(args): 29 | global errfile 30 | 31 | # Set up logging 32 | logfile = args.log + '/xcauth.log' 33 | signal.signal(signal.SIGUSR1, log_info) 34 | if (args.interactive or args.auth_test or args.isuser_test or args.roster_test): 35 | signal.signal(signal.SIGHUP, signal.SIG_IGN) 36 | logging.basicConfig(stream=sys.stderr, 37 | level=logging.DEBUG, 38 | format='%(asctime)s %(levelname)s: %(message)s') 39 | else: 40 | errfile = args.log + '/xcauth.err' 41 | rebind_stderr(0, None) 42 | signal.signal(signal.SIGHUP, rebind_stderr) 43 | try: 44 | from logging.handlers import WatchedFileHandler 45 | logging.basicConfig(handlers=(WatchedFileHandler(logfile),), 46 | level=logging.DEBUG if args.debug else logging.INFO, 47 | format='%(asctime)s %(levelname)s: %(message)s') 48 | except OSError as e: 49 | logging.basicConfig(stream=sys.stderr) 50 | logging.warning('Cannot log to %s: %s' % (logfile, str(e))) 51 | 52 | logging.debug('Start external auth script %s for %s with endpoint: %s', VERSION, args.type, args.url) 53 | 54 | # Set up global environment (incl. cache, db) 55 | if args.cache_storage != 'none': 56 | try: 57 | import bcrypt 58 | except ImportError as e: 59 | logging.warn('Cannot import bcrypt (%s); caching disabled' % e) 60 | args.cache_storage = 'none' 61 | ttls = {'query': args.cache_query_ttl, 62 | 'verify': args.cache_verification_ttl, 63 | 'unreach': args.cache_unreachable_ttl} 64 | xc = xcauth(default_url = args.url, default_secret = args.secret, 65 | ejabberdctl = args.ejabberdctl if 'ejabberdctl' in args else None, 66 | sql_db = args.db, cache_storage = args.cache_storage, 67 | domain_db = args.domain_db, cache_db = args.cache_db, 68 | shared_roster_db = args.shared_roster_db, 69 | timeout = args.timeout, ttls = ttls, 70 | bcrypt_rounds = args.cache_bcrypt_rounds) 71 | 72 | # Check for one-shot commands 73 | if args.isuser_test: 74 | sc = sigcloud(xc, args.isuser_test[0], args.isuser_test[1]) 75 | success = sc.isuser() 76 | print(success) 77 | return 78 | if args.roster_test: 79 | sc = sigcloud(xc, args.roster_test[0], args.roster_test[1]) 80 | success, response = sc.roster_cloud() 81 | print(str(response)) 82 | if args.ejabberdctl: 83 | sc.try_roster(async_=False) 84 | return 85 | elif args.auth_test: 86 | sc = sigcloud(xc, args.auth_test[0], args.auth_test[1], args.auth_test[2]) 87 | success = sc.auth() 88 | print(success) 89 | return 90 | 91 | # Read commands from file descriptors 92 | # Acceptor socket? 93 | listeners = listen_fds_with_names() 94 | if listeners is None: 95 | # Single socket; unclear whether it is connected or an acceptor 96 | try: 97 | stdinfd = sys.stdin.fileno() 98 | # Is it a socket? 99 | s = socket.socket(fileno=stdinfd) 100 | try: 101 | # Is it an acceptor socket? 102 | s.listen() 103 | # Yes, accept connections (fake systemd context) 104 | perform_from_listeners({0: args.type}, xc, args.type) 105 | except OSError: 106 | # Not an acceptor socket, use for stdio 107 | perform_from_fd(sys.stdin, sys.stdout, xc, args.type, closefds=(sys.stdin,sys.stdout,s)) 108 | except (io.UnsupportedOperation, OSError): 109 | # Not a real socket, assume stdio communication 110 | perform_from_fd(sys.stdin, sys.stdout, xc, args.type) 111 | else: 112 | # Uses systemd socket activation 113 | perform_from_listeners(listeners, xc, args.type) 114 | 115 | # Handle possibly multiple listening sockets 116 | def perform_from_listeners(listeners, xc, proto): 117 | sockets = {} 118 | while listeners: 119 | inputs = listeners.keys() 120 | r, w, x = select.select(inputs, (), inputs) 121 | for sfd in r: 122 | #logging.debug('Read %r, sockets=%r' % (r, sockets)) 123 | if sfd not in sockets: 124 | s = socket.socket(fileno=sfd) 125 | sockets[sfd] = s 126 | s = sockets[sfd] 127 | conn, remote_addr = s.accept() 128 | lproto = listeners[sfd] 129 | if lproto in ('generic', 'prosody', 'ejabberd', 'saslauthd', 'postfix'): 130 | fdproto = lproto 131 | else: 132 | fdproto = proto 133 | lproto = "%s/%s" % (lproto, fdproto) 134 | threading.Thread(target=perform_from_fd, 135 | name='worker-%s(%d)-%r' % (lproto, sfd, remote_addr), 136 | args=(conn, conn, xc, fdproto), 137 | kwargs={'closefds': (conn,)}).start() 138 | for sfd in x: 139 | logging.warn("Socket %d logged a complaint, dropping" % sfd) 140 | del listeners[sfd] 141 | 142 | # Handle a single I/O stream (stdin/stdout or acepted socket) 143 | def perform_from_fd(infd, outfd, xc, proto, closefds=()): 144 | if proto == 'ejabberd': 145 | from xclib.ejabberd_io import ejabberd_io 146 | xmpp = ejabberd_io 147 | if infd == outfd: 148 | infd = infd.makefile("rb") 149 | outfd = outfd.makefile("wb") 150 | closefds = closefds + (infd, outfd) 151 | elif proto == 'saslauthd': 152 | from xclib.saslauthd_io import saslauthd_io 153 | xmpp = saslauthd_io 154 | if infd == outfd: 155 | infd = infd.makefile("rb") 156 | outfd = outfd.makefile("wb") 157 | closefds = closefds + (infd, outfd) 158 | elif proto == 'postfix': 159 | from xclib.postfix_io import postfix_io 160 | xmpp = postfix_io 161 | if infd == outfd: 162 | infd = infd.makefile("r") 163 | outfd = outfd.makefile("w") 164 | closefds = closefds + (infd, outfd) 165 | else: # 'generic' or 'prosody' 166 | from xclib.prosody_io import prosody_io 167 | xmpp = prosody_io 168 | if infd == outfd: 169 | infd = infd.makefile("r") 170 | outfd = outfd.makefile("w") 171 | closefds = closefds + (infd, outfd) 172 | 173 | for data in xmpp.read_request(infd, outfd): 174 | logging.debug('Receive operation ' + data[0]); 175 | 176 | success = False 177 | if data[0] == "auth" and len(data) == 4: 178 | sc = sigcloud(xc, data[1], data[2], data[3]) 179 | success = sc.auth() 180 | elif data[0] == "isuser" and len(data) == 3: 181 | sc = sigcloud(xc, data[1], data[2]) 182 | success = sc.isuser() 183 | elif data[0] == "roster" and len(data) == 3: 184 | # Nonstandard extension, only useful with -t generic 185 | sc = sigcloud(xc, data[1], data[2]) 186 | success, response = sc.roster_cloud() 187 | success = str(response) # Convert from unicode 188 | elif data[0] == "quit" or data[0] == "exit": 189 | break 190 | 191 | xmpp.write_response(success, outfd) 192 | 193 | logging.debug('Closing connection') 194 | for c in closefds: 195 | c.close() 196 | -------------------------------------------------------------------------------- /xclib/check.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | def assertSimilar(a, b): 4 | if a != b: 5 | raise AssertionError('Assertion failed: Value mismatch: %r (%s) != %r (%s)' % (a, type(a), b, type(b))) 6 | 7 | def assertEqual(a, b): 8 | if type(a) == type(b): 9 | assertSimilar(a, b) 10 | else: 11 | raise AssertionError('Assertion failed: Type mismatch %r (%s) != %r (%s)' % (a, type(a), b, type(b))) 12 | -------------------------------------------------------------------------------- /xclib/configuration.py: -------------------------------------------------------------------------------- 1 | import configargparse 2 | import sys 3 | from xclib.version import VERSION 4 | default_db = '/var/lib/xcauth/xcauth.sqlite3' 5 | 6 | def parse_timespan(span): 7 | multipliers = {'s': 1, 'm': 60, 'h': 60*60, 'd': 60*60*24, 'w': 60*60*24*7} 8 | if span[-1] in multipliers: 9 | return int(span[:-1]) * multipliers[span[-1]] 10 | else: 11 | return int(span) 12 | 13 | def add_maybe(*args, **kwargs): 14 | if app_name == 'xcdbm': 15 | kwargs['help'] = '(ignored for config file compatibility)' 16 | parser.add_argument(*args, **kwargs) 17 | 18 | def get_args(logdir, desc, epilog, name, args=None, config_file_contents=None): 19 | # Config file in /etc or the program directory 20 | global parser, app_name 21 | app_name = name 22 | parser = configargparse.ArgumentParser(description=desc, 23 | epilog=epilog, 24 | default_config_files=['/etc/xcauth.conf']) 25 | 26 | parser.add_argument('--config-file', '-c', 27 | is_config_file=True, 28 | help='config file path') 29 | 30 | if name == 'xcdbm': 31 | parser.add_argument('--domain-db', '-b', 32 | required=True, 33 | help='persistent domain database; manipulated with -G, -P, -D, -L, -U') 34 | parser.add_argument('--get', '-G', 35 | help='retrieve (get) a database entry') 36 | parser.add_argument('--put', '-P', 37 | nargs=2, metavar=('KEY', 'VALUE'), 38 | help='store (put) a database entry (insert or update)') 39 | parser.add_argument('--delete', '-D', 40 | help='delete a database entry') 41 | parser.add_argument('--load', '-L', 42 | action='store_true', 43 | help='load multiple database entries from stdin') 44 | parser.add_argument('--unload', '-U', 45 | action='store_true', 46 | help='unload (dump) the database contents to stdout') 47 | else: 48 | parser.add_argument('--db', 49 | default=default_db, 50 | help='Path to the SQLite state database') 51 | parser.add_argument('--cache-storage', 52 | choices=['none', 'memory', 'db'], 53 | default='none', 54 | help='How to cache authentication information') 55 | parser.add_argument('--domain-db', '-b', 56 | help='''persistent domain database; manipulated with xcdbm.py. 57 | DEPRECATED, will only be used for migration purposes.''') 58 | parser.add_argument('--auth-test', '-A', 59 | nargs=3, metavar=("USER", "DOMAIN", "PASSWORD"), 60 | help='single, one-shot query of the user, domain, and password triple') 61 | parser.add_argument('--isuser-test', '-I', 62 | nargs=2, metavar=("USER", "DOMAIN"), 63 | help='single, one-shot query of the user and domain tuple') 64 | parser.add_argument('--roster-test', '-R', 65 | nargs=2, metavar=("USER", "DOMAIN"), 66 | help='single, one-shot query of the user\'s shared roster') 67 | parser.add_argument('--update-roster', '-T', 68 | action='store_true', 69 | help='DEPRECATED. Automatically activated when --ejabberdctl is set') 70 | 71 | add_maybe('--url', '-u', 72 | required=True, 73 | help='base URL') 74 | add_maybe('--secret', '-s', 75 | required=True, 76 | help='secure api token') 77 | add_maybe('--log', '-l', 78 | default=logdir, 79 | help='log directory (default: %(default)s)') 80 | add_maybe('--debug', '-d', 81 | action='store_true', 82 | help='enable debug mode') 83 | add_maybe('--interactive', '-i', 84 | action='store_true', 85 | help='log to stderr') 86 | add_maybe('--type', '-t', 87 | choices=['generic', 'prosody', 'ejabberd', 'saslauthd', 'postfix'], 88 | help='''XMPP server/query protocol type (prosody≘generic); 89 | implies reading requests from stdin. See doc/Installation.md 90 | and systemd/README.md for more information and overrides.''') 91 | add_maybe('--timeout', 92 | default='5,10', 93 | help='Timeout for connection setup, request processing') 94 | add_maybe('--cache-db', 95 | help='Database path for the user cache. DEPRECATED, only for conversion purposes') 96 | add_maybe('--cache-query-ttl', 97 | default='1h', 98 | help='Maximum time between queries') 99 | add_maybe('--cache-verification-ttl', 100 | default='1d', 101 | help='Maximum time between backend verifications') 102 | add_maybe('--cache-unreachable-ttl', 103 | default='1w', 104 | help='Maximum cache time when backend is unreachable (overrides the other TTLs)') 105 | add_maybe('--cache-bcrypt-rounds', 106 | default='12,4', 107 | help='''Encrypt passwords with 2^ROUNDS before storing 108 | (i.e., every increment of ROUNDS results in twice the 109 | computation time, both for us and an attacker). 110 | First value is for persistent, second value for in-memory 111 | cache, if both values are present.''') 112 | add_maybe('--ejabberdctl', 113 | metavar="PATH", 114 | help='''Enables shared roster updates on authentication; 115 | use ejabberdctl command at PATH to modify them''') 116 | add_maybe('--shared-roster-db', 117 | help='''Which groups a user has been added to (to ensure proper deletion). 118 | DEPRECATED, only for conversion purposes.''') 119 | 120 | parser.add_argument('--version', 121 | action='version', version=VERSION) 122 | 123 | args = parser.parse_args(args=args, config_file_contents=config_file_contents) 124 | if name != 'xcdbm': 125 | args.cache_query_ttl = parse_timespan(args.cache_query_ttl) 126 | args.cache_verification_ttl = parse_timespan(args.cache_verification_ttl) 127 | args.cache_unreachable_ttl = parse_timespan(args.cache_unreachable_ttl) 128 | 129 | if ',' in args.timeout: 130 | (a, b) = args.timeout.split(',', 1) 131 | args.timeout = (int(a), int(b)) 132 | else: 133 | args.timeout = int(args.timeout) 134 | 135 | if ',' in args.cache_bcrypt_rounds: 136 | (a, b) = args.cache_bcrypt_rounds.split(',', 1) 137 | args.cache_bcrypt_rounds = (int(a), int(b)) 138 | else: 139 | args.cache_bcrypt_rounds = (int(args.cache_bcrypt_rounds), 140 | int(args.cache_bcrypt_rounds)) 141 | 142 | if (args.auth_test is None and args.isuser_test is None and args.roster_test is None 143 | and args.type is None): # No work to do 144 | parser.print_help(sys.stderr) 145 | sys.exit(1) 146 | else: # xcauth 147 | command_count = 0 148 | for i in (args.get, args.put, args.delete, args.load, args.unload): 149 | if i is not None and i != False: 150 | command_count += 1 151 | if command_count != 1: 152 | parser.print_help(sys.stderr) 153 | sys.exit(1) 154 | return args 155 | -------------------------------------------------------------------------------- /xclib/db.py: -------------------------------------------------------------------------------- 1 | import bsddb3 2 | import sqlite3 3 | import os 4 | import time 5 | import logging 6 | from datetime import datetime 7 | from xclib.utf8 import unutf8 8 | 9 | class sq3c_debugger(sqlite3.Connection): 10 | # def execute(self, sql, *args, **kwargs): 11 | # logging.debug('EXECUTE: %s' % sql) 12 | # return super().execute(sql, *args, **kwargs) 13 | 14 | def begin(self, mode = ''): 15 | self.execute('BEGIN %s' % mode) 16 | 17 | def dump(self, tbl): 18 | logging.debug('DUMP %s START' % tbl) 19 | for row in self.execute('SELECT * from %s' % tbl): 20 | out = map(str, row) 21 | logging.debug(' | '.join(out)) 22 | logging.debug('DUMP %s STOP' % tbl) 23 | 24 | class connection: 25 | def __init__(self, args): 26 | logging.debug('Opening database connections main=%s, cache=%s' % (args.db, args.cache_storage)) 27 | db_was_there = (args.db != ':memory:' 28 | and os.path.exists(args.db)) 29 | # PySQLite by default does a weird 30 | # auto-start-transaction-but-don't-stop mode, 31 | # causing so much pain. Reset back to 32 | # SQLite default of 'autocommit'. 33 | self.conn = sqlite3.connect(args.db, 34 | factory=sq3c_debugger, 35 | check_same_thread=False, 36 | isolation_level = None, 37 | detect_types=sqlite3.PARSE_DECLTYPES) 38 | self.conn.row_factory = sqlite3.Row 39 | 40 | if args.cache_storage == 'memory': 41 | self.cache = sqlite3.connect(':memory:', 42 | factory=sq3c_debugger, 43 | check_same_thread=False, 44 | isolation_level = None, 45 | detect_types=sqlite3.PARSE_DECLTYPES) 46 | self.cache.row_factory = sqlite3.Row 47 | # Create in-memory structure on every creation (db is empty) 48 | self.db_create_cache(self.cache, olddb = None, upgrade = False) 49 | elif args.cache_storage == 'db': 50 | self.cache = self.conn 51 | else: # 'none' 52 | self.cache = fake_db() 53 | self.cache_storage = args.cache_storage 54 | 55 | if not db_was_there: # First-time opening of the SQLite3 db 56 | # Ensure persistent cache table is always created 57 | # on upgrade, independent of the --cache-storage mode 58 | # (so that a later mode change will not require any changes) 59 | logging.info('Initializing %s from %s, %s, and %s' 60 | % (args.db, args.domain_db, 61 | args.shared_roster_db, args.cache_db)) 62 | self.db_upgrade_domain(args.domain_db) 63 | # Always import cache into first DB (where it may be left stale) 64 | self.db_create_cache(self.conn, 65 | olddb = args.cache_db, upgrade = True) 66 | self.db_upgrade_roster(args.shared_roster_db) 67 | 68 | def db_upgrade_domain(self, olddb): 69 | logging.debug('Upgrading domain from %s' % olddb) 70 | try: 71 | self.conn.execute('''CREATE TABLE domains 72 | (xmppdomain TEXT PRIMARY KEY, 73 | authsecret TEXT, 74 | authurl TEXT, 75 | authdomain TEXT, 76 | regcontact TEXT, 77 | regfirst TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 78 | reglatest TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') 79 | except sqlite3.OperationalError as e: 80 | logging.warning('Cannot create `domains` table; maybe multiple processes started in parallel? %s' % str(e)) 81 | # Try to get out of the way of a parallel updater 82 | time.sleep(1) 83 | # Someone else already created the table; he probably also 84 | # migrated it 85 | return 86 | try: 87 | if olddb is None: 88 | return 89 | elif isinstance(olddb, str): 90 | db = bsddb3.hashopen(olddb, 'r') 91 | else: # dict 92 | db = olddb 93 | for k,v in db.items(): 94 | k = unutf8(k, 'illegal') 95 | v = unutf8(v, 'illegal') 96 | try: 97 | (authsecret, authurl, authdomain, extra) = v.split("\t", 3) 98 | except ValueError: 99 | (authsecret, authurl, authdomain) = v.split("\t", 2) 100 | extra = None 101 | self.conn.execute('''INSERT INTO domains (xmppdomain, authsecret, authurl, authdomain) VALUES (?, ?, ?, ?)''', (k, authsecret, authurl, authdomain)) 102 | if isinstance(olddb, str): 103 | db.close() 104 | except bsddb3.db.DBError as e: 105 | logging.error('Trouble converting %s: %s' % (olddb, e)) 106 | 107 | def db_create_cache(self, conn, olddb, upgrade): 108 | logging.debug('Creating cache table in %s' % str(conn)) 109 | try: 110 | conn.execute('''CREATE TABLE authcache 111 | (jid TEXT PRIMARY KEY, 112 | pwhash TEXT, 113 | firstauth TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 114 | remoteauth TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 115 | anyauth TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') 116 | except sqlite3.OperationalError as e: 117 | logging.warning('Cannot create `domains` table; maybe multiple processes started in parallel? %s' % str(e)) 118 | # Try to get out of the way of a parallel updater 119 | time.sleep(1) 120 | return 121 | if upgrade: 122 | self.db_upgrade_cache(olddb) 123 | 124 | def db_upgrade_cache(self, olddb): 125 | logging.debug('Upgrading cache from %s' % olddb) 126 | try: 127 | if olddb is None: 128 | return 129 | elif isinstance(olddb, str): 130 | db = bsddb3.hashopen(olddb, 'r') 131 | else: # dict 132 | db = olddb 133 | for k,v in db.items(): 134 | k = unutf8(k, 'illegal').replace(':', '@') 135 | v = unutf8(v, 'illegal') 136 | (pwhash, ts1, tsv, tsa, rest) = v.split("\t", 4) 137 | ts1 = datetime.utcfromtimestamp(int(ts1)) 138 | tsv = datetime.utcfromtimestamp(int(tsv)) 139 | tsa = datetime.utcfromtimestamp(int(tsa)) 140 | # First import goes into persistent database 141 | self.conn.execute('''INSERT INTO authcache (jid, pwhash, firstauth, remoteauth, anyauth) 142 | VALUES (?, ?, ?, ?, ?)''', (k, pwhash, ts1, tsv, tsa)) 143 | if isinstance(olddb, str): 144 | db.close() 145 | except bsddb3.db.DBError as e: 146 | logging.error('Trouble converting %s: %s' % (olddb, e)) 147 | 148 | def db_upgrade_roster(self, olddb): 149 | logging.debug('Upgrading roster from %s' % olddb) 150 | try: 151 | self.conn.execute('''CREATE TABLE rosterinfo 152 | (jid TEXT PRIMARY KEY, 153 | fullname TEXT, 154 | grouplist TEXT, 155 | responsehash TEXT, 156 | last_update TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') 157 | except sqlite3.OperationalError as e: 158 | logging.warning('Cannot create `domains` table; maybe multiple processes started in parallel? %s' % str(e)) 159 | # Try to get out of the way of a parallel updater 160 | time.sleep(1) 161 | # Continue, in case a previous table creation was aborted 162 | try: 163 | self.conn.execute('''CREATE TABLE rostergroups 164 | (groupname TEXT PRIMARY KEY, 165 | userlist TEXT)''') 166 | except sqlite3.OperationalError as e: 167 | logging.warning('Cannot create `domains` table; maybe multiple processes started in parallel? %s' % str(e)) 168 | # Try to get out of the way of a parallel updater 169 | time.sleep(1) 170 | # Someone else already created the table; he probably also 171 | # migrated it 172 | return 173 | 174 | rosterinfo_fn = {} 175 | rosterinfo_rh = {} 176 | rosterinfo_lg = {} 177 | rosterusers = set([]) 178 | rostergroups = {} 179 | try: 180 | if olddb is None: 181 | return 182 | elif isinstance(olddb, str): 183 | db = bsddb3.hashopen(olddb, 'r') 184 | else: # dict 185 | db = olddb 186 | for k,v in db.items(): 187 | k = unutf8(k, 'illegal') 188 | v = unutf8(v, 'illegal') 189 | if k.startswith('FNC:'): # Full name (cache only) 190 | jid = k[4:].replace(':', '@') 191 | rosterusers.add(jid) 192 | if '@' in jid: # Do not copy malformed (old buggy) entries 193 | rosterinfo_fn[jid] = v 194 | if k.startswith('LIG:'): # Login In Group (state information) 195 | jid = k[4:].replace(':', '@') 196 | rosterusers.add(jid) 197 | rosterinfo_lg[jid] = v 198 | if k.startswith('RGC:'): # Reverse Group Cache (state information) 199 | gid = k[4:].replace(':', '@') 200 | rostergroups[gid] = v 201 | elif k.startswith('RH:'): # Response body hash (cache only) 202 | jid = k[3:].replace(':', '@') 203 | rosterusers.add(jid) 204 | rosterinfo_rh[jid] = v 205 | if isinstance(olddb, str): 206 | db.close() 207 | except bsddb3.db.DBError as e: 208 | logging.error('Trouble converting %s: %s' % (olddb, e)) 209 | 210 | rg = [] 211 | for k,v in rostergroups.items(): 212 | rg.append([k,v]) 213 | self.conn.executemany('INSERT INTO rostergroups (groupname, userlist) VALUES (?, ?)', rg) 214 | 215 | ri = [] 216 | for k in rosterusers: 217 | ri.append([k, 218 | rosterinfo_fn[k] if k in rosterinfo_fn else None, 219 | rosterinfo_lg[k] if k in rosterinfo_lg else None, 220 | rosterinfo_rh[k] if k in rosterinfo_rh else None]) 221 | self.conn.executemany('INSERT INTO rosterinfo (jid, fullname, grouplist, responsehash) VALUES (?, ?, ?, ?)', ri) 222 | 223 | class fake_db: 224 | def execute(self, cmd, args=None): 225 | return None 226 | def close(self): 227 | return None 228 | -------------------------------------------------------------------------------- /xclib/dbmops.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import bsddb3 3 | from xclib.utf8 import utf8, unutf8 4 | 5 | def perform(args): 6 | domain_db = bsddb3.hashopen(args.domain_db, 'c', 0o600) 7 | if args.get: 8 | print(unutf8(domain_db[utf8(args.get)], 'illegal')) 9 | elif args.put: 10 | domain_db[utf8(args.put[0])] = args.put[1] 11 | elif args.delete: 12 | del domain_db[utf8(args.delete)] 13 | elif args.unload: 14 | for k in list(domain_db.keys()): 15 | print('%s\t%s' % (unutf8(k, 'illegal'), unutf8(domain_db[k], 'illegal'))) 16 | # Should work according to documentation, but doesn't 17 | # for k, v in DOMAIN_DB.iteritems(): 18 | # print k, '\t', v 19 | elif args.load: 20 | for line in sys.stdin: 21 | k, v = line.rstrip('\r\n').split('\t', 1) 22 | domain_db[utf8(k)] = v 23 | domain_db.close() 24 | 25 | # vim: tabstop=8 softtabstop=0 expandtab shiftwidth=4 26 | -------------------------------------------------------------------------------- /xclib/ejabberd_io.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | from struct import pack, unpack 4 | from xclib.utf8 import unutf8 5 | 6 | # Message formats described in `../doc/Protocol.md` 7 | 8 | class ejabberd_io: 9 | @classmethod 10 | def read_request(cls, infd, outfd): 11 | try: 12 | infd = infd.buffer 13 | except AttributeError: 14 | pass 15 | length_field = infd.read(2) 16 | while len(length_field) == 2: 17 | (size,) = unpack('>H', length_field) 18 | if size == 0: 19 | logging.info('command length 0, treating as logical EOF') 20 | return 21 | cmd = infd.read(size) 22 | if len(cmd) != size: 23 | logging.warn('premature EOF while reading cmd: %d != %d' % (len(cmd), size)) 24 | return 25 | x = unutf8(cmd).split(':', 3) 26 | yield tuple(x) 27 | length_field = infd.read(2) 28 | 29 | @classmethod 30 | def write_response(cls, flag, outfd): 31 | try: 32 | outfd = outfd.buffer 33 | except AttributeError: 34 | pass 35 | answer = 0 36 | if flag: 37 | answer = 1 38 | token = pack('>HH', 2, answer) 39 | outfd.write(token) 40 | outfd.flush() 41 | 42 | -------------------------------------------------------------------------------- /xclib/ejabberdctl.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | from xclib.utf8 import unutf8 4 | 5 | class ejabberdctl: 6 | def __init__(self, ctx): 7 | self.ctx = ctx 8 | 9 | def execute(self, args): 10 | logging.debug(self.ctx.ejabberdctl_path + str(args)) 11 | try: 12 | return unutf8(subprocess.check_output([self.ctx.ejabberdctl_path] + args)) 13 | except subprocess.CalledProcessError as err: 14 | return None 15 | logging.warn('ejabberdctl failed with %s' 16 | % str(err)) 17 | return None 18 | 19 | def members(self, group, domain): 20 | membership = self.execute(['srg_get_members', group, domain]) 21 | if membership is None: 22 | membership = () 23 | else: 24 | membership = membership.split('\n') 25 | # Delete empty values (e.g. from empty output) 26 | mem = [] 27 | for m in membership: 28 | if m != '': 29 | mem.append(m) 30 | logging.debug('%s@%s members: %s' % (group, domain, mem)) 31 | return mem 32 | -------------------------------------------------------------------------------- /xclib/isuser.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from xclib.utf8 import utf8 3 | 4 | # Is merged into sigcloud 5 | class isuser: 6 | def isuser_verbose(self): 7 | success, code, response, text = self.verbose_cloud_request({ 8 | 'operation': 'isuser', 9 | 'username': self.username, 10 | 'domain': self.authDomain 11 | }) 12 | return success, code, response 13 | 14 | def isuser_cloud(self): 15 | '''Returns: 16 | - True when user exists 17 | - False when user does not exist 18 | - None when there is a problem (connection failure or HTTP error code)''' 19 | response = self.cloud_request({ 20 | 'operation': 'isuser', 21 | 'username': self.username, 22 | 'domain': self.authDomain 23 | }) 24 | try: 25 | if isinstance(response, dict): 26 | if response['result'] == 'success': 27 | return bool(response['data']['isUser']) 28 | else: 29 | return None 30 | else: 31 | return None 32 | except KeyError: 33 | logging.error('Request for %s@%s returned malformed response: %s' 34 | % (self.username, self.domain, str(response))) 35 | return None 36 | 37 | def isuser(self): 38 | result = self.isuser_cloud() 39 | if result == None: 40 | logging.info('Cloud unreachable testing user %s@%s' % (self.username, self.domain)) 41 | elif result == True: 42 | logging.info('Cloud says user %s@%s exists' % (self.username, self.domain)) 43 | else: 44 | logging.info('Cloud says user %s@%s does not exist' % (self.username, self.domain)) 45 | return result 46 | -------------------------------------------------------------------------------- /xclib/postfix_io.py: -------------------------------------------------------------------------------- 1 | # Only supports isuser request for Postfix virtual mailbox maps 2 | import sys 3 | import re 4 | import logging 5 | 6 | # Message formats described in `../doc/Protocol.md` 7 | 8 | class postfix_io: 9 | @classmethod 10 | def read_request(cls, infd, outfd): 11 | # "for line in sys.stdin:" would be more concise but adds unwanted buffering 12 | while True: 13 | line = infd.readline() 14 | if not line: 15 | break 16 | line = line.rstrip("\r\n") 17 | match = re.match('^get ([^\000- @%]+)@([^\000- @%]+)$', line) 18 | if match: 19 | yield ('isuser',) + match.group(1,2) 20 | elif line == 'quit': 21 | yield ('quit',) 22 | else: 23 | logging.error('Illegal request format: ' + line) 24 | outfd.write('500 Illegal request format\n') 25 | outfd.flush() 26 | 27 | @classmethod 28 | def write_response(cls, flag, outfd): 29 | if flag == None: 30 | outfd.write('400 Trouble connecting to backend\n') 31 | elif flag: 32 | outfd.write('200 OK\n') 33 | else: 34 | outfd.write('500 No such user\n') 35 | outfd.flush() 36 | -------------------------------------------------------------------------------- /xclib/prosody_io.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | # Message formats described in `../doc/Protocol.md` 4 | 5 | class prosody_io: 6 | @classmethod 7 | def read_request(cls, infd, outfd): 8 | # "for line in sys.stdin:" would be more concise but adds unwanted buffering 9 | while True: 10 | line = infd.readline() 11 | if not line: 12 | break 13 | line = line.rstrip("\r\n") 14 | yield tuple(line.split(':', 3)) 15 | 16 | @classmethod 17 | def write_response(cls, flag, outfd): 18 | if isinstance(flag, str): 19 | # Hack for interactive 'roster' command used by tests/online_test.py 20 | answer = flag 21 | else: 22 | answer = '0' 23 | if flag: 24 | answer = '1' 25 | outfd.write(answer+"\n") 26 | outfd.flush() 27 | -------------------------------------------------------------------------------- /xclib/roster.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import logging 3 | import traceback 4 | import threading 5 | from datetime import datetime 6 | import sys 7 | from xclib.roster_thread import roster_thread 8 | from xclib.utf8 import utf8 9 | from xclib.check import assertEqual 10 | 11 | # Is merged into sigcloud 12 | class roster(roster_thread): 13 | def jidsplit(self, jid): 14 | '''Split jid into lhs@rhs''' 15 | (node, at, dom) = jid.partition('@') 16 | if at == '': 17 | return (node, self.domain) 18 | else: 19 | return (node, dom) 20 | 21 | def roster_cloud(self): 22 | '''Query roster JSON from cloud''' 23 | success, code, message, text = self.verbose_cloud_request({ 24 | 'operation': 'sharedroster', 25 | 'username': self.username, 26 | 'domain': self.authDomain 27 | }) 28 | if success: 29 | if code is not None and code != requests.codes.ok: 30 | return code, None 31 | else: 32 | sr = None 33 | try: 34 | sr = message['data']['sharedRoster'] 35 | return sr, text 36 | except Exception as e: 37 | logging.warn('Weird response: ' + str(e)) 38 | return message, text 39 | else: 40 | return False, None 41 | 42 | def try_roster(self, async_=True): 43 | '''Maybe update roster''' 44 | if (self.ctx.ejabberdctl_path is not None): 45 | try: 46 | response, text = self.roster_cloud() 47 | if response is not None and response != False: 48 | jid = '@'.join((self.username, self.domain)) 49 | texthash = hashlib.sha256(utf8(text)).hexdigest() 50 | # Response changed or first response for that user? 51 | cache_valid = False 52 | for row in self.ctx.db.conn.execute( 53 | 'SELECT responsehash FROM rosterinfo where jid=?', 54 | (jid,)): 55 | if row['responsehash'] == texthash: 56 | cache_valid = True 57 | if not cache_valid: 58 | self.ctx.db.conn.begin() 59 | self.ctx.db.conn.execute( 60 | '''INSERT OR IGNORE 61 | INTO rosterinfo (jid) 62 | VALUES (?)''', (jid,)) 63 | self.ctx.db.conn.execute( 64 | '''UPDATE rosterinfo 65 | SET responsehash = ?, last_update = ? 66 | WHERE jid = ?''', (texthash, datetime.utcnow(), jid)) 67 | self.ctx.db.conn.commit() 68 | t = threading.Thread(target=self.roster_background_thread, 69 | args=(response,)) 70 | t.start() 71 | if not async_: 72 | t.join() # For automated testing only 73 | else: 74 | # Try to do most before the user is actually logged in. 75 | # Thanks to improved caching, this should rarely be noticeable 76 | # and reduce the 'full names only visible on second login' 77 | # problem experienced especially in Gajim (maybe a race condition?) 78 | t.join(1.0) 79 | return True 80 | except Exception as err: 81 | (etype, value, tb) = sys.exc_info() 82 | traceback.print_exception(etype, value, tb) 83 | logging.warn('roster_groups thread: %s:\n%s' 84 | % (str(err), ''.join(traceback.format_tb(tb)))) 85 | return False 86 | return True 87 | -------------------------------------------------------------------------------- /xclib/roster_thread.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | import unicodedata 4 | import sys 5 | import time 6 | from xclib.ejabberdctl import ejabberdctl 7 | from xclib.utf8 import utf8, unutf8, utf8l 8 | 9 | def sanitize(name): 10 | name = str(name) 11 | printable = {'Lu', 'Ll', 'Lm', 'Lo', 'Nd', 'Nl', 'No', 'Pc', 'Pd', 'Ps', 'Pe', 'Pi', 'Pf', 'Po', 'Sm', 'Sc', 'Sk', 'So', 'Zs'} 12 | return ''.join(c for c in name if unicodedata.category(c) in printable and c != '@') 13 | 14 | class roster_thread: 15 | def roster_background_thread(self, sr): 16 | '''Entry for background roster update thread''' 17 | start = time.time() 18 | ucommands = gcommands = () 19 | try: 20 | logging.debug('roster_thread for ' + str(sr)) 21 | # Allow test hooks with static ejabberd_controller 22 | if hasattr(self.ctx, 'ejabberd_controller') and self.ctx.ejabberd_controller is not None: 23 | e = self.ctx.ejabberd_controller 24 | else: 25 | e = ejabberdctl(self.ctx) 26 | groups, ucommands = self.roster_update_users(e, sr) 27 | self.ctx.db.conn.dump('rosterinfo') 28 | gcommands = self.roster_update_groups(e, groups) 29 | except Exception as err: 30 | (etype, value, tb) = sys.exc_info() 31 | traceback.print_exception(etype, value, tb) 32 | logging.warn('roster_groups thread: %s:\n%s' 33 | % (str(err), ''.join(traceback.format_tb(tb)))) 34 | logging.debug('roster_groups thread failed after %s+%s commands in %s seconds', 35 | len(ucommands), len(gcommands), time.time() - start) 36 | return False 37 | finally: 38 | logging.debug('roster_groups thread finished %s+%s commands in %s seconds', 39 | len(ucommands), len(gcommands), time.time() - start) 40 | 41 | def roster_update_users(self, e, sr): 42 | '''Update users' full names and invert hash 43 | 44 | For all *users* we have information about: 45 | - collect the shared roster groups they belong to 46 | - set their full names if not yet defined 47 | Return inverted hash''' 48 | groups = {} 49 | commands = [] 50 | if len(sr) == 0: 51 | # Empty roster information arrives as [] instead of {}, 52 | # so sr.items() below would fail. 53 | return groups, commands 54 | for user, desc in sr.items(): 55 | logging.debug('roster_update_users: user=%s, desc=%s' % (user, desc)) 56 | if 'groups' in desc: 57 | for g in desc['groups']: 58 | # Ignore groups ending in U+200B Zero-Width Space 59 | if g.endswith('\u200b'): 60 | logging.info('Ignoring group %s (ends with U+200B)', g) 61 | continue 62 | if g in groups: 63 | groups[g].append(user) 64 | else: 65 | groups[g] = [user] 66 | if 'name' in desc: 67 | logging.debug('name in desc') 68 | lhs, rhs = self.jidsplit(user) 69 | jid = '@'.join((lhs, rhs)) 70 | cached_name = None 71 | for row in self.ctx.db.conn.execute( 72 | 'SELECT fullname FROM rosterinfo WHERE jid=?', 73 | (jid,)): 74 | cached_name = row['fullname'] 75 | logging.debug('cached_name = %s' % (cached_name,)) 76 | if cached_name != desc['name']: 77 | self.ctx.db.conn.begin() 78 | self.ctx.db.conn.execute( 79 | '''INSERT OR IGNORE INTO rosterinfo (jid) 80 | VALUES (?)''', (jid,)) 81 | self.ctx.db.conn.execute( 82 | '''UPDATE rosterinfo 83 | SET fullname = ? 84 | WHERE jid = ?''', (desc['name'], jid)) 85 | self.ctx.db.conn.commit() 86 | logging.debug('set_vcard') 87 | e.execute(['set_vcard', lhs, rhs, 'FN', desc['name']]) 88 | commands.append(('set_vcard', jid, desc['name'])) 89 | return groups, commands 90 | 91 | def roster_update_groups(self, e, groups): 92 | '''Update shared roster groups with ejabberdctl 93 | 94 | For all the *groups* we have information about: 95 | - create the group (idempotent) 96 | - delete the users that we do not know about anymore (idempotent) 97 | - add the users we know about (idempotent)''' 98 | commands = [] 99 | cleanname = {} 100 | loginjid = '@'.join((self.username, self.domain)) 101 | logingroups = [] 102 | for g in groups: 103 | cleanname[g] = sanitize(g) 104 | key = '@'.join((cleanname[g], self.domain)) 105 | logging.debug('roster_update_groups: %s', key) 106 | previous_users = () 107 | for row in self.ctx.db.conn.execute( 108 | '''SELECT userlist FROM rostergroups 109 | WHERE groupname=?''', (key,)): 110 | previous_users = row['userlist'].split('\t') 111 | logging.debug('previous_users=%s', previous_users) 112 | if previous_users == (): 113 | e.execute(['srg_create', cleanname[g], self.domain, cleanname[g], cleanname[g], cleanname[g]]) 114 | commands.append(('srg_create', cleanname[g], self.domain)) 115 | # Fill cache (again) 116 | previous_users = e.members(cleanname[g], self.domain) 117 | logging.debug('previous_users2=%s', previous_users) 118 | current_users = {} 119 | for u in groups[g]: 120 | if u == loginjid: 121 | logingroups.append(cleanname[g]) 122 | (lhs, rhs) = self.jidsplit(u) 123 | fulljid = '%s@%s' % (lhs, rhs) 124 | current_users[fulljid] = True 125 | logging.debug('current_users ' + ' '.join(sorted(current_users.keys()))) 126 | if not fulljid in previous_users: 127 | e.execute(['srg_user_add', lhs, rhs, cleanname[g], self.domain]) 128 | commands.append(('srg_user_add', fulljid, cleanname[g])) 129 | for p in previous_users: 130 | (lhs, rhs) = self.jidsplit(p) 131 | if p not in current_users: 132 | e.execute(['srg_user_del', lhs, rhs, cleanname[g], self.domain]) 133 | commands.append(('srg_user_del', p, cleanname[g])) 134 | # Here, we could use INSERT OR REPLACE, because we fill 135 | # all the fields. But only until someone would add 136 | # extra fields, which then would be reset to default values. 137 | # Better safe than sorry. 138 | self.ctx.db.conn.begin() 139 | self.ctx.db.conn.execute( 140 | '''INSERT OR IGNORE INTO rostergroups (groupname) 141 | VALUES (?)''', (key,)) 142 | self.ctx.db.conn.execute( 143 | '''UPDATE rostergroups 144 | SET userlist = ? 145 | WHERE groupname = ?''', ('\t'.join(sorted(current_users.keys())), key)) 146 | logging.debug('groupname %s, userlist %s', key, ('\t'.join(sorted(current_users.keys())))) 147 | self.ctx.db.conn.commit() 148 | self.ctx.db.conn.dump('rosterinfo') 149 | 150 | # For all the groups the login user was previously a member of: 151 | # - delete her from the shared roster group if no longer a member 152 | key = '@'.join((self.username, self.domain)) 153 | previous = () 154 | for row in self.ctx.db.conn.execute( 155 | '''SELECT grouplist FROM rosterinfo WHERE jid=?''', (key,)): 156 | if row['grouplist']: # Not None or '' 157 | previous = row['grouplist'].split('\t') 158 | logging.debug('previous %s group list %s', key, previous) 159 | logging.debug('cleannames %s', cleanname.values()) 160 | logging.debug('logingroups %s', logingroups) 161 | for p in previous: 162 | if p not in logingroups: 163 | e.execute(['srg_user_del', self.username, self.domain, p, self.domain]) 164 | commands.append(('srg_user_del2', key, p)) 165 | # Only update when necessary 166 | new = '\t'.join(sorted(logingroups)) 167 | logging.debug('new %s group list %s', key, new) 168 | if previous != new: 169 | self.ctx.db.conn.begin() 170 | self.ctx.db.conn.execute( 171 | '''INSERT OR IGNORE INTO rosterinfo (jid) 172 | VALUES (?)''', (key,)) 173 | self.ctx.db.conn.execute( 174 | '''UPDATE rosterinfo 175 | SET grouplist = ? 176 | WHERE jid = ?''', (new, key)) 177 | self.ctx.db.conn.commit() 178 | logging.debug('jid %s, set grouplist %s', key, new) 179 | return commands 180 | -------------------------------------------------------------------------------- /xclib/saslauthd_io.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | from struct import pack, unpack 4 | from xclib.utf8 import unutf8 5 | 6 | # Message formats described in `../doc/Protocol.md` 7 | 8 | class saslauthd_io: 9 | @classmethod 10 | def read_request(cls, infd, outfd): 11 | try: 12 | infd = infd.buffer 13 | except AttributeError: 14 | pass 15 | field_no = 0 16 | fields = [None, None, None, None] 17 | length_field = infd.read(2) 18 | while len(length_field) == 2: 19 | (size,) = unpack('>H', length_field) 20 | val = infd.read(size) 21 | if len(val) != size: 22 | logging.warn('premature EOF while reading field %d: %d != %d' % (field_no, len(val), size)) 23 | return 24 | fields[field_no] = val 25 | field_no = (field_no + 1) % 4 26 | if field_no == 0: 27 | logging.debug('from_saslauthd got %r, %r, %r, %r' % tuple(fields)) 28 | yield ('auth', unutf8(fields[0], 'illegal'), unutf8(fields[3], 'illegal'), unutf8(fields[1], 'illegal')) 29 | length_field = infd.read(2) 30 | 31 | @classmethod 32 | def write_response(cls, flag, outfd): 33 | try: 34 | outfd = outfd.buffer 35 | except AttributeError: 36 | pass 37 | answer = b'NO xcauth authentication failure' 38 | if flag: 39 | answer = b'OK success' 40 | token = pack('>H', len(answer)) + answer 41 | outfd.write(token) 42 | outfd.flush() 43 | -------------------------------------------------------------------------------- /xclib/sigcloud.py: -------------------------------------------------------------------------------- 1 | import urllib.request, urllib.parse, urllib.error 2 | import requests 3 | import hashlib 4 | import hmac 5 | import logging 6 | from datetime import datetime 7 | from xclib.isuser import isuser 8 | from xclib.auth import auth 9 | from xclib.roster import roster 10 | from xclib.utf8 import utf8 11 | 12 | class sigcloud(isuser, auth, roster): 13 | def __init__(self, ctx, username, domain, password=None, now=datetime.utcnow()): 14 | self.ctx = ctx 15 | self.username = username 16 | self.domain = domain 17 | self.password = password 18 | self.secret, self.url, self.authDomain = ctx.per_domain(domain) 19 | self.now = now 20 | 21 | def cloud_request(self, data): 22 | '''Performs a signed cloud request on data. 23 | 24 | Return values: 25 | - False: Connection problem 26 | - JSON: The successful JSON reply 27 | - int: The HTTP error 28 | ''' 29 | success, code, message, text = self.verbose_cloud_request(data) 30 | if success: 31 | if code is not None and code != requests.codes.ok: 32 | return code 33 | else: 34 | return message 35 | else: 36 | return False 37 | 38 | def verbose_cloud_request(self, data): 39 | '''Perform a signed cloud request on data with detailed result. 40 | 41 | Return tuple: 42 | - (True, None, json, body): Remote side answered with HTTP 200 and JSON body 43 | - (False, 200, None, None): Remote side answered with HTTP 200, but no JSON 44 | - (False, int, json, body): Remote side answered != 200, with JSON body 45 | - (False, int, None, None): Remote side answered != 200, without JSON 46 | - (False, None, err, None): Connection problem, described in err 47 | ''' 48 | # logging.debug("Sending %s to %s" % (data, url)) 49 | payload = utf8(urllib.parse.urlencode(data)) 50 | signature = hmac.new(self.secret, msg=payload, digestmod=hashlib.sha1).hexdigest() 51 | headers = { 52 | 'X-JSXC-SIGNATURE': 'sha1=' + signature, 53 | 'content-type': 'application/x-www-form-urlencoded' 54 | } 55 | try: 56 | r = self.ctx.session.post(self.url, data=payload, headers=headers, 57 | allow_redirects=False, timeout=self.ctx.timeout) 58 | except requests.exceptions.HTTPError as err: 59 | logging.warn(err) 60 | return False, None, err, None 61 | except requests.exceptions.RequestException as err: 62 | try: 63 | logging.warn('An error occured during the request to %s for domain %s: %s' % (self.url, data['domain'], err)) 64 | except TypeError as err: 65 | logging.warn('An unknown error occured during the request to %s, probably an SSL error. Try updating your "requests" and "urllib" libraries.' % url) 66 | return False, None, err, None 67 | if r.status_code != requests.codes.ok: 68 | try: 69 | return False, r.status_code, r.json(), r.text 70 | except ValueError: # Not a valid JSON response 71 | return False, r.status_code, None, None 72 | try: 73 | # Return True only for HTTP 200 with JSON body, False for everything else 74 | return True, None, r.json(), r.text 75 | except ValueError: # Not a valid JSON response 76 | return False, r.status_code, None, None 77 | -------------------------------------------------------------------------------- /xclib/sockact.py: -------------------------------------------------------------------------------- 1 | # systemd.daemon listen_fds_with_names() compatibility/abstraction library 2 | # for socket activation 3 | import os 4 | import logging 5 | 6 | def listen_fds_with_names(): 7 | '''Tries to get file descriptors from (in order): 8 | - listen_fds_with_names() # Not yet merged, see https://github.com/systemd/python-systemd/pull/60 9 | - listen_fds() # With own hack in case it finds $LISTEN_FDNAMES until listen_fds_with_names() is supported 10 | - None 11 | In the first two cases, it returns a hash of {fd: name} pairs''' 12 | try: 13 | from systemd.daemon import listen_fds_with_names 14 | # We have the real McCoy 15 | return listen_fds_with_names() 16 | except ImportError: 17 | # Try to fall back to listen_fds(), 18 | # possbily emulating listen_fds_with_names() here 19 | try: 20 | from systemd.daemon import listen_fds 21 | except ImportError: 22 | if os.path.exists('/run/systemd/system') and 'LISTEN_FDS' in os.environ: 23 | logging.error('Software from https://github.com/systemd/python-systemd/ missing; do `apt install python3-systemd` or `pip3 install systemd-python`. Please note the similarly-named `pip3 install python-systemd` does not provide the interfaces needed and may actually need to be UNINSTALLED first!') 24 | raise 25 | else: 26 | logging.info('Please `apt install python3-systemd` for future compatibility') 27 | return None 28 | fds = listen_fds() 29 | if fds: 30 | listeners = {} 31 | if 'LISTEN_FDNAMES' in os.environ: 32 | # Evil hack, should not be here! 33 | # Is here only because it seems unlikely 34 | # https://github.com/systemd/python-systemd/pull/60 35 | # will be merged and distributed anyting soon ;-(. 36 | # Diverges from original if not enough fdnames are provided 37 | # (but this should not happen anyway). 38 | names = os.environ['LISTEN_FDNAMES'].split(':') 39 | else: 40 | names = () 41 | for i in range(0, len(fds)): 42 | if i < len(names): 43 | listeners[fds[i]] = names[i] 44 | else: 45 | listeners[fds[i]] = 'unknown' 46 | return listeners 47 | else: 48 | return None 49 | -------------------------------------------------------------------------------- /xclib/tests/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.pyc 3 | -------------------------------------------------------------------------------- /xclib/tests/00_check_test.py: -------------------------------------------------------------------------------- 1 | # Test whether our own assertEqual works as it should 2 | from xclib.check import assertEqual, assertSimilar 3 | 4 | def test_assert_equal_type_success(): 5 | assertEqual(u'Hallo', u'Hallo') 6 | 7 | def test_assert_equal_type_fail(): 8 | try: 9 | assertEqual(b'Hallo', u'Hallo') 10 | except AssertionError: 11 | return 12 | raise AssertionError('Should have raised an exception') 13 | 14 | def test_assert_equal_type_fail2(): 15 | try: 16 | assertEqual(int(3), float(3)) 17 | except AssertionError: 18 | return 19 | raise AssertionError('Should have raised an exception') 20 | 21 | def test_assert_equal_value_fail(): 22 | try: 23 | assertEqual(u'Hallo', u'Tschüss') 24 | except AssertionError: 25 | return 26 | raise AssertionError('Should have raised an exception') 27 | 28 | def test_assert_similar_type_success(): 29 | assertSimilar(u'Hallo', u'Hallo') 30 | 31 | def test_assert_similar_type_mismatch(): 32 | assertSimilar(int(3), float(3)) 33 | 34 | def test_assert_equal_value_fail(): 35 | try: 36 | assertSimilar(int(3), float(4)) 37 | except AssertionError: 38 | return 39 | raise AssertionError('Should have raised an exception') 40 | 41 | -------------------------------------------------------------------------------- /xclib/tests/10_utf8_test.py: -------------------------------------------------------------------------------- 1 | # Tests wheter our UTF-8 convenience functions work as they should 2 | import sys 3 | import io 4 | from xclib.utf8 import utf8, unutf8, utf8l 5 | from xclib.check import assertEqual 6 | from xclib.tests.iostub import iostub 7 | 8 | def test_utf8_ascii(): 9 | assertEqual(b'hallo', utf8(u'hallo')) 10 | 11 | def test_utf8_valid(): 12 | assertEqual(b'Hall\xc3\xb6chen', utf8(u'Hallöchen')) 13 | 14 | def test_unutf8_ascii(): 15 | assertEqual(unutf8(b'Hallo'), u'Hallo') 16 | 17 | def test_unutf8_valid(): 18 | assertEqual(unutf8(b'Hall\xc3\xb6chen'), u'Hallöchen') 19 | 20 | def test_unutf8_invalid_ignore(): 21 | assertEqual(unutf8(b'Hall\xffchen', 'ignore'), u'Hallchen') 22 | 23 | def test_unutf8_invalid_ignore2(): 24 | assertEqual(unutf8(b'Hall\x80\x80chen', 'ignore'), u'Hallchen') 25 | 26 | def test_unutf8_invalid_ignore3(): 27 | assertEqual(unutf8(b'Hall\x80chen', 'ignore'), u'Hallchen') 28 | 29 | def test_unutf8_invalid_strict(): 30 | try: 31 | assertEqual(unutf8(b'Hall\x80chen', 'strict'), u'Hallchen') 32 | except UnicodeError: 33 | return 34 | raise AssertionError('Illegal UTF-8 sequence accepted under "strict"') 35 | 36 | def test_unutf8_invalid_illegal(): 37 | try: 38 | stderr = sys.stderr 39 | sys.stderr = io.StringIO() 40 | assertEqual(unutf8(b'Hall\x80chen', 'illegal'), u'illegal-utf8-sequence-Hallchen') 41 | finally: 42 | sys.stderr = stderr 43 | 44 | def test_utf8l_match(): 45 | assertEqual([b'b', b'\xc3\xb6', b's', b'e'], utf8l(['b', 'ö', 's', 'e'])) 46 | -------------------------------------------------------------------------------- /xclib/tests/11_config_test.py: -------------------------------------------------------------------------------- 1 | # Test whether the configuration parser works as it should 2 | import sys 3 | import unittest 4 | from xclib.tests.iostub import iostub 5 | from xclib.configuration import get_args 6 | 7 | def setup_module(): 8 | global arg_save 9 | arg_save = sys.argv 10 | sys.argv = [arg_save[0]] 11 | 12 | def teardown_module(): 13 | sys.argv = arg_save 14 | 15 | class TestConfiguration(unittest.TestCase, iostub): 16 | 17 | def test_xcauth(self): 18 | args = get_args('/var/log/xcauth', None, None, 'xcauth', 19 | config_file_contents='#', 20 | args=['-b', '/tmp/domdb.db', 21 | '--secret', '012345678', 22 | '--url', 'https://unconfigured.example.ch', 23 | '--type', 'generic', 24 | '--timeout', '5', 25 | '--cache-bcrypt-rounds', '9', 26 | '--cache-unreachable-ttl', '1w', 27 | '--cache-query-ttl', '3600']) 28 | print(args.timeout) 29 | self.assertEqual(args.timeout, 5) 30 | self.assertEqual(args.cache_bcrypt_rounds, (9, 9)) 31 | 32 | def test_xcauth_timeout(self): 33 | args = get_args('/var/log/xcauth', None, None, 'xcauth', 34 | config_file_contents='#', 35 | args=['-b', '/tmp/domdb.db', 36 | '--secret', '012345678', 37 | '--url', 'https://unconfigured.example.ch', 38 | '--type', 'generic', 39 | '--timeout', '1,2', 40 | '--cache-bcrypt-rounds', '8,4', 41 | '--cache-unreachable-ttl', '1w', 42 | '--cache-query-ttl', '3600']) 43 | print(args.timeout) 44 | self.assertEqual(args.timeout, (1, 2)) 45 | self.assertEqual(args.cache_bcrypt_rounds, (8, 4)) 46 | 47 | def test_xcauth_crash_timeout(self): 48 | self.stub_stdouts() 49 | self.assertRaises(ValueError, get_args, 50 | '/var/log/xcauth', None, None, 'xcauth', 51 | config_file_contents='#', 52 | args=['-b', '/tmp/domdb.db', 53 | '--secret', '012345678', 54 | '--ejabberdctl', '012345678', 55 | '--url', 'https://unconfigured.example.ch', 56 | '--type', 'generic', 57 | '--timeout', '1,2,3', 58 | '--cache-unreachable-ttl', '1w', 59 | '--cache-query-ttl', '3600']) 60 | 61 | def test_xcauth_crash_bcrypt(self): 62 | self.stub_stdouts() 63 | self.assertRaises(ValueError, get_args, 64 | '/var/log/xcauth', None, None, 'xcauth', 65 | config_file_contents='#', 66 | args=['-b', '/tmp/domdb.db', 67 | '--secret', '012345678', 68 | '--ejabberdctl', '012345678', 69 | '--url', 'https://unconfigured.example.ch', 70 | '--type', 'generic', 71 | '--cache-bcrypt-rounds', '1,2,3', 72 | '--cache-unreachable-ttl', '1w', 73 | '--cache-query-ttl', '3600']) 74 | 75 | def test_xcauth_exit_b(self): 76 | self.stub_stdouts() 77 | self.assertRaises(SystemExit, get_args, 78 | '/var/log/xcauth', None, None, 'xcauth', 79 | config_file_contents='#', 80 | args=['-b', '/tmp/domdb.db', 81 | '--secret', '012345678', 82 | '--url', 'https://unconfigured.example.ch', 83 | '--cache-query-ttl', '3600']) 84 | 85 | def test_xcdbm(self): 86 | args = get_args('/var/log/xcauth', None, None, 'xcdbm', 87 | config_file_contents='#', 88 | args=['-b', '/tmp/domdb.db', 89 | '--secret', '012345678', 90 | '--url', 'https://unconfigured.example.ch', 91 | '--unload']) 92 | -------------------------------------------------------------------------------- /xclib/tests/12_dbm_test.py: -------------------------------------------------------------------------------- 1 | # Checks some database functions 2 | import sys 3 | import os 4 | import io 5 | import bsddb3 6 | import unittest 7 | import tempfile 8 | import logging 9 | import subprocess 10 | from argparse import Namespace 11 | from xclib.dbmops import perform 12 | from xclib.tests.iostub import iostub 13 | from xclib.utf8 import utf8 14 | from xclib.check import assertEqual 15 | 16 | class TestDBM(unittest.TestCase, iostub): 17 | 18 | @classmethod 19 | def setup_class(cls): 20 | global dbname, dbfile, dirname 21 | dirname = tempfile.mkdtemp() 22 | dbname = dirname + "/domains.db" 23 | dbfile = bsddb3.hashopen(dbname, 'c', 0o600) 24 | 25 | @classmethod 26 | def teardown_class(cls): 27 | dbfile.close() 28 | os.remove(dbname) 29 | os.rmdir(dirname) 30 | 31 | def mkns(self, **kwargs): 32 | params = {'domain_db': dbname, 'get': None, 'put': None, 33 | 'delete': None, 'load': None, 'unload': None} 34 | params.update(**kwargs) 35 | return Namespace(**params) 36 | 37 | def test_01_load(self): 38 | self.stub_stdin(u'example.ch\tXmplScrt\thttps://example.ch/index.php/apps/ojsxc/ajax/externalApi.php\texample.ch\t\n' + 39 | u'example.de\tNothrXampl\thttps://nothing\t\n') 40 | ns = self.mkns(load=True) 41 | perform(ns) 42 | assertEqual(dbfile[b'example.ch'], b'XmplScrt\thttps://example.ch/index.php/apps/ojsxc/ajax/externalApi.php\texample.ch\t') 43 | assertEqual(dbfile[b'example.de'], b'NothrXampl\thttps://nothing\t') 44 | assert b'example.net' not in dbfile 45 | 46 | def test_02_put(self): 47 | ns = self.mkns(put=[u'example.net', u'dummy']) 48 | perform(ns) 49 | ns = self.mkns(get=u'example.net') 50 | perform(ns) 51 | dbfile = bsddb3.hashopen(dbname, 'c', 0o600) 52 | assert b'example.net' in dbfile 53 | assertEqual(dbfile[b'example.net'], b'dummy') 54 | dbfile.close() 55 | 56 | def test_03_get(self): 57 | self.stub_stdout(ioclass=io.StringIO) 58 | ns = self.mkns(get=u'example.net') 59 | perform(ns) 60 | self.assertEqual(sys.stdout.getvalue(), u'dummy\n') 61 | 62 | def test_04_delete(self): 63 | ns = self.mkns(delete=u'example.de') 64 | perform(ns) 65 | 66 | def test_05_unload(self): 67 | expected = [b'example.net', b'example.ch'] 68 | self.stub_stdout(ioclass=io.StringIO) 69 | ns = self.mkns(unload=True) 70 | perform(ns) 71 | v = sys.stdout.getvalue() 72 | for line in v.split('\n'): 73 | if line != '': 74 | (k, delim, v) = line.partition('\t') 75 | expected.remove(utf8(k)) 76 | assertEqual(expected, []) 77 | -------------------------------------------------------------------------------- /xclib/tests/13_sockact_test.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | from xclib.sockact import listen_fds_with_names 4 | 5 | have_systemd = None 6 | 7 | def systemd_present(): 8 | global have_systemd 9 | if have_systemd is None: 10 | try: 11 | from systemd.daemon import listen_fds 12 | have_systemd = True 13 | except ImportError: 14 | have_systemd = False 15 | return have_systemd 16 | 17 | # ============================================ 18 | 19 | @unittest.skipUnless(systemd_present(), 'systemd.daemon not available') 20 | class TestSystemdAvailable(unittest.TestCase): 21 | def setUp(self): 22 | # Make sure the first three fds (after std???) are valid 23 | # during test executions 24 | self.placeholderfds = [] 25 | for i in range(3): 26 | self.placeholderfds.append(open('/dev/null')) 27 | 28 | def test_listen_no_fds(self): 29 | os.unsetenv('LISTEN_FDS') 30 | os.unsetenv('LISTEN_PID') 31 | self.assertEqual(listen_fds_with_names(), None) 32 | 33 | def test_listen_1_fd_no_names(self): 34 | os.environ['LISTEN_FDS'] = '1' 35 | os.environ['LISTEN_PID'] = str(os.getpid()) 36 | os.unsetenv('LISTEN_FDNAMES') 37 | self.assertEqual(listen_fds_with_names(), 38 | {3: 'unknown'}) 39 | 40 | def test_listen_3_fds_no_names(self): 41 | os.environ['LISTEN_FDS'] = '3' 42 | os.environ['LISTEN_PID'] = str(os.getpid()) 43 | os.unsetenv('LISTEN_FDNAMES') 44 | self.assertEqual(listen_fds_with_names(), 45 | {3: 'unknown', 4: 'unknown', 5: 'unknown'}) 46 | 47 | def test_listen_3_fds_with_names(self): 48 | os.environ['LISTEN_FDS'] = '3' 49 | os.environ['LISTEN_PID'] = str(os.getpid()) 50 | os.environ['LISTEN_FDNAMES'] = 'one:two:three' 51 | self.assertEqual(listen_fds_with_names(), 52 | {3: 'one', 4: 'two', 5: 'three'}) 53 | 54 | @classmethod 55 | def cleanUpModule(cls): 56 | del os.environ['LISTEN_FDS'] 57 | del os.environ['LISTEN_PID'] 58 | 59 | # ============================================ 60 | 61 | @unittest.skipIf(systemd_present(), 'systemd.daemon available') 62 | class TestSystemdUnavailable(unittest.TestCase): 63 | def test_no_systemd_at_all(self): 64 | os.unsetenv('LISTEN_FDS') 65 | os.unsetenv('LISTEN_PID') 66 | self.assertEqual(listen_fds_with_names(), None) 67 | 68 | @unittest.skipUnless(os.path.exists('/run/systemd/system'), 'systemd not installed') 69 | def test_no_systemd_module_only(self): 70 | os.environ['LISTEN_FDS'] = '5' 71 | os.environ['LISTEN_PID'] = str(os.getpid()) 72 | with self.assertRaises(ImportError): 73 | listen_fds_with_names() 74 | 75 | @classmethod 76 | def cleanUpModule(cls): 77 | if 'LISTEN_FDS' in os.environ: 78 | del os.environ['LISTEN_FDS'] 79 | del os.environ['LISTEN_PID'] 80 | 81 | # Needed to reliably clean the environment (why?!?) 82 | def cleanup_test(): 83 | if 'LISTEN_FDS' in os.environ: 84 | del os.environ['LISTEN_FDS'] 85 | if 'LISTEN_PID' in os.environ: 86 | del os.environ['LISTEN_PID'] 87 | -------------------------------------------------------------------------------- /xclib/tests/14_db_migration_test.py: -------------------------------------------------------------------------------- 1 | # Checks database migration functions 2 | import sys 3 | import os 4 | import io 5 | import bsddb3 6 | import unittest 7 | import tempfile 8 | import logging 9 | import shutil 10 | from argparse import Namespace 11 | from xclib.dbmops import perform 12 | from xclib.tests.iostub import iostub 13 | from xclib.utf8 import utf8 14 | from xclib.check import assertEqual 15 | from xclib.db import connection 16 | 17 | class TestDBM(unittest.TestCase, iostub): 18 | 19 | @classmethod 20 | def setup_class(cls): 21 | global domname, cacname, rosname, sqlname, domfile, dirname, ucdname 22 | dirname = tempfile.mkdtemp() 23 | domname = dirname + "/domains.db" 24 | rosname = dirname + "/shared_roster.db" 25 | ucdname = dirname + "/user-cache.db" 26 | sqlname = dirname + "/xcauth.sqlite3" 27 | domfile = bsddb3.hashopen(domname, 'c', 0o600) 28 | 29 | @classmethod 30 | def teardown_class(cls): 31 | domfile.close() 32 | # shutil.rmtree(dirname) 33 | 34 | def mkns(self, **kwargs): 35 | params = {'domain_db': domname, 'get': None, 'put': None, 36 | 'delete': None, 'load': None, 'unload': None} 37 | params.update(**kwargs) 38 | return Namespace(**params) 39 | 40 | def mkpaths(self, **kwargs): 41 | paths = {'db': sqlname, 'domain_db': domname, 42 | 'shared_roster_db': rosname, 43 | 'cache_storage': 'memory', 'cache_db': ucdname} 44 | paths.update(**kwargs) 45 | return Namespace(**paths) 46 | 47 | def test_01_load(self): 48 | self.stub_stdin(u'example.ch\tXmplScrt\thttps://example.ch/index.php/apps/ojsxc/ajax/externalApi.php\texample.ch\t\n' + 49 | u'example.de\tNothrXampl\thttps://nothing\t\n') 50 | ns = self.mkns(load=True) 51 | perform(ns) 52 | assertEqual(domfile[b'example.ch'], b'XmplScrt\thttps://example.ch/index.php/apps/ojsxc/ajax/externalApi.php\texample.ch\t') 53 | assertEqual(domfile[b'example.de'], b'NothrXampl\thttps://nothing\t') 54 | assert b'example.net' not in domfile 55 | 56 | def test_02_execute(self): 57 | paths = self.mkpaths() 58 | sqlconn = connection(paths) 59 | r = set() 60 | for row in sqlconn.conn.execute('select * from domains'): 61 | r.add(row[0:4]) 62 | logging.error('r = %r', r) 63 | assertEqual(len(r), 2) 64 | assert ('example.ch', 'XmplScrt', 'https://example.ch/index.php/apps/ojsxc/ajax/externalApi.php', 'example.ch') in r 65 | assert ('example.de', 'NothrXampl', 'https://nothing', '') in r 66 | -------------------------------------------------------------------------------- /xclib/tests/20_prosody_test.py: -------------------------------------------------------------------------------- 1 | # Checks that prosody_io works as designed 2 | import sys 3 | import unittest 4 | from xclib.prosody_io import prosody_io 5 | from xclib.tests.iostub import iostub 6 | from xclib.check import assertEqual 7 | 8 | class TestProsody(unittest.TestCase, iostub): 9 | 10 | def test_input(self): 11 | self.stub_stdin('isuser:login:\n' + 12 | 'auth:log:dom:pass\n') 13 | tester = iter(prosody_io.read_request(sys.stdin, sys.stdout)) 14 | output = next(tester) 15 | assertEqual(output, ('isuser', 'login', '')) 16 | output = next(tester) 17 | assertEqual(output, ('auth', 'log', 'dom', 'pass')) 18 | self.assertRaises(StopIteration, next, tester) 19 | 20 | def test_output_false(self): 21 | self.stub_stdout() 22 | prosody_io.write_response(False, sys.stdout) 23 | self.assertEqual(sys.stdout.getvalue(), '0\n') 24 | 25 | # Cannot be merged, as getvalue() returns the aggregate value 26 | def test_output_true(self): 27 | self.stub_stdout() 28 | prosody_io.write_response(True, sys.stdout) 29 | self.assertEqual(sys.stdout.getvalue(), '1\n') 30 | -------------------------------------------------------------------------------- /xclib/tests/21_ejabberd_test.py: -------------------------------------------------------------------------------- 1 | # Checks that ejabberd_io works as designed 2 | import sys 3 | import io 4 | import unittest 5 | from xclib.ejabberd_io import ejabberd_io 6 | from xclib.tests.iostub import iostub 7 | 8 | class TestEjabberd(unittest.TestCase, iostub): 9 | 10 | def test_input(self): 11 | self.stub_stdin(b'\000\015isuser:login:' + 12 | b'\000\021auth:log:dom:pass', ioclass=io.BytesIO) 13 | tester = iter(ejabberd_io.read_request(sys.stdin, sys.stdout)) 14 | output = next(tester) 15 | self.assertEqual(output, ('isuser', 'login', '')) 16 | output = next(tester) 17 | self.assertEqual(output, ('auth', 'log', 'dom', 'pass')) 18 | self.assertRaises(StopIteration, next, tester) 19 | 20 | def test_input_fake_eof(self): 21 | self.stub_stdin(b'\000\000', ioclass=io.BytesIO) 22 | tester = iter(ejabberd_io.read_request(sys.stdin, sys.stdout)) 23 | self.assertRaises(StopIteration, next, tester) 24 | 25 | def test_input_short(self): 26 | self.stub_stdin(b'\001\000', ioclass=io.BytesIO) 27 | tester = iter(ejabberd_io.read_request(sys.stdin, sys.stdout)) 28 | self.assertRaises(StopIteration, next, tester) 29 | 30 | def test_input_negative(self): 31 | self.stub_stdin(b'\377\377', ioclass=io.BytesIO) 32 | tester = iter(ejabberd_io.read_request(sys.stdin, sys.stdout)) 33 | self.assertRaises(StopIteration, next, tester) 34 | 35 | def test_output_false(self): 36 | self.stub_stdout(ioclass=io.BytesIO) 37 | ejabberd_io.write_response(False, sys.stdout) 38 | self.assertEqual(sys.stdout.getvalue(), b'\000\002\000\000') 39 | 40 | # Cannot be merged, as getvalue() returns the aggregate value 41 | def test_output_true(self): 42 | self.stub_stdout(ioclass=io.BytesIO) 43 | ejabberd_io.write_response(True, sys.stdout) 44 | self.assertEqual(sys.stdout.getvalue(), b'\000\002\000\001') 45 | -------------------------------------------------------------------------------- /xclib/tests/22_saslauthd_test.py: -------------------------------------------------------------------------------- 1 | # Checks that saslauthd_io works as it should 2 | import sys 3 | import io 4 | import unittest 5 | from xclib.saslauthd_io import saslauthd_io 6 | from xclib.tests.iostub import iostub 7 | class TestSaslAuthD(unittest.TestCase, iostub): 8 | 9 | def test_input(self): 10 | self.stub_stdin(b'\000\005login\000\004pass\000\000\000\006domain' + 11 | b'\000\005login\000\004pass\000\006ignore\000\006domain', 12 | ioclass=io.BytesIO) 13 | tester = iter(saslauthd_io.read_request(sys.stdin, sys.stdout)) 14 | output = next(tester) 15 | self.assertEqual(output, ('auth', 'login', 'domain', 'pass')) 16 | output = next(tester) 17 | self.assertEqual(output, ('auth', 'login', 'domain', 'pass')) 18 | self.assertRaises(StopIteration, next, tester) 19 | 20 | def test_input_short(self): 21 | self.stub_stdin(b'\001\005login\000\004pass\000\000\000\006domain', 22 | ioclass=io.BytesIO) 23 | tester = iter(saslauthd_io.read_request(sys.stdin, sys.stdout)) 24 | self.assertRaises(StopIteration, next, tester) 25 | 26 | def test_output_false(self): 27 | self.stub_stdout(ioclass=io.BytesIO) 28 | saslauthd_io.write_response(False, sys.stdout) 29 | v = sys.stdout.getvalue() 30 | self.assertEqual(sys.stdout.getvalue(), b'\000\040NO xcauth authentication failure') 31 | 32 | # Cannot be merged, as getvalue() returns the aggregate value 33 | def test_output_true(self): 34 | self.stub_stdout(ioclass=io.BytesIO) 35 | saslauthd_io.write_response(True, sys.stdout) 36 | self.assertEqual(sys.stdout.getvalue(), b'\000\012OK success') 37 | -------------------------------------------------------------------------------- /xclib/tests/23_postfix_test.py: -------------------------------------------------------------------------------- 1 | # Checks that postfix_io (tcp_table) works as it should 2 | import sys 3 | import unittest 4 | from xclib.postfix_io import postfix_io 5 | from xclib.tests.iostub import iostub 6 | 7 | class TestPostfix(unittest.TestCase, iostub): 8 | 9 | def test_input(self): 10 | self.stub_stdin('get success@jsxc.ch\n' + 11 | 'get succ2@jsxc.org\n') 12 | tester = iter(postfix_io.read_request(sys.stdin, sys.stdout)) 13 | output = next(tester) 14 | self.assertEqual(output, ('isuser', 'success', 'jsxc.ch')) 15 | output = next(tester) 16 | self.assertEqual(output, ('isuser', 'succ2', 'jsxc.org')) 17 | self.assertRaises(StopIteration, next, tester) 18 | 19 | def test_input_ignore(self): 20 | self.stub_stdin('get success@jsxc.ch\n' + 21 | 'get ignore@@jsxc.ch\n' + 22 | 'get succ2@jsxc.org\n') 23 | self.stub_stdouts() 24 | tester = iter(postfix_io.read_request(sys.stdin, sys.stdout)) 25 | output = next(tester) 26 | self.assertEqual(output, ('isuser', 'success', 'jsxc.ch')) 27 | output = next(tester) 28 | self.assertEqual(sys.stdout.getvalue()[0:4], '500 ') 29 | self.assertEqual(output, ('isuser', 'succ2', 'jsxc.org')) 30 | self.assertRaises(StopIteration, next, tester) 31 | 32 | def test_output_false(self): 33 | self.stub_stdout() 34 | postfix_io.write_response(False, sys.stdout) 35 | self.assertEqual(sys.stdout.getvalue()[0:4], '500 ') 36 | 37 | def test_output_true(self): 38 | self.stub_stdout() 39 | postfix_io.write_response(True, sys.stdout) 40 | self.assertEqual(sys.stdout.getvalue()[0:4], '200 ') 41 | 42 | def test_output_none(self): 43 | self.stub_stdout() 44 | postfix_io.write_response(None, sys.stdout) 45 | self.assertEqual(sys.stdout.getvalue()[0:4], '400 ') 46 | -------------------------------------------------------------------------------- /xclib/tests/30_isuser_stub_test.py: -------------------------------------------------------------------------------- 1 | # Checks whether the isuser() function works as it should 2 | # Stubs the cloud_request() functions for these tests 3 | from xclib.sigcloud import sigcloud 4 | from xclib import xcauth 5 | from xclib.check import assertEqual 6 | 7 | def setup_module(): 8 | global xc, sc 9 | xc = xcauth(domain_db={ 10 | b'xdomain': b'99999\thttps://remotehost\tydomain\t', 11 | b'udomain': b'8888\thttps://oldhost\t', 12 | }, 13 | default_url='https://localhost', default_secret='01234') 14 | sc = sigcloud(xc, 'user1', 'domain1') 15 | 16 | def teardown_module(): 17 | pass 18 | 19 | def sc_timeout(data): 20 | assertEqual(data['operation'], 'isuser') 21 | assertEqual(data['username'], 'user1') 22 | assertEqual(data['domain'], 'domain1') 23 | return (False, None, 'Timeout', None) 24 | def test_timeout(): 25 | sc.verbose_cloud_request = sc_timeout 26 | assertEqual(sc.isuser(), None) 27 | 28 | def sc_404(data): 29 | return (False, 404, None, None) 30 | def test_http404(): 31 | sc.verbose_cloud_request = sc_404 32 | assertEqual(sc.isuser(), None) 33 | 34 | def sc_500json(data): 35 | return (False, 500, {'result': 'failure'}, None) 36 | def test_http500json(): 37 | sc.verbose_cloud_request = sc_500json 38 | assertEqual(sc.isuser(), None) 39 | 40 | def sc_malformed(data): 41 | return (True, None, {'result': 'success'}, None) 42 | def test_malformed(): 43 | sc.verbose_cloud_request = sc_malformed 44 | assertEqual(sc.isuser(), None) 45 | 46 | def sc_success(data): 47 | return (True, None, { 48 | 'result': 'success', 49 | 'data': { 50 | 'isUser': '1' 51 | }}, 'fake body') 52 | def test_success(): 53 | sc.verbose_cloud_request = sc_success 54 | assertEqual(sc.isuser(), True) 55 | 56 | def sc_xdomain(data): 57 | assertEqual(data['operation'], 'isuser') 58 | assertEqual(data['username'], 'xuser') 59 | assertEqual(data['domain'], 'ydomain') 60 | return (True, None, { 61 | 'result': 'success', 62 | 'data': { 63 | 'isUser': '1' 64 | }}, 'fake body') 65 | def test_xdomain(): 66 | sc = sigcloud(xc, 'xuser', 'xdomain') 67 | sc.verbose_cloud_request = sc_xdomain 68 | assertEqual(sc.isuser(), True) 69 | 70 | def test_domain_upgrade(): 71 | sc = sigcloud(xc, 'uuser', 'udomain') 72 | sc.verbose_cloud_request = sc_success 73 | assertEqual(sc.isuser(), True) 74 | -------------------------------------------------------------------------------- /xclib/tests/31_isuser_request_test.py: -------------------------------------------------------------------------------- 1 | # Checks whether the isuser() function works as it should 2 | # Test when replacing request.session 3 | # (a level further down from the `stub` tests before) 4 | import sys 5 | import requests 6 | from xclib.sigcloud import sigcloud 7 | from xclib import xcauth, verify_with_isuser 8 | import logging 9 | import hmac 10 | import hashlib 11 | from xclib.check import assertEqual 12 | from xclib.utf8 import unutf8 13 | 14 | class fakeResponse: 15 | # Will be called as follows: 16 | # r = self.ctx.session.post(self.url, data=payload, headers=headers, 17 | # allow_redirects=False, timeout=self.ctx.timeout) 18 | # r.status_code 19 | # r.json() 20 | # r.text 21 | def __init__(self, status, json, text): 22 | self.status_code = status 23 | self._json = json 24 | self.text = text 25 | 26 | def json(self): 27 | return self._json 28 | 29 | def post_timeout(url, data='', headers='', allow_redirects=False, 30 | timeout=5): 31 | raise requests.exceptions.ConnectTimeout("Connection timed out") 32 | 33 | def post_404(url, data='', headers='', allow_redirects=False, 34 | timeout=5): 35 | return fakeResponse(404, None, '404 Not found') 36 | 37 | def post_200_empty(url, data='', headers='', allow_redirects=False, 38 | timeout=5): 39 | return fakeResponse(200, None, '200 Success') 40 | 41 | def post_200_ok(url, data='', headers='', allow_redirects=False, 42 | timeout=5): 43 | return fakeResponse(200, { 44 | 'result': 'success', 45 | 'data': { 46 | 'isUser': '1' 47 | }}, 'fake body') 48 | 49 | def assertSortOf(a, b, separator): 50 | '''Check whether 'a' is any permutation of 'b', concatenated by 'separator'.''' 51 | sa = sorted(a.split(separator)) 52 | sb = sorted(b.split(separator)) 53 | assertEqual(sa, sb) 54 | 55 | def post_200_ok_verify(url, data='', headers='', allow_redirects=False, 56 | timeout=5): 57 | assertEqual(url, 'https://nosuchhost') 58 | assertSortOf(unutf8(data), 'username=usr&operation=isuser&domain=no.such.doma.in', '&') 59 | hash = hmac.new(b'999', msg=data, digestmod=hashlib.sha1).hexdigest() 60 | assertEqual(headers['X-JSXC-SIGNATURE'], 'sha1=' + hash) 61 | return post_200_ok(url, data, headers, allow_redirects, timeout) 62 | 63 | def setup_module(): 64 | global xc, sc 65 | xc = xcauth(domain_db={ 66 | b'xdomain': b'99999\thttps://remotehost\tydomain\t', 67 | b'udomain': b'8888\thttps://oldhost\t', 68 | }, 69 | default_url='https://localhost', default_secret='01234') 70 | sc = sigcloud(xc, 'user1', 'domain1') 71 | 72 | def teardown_module(): 73 | pass 74 | 75 | def test_timeout(): 76 | xc.session.post = post_timeout 77 | assertEqual(sc.isuser(), None) 78 | 79 | def test_http404(): 80 | xc.session.post = post_404 81 | assertEqual(sc.isuser(), None) 82 | 83 | def test_http200_empty(): 84 | xc.session.post = post_200_empty 85 | assertEqual(sc.isuser(), None) 86 | 87 | def test_success(): 88 | xc.session.post = post_200_ok 89 | assertEqual(sc.isuser(), True) 90 | 91 | def verify_hook(sc): 92 | sc.ctx.session.post = post_200_ok_verify 93 | 94 | def test_verify(): 95 | success, code, response = verify_with_isuser('https://nosuchhost', '999', 'no.such.doma.in', 'usr', (5, 10), verify_hook) 96 | assertEqual(success, True) 97 | assertEqual(code, None) 98 | assertEqual(response, {'data': {'isUser': '1'}, 'result': 'success'}) 99 | -------------------------------------------------------------------------------- /xclib/tests/32_auth_stub_test.py: -------------------------------------------------------------------------------- 1 | # Check whether the auth() function works as it should 2 | # Stubs the cloud_request() functions for this 3 | import time 4 | from datetime import datetime, timedelta 5 | from xclib.sigcloud import sigcloud 6 | from xclib import xcauth 7 | from xclib.check import assertEqual, assertSimilar 8 | from xclib.utf8 import unutf8 9 | 10 | cloud_count = 0 11 | 12 | def setup_module(): 13 | global xc, sc 14 | xc = xcauth(default_url='https://localhost', default_secret='01234', 15 | #sql_db='/tmp/auth.sqlite3', cache_storage='db', 16 | #sql_db='/tmp/auth.sqlite3', cache_storage='db', 17 | #sql_db=':memory:', cache_storage='db', 18 | sql_db=':memory:', cache_storage='memory', 19 | bcrypt_rounds=(6, 6)) 20 | sc = sigcloud(xc, 'user2', 'domain2', 'pass2') 21 | 22 | def teardown_module(): 23 | pass 24 | 25 | def sql0(sql, *args, **kwargs): 26 | xc.db.cache.execute(sql, *args, **kwargs) 27 | def sql1(sql, *args, **kwargs): 28 | return xc.db.cache.execute(sql, *args, **kwargs).fetchone() 29 | 30 | def utc(ts): 31 | return datetime.utcfromtimestamp(int(ts)) 32 | 33 | # Test group 10: Cloud operations (with cloud request stubs) 34 | def sc_params2(data): 35 | global cloud_count 36 | cloud_count += 1 37 | assertEqual(data['operation'], 'auth') 38 | assertEqual(data['username'], 'user2') 39 | assertEqual(data['domain'], 'domain2') 40 | assertEqual(data['password'], 'pass2') 41 | return (False, None, 'Connection refused', None) 42 | def test_10_params2(): 43 | sc.verbose_cloud_request = sc_params2 44 | assertEqual(sc.auth(), False) 45 | 46 | def sc_timeout(data): 47 | global cloud_count 48 | cloud_count += 1 49 | return (False, None, 'Timeout', None) 50 | def test_10_timeout(): 51 | sc.verbose_cloud_request = sc_timeout 52 | assertEqual(sc.auth(), False) 53 | 54 | def sc_404(data): 55 | global cloud_count 56 | cloud_count += 1 57 | return (False, 404, None, None) 58 | def test_10_http404(): 59 | sc.verbose_cloud_request = sc_404 60 | assertEqual(sc.auth(), False) 61 | 62 | def sc_500json(data): 63 | global cloud_count 64 | cloud_count += 1 65 | return (False, 500, {'result': 'failure'}, None) 66 | def test_10_http500json(): 67 | sc.verbose_cloud_request = sc_500json 68 | assertEqual(sc.auth(), False) 69 | 70 | def sc_malformed(data): 71 | global cloud_count 72 | cloud_count += 1 73 | return (True, None, {'foo': 'bar'}, None) 74 | def test_10_malformed(): 75 | sc.verbose_cloud_request = sc_malformed 76 | assertEqual(sc.auth(), False) 77 | 78 | def sc_malformed2(data): 79 | global cloud_count 80 | cloud_count += 1 81 | return (True, None, {'result': 'bar'}, None) 82 | def test_10_malformed2(): 83 | sc.verbose_cloud_request = sc_malformed2 84 | assertEqual(sc.auth(), False) 85 | 86 | def sc_error(data): 87 | global cloud_count 88 | cloud_count += 1 89 | return (False, 400, { 90 | 'result': 'error', 91 | }, 'fake body') 92 | def test_10_error(): 93 | sc.verbose_cloud_request = sc_error 94 | assertEqual(sc.auth(), False) 95 | 96 | def sc_noauth(data): 97 | global cloud_count 98 | cloud_count += 1 99 | return (False, 400, { 100 | 'result': 'error', 101 | }, 'fake body') 102 | def test_10_noauth(): 103 | sc.verbose_cloud_request = sc_noauth 104 | assertEqual(sc.auth(), False) 105 | 106 | def sc_success(data): 107 | global cloud_count 108 | cloud_count += 1 109 | return (True, None, { 110 | 'result': 'success', 111 | }, 'fake body') 112 | def test_10_success(): 113 | sc.verbose_cloud_request = sc_success 114 | assertEqual(0, sql1('SELECT COUNT(*) FROM authcache')[0]) 115 | assertEqual(sc.auth(), True) 116 | assertEqual(1, sql1('SELECT COUNT(*) FROM authcache')[0]) 117 | assertEqual(1, sql1('''SELECT COUNT(*) FROM authcache WHERE jid = 'user2@domain2' ''')[0]) 118 | 119 | # Test group 20: Time-limited tokens 120 | def sc_trap(data): 121 | assert False # Should not reach out to the cloud 122 | def test_20_token_success(): 123 | # ./generateTimeLimitedToken tuser tdomain 01234 3600 1000 124 | sc = sigcloud(xc, 'tuser', 'tdomain', 125 | 'AMydsCzkh8-8vjcb9U2gqV/FZQAAEfg', now=utc(2000)) 126 | sc.verbose_cloud_request = sc_trap 127 | assertEqual(sc.auth(), True) 128 | assertEqual(0, sql1('''SELECT COUNT(*) FROM authcache WHERE jid = 'tuser@tdomain' ''')[0]) 129 | 130 | def test_20_token_fail(): 131 | # ./generateTimeLimitedToken tuser tdomain 01234 3600 1000 132 | sc = sigcloud(xc, 'tuser', 'tdomain', 133 | 'AMydsCzkh8-8vjcb9U2gqV/FZQAAEfg', now=utc(5000)) 134 | sc.verbose_cloud_request = sc_noauth 135 | global cloud_count 136 | cloud_count = 0 137 | assertEqual(sc.auth(), False) 138 | assertEqual(cloud_count, 1) 139 | 140 | def test_20_token_version(): 141 | # Wrong version 142 | sc = sigcloud(xc, 'tuser', 'tdomain', 143 | 'BMydsCzkh8-8vjcb9U2gqV/FZQAAEfg', now=utc(5000)) 144 | sc.verbose_cloud_request = sc_noauth 145 | global cloud_count 146 | cloud_count = 0 147 | assertEqual(sc.auth(), False) 148 | assertEqual(cloud_count, 1) 149 | 150 | # Test group 30: Cache logic 151 | def test_30_cache(): 152 | global cloud_count 153 | cloud_count = 0 154 | sc = sigcloud(xc, 'user3', 'domain3', 'pass3', now=utc(1)) 155 | 156 | # Timeout first: No cache entry 157 | sc.verbose_cloud_request = sc_timeout 158 | assertEqual(sc.auth(), False) 159 | assertEqual(0, sql1('''SELECT COUNT(*) FROM authcache WHERE jid = 'user3@domain3' ''')[0]) 160 | assertEqual(cloud_count, 1) 161 | 162 | # Success: Cache entry 163 | sc.verbose_cloud_request = sc_success 164 | assertEqual(sc.auth(), True) 165 | assertEqual(1, sql1('''SELECT COUNT(*) FROM authcache WHERE jid = 'user3@domain3' ''')[0]) 166 | entry = sql1('''SELECT * FROM authcache WHERE jid = 'user3@domain3' ''') 167 | assert(entry != None) 168 | assert entry['pwhash'].startswith('$2b$06$') 169 | cachedpw = entry['pwhash'] 170 | assertEqual(entry['firstauth'], utc(1)) 171 | firstauth = entry['firstauth'] 172 | assertEqual(entry['remoteauth'], utc(1)) 173 | assertEqual(entry['anyauth'], utc(1)) 174 | assertEqual(cloud_count, 2) 175 | 176 | # Same request a little bit later: Should use cache (and note it) 177 | sc.now = utc(100) 178 | sc.verbose_cloud_request = sc_trap 179 | assertEqual(sc.auth(), True) 180 | entry = sql1('''SELECT * FROM authcache WHERE jid = 'user3@domain3' ''') 181 | assertEqual(cachedpw, entry['pwhash']) # No cache password update 182 | assertEqual(entry['firstauth'], firstauth) 183 | assertEqual(entry['remoteauth'], utc(1)) 184 | assertEqual(entry['anyauth'], utc(100)) 185 | assertEqual(cloud_count, 2) 186 | 187 | # Bad password request 188 | sc.now = utc(200) 189 | sc.verbose_cloud_request = sc_noauth 190 | sc.password = 'badpass' 191 | assertEqual(sc.auth(), False) 192 | assertEqual(entry, sql1('''SELECT * FROM authcache WHERE jid = 'user3@domain3' ''')) # Unmodified 193 | assertEqual(cloud_count, 3) 194 | # Test whether the DEFAULT values from the database are reapplied 195 | # (is the case when using INSERT OR REPLACE with DEFAULTs in schema) 196 | time.sleep(1) 197 | 198 | # New successful password request again: Should use cloud again 199 | sc.now = utc(300) 200 | sc.password = 'newpass' 201 | sc.verbose_cloud_request = sc_success 202 | assertEqual(sc.auth(), True) 203 | entry = sql1('''SELECT * FROM authcache WHERE jid = 'user3@domain3' ''') 204 | assert cachedpw != entry['pwhash'] # Update cached password 205 | assertEqual(entry['firstauth'], firstauth) 206 | assertEqual(entry['remoteauth'], utc(300)) 207 | assertEqual(entry['anyauth'], utc(300)) 208 | assertEqual(cloud_count, 4) 209 | 210 | # Token request should not change anything 211 | # ./generateTimeLimitedToken user3 domain3 01234 3600 1 212 | sc.password = 'ABbL+6M8K7HGF/vnfaZZi5XFZQAADhE' 213 | assertEqual(sc.auth(), True) 214 | assertEqual(entry, sql1('''SELECT * FROM authcache WHERE jid = 'user3@domain3' ''')) # Unmodified 215 | assertEqual(cloud_count, 4) 216 | 217 | # More than an hour of waiting: Go to the cloud again 218 | sc.now = utc(4000) 219 | sc.password = 'newpass' 220 | assertEqual(sc.auth(), True) 221 | assert entry != sql1('''SELECT * FROM authcache WHERE jid = 'user3@domain3' ''') # Updated 222 | entry = sql1('''SELECT * FROM authcache WHERE jid = 'user3@domain3' ''') 223 | assert cachedpw != entry['pwhash'] # Update cached password 224 | assertEqual(entry['firstauth'], firstauth) 225 | assertEqual(entry['remoteauth'], utc(4000)) 226 | assertEqual(entry['anyauth'], utc(4000)) 227 | assertEqual(cloud_count, 5) 228 | 229 | # Another hour has passed, but the server is now unreachable 230 | sc.now = utc(8000) 231 | sc.verbose_cloud_request = sc_timeout 232 | assertEqual(sc.auth(), True) 233 | entry = sql1('''SELECT * FROM authcache WHERE jid = 'user3@domain3' ''') 234 | assert cachedpw != entry['pwhash'] # Update cached password 235 | assertEqual(entry['firstauth'], firstauth) 236 | assertEqual(entry['remoteauth'], utc(4000)) 237 | assertEqual(entry['anyauth'], utc(8000)) 238 | assertEqual(cloud_count, 6) 239 | 240 | # Another request shortly after goes to the cache again 241 | sc.now = utc(8100) 242 | assertEqual(sc.auth(), True) 243 | entry = sql1('''SELECT * FROM authcache WHERE jid = 'user3@domain3' ''') 244 | assert cachedpw != entry['pwhash'] # Update cached password 245 | assertEqual(entry['firstauth'], firstauth) 246 | assertEqual(entry['remoteauth'], utc(4000)) 247 | assertEqual(entry['anyauth'], utc(8100)) 248 | assertEqual(cloud_count, 6) 249 | 250 | # Now 46 more requests spaced half an hour apart should all go to the cache 251 | while sc.now < utc(4000 + 86400 - 1800): 252 | sc.now += timedelta(seconds=1800) 253 | assertEqual(sc.auth(), True) 254 | assertEqual(cloud_count, 6) 255 | 256 | # The next goes to the cloud again, but as that times out, is considered OK as well 257 | sc.now += timedelta(seconds=1800) 258 | assertEqual(sc.auth(), True) 259 | assertEqual(cloud_count, 7) 260 | 261 | # This could go on for the rest of a week, but then it should finally fail 262 | sc.now += timedelta(days=6) 263 | assertEqual(sc.auth(), False) 264 | assertEqual(cloud_count, 8) 265 | -------------------------------------------------------------------------------- /xclib/tests/40_ejabberdctl_test.py: -------------------------------------------------------------------------------- 1 | # Check whether calling `ejabberdctl` would work 2 | # Uses `echo`, `true`, and `false` as external programs; 3 | # thus does not require in installation of *ejabberd* 4 | from xclib.ejabberdctl import ejabberdctl 5 | from xclib import xcauth 6 | from xclib.check import assertEqual 7 | 8 | def test_echo(): 9 | xc = xcauth(ejabberdctl='/bin/echo') 10 | e = ejabberdctl(xc) 11 | assertEqual(e.execute(['Hello', 'world']), 'Hello world\n') 12 | 13 | def test_true(): 14 | xc = xcauth(ejabberdctl='/bin/true') 15 | e = ejabberdctl(xc) 16 | assertEqual(e.execute(['Hello', 'world']), '') 17 | 18 | def test_false(): 19 | xc = xcauth(ejabberdctl='/bin/false') 20 | e = ejabberdctl(xc) 21 | assertEqual(e.execute(['Hello', 'world']), None) 22 | -------------------------------------------------------------------------------- /xclib/tests/42_roster_request_test.py: -------------------------------------------------------------------------------- 1 | # Exercises the roster_cloud() request 2 | # Test when replacing request.session 3 | import requests 4 | from xclib.sigcloud import sigcloud 5 | from xclib import xcauth 6 | from xclib.check import assertEqual 7 | 8 | class fakeResponse: 9 | # Will be called as follows: 10 | # r = self.ctx.session.post(self.url, data=payload, headers=headers, 11 | # allow_redirects=False, timeout=self.ctx.timeout) 12 | # r.status_code 13 | # r.json() 14 | # r.text 15 | def __init__(self, status, json, text): 16 | self.status_code = status 17 | self._json = json 18 | self.text = text 19 | 20 | def json(self): 21 | return self._json 22 | 23 | def post_timeout(url, data='', headers='', allow_redirects=False, 24 | timeout=5): 25 | raise requests.exceptions.ConnectTimeout("Connection timed out") 26 | 27 | def post_404(url, data='', headers='', allow_redirects=False, 28 | timeout=5): 29 | return fakeResponse(404, None, '404 Not found') 30 | 31 | def post_200_empty(url, data='', headers='', allow_redirects=False, 32 | timeout=5): 33 | return fakeResponse(200, None, '200 Success') 34 | 35 | def post_200_ok(url, data='', headers='', allow_redirects=False, 36 | timeout=5): 37 | return fakeResponse(200, { 38 | 'result': 'success', 39 | 'data': { 40 | 'sharedRoster': {'user1@domain1':{'name':'Ah Be','groups':['Lonely']}} 41 | }}, 'fake body') 42 | 43 | def setup_module(): 44 | global xc, sc 45 | xc = xcauth(domain_db={ 46 | b'xdomain': b'99999\thttps://remotehost\tydomain\t', 47 | b'udomain': b'8888\thttps://oldhost\t', 48 | }, 49 | sql_db=':memory:', 50 | cache_storage='db', 51 | default_url='https://localhost', default_secret='01234') 52 | sc = sigcloud(xc, 'user1', 'domain1') 53 | 54 | def teardown_module(): 55 | pass 56 | 57 | def test_timeout(): 58 | xc.session.post = post_timeout 59 | assertEqual(sc.roster_cloud(), (False, None)) 60 | 61 | def test_http404(): 62 | xc.session.post = post_404 63 | assertEqual(sc.roster_cloud(), (False, None)) 64 | 65 | def test_http200_empty(): 66 | xc.session.post = post_200_empty 67 | roster, body = sc.roster_cloud() 68 | assertEqual(roster, None) 69 | assertEqual(body, '200 Success') 70 | 71 | def test_success(): 72 | xc.session.post = post_200_ok 73 | roster, body = sc.roster_cloud() 74 | assertEqual(roster, {'user1@domain1':{'name':'Ah Be','groups':['Lonely']}}) 75 | assertEqual(body, 'fake body') 76 | 77 | -------------------------------------------------------------------------------- /xclib/tests/50_online_test.py: -------------------------------------------------------------------------------- 1 | # Performs the online auth(), isuser(), and roster() functions 2 | # if `/etc/xcauth.accounts` exists and the machine is online. 3 | import os 4 | import sys 5 | import requests 6 | import unittest 7 | import logging 8 | import shutil 9 | import tempfile 10 | import json 11 | import requests 12 | from xclib.sigcloud import sigcloud 13 | from xclib import xcauth 14 | from xclib.tests.iostub import iostub 15 | from xclib.configuration import get_args 16 | from xclib.authops import perform 17 | 18 | def setup_module(): 19 | global dirname 20 | dirname = tempfile.mkdtemp() 21 | 22 | def teardown_module(): 23 | shutil.rmtree(dirname) 24 | 25 | def is_online(): 26 | try: 27 | req = requests.get("https://xmpp.jsxc.ch/online_tests", timeout=5) 28 | return True 29 | except requests.exceptions.RequestException: 30 | return False 31 | 32 | class TestOnline(unittest.TestCase, iostub): 33 | # Skip online tests if /etc/xcauth.accounts does not exist 34 | # The overall operation is modeled after ../../tests/run-online.pl 35 | 36 | @unittest.skipUnless(os.path.isfile("/etc/xcauth.accounts"), "/etc/xcauth.accounts missing") 37 | @unittest.skipUnless(is_online(), "Not online") 38 | def test_online(self): 39 | self.maxDiff = None 40 | has_run = [] 41 | try: 42 | file = open('/etc/xcauth.accounts', 'r'); 43 | except IOError: 44 | raise unittest.SkipTest("/etc/xcauth.accounts unreadable") 45 | u = None 46 | d = None 47 | p = None 48 | for line in file: 49 | line = line.rstrip('\r\n') 50 | fields = line.split('\t', 2) 51 | if fields[0] == '': 52 | # Line with test command 53 | if fields[1] == 'isuser': 54 | option = '-I' 55 | params = [u, d] 56 | elif fields[1] == 'roster': 57 | option = '-R' 58 | params = [u, d] 59 | elif fields[1] == 'auth': 60 | option = '-A' 61 | params = [u, d, p] 62 | else: 63 | raise ValueError('Invalid /etc/xcauth.accounts command %s' % fields[1]) 64 | # To get maximum coverage with minimum duplicate requests: 65 | # The first time, use command line; afterward, use -t generic 66 | if fields[1] in has_run: 67 | self.generic_io([fields[1]] + params, fields[2]) 68 | else: 69 | # Test some more options on the first run 70 | if len(has_run) == 0: 71 | params += ['-b', dirname + '/domain.db', 72 | '-l', dirname, 73 | '--db', ':memory:', 74 | '--cache-db', dirname + '/cache.db', 75 | '--shared-roster-db', dirname + '/roster.db', 76 | '--ejabberdctl', '/bin/true'] 77 | self.command_line([option] + params, fields[2]) 78 | has_run += [fields[1]] 79 | else: 80 | # Line with account values 81 | (u, d, p) = fields 82 | file.close() 83 | 84 | def command_line(self, options, expected): 85 | logging.info('command_line ' + str(options) + ' =? ' + expected) 86 | self.stub_stdout() 87 | args = get_args(None, None, None, 'xcauth', args=options) 88 | perform(args) 89 | output = sys.stdout.getvalue().rstrip('\n') 90 | self.assertEqual(output, expected) 91 | 92 | def generic_io(self, command, expected): 93 | logging.info('generic_io ' + str(command) + ' =? ' + expected) 94 | self.stub_stdin(':'.join(command) + '\n') 95 | self.stub_stdout() 96 | args = get_args(None, None, None, 'xcauth', args=['-t', 'generic']) 97 | perform(args) 98 | output = sys.stdout.getvalue().rstrip('\n') 99 | logging.debug(output) 100 | logging.debug(expected) 101 | if output == '0' or output == 'None': 102 | assert str(expected) == 'False' or str(expected), 'None' 103 | elif output == '1': 104 | self.assertEqual(str(expected), 'True') 105 | else: 106 | # Only "roster" command will get here. 107 | # Convert both strs to dicts to avoid 108 | # problems with formatting (whitespace) and order. 109 | output = json.loads(output) 110 | expected = json.loads(expected) 111 | self.assertEqual(output, expected) 112 | -------------------------------------------------------------------------------- /xclib/tests/51_online_postfix_failure_test.py: -------------------------------------------------------------------------------- 1 | # Second half of the postfix_io tests. 2 | # If the authentication host is unreachable, postfix 3 | # should have a 400 error returned. This tests for it. 4 | # This test also works if the local machine is offline, 5 | # nevertheless, it sends packets to the network, therefore 6 | # is considered an online test 7 | import sys 8 | import unittest 9 | import logging 10 | import shutil 11 | import tempfile 12 | import json 13 | from xclib.sigcloud import sigcloud 14 | from xclib import xcauth 15 | from xclib.tests.iostub import iostub 16 | from xclib.configuration import get_args 17 | from xclib.authops import perform 18 | from xclib.check import assertEqual 19 | 20 | def setup_module(): 21 | global dirname 22 | dirname = tempfile.mkdtemp() 23 | 24 | def teardown_module(): 25 | shutil.rmtree(dirname) 26 | 27 | class TestOnlinePostfix(unittest.TestCase, iostub): 28 | # Run this (connection-error) online test even when /etc/xcauth.accounts does not exist 29 | def test_postfix_connection_error(self): 30 | self.stub_stdin('get user@example.org\n') 31 | self.stub_stdout() 32 | args = get_args(None, None, None, 'xcauth', 33 | args=['-t', 'postfix', 34 | '-u', 'https://no-connection.jsxc.org/', 35 | '-s', '0', 36 | '-l', dirname, 37 | '--db', ':memory:' 38 | ], 39 | config_file_contents='#') 40 | perform(args) 41 | output = sys.stdout.getvalue().rstrip('\n') 42 | logging.debug(output) 43 | assertEqual(output[0:4], '400 ') 44 | -------------------------------------------------------------------------------- /xclib/tests/README.md: -------------------------------------------------------------------------------- 1 | # Test classes 2 | 3 | - 0x: Testing system itself 4 | - 1x: Low-level xclib functions 5 | - 2x: I/O functions 6 | - 3x: Local tests (stubs) 7 | - 4x: Roster-related tests (local, with stubs) 8 | - 5x: Online tests 9 | -------------------------------------------------------------------------------- /xclib/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jsxc/xmpp-cloud-auth/46a73b164b8660722b9b235b31dcca8b901ccb64/xclib/tests/__init__.py -------------------------------------------------------------------------------- /xclib/tests/generateTimeLimitedToken: -------------------------------------------------------------------------------- 1 | #!/usr/bin/php -f 2 | 6) { 4 | print "Usage: generateTimeLimitedToken USER DOMAIN SECRET TTL [STARTTIME]\n"; 5 | exit(1); 6 | } 7 | if ($argc == 5) { 8 | $argv[5] = null; 9 | } 10 | print generateUser($argv[1], $argv[2], $argv[3], $argv[4], $argv[5]) . "\n"; 11 | 12 | # Taken from https://github.com/nextcloud/jsxc.nextcloud/blob/master/lib/TimeLimitedToken.php#L7 13 | # (without "public static") 14 | function generateUser($node, $domain, $secret, $ttl = 60 * 60, $time = null) 15 | { 16 | if (!isset($time) || $time === null) { 17 | $time = time(); 18 | } 19 | $jid = $node. '@' . $domain; 20 | $expiry = $time + $ttl; 21 | $version = hex2bin('00'); 22 | $secretID = substr(hash('sha256', $secret, true), 0, 2); 23 | $header = $secretID.pack('N', $expiry); 24 | $challenge = $version.$header.$jid; 25 | $hmac = hash_hmac('sha256', $challenge, $secret, true); 26 | $token = $version.substr($hmac, 0, 16).$header; 27 | // format as "user-friendly" base64 28 | $token = str_replace('=', '', strtr( 29 | base64_encode($token), 30 | 'OIl', 31 | '-$%' 32 | )); 33 | return $token; 34 | } 35 | -------------------------------------------------------------------------------- /xclib/tests/iostub.py: -------------------------------------------------------------------------------- 1 | # Based on metatoaster's answer to 2 | # https://stackoverflow.com/questions/38861101/how-can-i-test-the-standard-input-and-standard-output-in-python-script-with-a-un 3 | import sys 4 | import io 5 | 6 | class iostub: 7 | def stub_stdin(testcase_inst, inputs, ioclass=io.StringIO): 8 | stdin = sys.stdin 9 | 10 | def cleanup(): 11 | sys.stdin = stdin 12 | 13 | testcase_inst.addCleanup(cleanup) 14 | sys.stdin = ioclass(inputs) 15 | if ioclass == io.BytesIO: 16 | # Fake 'buffer' variable 17 | sys.stdin.buffer = sys.stdin 18 | 19 | def stub_stdout(testcase_inst, ioclass=io.StringIO): 20 | stdout = sys.stdout 21 | 22 | def cleanup(): 23 | sys.stdout = stdout 24 | 25 | testcase_inst.addCleanup(cleanup) 26 | sys.stdout = ioclass() 27 | if ioclass == io.BytesIO: 28 | # Fake 'buffer' variable 29 | sys.stdout.buffer = sys.stdout 30 | 31 | def stub_stdouts(testcase_inst, ioclass=io.StringIO): 32 | stderr = sys.stderr 33 | stdout = sys.stdout 34 | 35 | def cleanup(): 36 | sys.stderr = stderr 37 | sys.stdout = stdout 38 | 39 | testcase_inst.addCleanup(cleanup) 40 | sys.stderr = ioclass() 41 | sys.stdout = ioclass() 42 | if ioclass == io.BytesIO: 43 | # Fake 'buffer' variable 44 | sys.stdout.buffer = sys.stdout 45 | sys.stderr.buffer = sys.stderr 46 | -------------------------------------------------------------------------------- /xclib/utf8.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | import traceback 4 | 5 | def utf8(u, opts='strict'): 6 | return u.encode('utf-8', opts) 7 | 8 | def unutf8(u, opts='strict'): 9 | if opts == 'illegal': 10 | try: 11 | return u.decode('utf-8', 'strict') 12 | except UnicodeError: 13 | dec = u.decode('utf-8', 'ignore') 14 | logging.error('Illegal UTF-8 sequence: %r' % dec) 15 | sys.stderr.write('Illegal UTF-8 sequence: %r\n' % dec) 16 | traceback.print_exc() 17 | return 'illegal-utf8-sequence-' + dec 18 | else: 19 | return u.decode('utf-8', opts) 20 | 21 | def utf8l(l): 22 | '''Encode a copy of the list, converted to UTF-8''' 23 | return list(map(utf8, l)) 24 | -------------------------------------------------------------------------------- /xclib/version.py: -------------------------------------------------------------------------------- 1 | VERSION = '2.0.4+' 2 | --------------------------------------------------------------------------------