Repository: ZerataX/matrix-registration Branch: master Commit: db7aab3dbfb5 Files: 54 Total size: 151.0 KB Directory structure: gitextract_w5avl08v/ ├── .coveragerc ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.yml │ │ ├── config.yml │ │ └── feature_request.yml │ └── workflows/ │ ├── codeql-analysis.yml │ ├── docker.yml │ ├── pypi.yml │ └── tests.yml ├── .gitignore ├── .travis.yml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── alembic/ │ ├── README │ ├── env.py │ ├── script.py.mako │ └── versions/ │ ├── 130b5c2275d8_update_ip_token_association.py │ └── 140a25d5f185_create_tokens_table.py ├── alembic.ini ├── config.sample.yaml ├── default.nix ├── docker.nix ├── matrix_registration/ │ ├── __init__.py │ ├── api.py │ ├── app.py │ ├── config.py │ ├── config.schema.json │ ├── constants.py │ ├── limiter.py │ ├── matrix_api.py │ ├── static/ │ │ ├── css/ │ │ │ └── style.css │ │ └── fonts/ │ │ └── NUNITO-LICENSE │ ├── templates/ │ │ └── register.html │ ├── tokens.py │ ├── translation.py │ ├── translations/ │ │ ├── messages.de.yaml │ │ ├── messages.en.yaml │ │ ├── messages.pt_BR.yaml │ │ ├── messages.sv.yaml │ │ └── messages.zh_Hans.yaml │ └── wordlist.txt ├── resources/ │ ├── docker-run.sh │ ├── docker-serve.sh │ └── example.html ├── setup.py ├── shell.nix ├── tests/ │ ├── __init__.py │ ├── context.py │ ├── localhost.log.config │ ├── localhost.signing.key │ └── test_registration.py └── tox.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .coveragerc ================================================ [run] source = matrix_registration ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: "🐛 Bug report" description: Report errors or unexpected behavior labels: - bug body: - type: markdown attributes: value: Please make sure to [search for existing issues](https://github.com/zeratax/matrix-registration/issues) before filing a new one! - type: dropdown attributes: label: How did you install matrix-registration? multiple: false options: - pip - direct clone from repo - docker - matrix-docker-ansible-deploy validations: required: true - type: input attributes: label: What python version are you running? placeholder: "3.7" description: Only provide this, when you aren't using docker. validations: required: false - type: input attributes: label: What version of matrix-registration are you running? placeholder: "0.9.7" description: It's fine to write "latest" if you updated recently or "unknown" if your unsure. validations: required: true - type: textarea attributes: label: Your config.yml description: This is not always required - if your are unsure, please provide it. placeholder: DO NOT POST PASSWORDS! validations: required: false - type: textarea attributes: label: Your error log description: Provide it here if you got one. placeholder: | matrix[187]: Traceback (most recent call last): matrix[187]: File "/usr/local/lib/python3.8/site-packages/matrix_registration/app.py", line 9, in matrix[187]: from flask_limiter.util import get_ipaddr matrix[187]: ImportError: cannot import name 'get_ipaddr' from 'flask_limiter.util' systemd[1]: matrix-registration.service: Main process exited, code=exited, status=1/FAILURE render: text validations: required: false - type: dropdown attributes: label: Area of your issue? multiple: false options: - installation - api - general usage - other validations: required: true - type: textarea attributes: label: What happened description: Describe your issue here validations: required: true - type: textarea attributes: label: Steps to reproduce placeholder: Tell us the steps required to trigger your bug. validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: true contact_links: - name: "\U0001F4DA Explanation to all config entries" url: https://github.com/zeratax/matrix-registration/wiki#configuration about: Need help with your config.yaml? - name: "\U0001F4DA Examples for api usage" url: https://github.com/zeratax/matrix-registration/wiki/api about: Need help with using the api? - name: "\U0001F310 Our comunity matrix channel" url: https://matrix.to/#/#matrix-registration:dmnd.sh about: Need general help? ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: "⭐ Feature / enhancement request" description: Suggest an idea for this project labels: - enhancement body: - type: textarea attributes: label: Description of the new feature / enhancement placeholder: | What is the expected behavior of the proposed feature? validations: required: true - type: textarea attributes: label: Scenario when this would be used? placeholder: | What is the scenario this would be used? Is this enhancing to your workflow? Are there benefits for the end-user? validations: required: true - type: textarea attributes: label: Further information placeholder: | Do you want to provide anything else? Screenshots? Additional context? validations: required: false ================================================ FILE: .github/workflows/codeql-analysis.yml ================================================ # For most projects, this workflow file will not need changing; you simply need # to commit it to your repository. # # You may wish to alter this file to override the set of languages analyzed, # or to provide custom queries or build logic. # # ******** NOTE ******** # We have attempted to detect the languages in your repository. Please check # the `language` matrix defined below to confirm you have the correct set of # supported CodeQL languages. # name: "CodeQL" on: push: branches: [ master ] pull_request: # The branches below must be a subset of the branches above branches: [ master ] schedule: - cron: '24 3 * * 3' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] # Learn more: # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed steps: - name: Checkout repository uses: actions/checkout@v2 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # queries: ./path/to/local/query, your-org/your-repo/queries@main # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v1 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines # and modify them (or add more) to build your code if your project # uses a compiled language #- run: | # make bootstrap # make release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v1 ================================================ FILE: .github/workflows/docker.yml ================================================ name: Publish Docker Image on: [ push, pull_request ] jobs: build_image: name: Build Docker Image runs-on: ubuntu-latest outputs: tag: ${{ steps.vars.outputs.tag }} strategy: matrix: arch_name: [ 'x64', 'arm64', 'arm32' ] include: - arch: 'import {}' arch_name: 'x64' - arch: 'import { crossSystem.config = "aarch64-unknown-linux-musl"; }' arch_name: 'arm64' - arch: 'import { crossSystem.config = "armv7l-unknown-linux-musl"; }' arch_name: 'arm32' steps: - name: Check out the repo uses: actions/checkout@v2 - name: Get the version id: vars run: | tag="${GITHUB_REF:10}" if [[ "${tag}" == v* ]]; then echo ::set-output name=tag::$(echo "${tag}") else echo ::set-output name=tag::latest fi - name: Install nix uses: cachix/install-nix-action@v12 with: nix_path: nixpkgs=channel:nixos-22.11 - name: Build docker image env: tag: ${{ steps.vars.outputs.tag }} run: nix-build docker.nix --arg pkgs '${{ matrix.arch }}' --argstr tag "${tag}-${arch_name}" - name: Rename archive run: mv result image_${{ matrix.arch_name }}.tar.gz - uses: actions/upload-artifact@master with: name: image_${{ matrix.arch_name }} path: image_${{ matrix.arch_name }}.tar.gz test_image: name: Test Docker Image runs-on: ubuntu-latest needs: build_image services: postgres: image: postgres env: POSTGRES_PASSWORD: postgres options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - name: Check out the repo uses: actions/checkout@v2 - uses: actions/download-artifact@master with: name: image_x64 - name: Run synapse run: | docker run -d \ -e UID=$(id -u) \ -e GID=$(id -g) \ --volume="$(pwd)/tests:/data" \ --network="${{ job.services.postgres.network }}" \ --name="synapse" \ matrixdotorg/synapse:latest - name: Load docker image run: docker load < image_x64.tar.gz - name: Run docker image id: token env: tag: ${{ needs.build_image.outputs.tag }} run: | echo "test alembic" docker run --rm \ --volume="$(pwd)/tests:/data" \ --network="${{ job.services.postgres.network }}" \ matrix-registration:$tag \ alembic upgrade head echo "create a token" docker run --rm \ --volume="$(pwd)/tests:/data" \ --network="${{ job.services.postgres.network }}" \ matrix-registration:$tag \ matrix-registration generate 1> token echo "serve webpage" docker run -d \ --volume="$(pwd)/tests:/data" \ --network="${{ job.services.postgres.network }}" \ --publish 5000:5000 \ --name="matrix-registration" \ matrix-registration:$tag echo ::set-output name=token::$(cat token) - name: Register test account run: | echo "waiting until matrix-registration is up..." for run in {1..5}; do healthy=$(docker inspect -f "{{.State.Health.Status}}" matrix-registration) echo $healthy if [ "$healthy" = "healthy" ]; then echo "matrix-registration is up!" break else sleep 1 fi done echo "waiting until synapse is up..." for run in {1..60}; do healthy=$(docker inspect -f "{{.State.Health.Status}}" synapse) echo $healthy if [ "$healthy" = "healthy" ]; then echo "synapse is up!" break else sleep 1 fi done # register account curl -fSs \ -F 'username=test' \ -F 'password=verysecure' \ -F 'confirm=verysecure' \ -F 'token=${{ steps.token.outputs.token }}' \ http://matrix-registration:5000/register - name: Registering failed, check logs if: ${{ failure() }} run: | cat tests/mreg.log docker logs synapse - name: Stop manually started containers if: ${{ always() }} run: docker kill $(docker ps -q) push_to_registries: name: Push Docker Image to Multiple Registries runs-on: ubuntu-latest needs: test_image if: startsWith(github.ref, 'refs/tags/v') strategy: matrix: hub: [Github, Docker] include: - hub: Github registry: ghcr.io username: DOCKER_USERNAME password: GITHUB_TOKEN image: "/matrix-registration-image" - hub: Docker registry: docker.io username: DOCKER_USERNAME password: DOCKER_PASSWORD image: "" steps: - uses: actions/download-artifact@master with: name: image # ${GITHUB_REPOSITORY,,} => zeratax/matrix-registration - name: Push to ${{ matrix.hub }} run: | echo "login to registry..." docker login --username="${{secrets[matrix.username]}}" --password="${{secrets[matrix.password]}}" ${{ matrix.registry }} echo "logged in!" arches=(x64 amd64 amd32) image_names=() echo "uploading images for individual arches..." for arch in $arches do image="image_${arch}.tar.gz" echo "check if image '${image}' for arch ${arch} exists..." if [ ! -f "${image}" ]; then break fi echo "image exists!" full_tag="${tag}-${arch}" name="${GITHUB_REPOSITORY,,}${{matrix.image}}" echo "upload image for arch ${arch}..." docker load -i $image docker push ${name}:${full_tag} image_names=+("${name}:${full_tag}") echo "so far uploaded images: '${#image_names[@]}'" done if [ ${#image_names[@]} -eq 0 ]; then echo "no images uploaded!" return 1 fi echo "creating a manifest to associate arch images with multi-arch image..." docker manifest create --amend ${name}:${tag} ${image_names[@]} echo "uploading manifest..." docker manifest push ${name}:${tag} echo "success!" env: tag: ${{ needs.build_image.outputs.tag }} ================================================ FILE: .github/workflows/pypi.yml ================================================ name: Publish Python Package on: push: # Sequence of patterns matched against refs/tags tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 jobs: build-and-publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@master - name: Set up Python 3.9 uses: actions/setup-python@v1 with: python-version: 3.9 - name: Install pypa/build run: >- python -m pip install build --user - name: Build a binary wheel 👷 run: >- python -m build --wheel --outdir dist/ . - name: Publish Python 🐍 distributions 📦 to PyPI uses: pypa/gh-action-pypi-publish@master with: password: ${{ secrets.PYPI_API_TOKEN }} ================================================ FILE: .github/workflows/tests.yml ================================================ name: Tests on: push: branches: - master pull_request: branches: - master jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: [ '3.7', '3.8', '3.9' ] name: Python ${{ matrix.python-version }} steps: - uses: actions/checkout@v2 - name: Setup python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python setup.py develop - name: Lint with flake8 👕 run: | pip install flake8 # stop the build if there are Python syntax errors or undefined names flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide flake8 matrix_registration --per-file-ignores="__init__.py:F401" --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics - name: Test with parameterized unit tests 🚨 run: | python setup.py test ================================================ FILE: .gitignore ================================================ # Project specific *.yaml *.conf !config.sample.yaml !tests/test_config.yaml result data/ !matrix_registration/translations/*.yaml # vscode .vscode/ # nose man/ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging share/ .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ # Virtualenv # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ .Python [Bb]in [Ii]nclude [Ll]ib [Ll]ib64 [Ll]ocal [Ss]cripts pyvenv.cfg .venv pip-selfcheck.json ================================================ FILE: .travis.yml ================================================ language: python matrix: include: - python: 3.7 dist: focal sudo: true - python: 3.8 dist: focal sudo: true - python: 3.9 dist: focal sudo: true install: - pip install tox-travis - pip install coveralls script: tox after_success: coveralls ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at mail@zera.tax. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html [homepage]: https://www.contributor-covenant.org For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq ================================================ FILE: CONTRIBUTING.md ================================================ # CONTRIBUTING ## Code of Conduct See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) ## How Can I Contribute? ### Issues Filling issues is a great and easy way to help find bugs and get new features implemented. #### Bugs If you're reporting a security issue, please email me at security@zera.tax, otherwise if you're reporting a bug, please fill out this [form](https://github.com/ZerataX/matrix-registration/issues/new?labels=bug&template=bug_report.md). #### Feature Requests If you have an idea for a new feature or an enhancement, please fill out this [form](https://github.com/ZerataX/matrix-registration/issues/new?labels=enhancement&template=feature_request.md). #### Translations You can translate the registration page over at https://l10n.dmnd.sh/engage/matrix-registration/ [![Translation status](https://l10n.dmnd.sh/widgets/matrix-registration/-/open-graph.png)](https://l10n.dmnd.sh/engage/matrix-registration/) #### Getting Started To begin working on translating with WebLate, you will need to create an account linked to your GitHub account. From there, you'll be able to see a list of currently translated languages as well as incomplete lines. If you have any further questions about how to contribute, please make an issue on the GitHub page. ### Pull Requests Every PR should not break tests and ideally include tests for added functionality. Also it is recommend to follow the [PEP8](https://www.python.org/dev/peps/pep-0008/) styleguide #### Setting up the Project ```bash git clone https://github.com/ZerataX/matrix-registration.git cd matrix-registration virtualenv -p /usr/bin/python3.6 . source ./bin/activate python setup.py develop cp config.sample.yaml config.yaml ``` and edit config.yaml You can run tests by executing the following command in the project root ```bash python setup.py test ``` ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2018 Jona Abdinghoff Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ [![Build Status](https://travis-ci.org/ZerataX/matrix-registration.svg?branch=master)](https://travis-ci.org/ZerataX/matrix-registration) [![Coverage Status](https://coveralls.io/repos/github/ZerataX/matrix-registration/badge.svg)](https://coveralls.io/github/ZerataX/matrix-registration) [![Translation status](https://l10n.dmnd.sh/widgets/matrix-registration/-/svg-badge.svg)](http://l10n.dmnd.sh/engage/matrix-registration/) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/matrix-registration.svg) [![PyPI](https://img.shields.io/pypi/v/matrix-registration.svg)](https://pypi.org/project/matrix-registration/) [![Docker Pulls](https://img.shields.io/docker/pulls/zeratax/matrix-registration)](https://hub.docker.com/r/zeratax/matrix-registration) [![Matrix](https://img.shields.io/matrix/matrix-registration:dmnd.sh.svg?server_fqdn=matrix.org)](https://matrix.to/#/#matrix-registration:dmnd.sh) # matrix-registration A simple Python application enabling token-based registration for matrix servers. You may have, like me, encountered the situation where you want to invite your friends to create an account on your homeserver, but neither want to open up public registration nor create accounts for every individual user yourself. This project aims to solve this problem. With matrix-registration, you can quickly generate tokens on the fly and share them with your friends to allow them to register on your homeserver. ## Setup Install using pip: ```bash pip3 install matrix-registration ``` or check the [docker guide](https://github.com/ZerataX/matrix-registration/wiki/docker) ### First start To start, execute `matrix-registration`. A configuration file should be generated for you on first start. **Note:** For `server_location` it is recommended to use a local connect, e.g. `localhost:8008` (or whatever port synapse listens to). It is possible however to connect over the internet, but you will need to make sure `/_synapse/admin/v1/register` is accessible.
If the configuration file is not automatically discovered... you can create a configuration by copying [config.sample.yaml](/config.sample.yaml) to your server and editing it: ```bash wget https://raw.githubusercontent.com/ZerataX/matrix-registration/master/config.sample.yaml cp config.sample.yaml config.yaml nano config.yaml ``` Then pass the path to this configuration to the application on startup using `--config-path /path/to/config.yaml`.
__INFO:__ - This only asks you for the most important options. You should definitely take a look at the actual configuration file. The path to the file will be printed by `matrix-registration` the first time it runs. ## Usage ```bash $ matrix-registration -h Usage: matrix-registration [OPTIONS] COMMAND [ARGS]... a token based matrix registration app Options: --config-path TEXT specifies the config file to be used --version Show the flask version -h, --help Show this message and exit. Commands: generate generate new token serve start api server status view status or disable ``` After you've started the API server and [generated a token](https://github.com/ZerataX/matrix-registration/wiki/api#creating-a-new-token) you can register an account either: - with a simple post request, e.g.: ```bash curl -X POST \ -F 'username=test' \ -F 'password=verysecure' \ -F 'confirm=verysecure' \ -F 'token=DoubleWizardSki' \ http://localhost:5000/register ``` - or by visiting http://localhost:5000/register?token=DoubleWizardSki ## Further Resources ### Nginx reverse-proxy If you'd like to run matrix-registration behind a reverse-proxy, here is an example nginx setup: ```nginx location ~ ^/(static|register) { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://localhost:5000; } ``` If you'll be using the [web API](https://github.com/ZerataX/matrix-registration/wiki/api), you'll also need to forward that endpoint. More information on reverse proxying [here](https://github.com/ZerataX/matrix-registration/wiki/reverse-proxy#optional) ### Custom registration page If you want to write your own registration page, you can take a look at the sample in [resources/example.html](resources/example.html) The html page looks for the query paramater `token` and sets the token input field to it's value. this would allow you to directly share links with the token included, e.g.: `https://homeserver.tld/register.html?token=DoubleWizardSki` If you already have a website and want to use your own register page, the [wiki](https://github.com/ZerataX/matrix-registration/wiki/reverse-proxy#advanced) describes a more advanced nginx setup. ### bot if you're looking for a bot to interface with matrix-registration and manage your tokens, take a look at: [maubot-invite](https://github.com/williamkray/maubot-invite) ### Similar projects - [matrix-invite](https://gitlab.com/reivilibre/matrix-invite) live at https://librepush.net/matrix/registration/ - [matrix-register-bot](https://github.com/krombel/matrix-register-bot) using a bot to review accounts before sending out invite links - [MatrixRegistration](https://gitlab.com/olze/matrixregistration/) similar java project using my webui - [Mother Miounne](https://gitlab.com/etke.cc/miounne) "A bridge between matrix and external services", which also integrates matrix-registration For more info check the [wiki](https://github.com/ZerataX/matrix-registration/wiki) ### Artwork attribution - The valley cover photo on the registration page is photo by [Jesús Roncero](https://www.flickr.com/golan) used under the terms of [CC-BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/). No warranties are given. - The font used on the registration page is [Nunito](https://fonts.google.com/specimen/Nunito) which is licensed under [SIL Open Font License, Version 1.1](./matrix_registration/static/fonts/NUNITO-LICENSE). ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions This project is still in active develoment so be aware that only the latest version will receive security fixes. | Version | Supported | | ------- | ------------------ | | latest | :white_check_mark: | | < 1.0 | :x: | ## Reporting a Vulnerability If you happen to find a vulnerability, please send an E-mail using [this template](mailto:mail@zera.tax?Subject=matrix-registration%20vulnerability%20%7Bshort%20description%7D) including theese informations: - What kind of issue is it (leaked data, skipped authentification, ...) - How critical is the issue - Steps to reproduce - If possible: How can we contact you (E-Mail, matrix account) If it's a critical issue we will and try to fix it ASAP (note that this is a hobby project by a very limited number of people) ================================================ FILE: alembic/README ================================================ ================================================ FILE: alembic/env.py ================================================ from logging.config import fileConfig from sqlalchemy import engine_from_config from sqlalchemy import pool from alembic import context import sys from os import getcwd from os.path import abspath, dirname sys.path.insert(0, dirname(dirname(abspath(__file__)))) from matrix_registration import config as mr_config # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config # Interpret the config file for Python logging. # This line sets up loggers basically. fileConfig(config.config_file_name) # load matrix-registration config and set db path for alembic config_path = context.get_x_argument(as_dictionary=True).get("config") or "config.yaml" mr_config.config = mr_config.Config(config_path) config.set_main_option("sqlalchemy.url", mr_config.config.db.replace("{cwd}", f"{getcwd()}")) print(config.get_main_option("sqlalchemy.url")) # add your model's MetaData object here # for 'autogenerate' support # target_metadata = mymodel.Base.metadata target_metadata = None # other values from the config, defined by the needs of env.py, # can be acquired: # my_important_option = config.get_main_option("my_important_option") # ... etc. def run_migrations_offline(): """Run migrations in 'offline' mode. This configures the context with just a URL and not an Engine, though an Engine is acceptable here as well. By skipping the Engine creation we don't even need a DBAPI to be available. Calls to context.execute() here emit the given string to the script output. """ url = config.get_main_option("sqlalchemy.url") context.configure( url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, ) with context.begin_transaction(): context.run_migrations() def run_migrations_online(): """Run migrations in 'online' mode. In this scenario we need to create an Engine and associate a connection with the context. """ connectable = engine_from_config( config.get_section(config.config_ini_section), prefix="sqlalchemy.", poolclass=pool.NullPool, ) with connectable.connect() as connection: context.configure( connection=connection, target_metadata=target_metadata, render_as_batch=config.get_main_option('sqlalchemy.url').startswith('sqlite:///') ) with context.begin_transaction(): context.run_migrations() if context.is_offline_mode(): run_migrations_offline() else: run_migrations_online() ================================================ FILE: alembic/script.py.mako ================================================ """${message} Revision ID: ${up_revision} Revises: ${down_revision | comma,n} Create Date: ${create_date} """ from alembic import op import sqlalchemy as sa ${imports if imports else ""} # revision identifiers, used by Alembic. revision = ${repr(up_revision)} down_revision = ${repr(down_revision)} branch_labels = ${repr(branch_labels)} depends_on = ${repr(depends_on)} def upgrade(): ${upgrades if upgrades else "pass"} def downgrade(): ${downgrades if downgrades else "pass"} ================================================ FILE: alembic/versions/130b5c2275d8_update_ip_token_association.py ================================================ '''update ip token association Revision ID: 130b5c2275d8 Revises: 140a25d5f185 Create Date: 2021-07-10 20:40:46.937634 ''' from alembic import op import sqlalchemy as sa from sqlalchemy import Table, Column, Integer, String, ForeignKey from sqlalchemy.engine.reflection import Inspector from flask_sqlalchemy import SQLAlchemy # revision identifiers, used by Alembic. revision = '130b5c2275d8' down_revision = '140a25d5f185' branch_labels = None depends_on = None db = SQLAlchemy() conn = op.get_bind() def upgrade(): ips = conn.execute('select id, address from ips').fetchall() associations = conn.execute('select ips, tokens from association').fetchall() final_associations = [] for association in associations: association_ip, association_token = association for ip in ips: id, ip_address = ip if ip_address == association_ip: final_associations.append({'ips': id, 'tokens': association_token}) op.drop_table('association') association = op.create_table( 'association', db.Model.metadata, Column('ips', Integer, ForeignKey('ips.id'), primary_key=True), Column('tokens', String(255), ForeignKey('tokens.name'), primary_key=True) ) op.bulk_insert(association, final_associations) def downgrade(): ips = conn.execute('select id, address from ips').fetchall() associations = conn.execute('select ips, tokens from association').fetchall() final_associations = [] for association in associations: association_ip, association_token = association for ip in ips: id, ip_address = ip if id == association_ip: final_associations.append({'ips': ip_address, 'tokens': association_token}) op.drop_table('association') association = op.create_table( 'association', db.Model.metadata, Column('ips', String(255), ForeignKey('ips.address'), primary_key=True), Column('tokens', String(255), ForeignKey('tokens.name'), primary_key=True) ) op.bulk_insert(association, final_associations) ================================================ FILE: alembic/versions/140a25d5f185_create_tokens_table.py ================================================ """create tokens table Revision ID: 140a25d5f185 Revises: Create Date: 2020-12-12 01:44:28.195736 """ from alembic import op import sqlalchemy as sa from sqlalchemy import Table, Column, Integer, String, Boolean, DateTime, ForeignKey from sqlalchemy.engine.reflection import Inspector from flask_sqlalchemy import SQLAlchemy # revision identifiers, used by Alembic. revision = '140a25d5f185' down_revision = None branch_labels = None depends_on = None db = SQLAlchemy() def upgrade(): conn = op.get_bind() inspector = Inspector.from_engine(conn) tables = inspector.get_table_names() if 'ips' not in tables: op.create_table( 'ips', sa.Column('id', sa.Integer, primary_key=True), sa.Column('address', sa.String(255), nullable=True) ) if 'tokens' not in tables: op.create_table( 'tokens', sa.Column('name', String(255), primary_key=True), sa.Column('expiration_date', DateTime, nullable=True), sa.Column('max_usage', Integer, default=1), sa.Column('used', Integer, default=0), sa.Column('disabled', Boolean, default=False), sa.Column('ips', Integer, ForeignKey('association.id')) ) else: try: with op.batch_alter_table('tokens') as batch_op: batch_op.alter_column('ex_date', new_column_name='expiration_date', nullable=True) batch_op.alter_column('one_time', new_column_name='max_usage') batch_op.add_column( Column('disabled', Boolean, default=False) ) except KeyError: pass if 'association' not in tables: op.create_table( 'association', db.Model.metadata, Column('ips', String, ForeignKey('ips.address'), primary_key=True), Column('tokens', Integer, ForeignKey('tokens.name'), primary_key=True) ) op.execute("update tokens set expiration_date=null where expiration_date='None'") def downgrade(): op.alter_column('tokens', 'expiration_date', new_column_name='ex_date') op.alter_column('tokens', 'max_usage', new_column_name='one_time') ================================================ FILE: alembic.ini ================================================ # A generic, single database configuration. [alembic] # path to migration scripts script_location = alembic # template used to generate migration files # file_template = %%(rev)s_%%(slug)s # timezone to use when rendering the date # within the migration file as well as the filename. # string value is passed to dateutil.tz.gettz() # leave blank for localtime # timezone = # max length of characters to apply to the # "slug" field #truncate_slug_length = 40 # set to 'true' to run the environment during # the 'revision' command, regardless of autogenerate # revision_environment = false # set to 'true' to allow .pyc and .pyo files without # a source .py file to be detected as revisions in the # versions/ directory # sourceless = false # version location specification; this defaults # to alembic/versions. When using multiple version # directories, initial revisions must be specified with --version-path # version_locations = %(here)s/bar %(here)s/bat alembic/versions # the output encoding used when revision files # are written from script.py.mako # output_encoding = utf-8 # Logging configuration [loggers] keys = root,sqlalchemy,alembic [handlers] keys = console [formatters] keys = generic [logger_root] level = WARN handlers = console qualname = [logger_sqlalchemy] level = WARN handlers = qualname = sqlalchemy.engine [logger_alembic] level = INFO handlers = qualname = alembic [handler_console] class = StreamHandler args = (sys.stderr,) level = NOTSET formatter = generic [formatter_generic] format = %(levelname)-5.5s [%(name)s] %(message)s datefmt = %H:%M:%S ================================================ FILE: config.sample.yaml ================================================ server_location: 'http://localhost:8008' server_name: 'matrix.org' registration_shared_secret: 'RegistrationSharedSecret' # see your synapse's homeserver.yaml admin_api_shared_secret: 'APIAdminPassword' # to generate tokens via the web api base_url: '' # e.g. '/element' for https://example.tld/element/register client_redirect: 'https://app.element.io/#/login' client_logo: 'static/images/element-logo.png' # use '{cwd}' for current working directory db: 'sqlite:///{cwd}/db.sqlite3' host: 'localhost' port: 5000 rate_limit: ["100 per day", "10 per minute"] allow_cors: false ip_logging: false logging: disable_existing_loggers: false version: 1 root: level: DEBUG handlers: [console, file] formatters: brief: format: '%(name)s - %(levelname)s - %(message)s' precise: format: '%(asctime)s - %(name)s - %(levelname)s - %(message)s' handlers: console: class: logging.StreamHandler level: INFO formatter: brief stream: ext://sys.stdout file: class: logging.handlers.RotatingFileHandler formatter: precise level: INFO filename: m_reg.log maxBytes: 10485760 # 10MB backupCount: 3 encoding: utf8 # password requirements password: min_length: 8 # username requirements username: validation_regex: [] #list of regexes that the selected username must match. Example: '[a-zA-Z]\.[a-zA-Z]' invalidation_regex: [] #list of regexes that the selected username must NOT match. Example: '(admin|support)' ================================================ FILE: default.nix ================================================ { pkgs ? import { } }: with pkgs.python3.pkgs; buildPythonPackage rec { name = "matrix-registration"; src = builtins.path { inherit name; path = ./.; }; postPatch = '' sed -i -e '/alembic>/d' setup.py ''; propagatedBuildInputs = [ appdirs flask flask-cors flask-httpauth flask-limiter flask_sqlalchemy jsonschema python-dateutil pyyaml requests waitress wtforms psycopg2 ]; checkInputs = [ parameterized ]; } ================================================ FILE: docker.nix ================================================ { pkgs ? import { }, tag ? "latest" }: let matrix-registration-config = "/data/config.yaml"; python3 = let packageOverrides = self: super: rec { alembic = super.alembic.overridePythonAttrs (old: { makeWrapperArgs = [ "--chdir '${matrix-registration}'" ''--add-flags "-x config='${matrix-registration-config}'"'' ]; }); matrix-registration = (import ./default.nix { inherit pkgs; }).overridePythonAttrs (old: { makeWrapperArgs = [ ''--add-flags "--config-path='${matrix-registration-config}'"'' ]; }); }; in pkgs.python3.override { inherit packageOverrides; # enableOptimizations = true; # reproducibleBuild = false; self = python3; }; python-packages = ps: with ps; [ matrix-registration alembic ]; in pkgs.dockerTools.buildImage { name = "matrix-registration"; tag = tag; created = "now"; copyToRoot = python3.withPackages python-packages; config = { CMD = [ "matrix-registration" "serve"]; WorkingDir = "/data"; Volumes = { "/data" = { }; }; ExposedPorts = { "5000/tcp" = { }; }; HealthCheck = { Interval = 3000000000; Timeout = 1000000000; StartPeriod = 3000000000; Test = [ "CMD" "${pkgs.curl}/bin/curl" "-fSs" "http://localhost:5000/health" ]; }; }; } ================================================ FILE: matrix_registration/__init__.py ================================================ from . import api from . import tokens from . import config __version__ = "0.9.2.dev3" name = "matrix_registration" ================================================ FILE: matrix_registration/api.py ================================================ # Standard library imports... import logging import os import re from datetime import datetime # Third-party imports... from flask import ( Blueprint, abort, jsonify, request, make_response, render_template, send_file, ) from flask_httpauth import HTTPTokenAuth from requests import exceptions from werkzeug.exceptions import BadRequest from wtforms import Form, StringField, PasswordField, validators # Local imports... from . import config from . import tokens from .constants import __location__ from .limiter import limiter, get_default_rate_limit from .matrix_api import create_account from .translation import get_translations auth = HTTPTokenAuth(scheme="SharedSecret") logger = logging.getLogger(__name__) api = Blueprint("api", __name__) healthcheck = Blueprint("healthcheck", __name__) limiter.limit(get_default_rate_limit)(api) def validate_token(form, token): """ validates token Parameters ---------- arg1 : Form object arg2 : str token name, e.g. 'DoubleWizardSki' Raises ------- ValidationError Token is invalid """ tokens.tokens.load() if not tokens.tokens.active(token.data): raise validators.ValidationError("Token is invalid") def validate_username(form, username): """ validates username Parameters ---------- arg1 : Form object arg2 : str username name, e.g: '@user:matrix.org' or 'user' https://github.com/matrix-org/matrix-doc/blob/master/specification/appendices/identifier_grammar.rst#user-identifiers Raises ------- ValidationError Username doesn't follow mxid requirements """ re_mxid = f"^(?P@)?(?P[a-zA-Z_\-=\.\/0-9]+)(?P:{re.escape(config.config.server_name)})?$" match = re.search(re_mxid, username.data) if not match: raise validators.ValidationError( f"Username doesn't follow mxid pattern: /{re_mxid}/" ) username = match.group("username") for e in [ validators.ValidationError(f"Username does not follow custom pattern /{x}/") for x in config.config.username["validation_regex"] if not re.search(x, username) ]: raise e for e in [ validators.ValidationError(f"Username must not follow custom pattern /{x}/") for x in config.config.username["invalidation_regex"] if re.search(x, username) ]: raise e def validate_password(form, password): """ validates username Parameters ---------- arg1 : Form object arg2 : str password Raises ------- ValidationError Password doesn't follow length requirements """ min_length = config.config.password["min_length"] err = "Password should be between %s and 255 chars long" % min_length if len(password.data) < min_length or len(password.data) > 255: raise validators.ValidationError(err) class RegistrationForm(Form): """ Registration Form validates user account registration requests """ username = StringField( "Username", [ validators.Length(min=1, max=200), # validators.Regexp(re_mxid) validate_username, ], ) password = PasswordField( "New Password", [ # validators.Length(min=8), validate_password, validators.DataRequired(), validators.EqualTo("confirm", message="Passwords must match"), ], ) confirm = PasswordField("Repeat Password") token = StringField( "Token", [validators.Regexp(r"^([A-Z][a-z]+)+$"), validate_token] ) def get_request_ips(request): """ Get the chain of client and proxy IP addresses from the request as a nonempty list, where the closest IP in the chain is last. Each IP vouches only for the IP before it. This works best if all proxies conform the to the X-Forwarded-For header spec, including whatever reverse proxy (such as nginx) is directly in front of the app, if any. (X-Real-IP and similar are not supported at this time.) """ return request.headers.getlist("X-Forwarded-For") + [request.remote_addr] @auth.verify_token def verify_token(token): return ( token != "APIAdminPassword" and token == config.config.admin_api_shared_secret ) @auth.error_handler def unauthorized(): resp = {"errcode": "MR_BAD_SECRET", "error": "wrong shared secret"} return make_response(jsonify(resp), 401) @api.route("/static/replace/images/element-logo.png") def element_logo(): return send_file( config.config.client_logo.replace("{cwd}", f"{os.getcwd()}/"), mimetype="image/jpeg", ) @api.route("/register", methods=["GET", "POST"]) def register(): """ main user account registration endpoint to register an account you need to send a application/x-www-form-urlencoded request with - username - password - confirm - token as described in the RegistrationForm """ if request.method == "POST": logger.debug("an account registration started...") form = RegistrationForm(request.form) logger.debug("validating request data...") if form.validate(): logger.debug("request valid") return create_account_from_form(form) logger.debug("account creation failed!") resp = {"errcode": "MR_BAD_USER_REQUEST", "error": form.errors} return make_response(jsonify(resp), 400) # GET REQUEST server_name = config.config.server_name pw_length = config.config.password["min_length"] uname_regex = config.config.username["validation_regex"] uname_regex_inv = config.config.username["invalidation_regex"] lang = request.args.get("lang") or request.accept_languages.best replacements = {"server_name": server_name, "pw_length": pw_length} translations = get_translations(lang, replacements) return render_template( "register.html", server_name=server_name, pw_length=pw_length, uname_regex=uname_regex, uname_regex_inv=uname_regex_inv, client_redirect=config.config.client_redirect, base_url=config.config.base_url, translations=translations, ) def create_account_from_form(form): # remove sigil and the domain from the username username = form.username.data.rsplit(":")[0].split("@")[-1] logger.debug("creating account %s..." % username) # send account creation request to the hs try: account_data = create_account( form.username.data, form.password.data, config.config.server_location, config.config.registration_shared_secret, ) except exceptions.ConnectionError: logger.error( "can not connect to %s" % config.config.server_location, exc_info=True, ) abort(500) except exceptions.HTTPError as e: resp = e.response error = resp.json() status_code = resp.status_code if status_code == 404: logger.error("no HS found at %s" % config.config.server_location) elif status_code == 403: logger.error("wrong shared registration secret or not enabled") elif status_code == 400: # most likely this should only be triggered if a userid # is already in use return make_response(jsonify(error), 400) else: logger.error("failure communicating with HS", exc_info=True) abort(500) logger.debug("using token %s" % form.token.data) ips = ", ".join(get_request_ips(request)) if config.config.ip_logging else False tokens.tokens.use(form.token.data, ips) logger.debug("account creation succeded!") return jsonify( access_token=account_data["access_token"], home_server=account_data["home_server"], user_id=account_data["user_id"], status="success", status_code=200, ) def get_token(token): if tokens.tokens.get_token(token): return jsonify(tokens.tokens.get_token(token).toDict()) resp = {"errcode": "MR_TOKEN_NOT_FOUND", "error": "token does not exist"} return make_response(jsonify(resp), 404) def get_tokens(): return jsonify(tokens.tokens.toList()) def create_token(data): if not data: resp = { "errcode": "MR_BAD_USER_REQUEST", "error": "no data was sent", } return make_response(jsonify(resp), 400) max_usage = False expiration_date = None try: if "expiration_date" in data and data["expiration_date"] is not None: expiration_date = datetime.fromisoformat(data["expiration_date"]) if "max_usage" in data: max_usage = data["max_usage"] token = tokens.tokens.new(expiration_date=expiration_date, max_usage=max_usage) except ValueError: resp = { "errcode": "MR_BAD_DATE_FORMAT", "error": "date wasn't in YYYY-MM-DD format", } return make_response(jsonify(resp), 400) return jsonify(token.toDict()) def update_token(token, data): if "ips" in data or "active" in data or "name" in data: resp = { "errcode": "MR_BAD_USER_REQUEST", "error": "you're not allowed to change this property", } return make_response(jsonify(resp), 400) if tokens.tokens.update(token, data): return jsonify(tokens.tokens.get_token(token).toDict()) resp = {"errcode": "MR_TOKEN_NOT_FOUND", "error": "token does not exist"} return make_response(jsonify(resp), 404) def delete_token(token): if not tokens.tokens.get_token(token): resp = {"errcode": "MR_TOKEN_NOT_FOUND", "error": "token does not exist"} return (jsonify(resp), 404) if tokens.tokens.delete(token): resp = {"success": "true"} return make_response(jsonify(resp), 200) resp = {"success": "false"} return make_response(jsonify(resp), 500) @healthcheck.route("/health") def health(): return make_response("OK", 200) @api.route("/api/version") @auth.login_required def version(): with open(os.path.join(__location__, "__init__.py"), "r") as file: version_file = file.read() version_match = re.search( r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M ) resp = {"version": version_match.group(1)} return make_response(jsonify(resp), 200) @api.route("/api/token", methods=["GET", "POST"]) @auth.login_required def token(): tokens.tokens.load() if request.method == "GET": return get_tokens() elif request.method == "POST": return create_token(request.get_json()) resp = {"errcode": "MR_BAD_USER_REQUEST", "error": "malformed request"} return make_response(jsonify(resp), 400) @api.route("/api/token/", methods=["GET", "PATCH", "DELETE"]) @auth.login_required def token_status(token): tokens.tokens.load() data = False if request.method == "GET": return get_token(token) elif request.method == "PATCH": return update_token(token, request.get_json()) elif request.method == "DELETE": return delete_token(token) resp = {"errcode": "MR_BAD_USER_REQUEST", "error": "malformed request"} return make_response(jsonify(resp), 400) ================================================ FILE: matrix_registration/app.py ================================================ import json import logging import logging.config import os import click from flask import Flask from flask.cli import FlaskGroup, pass_script_info from flask_cors import CORS from waitress import serve from . import config from . import tokens from .limiter import limiter from .tokens import db def create_app(testing=False): app = Flask(__name__) app.testing = testing with app.app_context(): from .api import api, healthcheck app.register_blueprint(api) app.register_blueprint(healthcheck) limiter.init_app(app) return app @click.group( cls=FlaskGroup, add_default_commands=False, create_app=create_app, context_settings=dict(help_option_names=["-h", "--help"]), ) @click.option("--config-path", help="specifies the config file to be used") @pass_script_info def cli(info, config_path): """a token based matrix registration app""" config.config = config.Config(path=config_path) logging.config.dictConfig(config.config.logging) app = info.load_app() with app.app_context(): app.config.from_mapping( SQLALCHEMY_DATABASE_URI=config.config.db.format(cwd=f"{os.getcwd()}"), SQLALCHEMY_TRACK_MODIFICATIONS=False, ) db.init_app(app) db.create_all() tokens.tokens = tokens.Tokens() @cli.command("serve", help="start api server") @pass_script_info def run_server(info): app = info.load_app() if config.config.allow_cors: CORS(app) serve( app, host=config.config.host, port=config.config.port, url_prefix=config.config.base_url, ) @cli.command("generate", help="generate new token") @click.option("-m", "--maximum", default=0, help="times token can be used") @click.option( "-e", "--expires", type=click.DateTime(formats=["%Y-%m-%d"]), default=None, help="expire date: in ISO-8601 format (YYYY-MM-DD)", ) def generate_token(maximum, expires): token = tokens.tokens.new(expiration_date=expires, max_usage=maximum) print(token.name) @cli.command("status", help="view status or disable") @click.option("-s", "--status", default=None, help="token status") @click.option("-l", "--list", is_flag=True, help="list tokens") @click.option("-d", "--disable", default=None, help="disable token") def status_token(status, list, disable): if disable: if tokens.tokens.disable(disable): print("Token disabled") else: print("Token couldn't be disabled") elif status: token = tokens.tokens.get_token(status) if token: print(f"This token is{' ' if token.active() else ' not '}valid") print(json.dumps(token.toDict(), indent=2)) else: print("No token with that name") elif list: print(tokens.tokens) ================================================ FILE: matrix_registration/config.py ================================================ # Standard library imports... # from collections import namedtuple import logging import os import sys # Third-party imports... import yaml from jsonschema import validate, ValidationError # Local imports... from .constants import ( CONFIG_SCHEMA_PATH, CONFIG_DIR1, CONFIG_DIR2, CONFIG_DIR3, CONFIG_DIR4, CONFIG_DIR5, ) CONFIG_DIRS = [CONFIG_DIR1, CONFIG_DIR2, CONFIG_DIR3, CONFIG_DIR4, CONFIG_DIR5] CONFIG_SAMPLE_NAME = "config.sample.yaml" CONFIG_NAME = "config.yaml" logger = logging.getLogger(__name__) class Config: """ Config loads a dict or a yaml file to be accessible by all files in the module """ def __init__(self, path=None, data=None): self.secrets_dir = os.getenv("CREDENTIALS_DIRECTORY") self.data = data self.path = path self.default = True self.load() if self.secrets_dir: self.load_secrets() self.apply_options() def load(self): """ loads the options """ logger.debug("loading config...") if self.data: logger.debug("from dict...") config_default = False return logger.debug("from file...") self.load_from_file() def load_from_file(self): """ loads the options from a file """ options = None if not self.check_config_locations(): sys.exit("could not find any configuration file!") logger.debug(f"config found!") try: with open(self.path, "r") as stream: options = yaml.load(stream, Loader=yaml.SafeLoader) with open(CONFIG_SCHEMA_PATH, "r") as schemafile: validate(options, yaml.safe_load(schemafile)) except ValidationError as e: sys.exit( "Check you config and update it to the newest version! Do you have missing fields in your config.yaml?\n\nTraceback:\n" + str(e) ) except yaml.YAMLError as e: sys.exit("Invalid YAML Syntax\n\nTraceback:\n" + str(e)) except IOError as e: sys.exit(e) if not options: sys.exit("could not read file") if self.default: # ask for options that should not be set to default options = self.ask_for_options(options) self.data = options def load_secrets(self): """ loads secret options, see https://systemd.io/CREDENTIALS/ """ with open(f"{self.secrets_dir}/secrets") as file: for line in file: try: k, v = line.lower().split("=") except NameError: logger.error( f'secret "{line}" in wrong format, please use "key=value"' ) setattr(self, k.strip(), v.strip()) def check_config_locations(self): """ checks multiple locations for the config or config sample file """ if self.path: logger.debug(f"checking {self.path} ...") if os.path.isfile(self.path): self.default = False return True else: sys.exit("no configuration file at specified location") # check possible locations for config file for directory in CONFIG_DIRS: logger.debug(f"checking {directory} ...") path = directory + CONFIG_SAMPLE_NAME if os.path.isfile(path): self.path = path self.default = False return True # no config exists, use sample config instead # check typical installation dirs for sample configs for directory in CONFIG_DIRS: path = directory + CONFIG_SAMPLE_NAME if os.path.isfile(directory + CONFIG_SAMPLE_NAME): self.path = path self.config_exists = True return True return False def apply_options(self): """ applies options to the config object """ logger.debug("applying options...") # recusively set dictionary to class properties for k, v in self.data.items(): setattr(self, k, v) def ask_for_options(self, sample_options): """ asks the user how to set the essential options Parameters ---------- arg1 : dict with default values """ # important keys that need to be changed keys = ["server_location", "server_name", "port", "registration_shared_secret"] for key in keys: temp = sample_options[key] sample_options[key] = input("enter {}, e.g. {}\n".format(key, temp)) if not sample_options[key].strip(): sample_options[key] = temp return sample_options # write to config file directory = os.path.dirname(os.path.realpath(self.path)) new_path = f"{directory}/{CONFIG_NAME}" with open(new_path, "w") as stream: yaml.dump(self.data, stream, default_flow_style=False) print(f'config file written to "{os.path.relpath(new_path)}"') print() def update(self, data): """ resets all options and loads the new config Parameters ---------- arg1 : dict or path to config file """ logger.debug("updating config...") self.data = data self.path = None self.load() if self.secrets_dir: self.load_secrets() self.apply_options() logger.debug("config updated!") config = None ================================================ FILE: matrix_registration/config.schema.json ================================================ { "type": "object", "properties": { "server_location": { "type": "string", "format": "uri", "pattern": "^https?://" }, "server_name": { "type": "string" }, "registration_shared_secret": { "type": "string" }, "admin_api_shared_secret": { "type": "string" }, "base_url": { "type": "string" }, "client_redirect": { "type": "string" }, "client_logo": { "type": "string" }, "db": { "type": "string" }, "host": { "type": "string" }, "port": { "oneOf": [ {"type": "integer"}, {"type": "string", "pattern": "^/d+$"} ] }, "rate_limit": { "type": "array", "items": { "type": "string" } }, "allow_cors": { "type": "boolean" }, "ip_logging": { "type": "boolean" }, "logging": { "type": "object" }, "password": { "type": "object", "properties": { "min_length": { "type": "integer" } }, "required": [ "min_length" ] }, "username": { "type": "object", "properties": { "validation_regex": { "type": "array", "items": { "type": "string" } }, "invalidation_regex": { "type": "array", "items": { "type": "string" } } }, "required": [ "validation_regex", "invalidation_regex" ] } }, "required": [ "server_location", "server_name", "registration_shared_secret", "admin_api_shared_secret", "base_url", "client_redirect", "client_logo", "db", "host", "port", "rate_limit", "allow_cors", "ip_logging", "logging", "password", "username" ] } ================================================ FILE: matrix_registration/constants.py ================================================ # Standard library imports... import os import site import sys # Third-party imports... from appdirs import user_config_dir __location__ = os.path.realpath(os.path.join(os.getcwd(), os.path.dirname(__file__))) WORD_LIST_PATH = os.path.join(__location__, "wordlist.txt") CONFIG_SCHEMA_PATH = os.path.join(__location__, "config.schema.json") # first check in current working dir CONFIG_DIR1 = os.path.join(os.getcwd() + "/") CONFIG_DIR2 = os.path.join(os.getcwd() + "/config/") # then check in XDG_CONFIG_HOME CONFIG_DIR3 = os.path.join(user_config_dir("matrix-registration") + "/") # check at installed location CONFIG_DIR4 = os.path.join(__location__, "../") CONFIG_DIR5 = os.path.join(sys.prefix, "config/") ================================================ FILE: matrix_registration/limiter.py ================================================ from flask import request from flask_limiter import Limiter from . import config def get_real_user_ip() -> str: """ratelimit the users original ip instead of (optional) reverse proxy""" return next(iter(request.headers.getlist("X-Forwarded-For")), request.remote_addr) def get_default_rate_limit() -> str: """return limit_string""" return "; ".join(config.config.rate_limit) limiter = Limiter(key_func=get_real_user_ip) ================================================ FILE: matrix_registration/matrix_api.py ================================================ # Standard library imports... import hashlib import hmac import requests import logging logger = logging.getLogger(__name__) def create_account( user, password, server_location, shared_secret, admin=False, user_type=None ): """ creates account https://github.com/matrix-org/synapse/blob/master/synapse/_scripts/register_new_matrix_user.py Parameters ---------- arg1 : str local part of the new user arg2 : str password arg3 : str url to homeserver arg4 : str Registration Shared Secret as set in the homeserver.yaml arg5 : bool register new user as an admin. Raises ------- requests.exceptions.ConnectionError: can't connect to homeserver requests.exceptions.HTTPError: something with the communciation to the homeserver failed """ nonce = _get_nonce(server_location) mac = hmac.new(key=shared_secret.encode("utf8"), digestmod=hashlib.sha1) mac.update(nonce.encode("utf8")) mac.update(b"\x00") mac.update(user.encode("utf8")) mac.update(b"\x00") mac.update(password.encode("utf8")) mac.update(b"\x00") mac.update(b"admin" if admin else b"notadmin") if user_type: mac.update(b"\x00") mac.update(user_type.encode("utf8")) mac = mac.hexdigest() data = { "nonce": nonce, "username": user, "password": password, "mac": mac, "admin": admin, "user_type": user_type, } server_location = server_location.rstrip("/") r = requests.post("%s/_synapse/admin/v1/register" % (server_location), json=data) r.raise_for_status() return r.json() def _get_nonce(server_location): r = requests.get("%s/_synapse/admin/v1/register" % (server_location)) r.raise_for_status() return r.json()["nonce"] ================================================ FILE: matrix_registration/static/css/style.css ================================================ html, body { height: 100%; margin: 0; font-family: 'Nunito', sans-serif; } body { background-size: cover; background-attachment: fixed; overflow: hidden; } h1 { font-size: 1.3em; } article { color: white; } a:link, a:visited { color: #038db3 !important; } form { width: 320px; margin: 45px auto; } textarea { resize: none; } input, textarea { background: none; color: white; font-size: 18px; padding: 10px 10px 10px 5px; display: block; width: 320px; border: none; border-radius: 0; border-bottom: 1px solid white; } input:focus, textarea:focus { outline: none; } input:focus~label, input:not(:placeholder-shown)~label, textarea:focus~label, textarea:valid~label { top: -14px; font-size: 12px; color: #03b381; } input:focus~.bar:before, textarea:focus~.bar:before { width: 320px; } input[type="password"] { letter-spacing: 0.3em; } input:invalid { box-shadow: none; } input:invalid~.bar:before { background: #038db3; } input:invalid~label { color: #038db3; } input[type="submit"] { cursor: pointer; } label { color: white; font-size: 16px; font-weight: normal; position: absolute; pointer-events: none; left: 5px; top: 10px; transition: 300ms ease all; } *, :before, :after { box-sizing: border-box; } .center { text-align: center; margin-top: 2em; } .hidden { visibility: hidden; opacity: 0; } .group { position: relative; margin: 45px 0; } .bar { position: relative; display: block; width: 320px; } .bar:before { content: ''; height: 2px; width: 0; bottom: 0px; position: absolute; background: #03b381; transition: 300ms ease all; left: 0%; } .btn { background: white; color: black; border: none; padding: 10px 20px; border-radius: 3px; letter-spacing: 0.06em; text-transform: uppercase; text-decoration: none; outline: none; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); } .btn:hover { color: black; box-shadow: 0 7px 14px rgba(0, 0, 0, 0.18), 0 5px 5px rgba(0, 0, 0, 0.12); } .btn.btn-submit { background: #03b381; color: #bce0fb; } .btn.btn-submit:hover { background: #03b372; color: #deeffd; } .btn-box { text-align: center; margin: 50px 0; } .info { z-index: 2; position: absolute; bottom: .5vh; right: 1vw; text-align: left; color: grey; font-size: 0.8em; opacity: 0.1; transition: opacity 0.5s ease; } .info:hover { opacity: 1; } .info a { color: cyan; } .widget { position: absolute; left: 50%; top: 50%; border: 0px solid; border-radius: 5px; overflow: hidden; background-color: #1f1f1f; z-index: 1; box-shadow: 2px 2px 5px 0px rgba(0, 0, 0, 0.5); } .widget::before { position: absolute; top: 0; left: 0; z-index: -1; width: 100%; height: 100%; background-attachment: fixed; background-size: cover; opacity: 0.20; content: ""; } .blur:before { content: ""; position: absolute; width: 100%; height: 100%; background: inherit; z-index: -1; transform: scale(1.03); filter: blur(10px); } .register { margin-left: -15em; margin-top: -20em; width: 30em; height: 40em; } .modal { margin-left: -12.5em; margin-top: -7.5em; width: 25em; background-color: #f7f7f7; transition: visibility .3s, opacity .3s linear; } .modal article { margin-top: -5em; } .modal article, .modal p, .modal h2, .modal h3 { color: #1f1f1f; } .error { color: #b30335 !important; } @media only screen and (max-width: 500px) { .info { bottom: -2vh; } .widget { margin-top: -40vh; margin-left: -45vw; width: 90vw; min-width: 20em; } .modal { margin-top: -15vh; margin-left: -35vw; width: 70vw; min-width: 15em; } } @media only screen and (max-height: 768px) { body { overflow-y: visible; padding-bottom: -90vh; } .blur:before { filter: none; transform: none; padding-bottom: 50em; } .info { float: right; padding-top: 57em; position: static; } .widget { margin-top: -40vh; } .modal { margin-top: -15vh; } } ================================================ FILE: matrix_registration/static/fonts/NUNITO-LICENSE ================================================ Copyright 2014 The Nunito Project Authors (https://github.com/googlefonts/nunito) This Font Software is licensed under the SIL Open Font License, Version 1.1. This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL ----------------------------------------------------------- SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ----------------------------------------------------------- PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. DEFINITIONS "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. "Reserved Font Name" refers to any names specified as such after the copyright statement(s). "Original Version" refers to the collection of Font Software components as distributed by the Copyright Holder(s). "Modified Version" refers to any derivative made by adding to, deleting, or substituting -- in part or in whole -- any of the components of the Original Version, by changing formats or by porting the Font Software to a new environment. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. 2) Original or Modified Versions of the Font Software may be bundled, redistributed and/or sold with any software, provided that each copy contains the above copyright notice and this license. These can be included either as stand-alone text files, human-readable headers or in the appropriate machine-readable metadata fields within text or binary files as long as those fields can be easily viewed by the user. 3) No Modified Version of the Font Software may use the Reserved Font Name(s) unless explicit written permission is granted by the corresponding Copyright Holder. This restriction only applies to the primary font name as presented to the users. 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software shall not be used to promote, endorse or advertise any Modified Version, except to acknowledge the contribution(s) of the Copyright Holder(s) and the Author(s) or with their explicit written permission. 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. TERMINATION This license becomes null and void if any of the above conditions are not met. DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE. ================================================ FILE: matrix_registration/templates/register.html ================================================ {{ translations.server_registration }}

{{ translations.server_registration }}

{{ translations.requires_token }}
{{ translations.requires_username_and_password }}

================================================ FILE: matrix_registration/tokens.py ================================================ # Standard library imports... from datetime import datetime import logging import random from flask_sqlalchemy import SQLAlchemy from sqlalchemy import ( exc, Table, Column, Integer, String, Boolean, DateTime, ForeignKey, ) from sqlalchemy.orm import relationship # Local imports... from .constants import WORD_LIST_PATH logger = logging.getLogger(__name__) db = SQLAlchemy() session = db.session def random_readable_string(length=3, wordlist=WORD_LIST_PATH): with open(wordlist) as f: lines = f.read().splitlines() string = "" for _ in range(length): string += random.choice(lines).title() return string association_table = Table( "association", db.Model.metadata, Column("ips", Integer, ForeignKey("ips.id"), primary_key=True), Column("tokens", String(255), ForeignKey("tokens.name"), primary_key=True), ) class IP(db.Model): __tablename__ = "ips" id = Column(Integer, primary_key=True) address = Column(String(255)) def __repr__(self): return self.address class Token(db.Model): __tablename__ = "tokens" name = Column(String(255), primary_key=True) expiration_date = Column(DateTime, nullable=True) max_usage = Column(Integer, default=1) used = Column(Integer, default=0) disabled = Column(Boolean, default=False) ips = relationship( "IP", secondary=association_table, lazy="subquery", backref=db.backref("pages", lazy=True), ) def __init__(self, **kwargs): super(Token, self).__init__(**kwargs) if not self.name: self.name = random_readable_string() if not self.used: self.used = 0 if not self.max_usage: self.max_usage = 0 def __repr__(self): return self.name def toDict(self): _token = { "name": self.name, "used": self.used, "expiration_date": str(self.expiration_date) if self.expiration_date else None, "max_usage": self.max_usage, "ips": list(map(lambda x: x.address, self.ips)), "disabled": bool(self.disabled), "active": self.active(), } return _token def active(self): expired = False if self.expiration_date: expired = self.expiration_date < datetime.now() used = self.max_usage != 0 and self.max_usage <= self.used return (not expired) and (not used) and (not self.disabled) def use(self, ip_address=False): if self.active(): self.used += 1 if ip_address: self.ips.append(IP(address=ip_address)) return True return False def disable(self): if not self.disabled: self.disabled = True return True return False class Tokens: def __init__(self): self.tokens = {} self.load() def __repr__(self): result = "" for tokens_key in self.tokens: result += "%s, " % tokens_key return result[:-2] def toList(self): _tokens = [] for tokens_key in self.tokens: _tokens.append(self.tokens[tokens_key].toDict()) return _tokens def load(self): logger.debug("loading tokens from ..") self.tokens = {} for token in Token.query.all(): logger.debug(token) self.tokens[token.name] = token logger.debug("token loaded!") def get_token(self, token_name): logger.debug("getting token by name: %s" % token_name) try: token = Token.query.filter_by(name=token_name).first() except KeyError: return False return token def active(self, token_name): logger.debug('checking if "%s" is active' % token_name) token = self.get_token(token_name) if token: return token.active() return False def use(self, token_name, ip_address=False): logger.debug("using token: %s" % token_name) token = self.get_token(token_name) if token: if token.use(ip_address): session.commit() return True return False def update(self, token_name, data): logger.debug("updating token: %s" % token_name) token = self.get_token(token_name) if not token: return False if "expiration_date" in data: token.expiration_date = data["expiration_date"] if "max_usage" in data: token.max_usage = data["max_usage"] if "used" in data: token.used = data["used"] if "disabled" in data: token.disabled = data["disabled"] session.commit() return True def disable(self, token_name): logger.debug("disabling token: %s" % token_name) token = self.get_token(token_name) if token: if token.disable(): session.commit() return True return False def delete(self, token_name): logger.debug("disabling token: %s" % token_name) try: Token.query.filter_by(name=token_name).delete() session.commit() except exc.SQLAlchemyError as e: logger.exception(e) return False return True def new(self, expiration_date=None, max_usage=False): logger.debug( ( "creating new token, with options: max_usage: {}," + "expiration_dates: {}" ).format(max_usage, expiration_date) ) token = Token(expiration_date=expiration_date, max_usage=max_usage) self.tokens[token.name] = token session.add(token) session.commit() return token tokens = None ================================================ FILE: matrix_registration/translation.py ================================================ import os import re import yaml from .constants import __location__ replace_pattern = re.compile(r"{{\s*(?P.[a-zA-Z_\-]+)\s*}}") def get_translations(lang="en", replacements={}): default = _get_translations(replacements=replacements) try: selected = _get_translations(lang=lang, replacements=replacements) return {**default, **selected} except IOError: return default def _get_translations(lang="en", replacements={}): path = os.path.join(__location__, f"translations/messages.{lang}.yaml") with open(path, "r") as stream: translations = yaml.load(stream, Loader=yaml.SafeLoader) interpolated_translations = {} for key, value in translations["weblate"].items(): match = re.search(replace_pattern, value) while match: value = value.replace( match.group(0), str(replacements[match.group("name")]) ) match = re.search(replace_pattern, value) interpolated_translations[key] = value return interpolated_translations ================================================ FILE: matrix_registration/translations/messages.de.yaml ================================================ weblate: server_registration: "{{ server_name }} Registrierung" register_account: "Registriere einen Account auf {{ server_name }}" requires_token: "Die Registrierung erfordert ein geheimes Token" requires_username_and_password: | Für die Registrierung ist keine E-Mail erforderlich, nur ein Benutzername und ein Passwort, welches länger als {{ pw_length }} Zeichen ist. username: "Nutzername" password: "Passwort" confirm: "Bestätige" token: "Token" register: "registriere" click_to_login: "Klicke hier um einzuloggen:" choose_client: "oder wähle einen der vielen anderen Clienten hier:" username_format: "Format: @username:{{ server_name }}" case_sensitive: "Groß- und Kleinschreibung beachten, z.B.: SardineImpactReport" password_too_short: "mindestens {{ pw_length }} Zeichen lang" password_do_not_match: "Passwörter stimmen nicht überein" error: "Fehler" error_long: "Es gab einen Fehler währrend du registriert wurdest." internal_error: "Interner Server Fehler!" contact: "Bitte kontaktieren sie Ihren Server Administrator über dies." token_error: Token Fehler" password_error: "Password Fehler" username_error: "Nutzername Fehler" homeserver_error: "Homeserver Fehler" welcome: "Willkommen" ================================================ FILE: matrix_registration/translations/messages.en.yaml ================================================ weblate: server_registration: "{{ server_name }} registration" register_account: "register an account on {{ server_name }}" requires_token: "the registration requires a secret token" requires_username_and_password: > registration does not require an email, just a username and a password that's longer than {{ pw_length }} characters. username: "Username" password: "Password" confirm: "Confirm" token: "Token" register: "register" click_to_login: "Click here to login in:" choose_client: "or choose one of the many other clients here:" username_format: "format: @username:{{ server_name }}" case_sensitive: "case-sensitive, e.g: SardineImpactReport" password_too_short: "atleast {{ pw_length }} characters long" password_do_not_match: passwords don't match error: "Error" error_long: "There was an error while trying to register you." internal_error: "Internal Server Error!" contact: "Please contact the server admin about this." token_error: "Token Error" password_error: "Password Error" username_error: "Username Error" homeserver_error: "Homeserver Error" welcome: "Welcome" ================================================ FILE: matrix_registration/translations/messages.pt_BR.yaml ================================================ weblate: error_long: Ocorreu um erro enquanto tentávamos fazer seu registro. register: registrar confirm: Confirmar a senha internal_error: Erro Interno no Servidor! welcome: Bem-vindo homeserver_error: Erro no Homeserver username_error: Erro no Nome do Usuário password_error: Erro na Senha token_error: Erro no Token contact: Por favor, contacte o administrador do servidor sobre esse assunto. error: Erro password_do_not_match: as senhas não são iguais password_too_short: pelo menos {{ pw_length }} caracteres de comprimento case_sensitive: 'maiúsculas e minúsculas, p.ex.: SardinhaImpactoRelatorio' username_format: 'formato: @nome_do_usuário:{{ server_name }}' choose_client: 'ou escolha algum dos outros clientes aqui:' click_to_login: 'Clique aqui para logar em:' token: Token password: Senha username: Nome do usuário requires_username_and_password: "o registro não requer endereço de email, apenas\ \ um nome de usuário e uma senha maior que {{ pw_length }} caracteres.\n" requires_token: o registro necessita de um token secreto register_account: registre uma conta no {{ server_name }} server_registration: Registro no {{ server_name }} ================================================ FILE: matrix_registration/translations/messages.sv.yaml ================================================ weblate: welcome: Välkommen homeserver_error: Hemserverfel username_error: Användarnamnsfel password_error: Lösenordsfel token_error: Token-fel contact: Vänligen kontakta serveradministratören angående detta. internal_error: Internt Serverfel! error_long: Det uppstod ett fel när du skulle registreras. error: Error password_do_not_match: lösenorden matchar inte password_too_short: åtminstone {{ pw_length }} symboler långt case_sensitive: 'skiftlägeskänslighet, t.ex.: SardineImpactReport' username_format: 'format: @användarnamn:{{ server_name }}' choose_client: 'eller välj en av de många andra klienterna här:' click_to_login: 'Klicka här för att logga in:' register: registrera token: Token confirm: Bekräfta password: Lösenord username: Användarnamn requires_username_and_password: "registreringen kräver inte en mejladress, enbart\ \ ett användarnamn och ett lösenord som är längre än {{ pw_length }} tecken.\n" requires_token: registreringen kräver en hemlig token server_registration: '{{ server_name }} registrering' register_account: registrera ett konto på {{ server_name }} ================================================ FILE: matrix_registration/translations/messages.zh_Hans.yaml ================================================ weblate: requires_token: 注册需要一个秘密令牌 welcome: 欢迎 homeserver_error: 主服务器错误 username_error: 用户名错误 password_error: 密码错误 token_error: 令牌错误 contact: 请就此事与服务器管理员联系。 internal_error: 服务器内部错误! error_long: 在为你注册时出现了错误。 error: 错误 password_do_not_match: 密码不匹配 password_too_short: 至少 {{ pw_length }} 个字符 case_sensitive: 大小写敏感,如 SardineImpactReport username_format: 格式:@用户名:{{ server_name }} choose_client: 或从这些其他客户端中选择: click_to_login: 点此登陆: register: 注册 token: 令牌 confirm: 确认密码 password: 密码 username: 用户名 requires_username_and_password: "注册不需要电子邮箱,仅需用户名与长于{{ pw_length }}个字符的密码。\n" register_account: 在 {{ server_name }} 注册账户 server_registration: '{{ server_name }} 注册' ================================================ FILE: matrix_registration/wordlist.txt ================================================ acrobat africa alaska albert albino album alcohol alex alpha amadeus amanda amazon america analog animal antenna antonio apollo april aroma artist aspirin athlete atlas banana bandit banjo bikini bingo bonus camera canada carbon casino catalog cinema citizen cobra comet compact complex context credit critic crystal culture david delta dialog diploma doctor domino dragon drama extra fabric final focus forum galaxy gallery global harmony hotel humor index japan kilo lemon liter lotus mango melon menu meter metro mineral model music object piano pirate plastic radio report signal sport studio subject super tango taxi tempo tennis textile tokyo total tourist video visa academy alfred atlanta atomic barbara bazaar brother budget cabaret cadet candle capsule caviar channel chapter circle cobalt comrade condor crimson cyclone darwin declare denver desert divide dolby domain double eagle echo eclipse editor educate edward effect electra emerald emotion empire eternal evening exhibit expand explore extreme ferrari forget freedom friday fuji galileo genesis gravity habitat hamlet harlem helium holiday hunter ibiza iceberg imagine infant isotope jackson jamaica jasmine java jessica kitchen lazarus letter license lithium loyal lucky magenta manual marble maxwell mayor monarch monday money morning mother mystery native nectar nelson network nikita nobel nobody nominal norway nothing number october office oliver opinion option order outside package pandora panther papa pattern pedro pencil people phantom philips pioneer pluto podium portal potato process proxy pupil python quality quarter quiet rabbit radical radius rainbow ramirez ravioli raymond respect respond result resume richard river roger roman rondo sabrina salary salsa sample samuel saturn savage scarlet scorpio sector serpent shampoo sharon silence simple society sonar sonata soprano sparta spider sponsor abraham action active actor adam address admiral adrian agenda agent airline airport alabama aladdin alarm algebra alibi alice alien almond alpine amber amigo ammonia analyze anatomy angel annual answer apple archive arctic arena arizona armada arnold arsenal arthur asia aspect athena audio august austria avenue average axiom aztec bagel baker balance ballad ballet bambino bamboo baron basic basket battery belgium benefit berlin bermuda bernard bicycle binary biology bishop blitz block blonde bonjour boris boston bottle boxer brandy bravo brazil bridge british bronze brown bruce bruno brush burger burma cabinet cactus cafe cairo calypso camel campus canal cannon canoe cantina canvas canyon capital caramel caravan career cargo carlo carol carpet cartel cartoon castle castro cecilia cement center century ceramic chamber chance change chaos charlie charm charter cheese chef chemist cherry chess chicago chicken chief china cigar circus city clara classic claudia clean client climax clinic clock club cockpit coconut cola collect colombo colony color combat comedy command company concert connect consul contact contour control convert copy corner corona correct cosmos couple courage cowboy craft crash cricket crown cuba dallas dance daniel decade decimal degree delete deliver delphi deluxe demand demo denmark derby design detect develop diagram diamond diana diego diesel diet digital dilemma direct disco disney distant dollar dolphin donald drink driver dublin duet dynamic earth east ecology economy edgar egypt elastic elegant element elite elvis email empty energy engine english episode equator escape escort ethnic europe everest evident exact example exit exotic export express factor falcon family fantasy fashion fiber fiction fidel fiesta figure film filter finance finish finland first flag flash florida flower fluid flute folio ford forest formal formula fortune forward fragile france frank fresh friend frozen future gabriel gamma garage garcia garden garlic gemini general genetic genius germany gloria gold golf gondola gong good gordon gorilla grand granite graph green group guide guitar guru hand happy harbor harvard havana hawaii helena hello henry hilton history horizon house human icon idea igloo igor image impact import india indigo input insect instant iris italian jacket jacob jaguar janet jargon jazz jeep john joker jordan judo jumbo june jungle junior jupiter karate karma kayak kermit king koala korea labor lady lagoon laptop laser latin lava lecture left legal level lexicon liberal libra lily limbo limit linda linear lion liquid little llama lobby lobster local logic logo lola london lucas lunar machine macro madam madonna madrid maestro magic magnet magnum mailbox major mama mambo manager manila marco marina market mars martin marvin mary master matrix maximum media medical mega melody memo mental mentor mercury message metal meteor method mexico miami micro milk million minimum minus minute miracle mirage miranda mister mixer mobile modem modern modular moment monaco monica monitor mono monster montana morgan motel motif motor mozart multi museum mustang natural neon nepal neptune nerve neutral nevada news next ninja nirvana normal nova novel nuclear numeric nylon oasis observe ocean octopus olivia olympic omega opera optic optimal orange orbit organic orient origin orlando oscar oxford oxygen ozone pablo pacific pagoda palace pamela panama pancake panda panel panic paradox pardon paris parker parking parody partner passage passive pasta pastel patent patient patriot patrol pegasus pelican penguin pepper percent perfect perfume period permit person peru phone photo picasso picnic picture pigment pilgrim pilot pixel pizza planet plasma plaza pocket poem poetic poker polaris police politic polo polygon pony popcorn popular postage precise prefix premium present price prince printer prism private prize product profile program project protect proton public pulse puma pump pyramid queen radar ralph random rapid rebel record recycle reflex reform regard regular relax reptile reverse ricardo right ringo risk ritual robert robot rocket rodeo romeo royal russian safari salad salami salmon salon salute samba sandra santana sardine school scoop scratch screen script scroll second secret section segment select seminar senator senior sensor serial service shadow sharp sheriff shock short shrink sierra silicon silk silver similar simon single siren slang slogan smart smoke snake social soda solar solid solo sonic source soviet special speed sphere spiral spirit spring static status stereo stone stop street strong student style sultan susan sushi suzuki switch symbol system tactic tahiti talent tarzan telex texas theory thermos tiger titanic tomato topic tornado toronto torpedo totem tractor traffic transit trapeze travel tribal trick trident trilogy tripod tropic trumpet tulip tuna turbo twist ultra uniform union uranium vacuum valid vampire vanilla vatican velvet ventura venus vertigo veteran victor vienna viking village vincent violet violin virtual virus vision visitor visual vitamin viva vocal vodka volcano voltage volume voyage water weekend welcome western window winter wizard wolf world xray yankee yoga yogurt yoyo zebra zero zigzag zipper zodiac zoom acid adios agatha alamo alert almanac aloha andrea anita arcade aurora avalon baby baggage balloon bank basil begin biscuit blue bombay botanic brain brenda brigade cable calibre carmen cello celtic chariot chrome citrus civil cloud combine common cool copper coral crater cubic cupid cycle depend door dream dynasty edison edition enigma equal eric event evita exodus extend famous farmer food fossil frog fruit geneva gentle george giant gilbert gossip gram greek grille hammer harvest hazard heaven herbert heroic hexagon husband immune inca inch initial isabel ivory jason jerome joel joshua journal judge juliet jump justice kimono kinetic leonid leopard lima maze medusa member memphis michael miguel milan mile miller mimic mimosa mission monkey moral moses mouse nancy natasha nebula nickel nina noise orchid oregano origami orinoco orion othello paper paprika prelude prepare pretend promise prosper provide puzzle remote repair reply rival riviera robin rose rover rudolf saga sahara scholar shelter ship shoe sigma sister sleep smile spain spark split spray square stadium star storm story strange stretch stuart subway sugar sulfur summer survive sweet swim table taboo target teacher telecom temple tibet ticket tina today toga tommy tower trivial tunnel turtle twin uncle unicorn unique update valery vega version voodoo warning william wonder year yellow young absent absorb absurd accent alfonso alias ambient anagram andy anvil appear apropos archer ariel armor arrow austin avatar axis baboon bahama bali balsa barcode bazooka beach beast beatles beauty before benny betty between beyond billy bison blast bless bogart bonanza book border brave bread break broken bucket buenos buffalo bundle button buzzer byte caesar camilla canary candid carrot cave chant child choice chris cipher clarion clark clever cliff clone conan conduct congo costume cotton cover crack current danube data decide deposit desire detail dexter dinner donor druid drum easy eddie enjoy enrico epoxy erosion except exile explain fame fast father felix field fiona fire fish flame flex flipper float flood floor forbid forever fractal frame freddie front fuel gallop game garbo gate gelatin gibson ginger giraffe gizmo glass goblin gopher grace gray gregory grid griffin ground guest gustav gyro hair halt harris heart heavy herman hippie hobby honey hope horse hostel hydro imitate info ingrid inside invent invest invite ivan james jester jimmy join joseph juice julius july kansas karl kevin kiwi ladder lake laura learn legacy legend lesson life light list locate lopez lorenzo love lunch malta mammal margin margo marion mask match mayday meaning mercy middle mike mirror modest morph morris mystic nadia nato navy needle neuron never newton nice night nissan nitro nixon north oberon octavia ohio olga open opus orca oval owner page paint palma parent parlor parole paul peace pearl perform phoenix phrase pierre pinball place plate plato plume pogo point polka poncho powder prague press presto pretty prime promo quest quick quiz quota race rachel raja ranger region remark rent reward rhino ribbon rider road rodent round rubber ruby rufus sabine saddle sailor saint salt scale scuba season secure shake shallow shannon shave shelf sherman shine shirt side sinatra sincere size slalom slow small snow sofia song sound south speech spell spend spoon stage stamp stand state stella stick sting stock store sunday sunset support supreme sweden swing tape tavern think thomas tictac time toast tobacco tonight torch torso touch toyota trade tribune trinity triton truck trust type under unit urban urgent user value vendor venice verona vibrate virgo visible vista vital voice vortex waiter watch wave weather wedding wheel whiskey wisdom android annex armani cake confide deal define dispute genuine idiom impress include ironic null nurse obscure prefer prodigy ego fax jet job rio ski yes ================================================ FILE: resources/docker-run.sh ================================================ #!/bin/sh docker run \ -it --rm \ --user "$(id -u):$(id -g)" \ --volume $(pwd)/data:/data \ matrix-registration:latest \ "$@" ================================================ FILE: resources/docker-serve.sh ================================================ #!/bin/sh docker run \ -d \ --user "$(id -u):$(id -g)" \ --network matrix \ --publish 5000:5000/tcp \ --volume $(pwd)/data:/data \ matrix-registration:latest ================================================ FILE: resources/example.html ================================================ Matrix Registration









================================================ FILE: setup.py ================================================ #!/usr/bin/env python import codecs import os import re import setuptools import glob here = os.path.abspath(os.path.dirname(__file__)) def read(*parts): with codecs.open(os.path.join(here, *parts), 'r') as fp: return fp.read() def find_version(*file_paths): version_file = read(*file_paths) version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) if version_match: return version_match.group(1) raise RuntimeError("Unable to find version string.") test_requirements = [ "parameterized>=0.7.0" ] setuptools.setup( name='matrix-registration', version=find_version("matrix_registration", "__init__.py"), description='token based matrix registration app', author='Jona Abdinghoff (ZerataX)', author_email='mail@zera.tax', long_description=open("README.md").read(), long_description_content_type="text/markdown", url="https://github.com/zeratax/matrix-registration", packages=['matrix_registration'], package_data={'matrix_registration': ['*.txt','*.json', 'translations/*.yaml', 'templates/*.html', 'static/css/*.css', 'static/fonts/*.woff2', 'static/images/*.jpg', 'static/images/*.png', 'static/images/*.ico']}, python_requires='~=3.7', install_requires=[ "alembic>=1.8", "appdirs>=1.4.4", "Flask>=2.2", "Flask-SQLAlchemy>=2.5.1", "flask-cors>=3.0.10", "flask-httpauth>=4.7.0", "flask-limiter>=2.6", "PyYAML>=6.0", "jsonschema>=4.17", "requests>=2.28", "SQLAlchemy>=1.4", "waitress>=2.1", "WTForms>=3.0" ], tests_require=test_requirements, extras_require={ "postgres": ["psycopg2-binary>=2.8.4"], "testing": test_requirements }, classifiers=[ "Development Status :: 4 - Beta", "Topic :: Communications :: Chat", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9" ], entry_points={ 'console_scripts': [ 'matrix-registration=matrix_registration.app:cli' ], }, data_files=[ ("config", ["config.sample.yaml"]), (".", ["alembic.ini"]), ("alembic", ["alembic/env.py"]), ("alembic/versions", glob.glob("alembic/versions/*.py")) ] ) ================================================ FILE: shell.nix ================================================ { pkgs ? import { } }: (let matrix-registration = pkgs.callPackage ./default.nix { inherit pkgs; }; in pkgs.python3.withPackages (ps: [ matrix-registration pkgs.black ps.alembic ps.parameterized ps.flake8 ])).env ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/context.py ================================================ import os import sys sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) import matrix_registration ================================================ FILE: tests/localhost.log.config ================================================ version: 1 formatters: precise: format: '%(asctime)s - %(name)s - %(lineno)d - %(levelname)s - %(request)s - %(message)s' handlers: console: class: logging.StreamHandler formatter: precise loggers: synapse.storage.SQL: level: INFO root: level: INFO handlers: [console] disable_existing_loggers: false ================================================ FILE: tests/localhost.signing.key ================================================ ed25519 a_snwG JXtOu5WvTJOETWmItITGUlnqWy6WO4Ovew2flTRYD90 ================================================ FILE: tests/test_registration.py ================================================ # -*- coding: utf-8 -*- # Standard library imports... import hashlib import hmac import json import logging.config import os import random import re import string import sys import unittest from datetime import datetime from unittest.mock import patch from urllib.parse import urlparse # Third-party imports... import yaml from parameterized import parameterized from requests import exceptions # Local imports... try: from .context import matrix_registration except ModuleNotFoundError: from context import matrix_registration from matrix_registration.config import Config from matrix_registration.tokens import db from matrix_registration.app import ( create_app, cli, ) logger = logging.getLogger(__name__) LOGGING = { "version": 1, "root": {"level": "NOTSET", "handlers": ["console"]}, "formatters": { "precise": {"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"} }, "handlers": { "console": { "class": "logging.StreamHandler", "level": "NOTSET", "formatter": "precise", "stream": "ext://sys.stdout", } }, } GOOD_CONFIG = { "server_location": "https://matrix.org", "server_name": "matrix.org", "registration_shared_secret": "coolsharesecret", "admin_api_shared_secret": "coolpassword", "base_url": "/element", "client_redirect": "", "client_logo": "", "db": "sqlite:///%s/tests/db.sqlite" % (os.getcwd(),), "host": "", "port": 5000, "rate_limit": ["1000 per day", "100 per minute"], "allow_cors": False, "password": {"min_length": 8}, "username": { "validation_regex": ["[a-z\d]"], "invalidation_regex": [".*?(admin|support).*?"], }, "ip_logging": False, "logging": LOGGING, } BAD_CONFIG1 = dict( # wrong matrix server location -> 500 GOOD_CONFIG.items(), server_location="https://wronghs.org", ) BAD_CONFIG2 = dict( # wrong admin secret password -> 401 GOOD_CONFIG.items(), admin_api_shared_secret="wrongpassword", ) BAD_CONFIG3 = dict( # wrong matrix shared password -> 500 GOOD_CONFIG.items(), registration_shared_secret="wrongsecret", ) usernames = [] nonces = [] logging.config.dictConfig(LOGGING) def mock_new_user(username): access_token = "".join( random.choices(string.ascii_lowercase + string.digits, k=256) ) device_id = "".join(random.choices(string.ascii_uppercase, k=8)) home_server = matrix_registration.config.config.server_location username = username.rsplit(":")[0].split("@")[-1] user_id = "@{}:{}".format(username, home_server) usernames.append(username) user = { "access_token": access_token, "device_id": device_id, "home_server": home_server, "user_id": user_id, } return user def mocked__get_nonce(server_location): nonce = "".join(random.choices(string.ascii_lowercase + string.digits, k=129)) nonces.append(nonce) return nonce def mocked_requests_post(*args, **kwargs): class MockResponse: def __init__(self, json_data, status_code): self.json_data = json_data self.status_code = status_code def json(self): return self.json_data def raise_for_status(self): if self.status_code == 200: return self.status_code else: raise exceptions.HTTPError(response=self) # print(args[0]) # print(matrix_registration.config.config.server_location) domain = urlparse(GOOD_CONFIG["server_location"]).hostname re_mxid = r"^@?[a-zA-Z_\-=\.\/0-9]+(:" + re.escape(domain) + r")?$" location = "_synapse/admin/v1/register" if args[0] == "%s/%s" % (GOOD_CONFIG["server_location"], location): if kwargs: req = kwargs["json"] if not req["nonce"] in nonces: return MockResponse( {"'errcode': 'M_UNKOWN", "'error': 'unrecognised nonce'"}, 400 ) mac = hmac.new( key=str.encode(GOOD_CONFIG["registration_shared_secret"]), digestmod=hashlib.sha1, ) mac.update(req["nonce"].encode()) mac.update(b"\x00") mac.update(req["username"].encode()) mac.update(b"\x00") mac.update(req["password"].encode()) mac.update(b"\x00") mac.update(b"admin" if req["admin"] else b"notadmin") mac = mac.hexdigest() if not re.search(re_mxid, req["username"]): return MockResponse( { "'errcode': 'M_INVALID_USERNAME", "'error': 'User ID can only contain" + "characters a-z, 0-9, or '=_-./'", }, 400, ) if req["username"].rsplit(":")[0].split("@")[-1] in usernames: return MockResponse( {"errcode": "M_USER_IN_USE", "error": "User ID already taken."}, 400 ) if req["mac"] != mac: return MockResponse( {"errcode": "M_UNKNOWN", "error": "HMAC incorrect"}, 403 ) return MockResponse(mock_new_user(req["username"]), 200) return MockResponse(None, 404) class TokensTest(unittest.TestCase): def setUp(self): matrix_registration.config.config = Config(data=GOOD_CONFIG) app = create_app(testing=True) with app.app_context(): app.config.from_mapping( SQLALCHEMY_DATABASE_URI=matrix_registration.config.config.db, SQLALCHEMY_TRACK_MODIFICATIONS=False, ) db.init_app(app) db.create_all() self.app = app def tearDown(self): os.remove(matrix_registration.config.config.db[10:]) def test_random_readable_string(self): for n in range(10): string = matrix_registration.tokens.random_readable_string(length=n) words = re.sub("([a-z])([A-Z])", r"\1 \2", string).split() self.assertEqual(len(words), n) def test_tokens_empty(self): with self.app.app_context(): test_tokens = matrix_registration.tokens.Tokens() # no token should exist at this point self.assertFalse(test_tokens.active("")) test_token = test_tokens.new() # no empty token should have been created self.assertFalse(test_tokens.active("")) def test_tokens_disable(self): with self.app.app_context(): test_tokens = matrix_registration.tokens.Tokens() test_token = test_tokens.new() # new tokens should be active first, inactive after disabling it self.assertTrue(test_token.active()) self.assertTrue(test_token.disable()) self.assertFalse(test_token.active()) test_token2 = test_tokens.new() self.assertTrue(test_tokens.active(test_token2.name)) self.assertTrue(test_tokens.disable(test_token2.name)) self.assertFalse(test_tokens.active(test_token2.name)) test_token3 = test_tokens.new() test_token3.use() self.assertFalse(test_tokens.active(test_token2.name)) self.assertFalse(test_tokens.disable(test_token2.name)) self.assertFalse(test_tokens.active(test_token2.name)) def test_tokens_load(self): with self.app.app_context(): test_tokens = matrix_registration.tokens.Tokens() test_token = test_tokens.new() test_token2 = test_tokens.new() test_token3 = test_tokens.new(max_usage=True) test_token4 = test_tokens.new( expiration_date=datetime.fromisoformat("2111-01-01") ) test_token5 = test_tokens.new( expiration_date=datetime.fromisoformat("1999-01-01") ) test_tokens.disable(test_token2.name) test_tokens.use(test_token3.name) test_tokens.use(test_token4.name) test_tokens.load() # token1: active, unused, no expiration date # token2: inactive, unused, no expiration date # token3: used once, one-time, now inactive # token4: active, used once, expiration date # token5: inactive, expiration date self.assertEqual( test_token.name, test_tokens.get_token(test_token.name).name ) self.assertEqual( test_token2.name, test_tokens.get_token(test_token2.name).name ) self.assertEqual( test_token2.active(), test_tokens.get_token(test_token2.name).active() ) self.assertEqual( test_token3.used, test_tokens.get_token(test_token3.name).used ) self.assertEqual( test_token3.active(), test_tokens.get_token(test_token3.name).active() ) self.assertEqual( test_token4.used, test_tokens.get_token(test_token4.name).used ) self.assertEqual( test_token4.expiration_date, test_tokens.get_token(test_token4.name).expiration_date, ) self.assertEqual( test_token5.active(), test_tokens.get_token(test_token5.name).active() ) @parameterized.expand( [ [None, False], [datetime.fromisoformat("2100-01-12"), False], [None, True], [datetime.fromisoformat("2100-01-12"), True], ] ) def test_tokens_new(self, expiration_date, max_usage): with self.app.app_context(): test_tokens = matrix_registration.tokens.Tokens() test_token = test_tokens.new( expiration_date=expiration_date, max_usage=max_usage ) self.assertIsNotNone(test_token) if expiration_date: self.assertIsNotNone(test_token.expiration_date) else: self.assertIsNone(test_token.expiration_date) if max_usage: self.assertTrue(test_token.max_usage) else: self.assertFalse(test_token.max_usage) self.assertTrue(test_tokens.active(test_token.name)) @parameterized.expand( [ [None, False, 10, True], [datetime.fromisoformat("2100-01-12"), False, 10, True], [None, True, 1, False], [None, True, 0, True], [datetime.fromisoformat("2100-01-12"), True, 1, False], [datetime.fromisoformat("2100-01-12"), True, 2, False], [datetime.fromisoformat("2100-01-12"), True, 0, True], ] ) def test_tokens_active_form(self, expiration_date, max_usage, times_used, active): with self.app.app_context(): test_tokens = matrix_registration.tokens.Tokens() test_token = test_tokens.new( expiration_date=expiration_date, max_usage=max_usage ) for n in range(times_used): test_tokens.use(test_token.name) if not max_usage: self.assertEqual(test_token.used, times_used) elif times_used == 0: self.assertEqual(test_token.used, 0) else: self.assertEqual(test_token.used, 1) self.assertEqual(test_tokens.active(test_token.name), active) @parameterized.expand( [ [None, True], [datetime.fromisoformat("2100-01-12"), False], [datetime.fromisoformat("2200-01-13"), True], ] ) def test_tokens_active(self, expiration_date, active): with self.app.app_context(): test_tokens = matrix_registration.tokens.Tokens() test_token = test_tokens.new(expiration_date=expiration_date) self.assertEqual(test_tokens.active(test_token.name), True) # date changed to after expiration date with patch("matrix_registration.tokens.datetime") as mock_date: mock_date.now.return_value = datetime.fromisoformat("2200-01-12") self.assertEqual(test_tokens.active(test_token.name), active) @parameterized.expand( [ ["DoubleWizardSky"], ["null"], ["false"], ] ) def test_tokens_repr(self, name): with self.app.app_context(): test_token1 = matrix_registration.tokens.Token(name=name) self.assertEqual(str(test_token1), name) def test_token_repr(self): with self.app.app_context(): test_tokens = matrix_registration.tokens.Tokens() test_token1 = test_tokens.new() test_token2 = test_tokens.new() test_token3 = test_tokens.new() test_token4 = test_tokens.new() test_token5 = test_tokens.new() expected_answer = ( "%s, " % test_token1.name + "%s, " % test_token2.name + "%s, " % test_token3.name + "%s, " % test_token4.name + "%s" % test_token5.name ) self.assertEqual(str(test_tokens), expected_answer) class ApiTest(unittest.TestCase): def setUp(self): matrix_registration.config.config = Config(data=GOOD_CONFIG) app = create_app(testing=True) with app.app_context(): app.config.from_mapping( SQLALCHEMY_DATABASE_URI=matrix_registration.config.config.db, SQLALCHEMY_TRACK_MODIFICATIONS=False, ) db.init_app(app) db.create_all() self.client = app.test_client() self.app = app def tearDown(self): os.remove(matrix_registration.config.config.db[10:]) @parameterized.expand( [ ["test1", "test1234", "test1234", True, 200], ["", "test1234", "test1234", True, 400], ["test2", "", "test1234", True, 400], ["test3", "test1234", "", True, 400], ["test4", "test1234", "test1234", False, 400], ["@test5:matrix.org", "test1234", "test1234", True, 200], ["@test6:wronghs.org", "test1234", "test1234", True, 400], ["test7", "test1234", "tet1234", True, 400], ["teüst8", "test1234", "test1234", True, 400], ["@test9@matrix.org", "test1234", "test1234", True, 400], ["test11@matrix.org", "test1234", "test1234", True, 400], ["", "test1234", "test1234", True, 400], [ "".join(random.choices(string.ascii_uppercase, k=256)), "test1234", "test1234", True, 400, ], ["@admin:matrix.org", "test1234", "test1234", True, 400], ["matrixadmin123", "test1234", "test1234", True, 400], ] ) # check form activeators @patch("matrix_registration.matrix_api._get_nonce", side_effect=mocked__get_nonce) @patch( "matrix_registration.matrix_api.requests.post", side_effect=mocked_requests_post ) def test_register( self, username, password, confirm, token, status, mock_get, mock_nonce ): matrix_registration.config.config = Config(data=GOOD_CONFIG) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new( expiration_date=None, max_usage=True ) # replace matrix with in config set hs domain = urlparse( matrix_registration.config.config.server_location ).hostname if username: username = username.replace("matrix.org", domain) if not token: test_token.name = "" rv = self.client.post( "/register", data=dict( username=username, password=password, confirm=confirm, token=test_token.name, ), ) if rv.status_code == 200: account_data = json.loads(rv.data.decode("utf8").replace("'", '"')) # print(account_data) self.assertEqual(rv.status_code, status) @patch("matrix_registration.matrix_api._get_nonce", side_effect=mocked__get_nonce) @patch( "matrix_registration.matrix_api.requests.post", side_effect=mocked_requests_post ) def test_register_wrong_hs(self, mock_get, mock_nonce): matrix_registration.config.config = Config(data=BAD_CONFIG1) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new( expiration_date=None, max_usage=True ) rv = self.client.post( "/register", data=dict( username="username", password="password", confirm="password", token=test_token.name, ), ) self.assertEqual(rv.status_code, 500) @patch("matrix_registration.matrix_api._get_nonce", side_effect=mocked__get_nonce) @patch( "matrix_registration.matrix_api.requests.post", side_effect=mocked_requests_post ) def test_register_wrong_secret(self, mock_get, mock_nonce): matrix_registration.config.config = Config(data=BAD_CONFIG3) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new( expiration_date=None, max_usage=True ) rv = self.client.post( "/register", data=dict( username="username", password="password", confirm="password", token=test_token.name, ), ) self.assertEqual(rv.status_code, 500) def test_get_tokens(self): matrix_registration.config.config = Config(data=GOOD_CONFIG) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new( expiration_date=None, max_usage=True ) secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} rv = self.client.get("/api/token", headers=headers) self.assertEqual(rv.status_code, 200) token_data = json.loads(rv.data.decode("utf8").replace("'", '"')) self.assertEqual(token_data[0]["expiration_date"], None) self.assertEqual(token_data[0]["max_usage"], True) def test_error_get_tokens(self): matrix_registration.config.config = Config(data=BAD_CONFIG2) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new( expiration_date=None, max_usage=True ) secret = matrix_registration.config.config.admin_api_shared_secret matrix_registration.config.config = Config(data=GOOD_CONFIG) headers = {"Authorization": "SharedSecret %s" % secret} rv = self.client.get("/api/token", headers=headers) self.assertEqual(rv.status_code, 401) token_data = json.loads(rv.data.decode("utf8").replace("'", '"')) self.assertEqual(token_data["errcode"], "MR_BAD_SECRET") self.assertEqual(token_data["error"], "wrong shared secret") @parameterized.expand( [ [None, True, None], ["2020-12-24", False, "2020-12-24 00:00:00"], ["2200-05-12", True, "2200-05-12 00:00:00"], ] ) def test_post_token(self, expiration_date, max_usage, parsed_date): matrix_registration.config.config = Config(data=GOOD_CONFIG) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new( expiration_date=None, max_usage=True ) secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} rv = self.client.post( "/api/token", data=json.dumps( dict(expiration_date=expiration_date, max_usage=max_usage) ), content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 200) token_data = json.loads(rv.data.decode("utf8").replace("'", '"')) self.assertEqual(token_data["expiration_date"], parsed_date) self.assertEqual(token_data["max_usage"], max_usage) self.assertTrue(token_data["name"] is not None) def test_error_post_token(self): matrix_registration.config.config = Config(data=BAD_CONFIG2) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new( expiration_date=None, max_usage=True ) secret = matrix_registration.config.config.admin_api_shared_secret matrix_registration.config.config = Config(data=GOOD_CONFIG) headers = {"Authorization": "SharedSecret %s" % secret} rv = self.client.post( "/api/token", data=json.dumps(dict(expiration_date="24.12.2020", max_usage=False)), content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 401) token_data = json.loads(rv.data.decode("utf8").replace("'", '"')) self.assertEqual(token_data["errcode"], "MR_BAD_SECRET") self.assertEqual(token_data["error"], "wrong shared secret") secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} rv = self.client.post( "/api/token", data=json.dumps(dict(expiration_date="2020-24-12", max_usage=False)), content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 400) token_data = json.loads(rv.data.decode("utf8")) self.assertEqual(token_data["errcode"], "MR_BAD_DATE_FORMAT") self.assertEqual(token_data["error"], "date wasn't in YYYY-MM-DD format") def test_patch_token(self): matrix_registration.config.config = Config(data=GOOD_CONFIG) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new(max_usage=True) secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} rv = self.client.patch( "/api/token/" + test_token.name, data=json.dumps(dict(disabled=True)), content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 200) token_data = json.loads(rv.data.decode("utf8").replace("'", '"')) self.assertEqual(token_data["active"], False) self.assertEqual(token_data["max_usage"], True) self.assertEqual(token_data["name"], test_token.name) def test_error_patch_token(self): matrix_registration.config.config = Config(data=BAD_CONFIG2) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new(max_usage=True) secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} matrix_registration.config.config = Config(data=GOOD_CONFIG) rv = self.client.patch( "/api/token/" + test_token.name, data=json.dumps(dict(disabled=True)), content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 401) token_data = json.loads(rv.data.decode("utf8").replace("'", '"')) self.assertEqual(token_data["errcode"], "MR_BAD_SECRET") self.assertEqual(token_data["error"], "wrong shared secret") secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} rv = self.client.patch( "/api/token/" + test_token.name, data=json.dumps(dict(active=False)), content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 400) token_data = json.loads(rv.data.decode("utf8")) self.assertEqual(token_data["errcode"], "MR_BAD_USER_REQUEST") self.assertEqual( token_data["error"], "you're not allowed to change this property" ) rv = self.client.patch( "/api/token/" + "nicememe", data=json.dumps(dict(disabled=True)), content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 404) token_data = json.loads(rv.data.decode("utf8")) self.assertEqual(token_data["errcode"], "MR_TOKEN_NOT_FOUND") self.assertEqual(token_data["error"], "token does not exist") def test_delete_token(self): matrix_registration.config.config = Config(data=GOOD_CONFIG) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new(max_usage=True) secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} rv = self.client.get( "/api/token/" + test_token.name, content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 200) rv = self.client.delete( "/api/token/" + test_token.name, content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 200) rv = self.client.get( "/api/token/" + test_token.name, content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 404) def test_error_delete_token(self): matrix_registration.config.config = Config(data=BAD_CONFIG2) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new(max_usage=True) secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} matrix_registration.config.config = Config(data=GOOD_CONFIG) rv = self.client.delete( "/api/token/" + test_token.name, content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 401) token_data = json.loads(rv.data.decode("utf8").replace("'", '"')) self.assertEqual(token_data["errcode"], "MR_BAD_SECRET") self.assertEqual(token_data["error"], "wrong shared secret") secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} rv = self.client.delete( "/api/token/" + "nicememe", content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 404) token_data = json.loads(rv.data.decode("utf8")) self.assertEqual(token_data["errcode"], "MR_TOKEN_NOT_FOUND") self.assertEqual(token_data["error"], "token does not exist") @parameterized.expand( [ [None, True, None], [datetime.fromisoformat("2020-12-24"), False, "2020-12-24 00:00:00"], [datetime.fromisoformat("2200-05-12"), True, "2200-05-12 00:00:00"], ] ) def test_get_token(self, expiration_date, max_usage, parsed_date): matrix_registration.config.config = Config(data=BAD_CONFIG2) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new( expiration_date=expiration_date, max_usage=max_usage ) secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} rv = self.client.get( "/api/token/" + test_token.name, content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 200) token_data = json.loads(rv.data.decode("utf8")) self.assertEqual(token_data["expiration_date"], parsed_date) self.assertEqual(token_data["max_usage"], max_usage) def test_error_get_token(self): matrix_registration.config.config = Config(data=BAD_CONFIG2) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() test_token = matrix_registration.tokens.tokens.new(max_usage=True) secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} rv = self.client.get( "/api/token/" + "nice_meme", content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 404) token_data = json.loads(rv.data.decode("utf8")) self.assertEqual(token_data["errcode"], "MR_TOKEN_NOT_FOUND") self.assertEqual(token_data["error"], "token does not exist") matrix_registration.config.config = Config(data=BAD_CONFIG2) secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} matrix_registration.config.config = Config(data=GOOD_CONFIG) rv = self.client.patch( "/api/token/" + test_token.name, data=json.dumps(dict(disabled=True)), content_type="application/json", headers=headers, ) self.assertEqual(rv.status_code, 401) token_data = json.loads(rv.data.decode("utf8").replace("'", '"')) self.assertEqual(token_data["errcode"], "MR_BAD_SECRET") self.assertEqual(token_data["error"], "wrong shared secret") def test_rate_limit_exempt(self): matrix_registration.config.config = Config(data=GOOD_CONFIG) with self.app.app_context(): matrix_registration.tokens.tokens = matrix_registration.tokens.Tokens() secret = matrix_registration.config.config.admin_api_shared_secret headers = {"Authorization": "SharedSecret %s" % secret} for i in range(110): self.client.get("/api/token", headers=headers) rv = self.client.get("/api/token", headers=headers) self.assertEqual(rv.status_code, 429) for i in range(110): self.client.get("/health") rv = self.client.get("/health") self.assertEqual(rv.status_code, 200) class ConfigTest(unittest.TestCase): def test_config_update(self): matrix_registration.config.config = Config(data=GOOD_CONFIG) self.assertEqual(matrix_registration.config.config.port, GOOD_CONFIG["port"]) self.assertEqual( matrix_registration.config.config.server_location, GOOD_CONFIG["server_location"], ) matrix_registration.config.config.update(BAD_CONFIG1) self.assertEqual(matrix_registration.config.config.port, BAD_CONFIG1["port"]) self.assertEqual( matrix_registration.config.config.server_location, BAD_CONFIG1["server_location"], ) def test_config_path(self): # BAD_CONFIG1_path = "x" good_config_path = "tests/test_config.yaml" with open(good_config_path, "w") as outfile: yaml.dump(GOOD_CONFIG, outfile, default_flow_style=False) matrix_registration.config.config = Config(path=good_config_path) self.assertIsNotNone(matrix_registration.config.config) os.remove(good_config_path) class CliTest(unittest.TestCase): path = "tests/test_config.yaml" db = "tests/db.sqlite" def setUp(self): try: os.remove(self.db) except FileNotFoundError: pass with open(self.path, "w") as outfile: yaml.dump(GOOD_CONFIG, outfile, default_flow_style=False) def tearDown(self): os.remove(self.path) os.remove(self.db) def test_create_token(self): runner = create_app().test_cli_runner() generate = runner.invoke(cli, ["--config-path", self.path, "generate", "-m", 1]) name1 = generate.output.strip() status = runner.invoke(cli, ["--config-path", self.path, "status", "-s", name1]) valid, info_dict_string = status.output.strip().split("\n", 1) self.assertEqual(valid, "This token is valid") comparison_dict = { "name": name1, "used": 0, "expiration_date": None, "max_usage": 1, "disabled": False, "ips": [], "active": True, } self.assertEqual(json.loads(info_dict_string), comparison_dict) runner.invoke(cli, ["--config-path", self.path, "status", "-d", name1]) status = runner.invoke(cli, ["--config-path", self.path, "status", "-s", name1]) valid, info_dict_string = status.output.strip().split("\n", 1) self.assertEqual(valid, "This token is not valid") comparison_dict = { "name": name1, "used": 0, "expiration_date": None, "max_usage": 1, "disabled": True, "ips": [], "active": False, } self.assertEqual(json.loads(info_dict_string), comparison_dict) generate = runner.invoke( cli, ["--config-path", self.path, "generate", "-e", "2220-05-12"] ) name2 = generate.output.strip() status = runner.invoke(cli, ["--config-path", self.path, "status", "-s", name2]) valid, info_dict_string = status.output.strip().split("\n", 1) self.assertEqual(valid, "This token is valid") comparison_dict = { "name": name2, "used": 0, "expiration_date": "2220-05-12 00:00:00", "max_usage": 0, "disabled": False, "ips": [], "active": True, } self.assertEqual(json.loads(info_dict_string), comparison_dict) status = runner.invoke(cli, ["--config-path", self.path, "status", "-l"]) list = status.output.strip() self.assertEqual(list, f"{name1}, {name2}") if "logging" in sys.argv: logging.basicConfig(level=logging.DEBUG) if __name__ == "__main__": unittest.main() ================================================ FILE: tox.ini ================================================ [tox] envlist = py37,py38,p39 [testenv] deps = coveralls commands = coverage erase {envbindir}/python setup.py develop coverage run -p setup.py test coverage combine - coverage html