├── .babelrc ├── .coveragerc ├── .dockerignore ├── .env.example ├── .github ├── dependabot.yml └── workflows │ └── build.yml ├── .gitignore ├── .mergify.yml ├── .pre-commit-config.yaml ├── .run ├── migrate dev.run.xml └── run dev.run.xml ├── Dockerfile ├── LICENSE.txt ├── README.md ├── closureexterns └── gapi.js ├── docker-compose.dev.yml ├── docker-compose.yml ├── entrypoint ├── run-worker.sh └── run.sh ├── gulpfile.babel.js ├── nginx.conf ├── package-lock.json ├── package.json ├── prospector.yml ├── pyproject.toml ├── pytest.ini ├── requirements ├── base.txt ├── dev.txt ├── prod.txt └── test.txt ├── spongeauth ├── accounts │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── forms.py │ ├── letter_avatar.py │ ├── middleware.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_add_username_validator.py │ │ ├── 0003_create_group_model.py │ │ ├── 0004_create_dummy_group.py │ │ ├── 0005_user_is_staff.py │ │ ├── 0006_add_username_insensitive_index.py │ │ ├── 0007_group_add_internal_name.py │ │ ├── 0008_auto_20180428_1214.py │ │ ├── 0009_add_spongepowered_tos_2018-03-10.py │ │ ├── 0010_termsofservice_group.py │ │ ├── 0011_user_full_name.py │ │ ├── 0012_auto_20190102_1545.py │ │ ├── 0013_user_discord_id.py │ │ └── __init__.py │ ├── models.py │ ├── tests │ │ ├── __init__.py │ │ ├── factories.py │ │ ├── test_admin.py │ │ ├── test_avatar_for_user.py │ │ ├── test_discord_id_validation.py │ │ ├── test_letter_avatar.py │ │ ├── test_middleware_enforce_verified_emails.py │ │ ├── test_models.py │ │ ├── test_small_views.py │ │ ├── test_username_validation.py │ │ ├── test_view_autocomplete.py │ │ ├── test_view_change_email.py │ │ ├── test_view_change_other_avatar.py │ │ ├── test_view_forgot.py │ │ ├── test_view_helpers.py │ │ ├── test_view_login.py │ │ ├── test_view_profile.py │ │ ├── test_view_register.py │ │ ├── test_view_verify.py │ │ └── testdata │ │ │ ├── 100x100.png │ │ │ ├── 100x50.png │ │ │ ├── 120x120.png │ │ │ ├── 120x240.png │ │ │ ├── 240x120.png │ │ │ ├── 240x240.png │ │ │ ├── 50x100.png │ │ │ └── input.png │ ├── urls.py │ └── views.py ├── api │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20170114_0120.py │ │ └── __init__.py │ ├── models.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_create_user.py │ │ ├── test_delete_user.py │ │ ├── test_get_user.py │ │ ├── test_list_users.py │ │ └── test_models.py │ ├── urls.py │ └── views.py ├── core │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── middleware.py │ ├── models.py │ ├── staticfiles.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_sourcemap_static_files_storage.py │ │ ├── test_views.py │ │ └── test_x_real_ip_middleware.py │ └── views.py ├── manage.py ├── migrator │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── import_play_data.py │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ ├── models.py │ ├── tests │ │ ├── __init__.py │ │ └── test_import_play_data.py │ └── views.py ├── spongeauth │ ├── __init__.py │ ├── settings │ │ ├── .gitignore │ │ ├── __init__.py │ │ ├── base.py │ │ ├── dev.py │ │ ├── prod.py │ │ ├── test.py │ │ └── utils.py │ ├── urls.py │ └── wsgi.py ├── spongemime │ ├── __init__.py │ ├── mime.types │ └── test_spongemime.py ├── sso │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── discourse_sso.py │ ├── management │ │ ├── __init__.py │ │ └── commands │ │ │ ├── __init__.py │ │ │ └── sso_ping_discourse.py │ ├── models.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_discourse_sso.py │ │ ├── test_make_payload.py │ │ ├── test_management_update_user.py │ │ ├── test_ping_on_save.py │ │ ├── test_send_update_ping.py │ │ └── test_view_begin.py │ ├── urls.py │ ├── utils.py │ └── views.py ├── static │ ├── images │ │ └── spongie-mark.svg │ ├── scripts │ │ └── app.js │ └── styles │ │ ├── _email.scss │ │ ├── _footer.scss │ │ ├── _home.scss │ │ ├── _nav.scss │ │ ├── _pallette.scss │ │ ├── _settings.scss │ │ ├── _signUp.scss │ │ ├── _sponge_variables.scss │ │ ├── _topbar.scss │ │ ├── _user.scss │ │ ├── _utils.scss │ │ ├── app.scss │ │ ├── bootstrap.scss │ │ └── font-awesome.scss ├── templates │ ├── _footer.html │ ├── _navbar.html │ ├── accounts │ │ ├── _avatar_block.html │ │ ├── _google_signin_form.html │ │ ├── agree_tos.html │ │ ├── change_email │ │ │ ├── confirmation_email.html │ │ │ ├── confirmation_email.txt │ │ │ ├── email.html │ │ │ ├── email.txt │ │ │ ├── step1.html │ │ │ └── step1done.html │ │ ├── change_other_avatar.html │ │ ├── forgot │ │ │ ├── email.html │ │ │ ├── email.txt │ │ │ ├── step1.html │ │ │ ├── step1done.html │ │ │ └── step2.html │ │ ├── login.html │ │ ├── logout.html │ │ ├── logout_success.html │ │ ├── profile.html │ │ ├── register.html │ │ └── verify │ │ │ ├── email.html │ │ │ ├── email.txt │ │ │ └── step1.html │ ├── base.html │ ├── bootstrap3 │ │ ├── field.html │ │ └── layout │ │ │ ├── formactions.html │ │ │ ├── help_text.html │ │ │ └── help_text_and_errors.html │ ├── core │ │ └── index.html │ └── twofa │ │ ├── list.html │ │ ├── setup │ │ ├── paper.html │ │ └── totp.html │ │ └── verify │ │ ├── base.html │ │ ├── paper.html │ │ └── totp.html └── twofa │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── forms.py │ ├── migrations │ ├── 0001_initial.py │ └── __init__.py │ ├── models.py │ ├── oath.py │ ├── tests │ ├── __init__.py │ ├── test_oath.py │ ├── test_view_helpers.py │ ├── test_view_list.py │ ├── test_view_regenerate.py │ ├── test_view_remove.py │ ├── test_view_setup_backup.py │ ├── test_view_setup_totp.py │ └── test_view_verify.py │ ├── urls.py │ └── views.py └── tox.ini /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/env"] 3 | } 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | # ignore manage.py 4 | manage.py 5 | # omit tests themselves 6 | */tests/* 7 | # omit migrations 8 | */migrations/* 9 | # omit spongeauth (config directory) 10 | spongeauth/* 11 | # omit apps.py 12 | */apps.py 13 | branch = True 14 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | env 2 | node_modules 3 | .tox 4 | 5 | spongeauth/media 6 | spongeauth/db.sqlite3 7 | spongeauth/static-build 8 | 9 | __pycache__ 10 | */__pycache__ 11 | */*/__pycache__ 12 | */*/*/__pycache__ 13 | */*/*/*/__pycache__ 14 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | ALLOWED_HOSTS=localhost 2 | CSRF_TRUSTED_ORIGINS=https://auth.example.com 3 | SECRET_KEY=secret 4 | DB_NAME=spongeauth 5 | DB_USER=user 6 | DB_PASSWORD=secretpassword 7 | DB_HOST=db.example.com 8 | EMAIL_HOST=mail.example.com 9 | EMAIL_PORT=587 10 | EMAIL_HOST_USER=example@example.com 11 | EMAIL_HOST_PASSWORD=secretpassword 12 | EMAIL_TLS=true 13 | EMAIL_SSL=false 14 | DEFAULT_FROM_EMAIL=test@admin.example.com 15 | SERVER_EMAIL=admin@admin.example.com 16 | SENTRY_DSN=https://public@sentry.example.com/1 17 | REDIS_HOST=redis 18 | LETTER_AVATAR_BASE=https://avatars.discourse-cdn.com/v4 19 | GOOGLE_CLIENT_ID=123.apps.googleusercontent.com 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pip" 4 | directory: "/requirements" 5 | schedule: 6 | interval: "daily" 7 | - package-ecosystem: "npm" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: [ '3.12' ] 12 | node-version: [ '22' ] 13 | tox-env: ['py3-cov', 'lint'] 14 | 15 | services: 16 | postgres: 17 | image: postgres:latest 18 | env: 19 | POSTGRES_PASSWORD: postgres 20 | options: >- 21 | --health-cmd pg_isready 22 | --health-interval 10s 23 | --health-timeout 5s 24 | --health-retries 5 25 | ports: 26 | - 5432:5432 27 | 28 | steps: 29 | - uses: actions/checkout@v4 30 | - name: Set up Python ${{ matrix.python-version }} 31 | uses: actions/setup-python@v5 32 | with: 33 | python-version: ${{ matrix.python-version }} 34 | cache: 'pip' 35 | - name: Setup Node ${{ matrix.node-version }} 36 | uses: actions/setup-node@v4 37 | with: 38 | node-version: ${{ matrix.node-version }} 39 | cache: 'npm' 40 | - name: Install dependencies 41 | run: | 42 | python -m pip install --upgrade pip 43 | pip install tox 44 | npm ci 45 | npm install gulp-cli 46 | - name: Gulp build 47 | run: | 48 | node_modules/.bin/gulp build 49 | - name: Test with tox ${{ matrix.tox-env }} 50 | run: | 51 | tox -e ${{ matrix.tox-env }} 52 | env: 53 | DB_USER: postgres 54 | DB_PASSWORD: postgres 55 | DB_NAME: postgres 56 | DB_HOST: localhost 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | env/ 2 | db.sqlite3 3 | __pycache__/ 4 | static-build/ 5 | node_modules/ 6 | media/ 7 | *.sw? 8 | .cache/ 9 | .tox/ 10 | .coverage 11 | .idea 12 | *.iml 13 | .DS_Store 14 | .pytest_cache 15 | venv/ 16 | Pipfile* 17 | spongeauth/spongeauth/settings/local_settigs.py 18 | .env 19 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | queue_rules: 2 | - name: duplicated default from Automatic merge on approval 3 | queue_conditions: 4 | - "author~=^(pyup-bot|dependabot\\[bot\\])$" 5 | - "-merged" 6 | - "status-success=test (3.12, 22, py3-cov)" 7 | - "status-success=test (3.12, 22, lint)" 8 | - "#approved-reviews-by>=1" 9 | - "#changes-requested-reviews-by=0" 10 | - "label=ready to merge" 11 | merge_conditions: [] 12 | merge_method: merge 13 | - name: default 14 | queue_conditions: 15 | - "author~=^(pyup-bot|dependabot\\[bot\\])$" 16 | - "-merged" 17 | - "status-success=test (3.12, 22, py3-cov)" 18 | - "status-success=test (3.12, 22, lint)" 19 | merge_conditions: 20 | - "status-success=test (3.12, 22, py3-cov)" 21 | - "status-success=test (3.12, 22, lint)" 22 | merge_method: rebase 23 | 24 | pull_request_rules: 25 | - name: Dependency updater - automatic merge on CI passing 26 | conditions: 27 | - "author~=^(pyup-bot|dependabot\\[bot\\])$" 28 | - "-merged" 29 | - "status-success=test (3.12, 22, py3-cov)" 30 | - "status-success=test (3.12, 22, lint)" 31 | actions: 32 | delete_head_branch: {} 33 | - name: Dependency updater - flag for human review on coverage failing 34 | conditions: 35 | - "author~=^(pyup-bot|dependabot)$" 36 | - "-merged" 37 | - "status-failure=test (3.12, 22, py3-cov)" 38 | actions: 39 | request_reviews: 40 | users: [lukegb, felixoi] 41 | comment: 42 | message: "This PR failed tests; please review." 43 | - name: Dependency updater - flag for human review on lint failing 44 | conditions: 45 | - "author~=^(pyup-bot|dependabot)$" 46 | - "-merged" 47 | - "status-failure=test (3.12, 22, lint)" 48 | - "-status-failure=test (3.12, 22, py3-cov)" 49 | actions: 50 | request_reviews: 51 | users: [lukegb, felixoi] 52 | comment: 53 | message: "This PR failed tests; please review." 54 | - name: Automatic merge on approval 55 | conditions: 56 | - "#approved-reviews-by>=1" 57 | - "#changes-requested-reviews-by=0" 58 | - "label=ready to merge" 59 | actions: 60 | delete_head_branch: {} 61 | - name: Automatic merge on approval + Dependency updater - automatic merge on CI passing 62 | conditions: [] 63 | actions: 64 | queue: 65 | priority_rules: 66 | - name: priority for queue `duplicated default from Automatic merge on approval` 67 | conditions: 68 | - "#approved-reviews-by>=1" 69 | - "#changes-requested-reviews-by=0" 70 | - "label=ready to merge" 71 | priority: 2250 72 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | default_language_version: 2 | python: python3 3 | repos: 4 | - repo: https://github.com/ambv/black 5 | rev: stable 6 | hooks: 7 | - id: black 8 | -------------------------------------------------------------------------------- /.run/migrate dev.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | -------------------------------------------------------------------------------- /.run/run dev.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ######################################### 2 | # BUILDER # 3 | ######################################### 4 | 5 | FROM python:3-alpine as builder 6 | 7 | WORKDIR /app 8 | 9 | ENV PYTHONDONTWRITEBYTECODE 1 10 | ENV PYTHONUNBUFFERED 1 11 | 12 | RUN apk update \ 13 | && apk add postgresql-dev python3-dev nodejs npm git py-pip zlib-dev jpeg-dev libpng-dev libwebp-dev musl-dev gcc py3-virtualenv 14 | 15 | COPY . /app 16 | 17 | RUN python3 -m venv /venv && /venv/bin/pip install --upgrade --no-cache pip wheel && \ 18 | /venv/bin/pip wheel --no-cache-dir --wheel-dir /app/wheels \ 19 | -r /app/requirements/base.txt \ 20 | -r /app/requirements/prod.txt 21 | 22 | RUN npm ci 23 | RUN production=true node_modules/.bin/gulp build 24 | 25 | ######################################### 26 | # FINAL # 27 | ######################################### 28 | 29 | FROM python:3-alpine 30 | 31 | ENV APP_NAME=spongeauth 32 | ENV HOME=/home/$APP_NAME 33 | ENV APP_HOME=$HOME/app 34 | 35 | RUN mkdir -p $APP_HOME 36 | RUN addgroup -g 1000 -S $APP_NAME && adduser -u 1000 -S $APP_NAME -G $APP_NAME 37 | WORKDIR $APP_HOME 38 | 39 | RUN apk update && apk add libpq py3-virtualenv zlib-dev jpeg-dev libpng-dev libwebp-dev 40 | 41 | COPY . $APP_HOME 42 | COPY --from=builder /app/spongeauth/static-build $APP_HOME/spongeauth/static-build 43 | COPY --from=builder /app/wheels /wheels 44 | RUN mkdir -p $HOME/public_html/static 45 | RUN mkdir -p $HOME/public_html/media 46 | 47 | RUN chown -R $APP_NAME:$APP_NAME $HOME 48 | USER $APP_NAME 49 | 50 | RUN sed -i 's/-e\ git+https:\/\/github\.com\/jazzband\/django-user-sessions.git#egg=//g' $APP_HOME/requirements/base.txt 51 | RUN python3 -m venv $HOME/env && $HOME/env/bin/pip install --upgrade --no-cache pip && $HOME/env/bin/pip install \ 52 | -r requirements/base.txt \ 53 | -r requirements/prod.txt \ 54 | --no-cache /wheels/* 55 | 56 | ENV DJANGO_SETTINGS_MODULE=spongeauth.settings.prod 57 | 58 | ENTRYPOINT ["sh", "-c", "$APP_HOME/entrypoint/run.sh"] 59 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) SpongePowered 4 | Copyright (c) contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | SpongeAuth 2 | ========== 3 | 4 | [![build](https://github.com/SpongePowered/SpongeAuth/workflows/build/badge.svg?branch=master)](https://github.com/SpongePowered/SpongeAuth/actions?query=branch%3Amaster) [![Coverage Status](https://coveralls.io/repos/github/SpongePowered/SpongeAuth/badge.svg?branch=master)](https://coveralls.io/github/SpongePowered/SpongeAuth?branch=master) 5 | 6 | An authentication portal for shared user accounts between Sponge services. 7 | 8 | Originally written in Play, but ported to Django and made more robust with more extensive testing. 9 | 10 | Developing 11 | ---------- 12 | 13 | You'll need: 14 | 15 | * A working Docker install (for Linux, install from your package manager; for macOS, use [Docker for Mac](https://docs.docker.com/docker-for-mac/install/); for Windows, use [Docker for Windows](https://docs.docker.com/docker-for-windows/install/)) 16 | * docker-compose (for Linux, install from your package manager; for macOS/Windows, these should be included with Docker for Mac/Windows) 17 | 18 | Run 19 | 20 | ``` 21 | docker-compose up 22 | ``` 23 | 24 | and wait for a bit. When you see 25 | 26 | ``` 27 | su -c '/env/bin/python spongeauth/manage.py runserver 0.0.0.0:8000' spongeauth 28 | ``` 29 | 30 | then you should be able to visit http://localhost:8000 and have a working SpongeAuth install. 31 | 32 | If you need an administrator account, you should be able to run: 33 | 34 | ``` 35 | docker-compose run app /env/bin/python spongeauth/manage.py createsuperuser 36 | ``` 37 | 38 | and follow the prompts to get an administrator account. This must be done after the `up` command above. 39 | -------------------------------------------------------------------------------- /closureexterns/gapi.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Externs for Google Platform APIs. 3 | */ 4 | 5 | /** @const */ 6 | var gapi = {}; 7 | window.gapi = gapi; 8 | 9 | /** @const */ 10 | gapi.auth2 = {}; 11 | 12 | gapi.auth2.init = function() {}; 13 | 14 | /** @typedef {{access_token: string, id_token: string, login_hint: string, scope: string, expires_in: string, first_issued_at: string, expires_at: string}} */ 15 | gapi.auth2.AuthResponse; 16 | 17 | /** @constructor */ 18 | gapi.auth2.BasicProfile = function() {}; 19 | 20 | /** @return {string} */ 21 | gapi.auth2.BasicProfile.prototype.getId = function() {}; 22 | 23 | /** @return {string} */ 24 | gapi.auth2.BasicProfile.prototype.getName = function() {}; 25 | 26 | /** @return {string} */ 27 | gapi.auth2.BasicProfile.prototype.getGivenName = function() {}; 28 | 29 | /** @return {string} */ 30 | gapi.auth2.BasicProfile.prototype.getFamilyName = function() {}; 31 | 32 | /** @return {string} */ 33 | gapi.auth2.BasicProfile.prototype.getImageUrl = function() {}; 34 | 35 | /** @return {string} */ 36 | gapi.auth2.BasicProfile.prototype.getEmail = function() {}; 37 | 38 | /** @constructor */ 39 | gapi.auth2.GoogleUser = function() {}; 40 | 41 | /** @return {?string} */ 42 | gapi.auth2.GoogleUser.prototype.getId = function() {}; 43 | 44 | /** @return {boolean} */ 45 | gapi.auth2.GoogleUser.prototype.isSignedIn = function() {}; 46 | 47 | /** @return {?string} */ 48 | gapi.auth2.GoogleUser.prototype.getHostedDomain = function() {}; 49 | 50 | /** @return {?string} */ 51 | gapi.auth2.GoogleUser.prototype.getGrantedScopes = function() {}; 52 | 53 | /** @return {!gapi.auth2.BasicProfile|undefined} */ 54 | gapi.auth2.GoogleUser.prototype.getBasicProfile = function() {}; 55 | 56 | /** @return {!gapi.auth2.AuthResponse} */ 57 | gapi.auth2.GoogleUser.prototype.getAuthResponse = function() {}; 58 | 59 | /** @return {!Promise} */ 60 | gapi.auth2.GoogleUser.prototype.reloadAuthResponse = function() {}; 61 | 62 | /** 63 | * @param {string} scopes 64 | * @return {boolean} 65 | */ 66 | gapi.auth2.GoogleUser.prototype.hasGrantedScopes = function(scopes) {}; 67 | 68 | gapi.auth2.GoogleUser.prototype.disconnect = function() {}; 69 | 70 | /** @constructor */ 71 | gapi.auth2.GoogleAuth = function() {}; 72 | 73 | gapi.auth2.GoogleAuth.prototype.isSignedIn = { 74 | /** @return {boolean} */ 75 | get: function() {}, 76 | 77 | /** @param {function(boolean)} listener */ 78 | listen: function(listener) {}, 79 | }; 80 | 81 | /** @constructor */ 82 | gapi.auth2.SigninOptionsBuilder = function() {}; 83 | 84 | /** 85 | * @param {(!Object|!gapi.auth2.SigninOptionsBuilder)=} options 86 | * @return {!Promise} 87 | */ 88 | gapi.auth2.GoogleAuth.prototype.signIn = function(options) {}; 89 | 90 | /** 91 | * @return {!Promise} 92 | */ 93 | gapi.auth2.GoogleAuth.prototype.signOut = function() {}; 94 | 95 | gapi.auth2.GoogleAuth.prototype.disconnect = function() {}; 96 | 97 | /** 98 | * @param {!Object=} options 99 | */ 100 | gapi.auth2.GoogleAuth.prototype.grantOfflineAccess = function(options) {}; 101 | 102 | /** 103 | * @param {string} container 104 | * @param {!Object=} options 105 | * @param {function()=} onsuccess 106 | * @param {function()=} onfailure 107 | */ 108 | gapi.auth2.GoogleAuth.prototype.attachClickHandler = function(container, options, onsuccess, onfailure) {}; 109 | 110 | gapi.auth2.GoogleAuth.prototype.currentUser = { 111 | /** @return {!gapi.auth2.GoogleUser} */ 112 | get: function() {}, 113 | 114 | /** @param {function(!gapi.auth2.GoogleUser)} listener */ 115 | listen: function(listener) {}, 116 | }; 117 | 118 | /** @returns {!gapi.auth2.GoogleAuth} */ 119 | gapi.auth2.getAuthInstance = function() {}; 120 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | postgres: 5 | image: postgres:latest 6 | container_name: spongeauth-postgres 7 | environment: 8 | POSTGRES_DB: spongeauth 9 | POSTGRES_USER: spongeauth 10 | POSTGRES_PASSWORD: spongeauth 11 | ports: 12 | - "5432:5432" 13 | volumes: 14 | - postgres_data:/var/lib/postgresql/data 15 | 16 | volumes: 17 | postgres_data: 18 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | 3 | volumes: 4 | redis_data: 5 | static: 6 | 7 | services: 8 | redis: 9 | image: redis:alpine 10 | command: redis-server --save 60 1 11 | restart: always 12 | volumes: 13 | - redis_data:/data 14 | app: 15 | build: . 16 | restart: always 17 | volumes: 18 | - static:/home/spongeauth/public_html/static 19 | - ../media:/home/spongeauth/public_html/media 20 | env_file: .env 21 | extra_hosts: 22 | - "host.docker.internal:host-gateway" 23 | links: 24 | - "redis" 25 | proxy: 26 | image: nginx:mainline-alpine 27 | restart: always 28 | volumes: 29 | - static:/usr/share/nginx/public_html/static 30 | - ../media:/usr/share/nginx/public_html/media 31 | - ./nginx.conf:/etc/nginx/nginx.conf 32 | links: 33 | - "app" 34 | ports: 35 | - "8080:80" 36 | -------------------------------------------------------------------------------- /entrypoint/run-worker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set +euxo pipefail 4 | 5 | # run worker 6 | $HOME/env/bin/python spongeauth/manage.py rqworker default 7 | -------------------------------------------------------------------------------- /entrypoint/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -euxo pipefail 4 | 5 | cd $APP_HOME 6 | 7 | $HOME/env/bin/python spongeauth/manage.py migrate 8 | $HOME/env/bin/python spongeauth/manage.py collectstatic --noinput 9 | 10 | set +euxo pipefail 11 | 12 | # run worker - necessary for background sso syncs 13 | ./entrypoint/run-worker.sh & 14 | 15 | $HOME/env/bin/gunicorn -b :8080 -w 4 --chdir spongeauth spongeauth.wsgi 16 | -------------------------------------------------------------------------------- /gulpfile.babel.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import minimist from 'minimist' 4 | import gulp from 'gulp' 5 | import sourcemaps from 'gulp-sourcemaps'; 6 | import cleanCSS from 'gulp-clean-css'; 7 | import moduleImporter from 'sass-module-importer'; 8 | import compilerPackage from 'google-closure-compiler'; 9 | import babel from 'gulp-babel' 10 | 11 | const sass = require('gulp-sass')(require('sass')); 12 | const closureCompiler = compilerPackage.gulp(); 13 | 14 | const paths = { 15 | inBase: './spongeauth/static', 16 | outBase: './spongeauth/static-build', 17 | 18 | styles: '/styles', 19 | appStyle: '/styles/app.scss', 20 | 21 | fonts: '/fonts', 22 | 23 | scripts: '/scripts', 24 | appScript: '/scripts/app.js', 25 | 26 | images: '/images', 27 | }; 28 | const production = minimist(process.argv).production || false; 29 | 30 | function styles() { 31 | let pipe = gulp.src(paths.inBase + paths.styles + '/*.scss') 32 | .pipe(sourcemaps.init()) 33 | .pipe(sass({ importer: moduleImporter() })); 34 | 35 | if (production) { 36 | pipe = pipe.pipe(cleanCSS()); 37 | } 38 | 39 | return pipe 40 | .pipe(sourcemaps.write('../maps/')) 41 | .pipe(gulp.dest(paths.outBase + paths.styles)); 42 | } 43 | 44 | function fonts() { 45 | return gulp.src([ 46 | './node_modules/font-awesome/fonts/fontawesome-webfont.*', 47 | './node_modules/bootstrap-sass/assets/fonts/bootstrap/glyphicons-halflings-regular.*', 48 | ]).pipe(gulp.dest(paths.outBase + paths.fonts)); 49 | } 50 | 51 | function images() { 52 | return gulp.src(paths.inBase + paths.images + '/**') 53 | .pipe(gulp.dest(paths.outBase + paths.images)); 54 | } 55 | 56 | const buildExterns = () => { 57 | const externsDir = './closureexterns'; 58 | const externsFiles = fs.readdirSync(externsDir); 59 | return externsFiles 60 | .filter((fn) => fn.endsWith('.js')) 61 | .map((fn) => path.join(externsDir, fn)); 62 | }; 63 | 64 | function scripts() { 65 | const compiler = production ? closureCompiler({ 66 | compilationLevel: 'ADVANCED', 67 | languageIn: 'STABLE', 68 | languageOut: 'ECMASCRIPT5_STRICT', 69 | jsOutputFile: 'app.js', 70 | assumeFunctionWrapper: true, 71 | outputWrapper: '(function(){%output%}).call(this)', 72 | externs: buildExterns(), 73 | warningLevel: 'VERBOSE', 74 | }) : babel({ 75 | presets: ['@babel/env'], 76 | }); 77 | 78 | return gulp.src(paths.inBase + paths.appScript) 79 | .pipe(sourcemaps.init()) 80 | .pipe(compiler) 81 | .pipe(sourcemaps.write('../maps/')) 82 | .pipe(gulp.dest(paths.outBase + paths.scripts)); 83 | } 84 | 85 | function watch() { 86 | gulp.watch(paths.inBase + paths.styles + '/**', styles); 87 | gulp.watch(paths.inBase + paths.scripts + '/**', scripts); 88 | gulp.watch(paths.inBase + paths.images + '/**', images); 89 | } 90 | 91 | const build = gulp.parallel(styles, fonts, scripts, images); 92 | 93 | exports.build = build; 94 | exports.watch = watch; 95 | exports.default = gulp.series(build, watch); 96 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | error_log /var/log/nginx/error.log; 4 | pid /run/nginx.pid; 5 | 6 | # Load dynamic modules. See /usr/share/nginx/README.dynamic. 7 | include /usr/share/nginx/modules/*.conf; 8 | 9 | events { 10 | worker_connections 1024; 11 | } 12 | 13 | http { 14 | log_format main '$remote_addr - $remote_user [$time_local] "$request" ' 15 | '$status $body_bytes_sent "$http_referer" ' 16 | '"$http_user_agent" "$http_x_forwarded_for"'; 17 | 18 | access_log /var/log/nginx/access.log main; 19 | 20 | sendfile on; 21 | tcp_nopush on; 22 | tcp_nodelay on; 23 | keepalive_timeout 65; 24 | types_hash_max_size 2048; 25 | 26 | client_max_body_size 300M; 27 | 28 | include /etc/nginx/mime.types; 29 | default_type application/octet-stream; 30 | 31 | proxy_cache_path /srv/cache levels=1:2 keys_zone=STATIC:10m inactive=6h max_size=2g; 32 | 33 | upstream spongeauth { 34 | server app:8080; 35 | } 36 | 37 | server { 38 | listen 80 default_server; 39 | listen [::]:80 default_server; 40 | server_name _; 41 | 42 | root /usr/share/nginx/public_html; 43 | 44 | rewrite ^/avatars(.*)$ /avatar$1; 45 | rewrite ^/settings$ /accounts/settings/ permanent; 46 | 47 | location / { 48 | try_files $uri @proxy_to_app; 49 | } 50 | 51 | location /static/ { 52 | add_header Cache-Control "public, max-age=3600, must-revalidate"; 53 | } 54 | 55 | location /avatar/ { 56 | proxy_cache_valid 5m; 57 | proxy_cache STATIC; 58 | proxy_cache_key "$request_method $request_uri $http_accept"; 59 | proxy_cache_lock on; 60 | add_header X-Cache-Status $upstream_cache_status; 61 | expires 5m; 62 | proxy_hide_header Vary; 63 | add_header Vary "Accept"; 64 | 65 | add_header Cache-Control "public, max-age=300, must-revalidate"; 66 | 67 | proxy_set_header X-Real-IP $remote_addr; 68 | proxy_set_header X-Forwarded-Proto $scheme; 69 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 70 | proxy_set_header Host $http_host; 71 | proxy_redirect off; 72 | proxy_http_version 1.1; 73 | proxy_pass http://spongeauth; 74 | } 75 | 76 | location @proxy_to_app { 77 | proxy_set_header X-Real-IP $remote_addr; 78 | proxy_set_header X-Forwarded-Proto $scheme; 79 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 80 | proxy_set_header Host $http_host; 81 | proxy_redirect off; 82 | proxy_http_version 1.1; 83 | proxy_pass http://spongeauth; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spongeauth", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "repository": "https://github.com/SpongePowered/SpongeAuth.git", 6 | "author": "The SpongePowered Team", 7 | "license": "MIT", 8 | "devDependencies": { 9 | "@babel/core": "^7.27.4", 10 | "@babel/preset-env": "^7.27.2", 11 | "@babel/register": "^7.27.1", 12 | "bootstrap-sass": "3.4.3", 13 | "font-awesome": "^4.7.0", 14 | "google-closure-compiler": "^20240317.0.0", 15 | "gulp": "^5.0.1", 16 | "gulp-babel": "^8.0.0", 17 | "gulp-clean-css": "^4.3.0", 18 | "gulp-cli": "^3.1.0", 19 | "gulp-sass": "^5.1.0", 20 | "gulp-sourcemaps": "^3.0.0", 21 | "minimist": "^1.2.8", 22 | "sass": "^1.89.1", 23 | "sass-module-importer": "^1.4.0" 24 | }, 25 | "resolutions": { 26 | "natives": "1.1.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /prospector.yml: -------------------------------------------------------------------------------- 1 | strictness: veryhigh 2 | 3 | doc-warnings: false 4 | test-warnings: true 5 | 6 | uses: 7 | - django 8 | 9 | ignore-paths: 10 | - node_modules 11 | - env 12 | - spongeauth/spongeauth/settings 13 | 14 | ignore-patterns: 15 | - ^spongeauth/[a-z]+/migrations(/|$) 16 | 17 | pycodestyle: 18 | full: true 19 | disable: 20 | - E203 21 | options: 22 | max-line-length: 120 23 | 24 | mccabe: 25 | run: false 26 | 27 | pylint: 28 | run: false # broken 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | target-version = ["py37"] 4 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE=spongeauth.settings.test 3 | -------------------------------------------------------------------------------- /requirements/base.txt: -------------------------------------------------------------------------------- 1 | Django==5.1.3 2 | -e git+https://github.com/jazzband/django-user-sessions.git#egg=django-user-sessions 3 | Pillow==11.2.1 4 | oauth2client==4.1.3 5 | django-crispy-forms==2.4 6 | crispy-bootstrap3==2024.1 7 | django-model-utils==5.0.0 8 | qrcode==8.2 9 | pytz==2025.2 10 | requests==2.32.3 11 | psycopg2-binary==2.9.10 12 | django-autocomplete-light==3.11.0 13 | django-rq==3.0.1 14 | rq<2.4 15 | setuptools==80.9.0 16 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | pytest-django==4.11.1 4 | prospector==1.11.0 5 | factory_boy==3.3.3 6 | Faker==37.3.0 7 | django-debug-toolbar==5.2.0 8 | pylint<3 9 | black==25.1.0 10 | pre-commit==4.2.0 11 | fakeredis==2.29.0 12 | -------------------------------------------------------------------------------- /requirements/prod.txt: -------------------------------------------------------------------------------- 1 | -r base.txt 2 | 3 | sentry-sdk==2.29.1 4 | gunicorn==23.0.0 5 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | -r dev.txt 2 | 3 | coveralls==4.0.1 4 | pytest-cov==6.1.1 5 | -------------------------------------------------------------------------------- /spongeauth/accounts/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/accounts/__init__.py -------------------------------------------------------------------------------- /spongeauth/accounts/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AccountsConfig(AppConfig): 5 | name = "accounts" 6 | -------------------------------------------------------------------------------- /spongeauth/accounts/middleware.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | from django.urls import resolve, reverse 4 | from django.shortcuts import redirect 5 | from django.conf import settings 6 | import django.urls.exceptions 7 | 8 | 9 | class RedirectIfConditionUnmet: 10 | REDIRECT_TO = None 11 | 12 | def __init__(self, get_response): 13 | self.get_response = get_response 14 | 15 | def __call__(self, request): 16 | if self.must_verify(request.user) and not self.may_pass(request.path): 17 | params = urllib.parse.urlencode({"next": request.get_full_path()}) 18 | return redirect("{}?{}".format(reverse(self.REDIRECT_TO), params)) 19 | 20 | response = self.get_response(request) 21 | return response 22 | 23 | @staticmethod 24 | def must_verify(user): 25 | raise NotImplementedError 26 | 27 | @staticmethod 28 | def may_pass(url): 29 | try: 30 | func = resolve(url).func 31 | except django.urls.exceptions.Resolver404: 32 | return False 33 | for f in ["allow_without_verified_email", "allow_without_agreed_tos"]: 34 | if getattr(func, f, False): 35 | return True 36 | return False 37 | 38 | 39 | class EnforceVerifiedEmails(RedirectIfConditionUnmet): 40 | REDIRECT_TO = "accounts:verify" 41 | 42 | @staticmethod 43 | def must_verify(user): 44 | return user.is_authenticated and not user.email_verified and settings.REQUIRE_EMAIL_CONFIRM 45 | 46 | 47 | def allow_without_verified_email(f): 48 | f.allow_without_verified_email = True 49 | return f 50 | 51 | 52 | class EnforceToSAccepted(RedirectIfConditionUnmet): 53 | REDIRECT_TO = "accounts:agree-tos" 54 | 55 | @staticmethod 56 | def must_verify(user): 57 | if not user.is_authenticated: 58 | return False 59 | return user.must_agree_tos().exists() 60 | 61 | 62 | def allow_without_agreed_tos(f): 63 | f.allow_without_agreed_tos = True 64 | return f 65 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-01-10 00:05 3 | from __future__ import unicode_literals 4 | 5 | import accounts.models 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | import django.db.models.deletion 9 | 10 | 11 | class Migration(migrations.Migration): 12 | 13 | initial = True 14 | 15 | dependencies = [] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="User", 20 | fields=[ 21 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 22 | ("password", models.CharField(max_length=128, verbose_name="password")), 23 | ("last_login", models.DateTimeField(blank=True, null=True, verbose_name="last login")), 24 | ("username", models.CharField(max_length=20, unique=True)), 25 | ("email", models.EmailField(max_length=255, unique=True)), 26 | ("email_verified", models.BooleanField(default=False)), 27 | ("is_active", models.BooleanField(default=True)), 28 | ("is_admin", models.BooleanField(default=False)), 29 | ( 30 | "mc_username", 31 | models.CharField(blank=True, max_length=255, null=True, verbose_name="Minecraft Username"), 32 | ), 33 | ("irc_nick", models.CharField(blank=True, max_length=255, null=True, verbose_name="IRC Nick")), 34 | ( 35 | "gh_username", 36 | models.CharField(blank=True, max_length=255, null=True, verbose_name="GitHub Username"), 37 | ), 38 | ("joined_at", models.DateTimeField(auto_now_add=True)), 39 | ("deleted_at", models.DateTimeField(blank=True, default=None, null=True)), 40 | ("twofa_enabled", models.BooleanField(default=False, verbose_name="2FA Enabled")), 41 | ], 42 | options={"abstract": False}, 43 | ), 44 | migrations.CreateModel( 45 | name="Avatar", 46 | fields=[ 47 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 48 | ("added_at", models.DateTimeField(auto_now_add=True)), 49 | ("image_file", models.ImageField(blank=True, null=True, upload_to=accounts.models._avatar_upload_path)), 50 | ("remote_url", models.URLField(blank=True, null=True)), 51 | ( 52 | "source", 53 | models.CharField( 54 | choices=[("upload", "Uploaded avatar"), ("url", "Avatar at URL")], 55 | default="upload", 56 | max_length=10, 57 | ), 58 | ), 59 | ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 60 | ], 61 | ), 62 | migrations.CreateModel( 63 | name="ExternalAuthenticator", 64 | fields=[ 65 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 66 | ("source", models.CharField(choices=[("google", "Google")], default="google", max_length=20)), 67 | ("external_id", models.CharField(max_length=255)), 68 | ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 69 | ], 70 | ), 71 | migrations.AddField( 72 | model_name="user", 73 | name="current_avatar", 74 | field=models.ForeignKey( 75 | blank=True, 76 | null=True, 77 | on_delete=django.db.models.deletion.SET_NULL, 78 | related_name="+", 79 | to="accounts.Avatar", 80 | ), 81 | ), 82 | ] 83 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0002_add_username_validator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-01-14 01:19 3 | from __future__ import unicode_literals 4 | 5 | import accounts.models 6 | from django.db import migrations, models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | dependencies = [("accounts", "0001_initial")] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="user", 16 | name="username", 17 | field=models.CharField(max_length=20, unique=True, validators=[accounts.models.validate_username]), 18 | ) 19 | ] 20 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0003_create_group_model.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-01-14 01:22 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("accounts", "0002_add_username_validator")] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Group", 15 | fields=[ 16 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 17 | ("name", models.CharField(max_length=80, unique=True)), 18 | ("internal_only", models.BooleanField(default=True)), 19 | ], 20 | ), 21 | migrations.AddField( 22 | model_name="user", 23 | name="groups", 24 | field=models.ManyToManyField( 25 | blank=True, 26 | related_name="user_set", 27 | related_query_name="user", 28 | to="accounts.Group", 29 | verbose_name="groups", 30 | ), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0004_create_dummy_group.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Creates "Dummy" group, used to flag dummy accounts created by API. 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | def forwards_func(apps, schema_editor): 9 | Group = apps.get_model("accounts", "Group") 10 | db_alias = schema_editor.connection.alias 11 | Group.objects.using(db_alias).bulk_create([Group(name="Dummy")]) 12 | 13 | 14 | def reverse_func(apps, schema_editor): 15 | Group = apps.get_model("accounts", "Group") 16 | db_alias = schema_editor.connection.alias 17 | Group.objects.using(db_alias).filter(name="Dummy").delete() 18 | 19 | 20 | class Migration(migrations.Migration): 21 | 22 | dependencies = [("accounts", "0003_create_group_model")] 23 | 24 | operations = [migrations.RunPython(forwards_func, reverse_func)] 25 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0005_user_is_staff.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.3 on 2017-07-21 22:03 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | def forwards_func(apps, schema_editor): 9 | User = apps.get_model("accounts", "User") 10 | db_alias = schema_editor.connection.alias 11 | User.objects.using(db_alias).filter(is_admin=True).update(is_staff=True) 12 | 13 | 14 | def reverse_func(apps, schema_editor): 15 | pass 16 | 17 | 18 | class Migration(migrations.Migration): 19 | 20 | dependencies = [("accounts", "0004_create_dummy_group")] 21 | 22 | operations = [ 23 | migrations.AddField(model_name="user", name="is_staff", field=models.BooleanField(default=False)), 24 | migrations.RunPython(forwards_func, reverse_func), 25 | ] 26 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0006_add_username_insensitive_index.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.11.3 on 2017-07-29 15:09 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("accounts", "0005_user_is_staff")] 11 | 12 | operations = [ 13 | migrations.RunSQL( 14 | "CREATE UNIQUE INDEX accounts_user_unique_username ON accounts_user (UPPER(username))", 15 | "DROP INDEX IF EXISTS accounts_user_unique_username", 16 | ) 17 | ] 18 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0007_group_add_internal_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.2 on 2018-03-17 14:54 2 | 3 | import accounts.models 4 | from django.db import migrations, models 5 | 6 | 7 | def populate_internal_name(apps, schema_editor): 8 | Group = apps.get_model("accounts", "Group") 9 | for group in Group.objects.all(): 10 | group.internal_name = group.name.lower().replace(" ", "_") 11 | group.save() 12 | 13 | 14 | class Migration(migrations.Migration): 15 | 16 | dependencies = [("accounts", "0006_add_username_insensitive_index")] 17 | 18 | operations = [ 19 | migrations.AddField( 20 | model_name="group", 21 | name="internal_name", 22 | field=models.CharField( 23 | default="", max_length=20, unique=True, validators=[accounts.models.validate_username] 24 | ), 25 | ), 26 | migrations.RunPython(populate_internal_name), 27 | migrations.AlterField( 28 | model_name="group", 29 | name="internal_name", 30 | field=models.CharField(max_length=20, unique=True, validators=[accounts.models.validate_username]), 31 | ), 32 | ] 33 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0008_auto_20180428_1214.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.3 on 2018-04-28 12:14 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("accounts", "0007_group_add_internal_name")] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="TermsOfService", 15 | fields=[ 16 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 17 | ("name", models.CharField(max_length=60, unique=True)), 18 | ("tos_date", models.DateField()), 19 | ("tos_url", models.URLField(unique=True)), 20 | ("current_tos", models.BooleanField(default=False)), 21 | ], 22 | ), 23 | migrations.CreateModel( 24 | name="TermsOfServiceAcceptance", 25 | fields=[ 26 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 27 | ("accepted_at", models.DateTimeField(auto_now_add=True)), 28 | ("tos", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="accounts.TermsOfService")), 29 | ("user", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 30 | ], 31 | ), 32 | migrations.AddField( 33 | model_name="user", 34 | name="tos_accepted", 35 | field=models.ManyToManyField( 36 | blank=True, 37 | related_name="agreed_users", 38 | related_query_name="agreed_users", 39 | through="accounts.TermsOfServiceAcceptance", 40 | to="accounts.TermsOfService", 41 | verbose_name="terms of service", 42 | ), 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0009_add_spongepowered_tos_2018-03-10.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.3 on 2018-04-28 12:53 2 | 3 | import datetime 4 | 5 | from django.db import migrations 6 | 7 | 8 | def create_tos(apps, schema_editor): 9 | TermsOfService = apps.get_model("accounts", "TermsOfService") 10 | db_alias = schema_editor.connection.alias 11 | tos = TermsOfService( 12 | name="SpongePowered Terms of Service (2018-03-10)", 13 | tos_date=datetime.date(2018, 3, 10), 14 | tos_url="https://docs.spongepowered.org/stable/en/about/tos.html", 15 | current_tos=True, 16 | ) 17 | tos.save(using=db_alias) 18 | 19 | 20 | class Migration(migrations.Migration): 21 | 22 | dependencies = [("accounts", "0008_auto_20180428_1214")] 23 | 24 | operations = [migrations.RunPython(create_tos)] 25 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0010_termsofservice_group.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.0.3 on 2018-04-28 15:05 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | def create_group_for_each_tos(apps, schema_editor): 8 | TermsOfService = apps.get_model("accounts", "TermsOfService") 9 | Group = apps.get_model("accounts", "Group") 10 | db_alias = schema_editor.connection.alias 11 | for tos in TermsOfService.objects.using(db_alias).all(): 12 | group = Group( 13 | name="Accepted ToS: " + tos.name, internal_name="accepted_tos_{}".format(tos.pk), internal_only=True 14 | ) 15 | group.save(using=db_alias) 16 | group.user_set.set(tos.agreed_users.all()) 17 | tos.group = group 18 | tos.save(using=db_alias) 19 | 20 | 21 | class Migration(migrations.Migration): 22 | 23 | dependencies = [("accounts", "0009_add_spongepowered_tos_2018-03-10")] 24 | 25 | operations = [ 26 | migrations.AddField( 27 | model_name="termsofservice", 28 | name="group", 29 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to="accounts.Group"), 30 | ), 31 | migrations.RunPython(create_group_for_each_tos), 32 | migrations.AlterField( 33 | model_name="termsofservice", 34 | name="group", 35 | field=models.ForeignKey(null=False, on_delete=django.db.models.deletion.CASCADE, to="accounts.Group"), 36 | ), 37 | ] 38 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0011_user_full_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-01-02 14:10 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("accounts", "0010_termsofservice_group")] 9 | 10 | operations = [ 11 | migrations.AddField( 12 | model_name="user", 13 | name="full_name", 14 | field=models.CharField(blank=True, max_length=255, null=True, unique=True), 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0012_auto_20190102_1545.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.4 on 2019-01-02 15:45 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [("accounts", "0011_user_full_name")] 9 | 10 | operations = [ 11 | migrations.AlterField( 12 | model_name="user", 13 | name="full_name", 14 | field=models.CharField(blank=True, max_length=255, null=True, verbose_name="Full Name"), 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/0013_user_discord_id.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.1.5 on 2019-02-13 14:28 2 | 3 | import accounts.models 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [("accounts", "0012_auto_20190102_1545")] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="user", 14 | name="discord_id", 15 | field=models.CharField( 16 | blank=True, max_length=255, validators=[accounts.models.validate_discord_id], verbose_name="Discord ID" 17 | ), 18 | ) 19 | ] 20 | -------------------------------------------------------------------------------- /spongeauth/accounts/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/accounts/migrations/__init__.py -------------------------------------------------------------------------------- /spongeauth/accounts/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/accounts/tests/__init__.py -------------------------------------------------------------------------------- /spongeauth/accounts/tests/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | import factory.django 3 | 4 | from .. import models 5 | 6 | 7 | class AvatarFactory(factory.django.DjangoModelFactory): 8 | class Meta: 9 | model = models.Avatar 10 | 11 | user = None 12 | added_at = factory.Faker("date_time_this_decade") 13 | 14 | source = models.Avatar.URL 15 | image_file = None 16 | remote_url = factory.Faker("image_url") 17 | 18 | class Params: 19 | uploaded = factory.Trait(source=models.Avatar.UPLOAD, image_file=factory.django.ImageField(), remote_url=None) 20 | 21 | 22 | class UserFactory(factory.django.DjangoModelFactory): 23 | class Meta: 24 | model = models.User 25 | 26 | email = factory.Faker("safe_email") 27 | email_verified = True 28 | username = factory.Faker("user_name") 29 | password = factory.PostGenerationMethodCall("set_password", "secret") 30 | 31 | full_name = factory.Faker("name") 32 | mc_username = factory.Faker("user_name") 33 | gh_username = factory.Faker("user_name") 34 | irc_nick = factory.Faker("user_name") 35 | discord_id = "user_name#1234" 36 | 37 | joined_at = factory.Faker("date_time_this_decade") 38 | 39 | tos_accepted = factory.PostGenerationMethodCall("_test_agree_all_tos") 40 | 41 | 42 | class GroupFactory(factory.django.DjangoModelFactory): 43 | class Meta: 44 | model = models.Group 45 | 46 | name = factory.Faker("user_name") 47 | internal_name = factory.Faker("user_name") 48 | -------------------------------------------------------------------------------- /spongeauth/accounts/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | import unittest.mock 2 | import pytest 3 | 4 | from .. import admin 5 | from .. import models 6 | from . import factories 7 | 8 | 9 | class TestAdminUserChangeForm: 10 | def test_clean_password(self): 11 | user = factories.UserFactory.build() 12 | form = admin.AdminUserChangeForm(instance=user) 13 | assert form.clean_password() == user.password 14 | 15 | 16 | @pytest.mark.django_db 17 | class TestAdminUserChangeFormWithDatabase: 18 | def make_post_data(self, user, **kwargs): 19 | post_data = {"username": user.username, "email": user.email} 20 | bool_fields = ["email_verified", "is_active"] 21 | for bool_field in bool_fields: 22 | if getattr(user, bool_field): 23 | post_data[bool_field] = "on" 24 | post_data.update(**kwargs) 25 | return post_data 26 | 27 | def test_does_not_validate_username_if_it_is_unchanged(self): 28 | user = factories.UserFactory.create(username="ewoutvs_") 29 | post_data = self.make_post_data(user) 30 | form = admin.AdminUserChangeForm(post_data, instance=user) 31 | form.save() 32 | 33 | def test_does_validate_username_if_it_changes(self): 34 | user = factories.UserFactory.create(username="ewoutvs_") 35 | post_data = self.make_post_data(user, username="ewoutvs__") 36 | form = admin.AdminUserChangeForm(post_data, instance=user) 37 | with pytest.raises(ValueError): 38 | form.save() 39 | 40 | def test_validates_username(self): 41 | user = factories.UserFactory.create() 42 | post_data = self.make_post_data(user, username="ewoutvs_") 43 | form = admin.AdminUserChangeForm(post_data, instance=user) 44 | with pytest.raises(ValueError): 45 | form.save() 46 | 47 | 48 | class TestUserAdmin: 49 | def test_get_readonly_fields(self): 50 | admin_user = factories.UserFactory.build(is_admin=True, is_staff=True) 51 | staff_user = factories.UserFactory.build(is_staff=True) 52 | user = factories.UserFactory.build() 53 | 54 | request = unittest.mock.MagicMock() 55 | obj = admin.UserAdmin(models.User, None) 56 | 57 | request.user = admin_user 58 | assert obj.get_readonly_fields(request, admin_user) == () 59 | assert obj.get_readonly_fields(request, staff_user) == () 60 | assert obj.get_readonly_fields(request, user) == () 61 | 62 | request.user = staff_user 63 | assert "username" in obj.get_readonly_fields(request, admin_user) 64 | assert "username" not in obj.get_readonly_fields(request, staff_user) 65 | assert "is_staff" in obj.get_readonly_fields(request, staff_user) 66 | assert "is_admin" in obj.get_readonly_fields(request, staff_user) 67 | assert "is_staff" in obj.get_readonly_fields(request, user) 68 | assert "is_admin" in obj.get_readonly_fields(request, user) 69 | 70 | def test_delete_model(self): 71 | user = factories.UserFactory.build() 72 | assert user.deleted_at is None 73 | assert user.is_active 74 | user.save = lambda: None 75 | 76 | obj = admin.UserAdmin(models.User, None) 77 | obj.delete_model(None, user) 78 | assert user.deleted_at is not None 79 | assert not user.is_active 80 | -------------------------------------------------------------------------------- /spongeauth/accounts/tests/test_avatar_for_user.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os.path 3 | 4 | import pytest 5 | import unittest.mock 6 | import PIL 7 | 8 | from .. import models, views 9 | 10 | _TESTDATA = os.path.join(os.path.dirname(__file__), "testdata") 11 | _TEST_INPUT_FILE = open(os.path.join(_TESTDATA, "input.png"), "rb").read() 12 | 13 | _WRITE_OUT_IMAGES = False 14 | 15 | 16 | def _create_mocks(size, accept): 17 | avatar = unittest.mock.MagicMock() 18 | avatar.source = models.Avatar.UPLOAD 19 | avatar.image_file.file = io.BytesIO(_TEST_INPUT_FILE) 20 | user = unittest.mock.MagicMock() 21 | user.avatar = avatar 22 | request = unittest.mock.MagicMock() 23 | request.GET = {"size": size} 24 | request.META = {"HTTP_ACCEPT": accept} 25 | return user, request 26 | 27 | 28 | @pytest.mark.parametrize( 29 | "size,out_filename", 30 | [ 31 | ("210x210", "input.png"), 32 | ("210", "input.png"), 33 | ("zzrot", "120x120.png"), 34 | ("100x100", "100x100.png"), 35 | ("100x50", "100x50.png"), 36 | ("50x100", "50x100.png"), 37 | ("1024x1024", "240x240.png"), 38 | ("2048x1024", "240x120.png"), 39 | ("1024x2048", "120x240.png"), 40 | ], 41 | ) 42 | def test_avatar_for_user_upload(size, out_filename): 43 | user, request = _create_mocks(size, "image/png") 44 | with unittest.mock.patch.object(views, "get_object_or_404") as get_object_or_404: 45 | get_object_or_404.return_value = user 46 | resp = views.avatar_for_user(request, "foo") 47 | assert resp.status_code == 200 48 | assert resp["Content-Type"] == "image/png" 49 | im = PIL.Image.open(io.BytesIO(resp.getvalue())) 50 | if out_filename is not None: 51 | if _WRITE_OUT_IMAGES: 52 | im.save(os.path.join(_TESTDATA, out_filename)) 53 | else: 54 | want_im = PIL.Image.open(os.path.join(_TESTDATA, out_filename)) 55 | assert PIL.ImageChops.difference(im, want_im).getbbox() is None 56 | 57 | 58 | def test_avatar_for_user_upload_webp(): 59 | user, request = _create_mocks("210x210", "image/webp") 60 | with unittest.mock.patch.object(views, "get_object_or_404") as get_object_or_404: 61 | get_object_or_404.return_value = user 62 | resp = views.avatar_for_user(request, "foo") 63 | assert resp.status_code == 200 64 | assert resp["Content-Type"] == "image/webp" 65 | assert PIL.Image.open(io.BytesIO(resp.getvalue())) 66 | 67 | 68 | def test_avatar_for_user_upload_slowpath(): 69 | user, request = _create_mocks("210x210", "image/png") 70 | user.avatar.image_file.read = user.avatar.image_file.file.read 71 | user.avatar.image_file.file = None 72 | with unittest.mock.patch.object(views, "get_object_or_404") as get_object_or_404: 73 | get_object_or_404.return_value = user 74 | resp = views.avatar_for_user(request, "foo") 75 | assert resp.status_code == 200 76 | assert resp["Content-Type"] == "image/png" 77 | assert PIL.Image.open(io.BytesIO(resp.getvalue())) 78 | 79 | 80 | @pytest.mark.parametrize( 81 | "size,out_s", 82 | [ 83 | ("210x210", "210"), 84 | ("210", "210"), 85 | ("zzrot", "120"), 86 | ("100x100", "100"), 87 | ("100x50", "100"), 88 | ("50x100", "100"), 89 | ("1024x1024", "240"), 90 | ("2048x1024", "240"), 91 | ("1024x2048", "240"), 92 | ], 93 | ) 94 | def test_avatar_for_user_gravatar(size, out_s): 95 | user, request = _create_mocks(size, "") 96 | user.avatar = models.Avatar(source=models.Avatar.URL, remote_url="https://example.com/foo.png") 97 | with unittest.mock.patch.object(views, "get_object_or_404") as get_object_or_404: 98 | get_object_or_404.return_value = user 99 | resp = views.avatar_for_user(request, "foo") 100 | assert resp.status_code == 302 101 | assert resp["Location"] == "https://example.com/foo.png?s=" + out_s 102 | -------------------------------------------------------------------------------- /spongeauth/accounts/tests/test_discord_id_validation.py: -------------------------------------------------------------------------------- 1 | import django.core.exceptions 2 | 3 | import pytest 4 | 5 | import accounts.models 6 | import accounts.tests.factories 7 | 8 | 9 | BAD_EXAMPLES = [ 10 | ("felixoi#3123", []), 11 | ("testaccountxyz#9872", []), 12 | ("foobar#12345", ["wrong_pattern"]), 13 | ("ewoutvs_", ["wrong_pattern"]), 14 | ("#1234", ["wrong_pattern"]), 15 | ] 16 | 17 | 18 | @pytest.mark.parametrize("test_input,expected", BAD_EXAMPLES) 19 | def test_validate_username(test_input, expected): 20 | got = set() 21 | try: 22 | accounts.models.validate_discord_id(test_input) 23 | except django.core.exceptions.ValidationError as err: 24 | for suberr in err.error_list: 25 | got.add(suberr.code) 26 | assert got == set(expected) 27 | 28 | 29 | @pytest.mark.parametrize("test_input,expected", BAD_EXAMPLES) 30 | def test_validate_username_model(test_input, expected): 31 | got = set() 32 | try: 33 | user = accounts.tests.factories.UserFactory.build(discord_id=test_input) 34 | user.clean_fields() 35 | except django.core.exceptions.ValidationError as err: 36 | for suberr in err.error_dict["discord_id"]: 37 | got.add(suberr.code) 38 | assert got == set(expected) 39 | -------------------------------------------------------------------------------- /spongeauth/accounts/tests/test_letter_avatar.py: -------------------------------------------------------------------------------- 1 | from .. import letter_avatar 2 | 3 | import pytest 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "username,expected", [("lukegb", "e47774"), ("windy", "91b2a8"), ("Moose", "ee7513"), ("sAlaMi", "f05b48")] 8 | ) 9 | def test_colours(username, expected): 10 | assert letter_avatar.LetterAvatar(username).colour == expected 11 | 12 | 13 | def test_get_absolute_url(): 14 | av = letter_avatar.LetterAvatar("sAlaMi") 15 | assert av.get_absolute_url() == ( 16 | "https://avatars.discourse-cdn.com/v4/letter/s/f05b48/240.png" 17 | ) 18 | -------------------------------------------------------------------------------- /spongeauth/accounts/tests/test_middleware_enforce_verified_emails.py: -------------------------------------------------------------------------------- 1 | import unittest.mock 2 | 3 | from django.urls import re_path, include 4 | import django.http 5 | import django.test 6 | import django.shortcuts 7 | from django.contrib.auth.models import AnonymousUser 8 | 9 | import accounts.tests.factories 10 | from .. import middleware 11 | 12 | 13 | class TestMustVerify: 14 | def test_not_logged_in(self): 15 | assert not middleware.EnforceVerifiedEmails.must_verify(AnonymousUser()) 16 | 17 | def test_email_verified(self): 18 | user = accounts.tests.factories.UserFactory.build() 19 | assert not middleware.EnforceVerifiedEmails.must_verify(user) 20 | 21 | def test_email_not_verified(self): 22 | user = accounts.tests.factories.UserFactory.build(email_verified=False) 23 | assert middleware.EnforceVerifiedEmails.must_verify(user) 24 | 25 | @django.test.override_settings(REQUIRE_EMAIL_CONFIRM=False) 26 | def test_setting_require_email_confirm(self): 27 | user = accounts.tests.factories.UserFactory.build(email_verified=False) 28 | assert not middleware.EnforceVerifiedEmails.must_verify(user) 29 | 30 | 31 | @django.test.override_settings(ROOT_URLCONF="accounts.tests.test_middleware_enforce_verified_emails") 32 | class TestMayPass(django.test.SimpleTestCase): 33 | def test_allowed(self): 34 | assert middleware.EnforceVerifiedEmails.may_pass("/allowed/") 35 | 36 | def test_not_allowed(self): 37 | assert not middleware.EnforceVerifiedEmails.may_pass("/not-allowed/") 38 | 39 | def test_fourohfour(self): 40 | assert not middleware.EnforceVerifiedEmails.may_pass("/404/") 41 | 42 | 43 | @unittest.mock.patch("accounts.middleware.EnforceVerifiedEmails.must_verify") 44 | def test_need_not_verify(mock_must_verify): 45 | mock_must_verify.return_value = False 46 | request = unittest.mock.MagicMock() 47 | get_response = unittest.mock.MagicMock() 48 | get_response.return_value = object() 49 | request.user = object() 50 | request.path = object() 51 | assert middleware.EnforceVerifiedEmails(get_response)(request) is get_response.return_value 52 | 53 | 54 | @unittest.mock.patch("accounts.middleware.EnforceVerifiedEmails.must_verify") 55 | @unittest.mock.patch("accounts.middleware.EnforceVerifiedEmails.may_pass") 56 | def test_must_verify_may_pass(mock_may_pass, mock_must_verify): 57 | mock_must_verify.return_value = True 58 | mock_may_pass.return_value = True 59 | request = unittest.mock.MagicMock() 60 | get_response = unittest.mock.MagicMock() 61 | get_response.return_value = object() 62 | request.user = object() 63 | request.path = object() 64 | assert middleware.EnforceVerifiedEmails(get_response)(request) is get_response.return_value 65 | 66 | 67 | @unittest.mock.patch("accounts.middleware.EnforceVerifiedEmails.must_verify") 68 | @unittest.mock.patch("accounts.middleware.EnforceVerifiedEmails.may_pass") 69 | def test_must_verify_may_not_pass(mock_may_pass, mock_must_verify): 70 | mock_must_verify.return_value = True 71 | mock_may_pass.return_value = False 72 | request = unittest.mock.MagicMock() 73 | get_response = unittest.mock.MagicMock() 74 | get_response.return_value = object() 75 | request.user = object() 76 | request.path = object() 77 | resp = middleware.EnforceVerifiedEmails(get_response)(request) 78 | assert resp is not get_response.return_value 79 | assert isinstance(resp, django.http.HttpResponseRedirect) 80 | 81 | 82 | def not_decorated_view(request): 83 | return django.http.HttpResponse("hi") 84 | 85 | 86 | @middleware.allow_without_verified_email 87 | def decorated_view(request): 88 | return django.http.HttpResponse("nay") 89 | 90 | 91 | urlpatterns = [ 92 | re_path(r"^allowed/$", decorated_view, name="allowed"), 93 | re_path(r"^not-allowed/$", not_decorated_view, name="not-allowed"), 94 | re_path(r"", include(([re_path(r"^verify/$", decorated_view, name="verify")], "accounts"))), 95 | ] 96 | -------------------------------------------------------------------------------- /spongeauth/accounts/tests/test_small_views.py: -------------------------------------------------------------------------------- 1 | import django.test 2 | import django.shortcuts 3 | import django.http 4 | 5 | from . import factories 6 | 7 | 8 | class TestLogoutSuccess(django.test.TestCase): 9 | def path(self): 10 | return django.shortcuts.reverse("accounts:logout-success") 11 | 12 | def test_redirects_to_index_if_logged_in(self): 13 | user = factories.UserFactory.create() 14 | client = django.test.Client() 15 | client.login(username=user.username, password="secret") 16 | 17 | resp = client.get(self.path()) 18 | assert resp.status_code == 302 19 | assert resp["Location"] == django.shortcuts.reverse("index") 20 | 21 | def test_renders_logged_out_page(self): 22 | client = django.test.Client() 23 | with self.assertTemplateUsed("accounts/logout_success.html"): 24 | resp = client.get(self.path()) 25 | assert resp.status_code == 200 26 | 27 | 28 | class TestLogout(django.test.TestCase): 29 | def setUp(self): 30 | self.user = factories.UserFactory.create() 31 | self.client = django.test.Client() 32 | self.login(self.client, self.user) 33 | 34 | def login(self, c, user): 35 | assert c.login(username=user.username, password="secret") 36 | 37 | def path(self): 38 | return django.shortcuts.reverse("accounts:logout") 39 | 40 | def test_redirects_to_index_if_logged_out(self): 41 | client = django.test.Client() 42 | resp = client.get(self.path()) 43 | assert resp.status_code == 302 44 | assert resp["Location"] == django.shortcuts.reverse("index") 45 | 46 | def test_renders_form_on_get(self): 47 | with self.assertTemplateUsed("accounts/logout.html"): 48 | resp = self.client.get(self.path()) 49 | assert resp.status_code == 200 50 | 51 | def test_logs_out_on_post(self): 52 | resp = self.client.post(self.path()) 53 | self.assertRedirects(resp, django.shortcuts.reverse("accounts:logout-success")) 54 | assert resp.status_code == 302 55 | user = django.contrib.auth.get_user(self.client) 56 | assert not user.is_authenticated 57 | 58 | 59 | class TestAvatarForUser(django.test.TestCase): 60 | def setUp(self): 61 | self.user = factories.UserFactory.create() 62 | 63 | def path(self, username): 64 | return django.shortcuts.reverse("avatar-for-user", kwargs={"username": username}) 65 | 66 | def test_404_on_not_exist(self): 67 | resp = self.client.get(self.path(self.user.username + "b")) 68 | assert resp.status_code == 404 69 | 70 | def test_redirects(self): 71 | resp = self.client.get(self.path(self.user.username)) 72 | assert resp.status_code == 302 73 | assert resp["Location"] == self.user.avatar.get_absolute_url() 74 | -------------------------------------------------------------------------------- /spongeauth/accounts/tests/test_username_validation.py: -------------------------------------------------------------------------------- 1 | import django.core.exceptions 2 | 3 | import pytest 4 | 5 | import accounts.models 6 | import accounts.tests.factories 7 | 8 | 9 | BAD_EXAMPLES = [ 10 | ("lukegb", []), 11 | ("_lukegb", []), 12 | ("a", ["username_min_length"]), 13 | ("__", ["username_double_special", "username_min_length", "username_ending_charset"]), 14 | ("._", ["username_double_special", "username_min_length", "username_ending_charset", "username_initial_charset"]), 15 | ("\N{SNOWMAN}", ["username_charset", "username_min_length", "username_ending_charset", "username_initial_charset"]), 16 | (".png", ["username_file_suffix", "username_initial_charset"]), 17 | ("lukegb.png", ["username_file_suffix"]), 18 | ("luke__gb", ["username_double_special"]), 19 | ("luke_.gb", ["username_double_special"]), 20 | ("lukegb_", ["username_ending_charset"]), 21 | ("-lukegb", ["username_initial_charset"]), 22 | ] 23 | 24 | 25 | @pytest.mark.parametrize("test_input,expected", BAD_EXAMPLES) 26 | def test_validate_username(test_input, expected): 27 | got = set() 28 | try: 29 | accounts.models.validate_username(test_input) 30 | except django.core.exceptions.ValidationError as err: 31 | for suberr in err.error_list: 32 | got.add(suberr.code) 33 | assert got == set(expected) 34 | 35 | 36 | @pytest.mark.parametrize("test_input,expected", BAD_EXAMPLES) 37 | def test_validate_username_model(test_input, expected): 38 | got = set() 39 | try: 40 | user = accounts.tests.factories.UserFactory.build(username=test_input) 41 | user.clean_fields() 42 | except django.core.exceptions.ValidationError as err: 43 | for suberr in err.error_dict["username"]: 44 | got.add(suberr.code) 45 | assert got == set(expected) 46 | -------------------------------------------------------------------------------- /spongeauth/accounts/tests/test_view_autocomplete.py: -------------------------------------------------------------------------------- 1 | import django.test 2 | import django.shortcuts 3 | 4 | from . import factories 5 | 6 | import json 7 | 8 | 9 | class TestViewAutocomplete(django.test.TestCase): 10 | def setUp(self): 11 | self.user = factories.UserFactory.create(is_staff=True, is_admin=True) 12 | self.client = django.test.Client() 13 | assert self.client.login(username=self.user.username, password="secret") 14 | 15 | def path(self): 16 | return django.shortcuts.reverse("accounts:users-autocomplete") 17 | 18 | def test_get_no_permission(self): 19 | self.user.is_staff = False 20 | self.user.is_admin = False 21 | self.user.save() 22 | resp = self.client.get(self.path()) 23 | assert resp.status_code == 200 24 | resp_dict = json.loads(resp.content) 25 | assert resp_dict["results"] == [] 26 | 27 | def test_get_without_query(self): 28 | resp = self.client.get(self.path()) 29 | assert resp.status_code == 200 30 | resp_dict = json.loads(resp.content) 31 | assert resp_dict["results"] == [ 32 | {"id": str(self.user.id), "text": self.user.username, "selected_text": self.user.username} 33 | ] 34 | 35 | def test_get_with_query_match(self): 36 | resp = self.client.get(self.path() + "?q=" + self.user.username[:2]) 37 | assert resp.status_code == 200 38 | resp_dict = json.loads(resp.content) 39 | assert resp_dict["results"] == [ 40 | {"id": str(self.user.id), "text": self.user.username, "selected_text": self.user.username} 41 | ] 42 | 43 | def test_get_with_query_no_match(self): 44 | resp = self.client.get(self.path() + "?q=!") 45 | assert resp.status_code == 200 46 | resp_dict = json.loads(resp.content) 47 | assert resp_dict["results"] == [] 48 | -------------------------------------------------------------------------------- /spongeauth/accounts/tests/testdata/100x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/accounts/tests/testdata/100x100.png -------------------------------------------------------------------------------- /spongeauth/accounts/tests/testdata/100x50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/accounts/tests/testdata/100x50.png -------------------------------------------------------------------------------- /spongeauth/accounts/tests/testdata/120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/accounts/tests/testdata/120x120.png -------------------------------------------------------------------------------- /spongeauth/accounts/tests/testdata/120x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/accounts/tests/testdata/120x240.png -------------------------------------------------------------------------------- /spongeauth/accounts/tests/testdata/240x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/accounts/tests/testdata/240x120.png -------------------------------------------------------------------------------- /spongeauth/accounts/tests/testdata/240x240.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/accounts/tests/testdata/240x240.png -------------------------------------------------------------------------------- /spongeauth/accounts/tests/testdata/50x100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/accounts/tests/testdata/50x100.png -------------------------------------------------------------------------------- /spongeauth/accounts/tests/testdata/input.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/accounts/tests/testdata/input.png -------------------------------------------------------------------------------- /spongeauth/accounts/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | import accounts.views 4 | 5 | app_name = "accounts" 6 | 7 | RESET_TOKEN_RE = r"(?P[0-9A-Za-z_\-]+)/" r"(?P[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,32})/" 8 | 9 | urlpatterns = [ 10 | re_path(r"^logout/$", accounts.views.logout, name="logout"), 11 | re_path(r"^logout/success/$", accounts.views.logout_success, name="logout-success"), 12 | re_path(r"^settings/$", accounts.views.settings, name="settings"), 13 | re_path(r"^login/$", accounts.views.login, name="login"), 14 | re_path(r"^register/$", accounts.views.register, name="register"), 15 | re_path(r"^verify/$", accounts.views.verify, name="verify"), 16 | re_path(r"^verify/" + RESET_TOKEN_RE + r"$", accounts.views.verify_step2, name="verify-step2"), 17 | re_path(r"^change-email/$", accounts.views.change_email, name="change-email"), 18 | re_path(r"^change-email/sent/$", accounts.views.change_email_step1done, name="change-email-sent"), 19 | re_path( 20 | r"^change-email/" + RESET_TOKEN_RE + r"(?P[0-9A-Za-z_\-]+)/$", 21 | accounts.views.change_email_step2, 22 | name="change-email-step2", 23 | ), 24 | re_path(r"^reset/$", accounts.views.forgot, name="forgot"), 25 | re_path(r"^reset/sent/$", accounts.views.forgot_step1done, name="forgot-sent"), 26 | re_path(r"^reset/" + RESET_TOKEN_RE + r"$", accounts.views.forgot_step2, name="forgot-step2"), 27 | re_path(r"^agree-tos/$", accounts.views.agree_tos, name="agree-tos"), 28 | re_path(r"^user/(?P[^/]+)/change-avatar/$", accounts.views.change_other_avatar, name="change-avatar"), 29 | re_path(r"^__internal/autocomplete/users/$", accounts.views.UserAutocomplete.as_view(), name="users-autocomplete"), 30 | ] 31 | -------------------------------------------------------------------------------- /spongeauth/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/api/__init__.py -------------------------------------------------------------------------------- /spongeauth/api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | import api.models 4 | 5 | admin.site.register(api.models.APIKey) 6 | -------------------------------------------------------------------------------- /spongeauth/api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | name = "api" 6 | -------------------------------------------------------------------------------- /spongeauth/api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-01-13 20:58 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name="APIKey", 17 | fields=[ 18 | ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), 19 | ("key", models.CharField(max_length=52)), 20 | ("description", models.CharField(blank=True, max_length=255)), 21 | ], 22 | ) 23 | ] 24 | -------------------------------------------------------------------------------- /spongeauth/api/migrations/0002_auto_20170114_0120.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-01-14 01:20 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [("api", "0001_initial")] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="apikey", name="description", field=models.CharField(blank=True, default="", max_length=255) 15 | ) 16 | ] 17 | -------------------------------------------------------------------------------- /spongeauth/api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/api/migrations/__init__.py -------------------------------------------------------------------------------- /spongeauth/api/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class APIKey(models.Model): 5 | key = models.CharField(max_length=52, null=False, blank=False) 6 | description = models.CharField(max_length=255, null=False, blank=True, default="") 7 | 8 | def __str__(self): 9 | return self.description or "" 10 | -------------------------------------------------------------------------------- /spongeauth/api/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/api/tests/__init__.py -------------------------------------------------------------------------------- /spongeauth/api/tests/test_delete_user.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | import django.shortcuts 4 | 5 | import pytest 6 | import faker 7 | 8 | import accounts.tests.factories 9 | import api.models 10 | 11 | 12 | @pytest.fixture 13 | def fake(): 14 | return faker.Faker() 15 | 16 | 17 | def _make_path(data): 18 | return "{}?{}".format(django.shortcuts.reverse("api:users-list"), urllib.parse.urlencode(data)) 19 | 20 | 21 | @pytest.mark.django_db 22 | def test_invalid_api_key(client, fake): 23 | assert not api.models.APIKey.objects.exists() 24 | resp = client.delete(_make_path({"apiKey": "foobar", "username": fake.user_name()})) 25 | assert resp.status_code == 403 26 | 27 | 28 | @pytest.mark.django_db 29 | def test_works(client): 30 | api.models.APIKey.objects.create(key="foobar") 31 | 32 | assert not accounts.models.User.objects.exists() 33 | user = accounts.tests.factories.UserFactory.create() 34 | assert user.deleted_at is None 35 | assert user.is_active 36 | 37 | resp = client.delete(_make_path({"apiKey": "foobar", "username": user.username})) 38 | assert resp.status_code == 200 39 | 40 | # check database 41 | user = accounts.models.User.objects.get(id=user.id) 42 | assert user.deleted_at is not None 43 | assert not user.is_active 44 | 45 | # check response 46 | data = resp.json() 47 | assert data["id"] == user.id 48 | assert data["username"] == user.username 49 | assert data["email"] == user.email 50 | assert "avatar_url" in data 51 | 52 | 53 | @pytest.mark.django_db 54 | def test_not_existing(client, fake): 55 | api.models.APIKey.objects.create(key="foobar") 56 | 57 | resp = client.delete(_make_path({"apiKey": "foobar", "username": fake.user_name()})) 58 | assert resp.status_code == 404 59 | 60 | 61 | @pytest.mark.django_db 62 | def test_deleted(client, fake): 63 | api.models.APIKey.objects.create(key="foobar") 64 | 65 | user = accounts.tests.factories.UserFactory.create(deleted_at=fake.date_time_this_century(), is_active=False) 66 | 67 | resp = client.delete(_make_path({"apiKey": "foobar", "username": user.username})) 68 | assert resp.status_code == 404 69 | -------------------------------------------------------------------------------- /spongeauth/api/tests/test_get_user.py: -------------------------------------------------------------------------------- 1 | import django.shortcuts 2 | 3 | import pytest 4 | import faker 5 | 6 | import accounts.models 7 | import accounts.tests.factories 8 | import api.models 9 | 10 | 11 | @pytest.fixture 12 | def fake(): 13 | return faker.Faker() 14 | 15 | 16 | @pytest.mark.django_db 17 | def test_invalid_api_key(client, fake): 18 | assert not api.models.APIKey.objects.exists() 19 | resp = client.get( 20 | django.shortcuts.reverse("api:users-detail", kwargs={"username": fake.user_name()}), {"apiKey": "foobar"} 21 | ) 22 | assert resp.status_code == 403 23 | 24 | 25 | @pytest.mark.django_db 26 | def test_existing_user(client, fake): 27 | api.models.APIKey.objects.create(key="foobar") 28 | 29 | user = accounts.tests.factories.UserFactory.create() 30 | resp = client.get( 31 | django.shortcuts.reverse("api:users-detail", kwargs={"username": user.username}), {"apiKey": "foobar"} 32 | ) 33 | assert resp.status_code == 200 34 | 35 | data = resp.json() 36 | assert data["id"] == user.id 37 | assert data["username"] == user.username 38 | assert data["email"] == user.email 39 | assert "avatar_url" in data 40 | 41 | 42 | @pytest.mark.django_db 43 | def test_deleted_user(client, fake): 44 | api.models.APIKey.objects.create(key="foobar") 45 | 46 | user = accounts.tests.factories.UserFactory.create(is_active=False, deleted_at=fake.date_time_this_century()) 47 | resp = client.get( 48 | django.shortcuts.reverse("api:users-detail", kwargs={"username": user.username}), {"apiKey": "foobar"} 49 | ) 50 | assert resp.status_code == 404 51 | 52 | 53 | @pytest.mark.django_db 54 | def test_nonexistent_user(client, fake): 55 | api.models.APIKey.objects.create(key="foobar") 56 | 57 | resp = client.get( 58 | django.shortcuts.reverse("api:users-detail", kwargs={"username": fake.user_name()}), {"apiKey": "foobar"} 59 | ) 60 | assert resp.status_code == 404 61 | 62 | 63 | @pytest.mark.django_db 64 | def test_existing_user_in_group(client, fake): 65 | api.models.APIKey.objects.create(key="foobar") 66 | 67 | user = accounts.tests.factories.UserFactory.create() 68 | group = accounts.tests.factories.GroupFactory.create() 69 | user.groups.set([group]) 70 | user.save() 71 | 72 | resp = client.get( 73 | django.shortcuts.reverse("api:users-detail", kwargs={"username": user.username}), {"apiKey": "foobar"} 74 | ) 75 | assert resp.status_code == 200 76 | 77 | data = resp.json() 78 | assert "groups" in data 79 | assert len(data["groups"]) == 1 80 | assert data["groups"][0] == {"id": group.id, "name": group.name} 81 | -------------------------------------------------------------------------------- /spongeauth/api/tests/test_list_users.py: -------------------------------------------------------------------------------- 1 | import django.shortcuts 2 | 3 | import pytest 4 | import faker 5 | 6 | import api.models 7 | 8 | 9 | @pytest.fixture 10 | def fake(): 11 | return faker.Faker() 12 | 13 | 14 | @pytest.mark.django_db 15 | def test_invalid_api_key(client, fake): 16 | assert not api.models.APIKey.objects.exists() 17 | resp = client.get(django.shortcuts.reverse("api:users-list"), {"api-key": "foobar"}) 18 | assert resp.status_code == 403 19 | 20 | 21 | @pytest.mark.django_db 22 | def test_four_oh_five(client): 23 | api.models.APIKey.objects.create(key="foobar") 24 | 25 | resp = client.get(django.shortcuts.reverse("api:users-list"), {"apiKey": "foobar"}) 26 | assert resp.status_code == 405 27 | -------------------------------------------------------------------------------- /spongeauth/api/tests/test_models.py: -------------------------------------------------------------------------------- 1 | from .. import models 2 | 3 | 4 | def test_str(): 5 | apikey = models.APIKey(description="blah") 6 | assert str(apikey) == "blah" 7 | 8 | apikey = models.APIKey(description="foo") 9 | assert str(apikey) == "foo" 10 | 11 | apikey = models.APIKey(description="") 12 | assert str(apikey) == "" 13 | -------------------------------------------------------------------------------- /spongeauth/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | 3 | import api.views 4 | 5 | app_name = "api" 6 | 7 | urlpatterns = [ 8 | re_path(r"^users$", api.views.list_users, name="users-list"), 9 | re_path(r"^users/(?P[^/]+)$", api.views.user_detail, name="users-detail"), 10 | re_path( 11 | r"^users/(?P[^/]+)/change-avatar-token/$", 12 | api.views.change_other_avatar_key, 13 | name="change-avatar-token", 14 | ), 15 | ] 16 | -------------------------------------------------------------------------------- /spongeauth/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/core/__init__.py -------------------------------------------------------------------------------- /spongeauth/core/admin.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/core/admin.py -------------------------------------------------------------------------------- /spongeauth/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoreConfig(AppConfig): 5 | name = "core" 6 | -------------------------------------------------------------------------------- /spongeauth/core/middleware.py: -------------------------------------------------------------------------------- 1 | class XRealIPMiddleware: 2 | def __init__(self, get_response): 3 | self.get_response = get_response 4 | 5 | def __call__(self, request): 6 | if request.META.get("HTTP_X_REAL_IP", ""): 7 | request.META["REMOTE_ADDR"] = request.META["HTTP_X_REAL_IP"] 8 | 9 | response = self.get_response(request) 10 | return response 11 | -------------------------------------------------------------------------------- /spongeauth/core/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/core/models.py -------------------------------------------------------------------------------- /spongeauth/core/staticfiles.py: -------------------------------------------------------------------------------- 1 | from django.contrib.staticfiles.storage import ManifestStaticFilesStorage 2 | 3 | 4 | class SourcemapManifestStaticFilesStorage(ManifestStaticFilesStorage): 5 | patterns = ( 6 | ("*.css", ( 7 | r"""(?Purl\(['"]{0,1}\s*(?P.*?)["']{0,1}\))""", 8 | ( 9 | r"""(?P@import\s*["']\s*(?P.*?)["'])""", 10 | """@import url("%(url)s")""", 11 | ), 12 | ( 13 | r'(?m)(?P)^(/\*# (?-i:sourceMappingURL)=(?P.*) \*/)$', 14 | '/*# sourceMappingURL=%(url)s */', 15 | ), 16 | )), 17 | ('*.js', ( 18 | ( 19 | '(?m)(?P)^(//# (?-i:sourceMappingURL)=(?P.*))$', 20 | '//# sourceMappingURL=%(url)s', 21 | ), 22 | )), 23 | ) 24 | -------------------------------------------------------------------------------- /spongeauth/core/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/core/tests/__init__.py -------------------------------------------------------------------------------- /spongeauth/core/tests/test_sourcemap_static_files_storage.py: -------------------------------------------------------------------------------- 1 | from django.core.files.base import ContentFile 2 | 3 | import pytest 4 | 5 | from ..staticfiles import SourcemapManifestStaticFilesStorage 6 | 7 | 8 | @pytest.fixture 9 | def smsf_storage(settings): 10 | settings.STATIC_ROOT = "/static/" 11 | 12 | storage = SourcemapManifestStaticFilesStorage() 13 | storage._files = {} 14 | 15 | def _open(fn, *args, **kwargs): 16 | if fn not in storage._files: 17 | raise ValueError("No such file") 18 | return ContentFile(storage._files[fn]) 19 | 20 | storage.open = _open 21 | 22 | def _save(filename, f): 23 | f.seek(0) 24 | storage._files[filename] = f.read() 25 | return filename 26 | 27 | storage._save = _save 28 | 29 | def _exists(filename): 30 | return filename in storage._files 31 | 32 | storage.exists = _exists 33 | 34 | def _paths(): 35 | return {k: (storage, k) for k in storage._files} 36 | 37 | storage.paths = _paths 38 | 39 | return storage 40 | 41 | 42 | def test_js_sourcemap(smsf_storage): 43 | smsf_storage._files[ 44 | "blah.js" 45 | ] = br""" 46 | (function() { some js here })(); 47 | //# sourceMappingURL=maps/blah2.map 48 | """ 49 | smsf_storage._files["maps/blah2.map"] = b"sourcemap" 50 | 51 | list(smsf_storage.post_process(smsf_storage.paths())) 52 | 53 | assert "maps/blah2.ae359e87985b.map" in smsf_storage._files 54 | assert "blah.ba512a3f6190.js" in smsf_storage._files 55 | assert ( 56 | smsf_storage._files["blah.ba512a3f6190.js"] 57 | == b"\n(function() { some js here })();\n//# sourceMappingURL=maps/blah2.ae359e87985b.map\n" 58 | ) 59 | 60 | 61 | def test_css_sourcemap(smsf_storage): 62 | smsf_storage._files[ 63 | "blah.css" 64 | ] = br""" 65 | somecss { transition: none; } 66 | /*# sourceMappingURL=maps/blah2.map */ 67 | """ 68 | smsf_storage._files["maps/blah2.map"] = b"sourcemap" 69 | 70 | list(smsf_storage.post_process(smsf_storage.paths())) 71 | 72 | assert "maps/blah2.ae359e87985b.map" in smsf_storage._files 73 | assert "blah.6315db321095.css" in smsf_storage._files 74 | assert ( 75 | smsf_storage._files["blah.6315db321095.css"] 76 | == b"\nsomecss { transition: none; }\n/*# sourceMappingURL=maps/blah2.ae359e87985b.map */\n" 77 | ) 78 | -------------------------------------------------------------------------------- /spongeauth/core/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import django.test 2 | 3 | import pytest 4 | 5 | import accounts.tests.factories 6 | 7 | 8 | def test_admin_login_redirect(): 9 | client = django.test.Client() 10 | resp = client.get("/admin/", follow=True) 11 | assert resp.redirect_chain == [("/admin/login/?next=/admin/", 302), ("/accounts/login/?next=%2Fadmin%2F", 302)] 12 | 13 | 14 | @pytest.mark.django_db 15 | def test_admin_login_redirect_not_staff(): 16 | user = accounts.tests.factories.UserFactory.create() 17 | client = django.test.Client() 18 | client.login(username=user.username, password="secret") 19 | resp = client.get("/admin/", follow=True) 20 | assert resp.redirect_chain == [("/admin/login/?next=/admin/", 302), ("/", 302)] 21 | -------------------------------------------------------------------------------- /spongeauth/core/tests/test_x_real_ip_middleware.py: -------------------------------------------------------------------------------- 1 | import django.http 2 | 3 | import unittest.mock 4 | 5 | from .. import middleware 6 | 7 | 8 | def get_response(req): 9 | # dummy get_response, just return an empty response 10 | return django.http.HttpResponse() 11 | 12 | 13 | def test_leaves_remote_addr_alone_if_no_real_ip(): 14 | remote_addr = object() 15 | request = unittest.mock.MagicMock() 16 | request.META = {"REMOTE_ADDR": remote_addr} 17 | 18 | middleware.XRealIPMiddleware(get_response)(request) 19 | 20 | assert request.META["REMOTE_ADDR"] is remote_addr 21 | 22 | 23 | def test_switches_out_x_real_ip_if_available(): 24 | remote_addr = object() 25 | x_real_ip = object() 26 | 27 | request = unittest.mock.MagicMock() 28 | request.META = {"REMOTE_ADDR": remote_addr, "HTTP_X_REAL_IP": x_real_ip} 29 | 30 | middleware.XRealIPMiddleware(get_response)(request) 31 | 32 | assert request.META["REMOTE_ADDR"] is x_real_ip 33 | assert request.META["HTTP_X_REAL_IP"] is x_real_ip 34 | -------------------------------------------------------------------------------- /spongeauth/core/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect 2 | from django.urls import reverse 3 | from django.contrib.auth.decorators import login_required 4 | from django.utils.http import urlencode 5 | 6 | 7 | @login_required 8 | def index(request): 9 | return render(request, "core/index.html") 10 | 11 | 12 | def admin_login_redirect(request): 13 | if request.user.is_authenticated and not request.user.is_staff: 14 | return redirect("index") 15 | 16 | return redirect("{}?{}".format(reverse("accounts:login"), urlencode({"next": request.GET.get("next", "/")}))) 17 | -------------------------------------------------------------------------------- /spongeauth/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | assert sys.version_info >= (3, 6), "SpongeAuth needs at least Python 3.6 to operate. You are running {}".format( 6 | sys.version 7 | ) 8 | 9 | if __name__ == "__main__": 10 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "spongeauth.settings.dev") 11 | try: 12 | from django.core.management import execute_from_command_line 13 | except ImportError: 14 | # The above import may fail for some other reason. Ensure that the 15 | # issue is really that Django is missing to avoid masking other 16 | # exceptions on Python 2. 17 | try: 18 | import django 19 | 20 | assert django 21 | except ImportError: 22 | raise ImportError( 23 | "Couldn't import Django. Are you sure it's installed and " 24 | "available on your PYTHONPATH environment variable? Did you " 25 | "forget to activate a virtual environment?" 26 | ) 27 | raise 28 | execute_from_command_line(sys.argv) 29 | -------------------------------------------------------------------------------- /spongeauth/migrator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/migrator/__init__.py -------------------------------------------------------------------------------- /spongeauth/migrator/admin.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/migrator/admin.py -------------------------------------------------------------------------------- /spongeauth/migrator/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class MigratorConfig(AppConfig): 5 | name = "migrator" 6 | -------------------------------------------------------------------------------- /spongeauth/migrator/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/migrator/management/__init__.py -------------------------------------------------------------------------------- /spongeauth/migrator/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/migrator/management/commands/__init__.py -------------------------------------------------------------------------------- /spongeauth/migrator/management/commands/import_play_data.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import binascii 3 | 4 | from django.core.management.base import BaseCommand 5 | from django.db import transaction 6 | from django.utils import timezone 7 | 8 | import migrator.models 9 | import accounts.models 10 | import twofa.models 11 | 12 | 13 | class Command(BaseCommand): 14 | help = "Imports SpongeAuth data from Play implementation." 15 | 16 | def handle(self, *args, **options): 17 | now = timezone.now() 18 | with transaction.atomic(): 19 | for muser in migrator.models.User.objects.all(): 20 | auser = accounts.models.User( 21 | id=muser.id, 22 | username=muser.username, 23 | email=muser.email, 24 | email_verified=muser.is_email_confirmed, 25 | is_active=True, 26 | is_admin=muser.is_admin, 27 | mc_username=muser.mc_username, 28 | irc_nick=muser.irc_nick, 29 | gh_username=muser.gh_username, 30 | joined_at=muser.join_date, 31 | deleted_at=None, 32 | ) 33 | if muser.password: 34 | auser.password = "pbkdf2_sha256${iterations}${salt}${password_b64}".format( 35 | iterations=64000, 36 | salt=muser.salt, 37 | password_b64=base64.b64encode(binascii.unhexlify(muser.password)).decode("ascii"), 38 | ) 39 | else: 40 | auser.set_unusable_password() 41 | auser.save() 42 | if muser.is_totp_confirmed: 43 | twofa.models.TOTPDevice( 44 | owner=auser, last_t=0, drift=0, activated_at=now, base32_secret=muser.totp_secret 45 | ).save() 46 | auser.totp_enabled = True 47 | if muser.avatar_url: 48 | avatar = accounts.models.Avatar( 49 | user=auser, remote_url=muser.avatar_url, source=accounts.models.Avatar.URL 50 | ) 51 | avatar.save() 52 | auser.current_avatar = avatar 53 | if muser.google_id: 54 | accounts.models.ExternalAuthenticator( 55 | user=auser, source=accounts.models.ExternalAuthenticator.GOOGLE, external_id=muser.google_id 56 | ).save() 57 | auser.save() 58 | 59 | accounts.models.User.objects.filter(id=muser.id).update(joined_at=muser.join_date) 60 | -------------------------------------------------------------------------------- /spongeauth/migrator/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by Django 1.10.5 on 2017-01-13 16:03 3 | from __future__ import unicode_literals 4 | 5 | from django.db import migrations, models 6 | from django.conf import settings 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name="User", 18 | fields=[ 19 | ("id", models.BigIntegerField(primary_key=True, serialize=False)), 20 | ("created_at", models.DateTimeField()), 21 | ("email", models.CharField(max_length=255)), 22 | ("username", models.CharField(max_length=20)), 23 | ("password", models.CharField(max_length=255, null=True)), 24 | ("mc_username", models.CharField(max_length=255)), 25 | ("irc_nick", models.CharField(max_length=255)), 26 | ("gh_username", models.CharField(max_length=255)), 27 | ("is_email_confirmed", models.BooleanField()), 28 | ("totp_secret", models.CharField(max_length=255)), 29 | ("is_totp_confirmed", models.BooleanField()), 30 | ("salt", models.CharField(max_length=255)), 31 | ("is_admin", models.BooleanField()), 32 | ("failed_totp_attempts", models.IntegerField()), 33 | ("deleted_at", models.DateTimeField(blank=True, null=True)), 34 | ("avatar_url", models.CharField(max_length=255)), 35 | ("join_date", models.DateTimeField()), 36 | ("google_id", models.CharField(max_length=255)), 37 | ], 38 | options={"db_table": "users", "managed": settings.IS_TESTING}, 39 | ) 40 | ] 41 | -------------------------------------------------------------------------------- /spongeauth/migrator/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/migrator/migrations/__init__.py -------------------------------------------------------------------------------- /spongeauth/migrator/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.conf import settings 3 | 4 | 5 | class User(models.Model): 6 | id = models.BigIntegerField(primary_key=True) 7 | created_at = models.DateTimeField() 8 | email = models.CharField(max_length=255) 9 | username = models.CharField(max_length=20) 10 | password = models.CharField(max_length=255, null=True) 11 | mc_username = models.CharField(max_length=255) 12 | irc_nick = models.CharField(max_length=255) 13 | gh_username = models.CharField(max_length=255) 14 | is_email_confirmed = models.BooleanField() 15 | totp_secret = models.CharField(max_length=255) 16 | is_totp_confirmed = models.BooleanField() 17 | salt = models.CharField(max_length=255) 18 | is_admin = models.BooleanField() 19 | failed_totp_attempts = models.IntegerField() 20 | deleted_at = models.DateTimeField(null=True, blank=True) 21 | avatar_url = models.CharField(max_length=255) 22 | join_date = models.DateTimeField() 23 | google_id = models.CharField(max_length=255) 24 | 25 | class Meta: 26 | db_table = "users" 27 | managed = settings.IS_TESTING 28 | -------------------------------------------------------------------------------- /spongeauth/migrator/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/migrator/tests/__init__.py -------------------------------------------------------------------------------- /spongeauth/migrator/views.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/migrator/views.py -------------------------------------------------------------------------------- /spongeauth/spongeauth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/spongeauth/__init__.py -------------------------------------------------------------------------------- /spongeauth/spongeauth/settings/.gitignore: -------------------------------------------------------------------------------- 1 | local_settings.py 2 | -------------------------------------------------------------------------------- /spongeauth/spongeauth/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/spongeauth/settings/__init__.py -------------------------------------------------------------------------------- /spongeauth/spongeauth/settings/dev.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base import * 4 | 5 | DEBUG = True 6 | ALLOWED_HOSTS += ["localhost", "127.0.0.1", "::1"] 7 | CSRF_TRUSTED_ORIGINS = ["http://localhost"] 8 | INTERNAL_IPS = ["127.0.0.1", "::1"] 9 | REQUIRE_EMAIL_CONFIRM = False 10 | 11 | MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware"] + MIDDLEWARE 12 | 13 | INSTALLED_APPS = INSTALLED_APPS + ["debug_toolbar"] 14 | 15 | for queue in RQ_QUEUES.values(): 16 | queue["ASYNC"] = False 17 | from fakeredis import FakeRedis, FakeStrictRedis 18 | import django_rq.queues 19 | 20 | django_rq.queues.get_redis_connection = lambda _, strict: FakeStrictRedis() if strict else FakeRedis() 21 | 22 | 23 | if not os.environ.get("DJANGO_SETTINGS_SKIP_LOCAL", False): 24 | try: 25 | from .local_settings import * 26 | except ImportError: 27 | pass 28 | -------------------------------------------------------------------------------- /spongeauth/spongeauth/settings/prod.py: -------------------------------------------------------------------------------- 1 | import sentry_sdk 2 | from sentry_sdk.integrations.django import DjangoIntegration 3 | from sentry_sdk.integrations.redis import RedisIntegration 4 | 5 | from .utils import fetch_git_sha 6 | from .base import * 7 | 8 | GIT_REPO_ROOT = os.path.dirname(BASE_DIR) 9 | PARENT_ROOT = os.path.dirname(GIT_REPO_ROOT) 10 | 11 | DEBUG = False 12 | 13 | SECRET_KEY = os.environ["SECRET_KEY"] 14 | 15 | DEFAULT_FROM_EMAIL = os.environ["DEFAULT_FROM_EMAIL"] 16 | SERVER_EMAIL = os.environ["SERVER_EMAIL"] 17 | 18 | SESSION_COOKIE_SECURE = os.getenv("SESSION_COOKIE_SECURE", "true") != 'false' 19 | SESSION_COOKIE_HTTPONLY = True 20 | CSRF_COOKIE_SECURE = os.getenv("CSRF_COOKIE_SECURE", "true") != 'false' 21 | CSRF_COOKIE_HTTPONLY = True 22 | CSRF_TRUSTED_ORIGINS = os.environ["CSRF_TRUSTED_ORIGINS"].split(',') 23 | 24 | EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" 25 | EMAIL_USE_TLS = os.environ["EMAIL_TLS"] == 'true' 26 | EMAIL_USE_SSL = os.environ["EMAIL_SSL"] == 'true' 27 | EMAIL_HOST = os.environ["EMAIL_HOST"] 28 | EMAIL_PORT = int(os.environ["EMAIL_PORT"]) 29 | EMAIL_HOST_USER = os.environ["EMAIL_HOST_USER"] 30 | EMAIL_HOST_PASSWORD = os.environ["EMAIL_HOST_PASSWORD"] 31 | 32 | TEMPLATES = [ 33 | { 34 | "BACKEND": "django.template.backends.django.DjangoTemplates", 35 | "DIRS": [os.path.join(BASE_DIR, "templates")], 36 | "OPTIONS": { 37 | "loaders": [ 38 | ( 39 | "django.template.loaders.cached.Loader", 40 | ["django.template.loaders.filesystem.Loader", "django.template.loaders.app_directories.Loader"], 41 | ) 42 | ], 43 | "context_processors": [ 44 | "django.template.context_processors.debug", 45 | "django.template.context_processors.request", 46 | "django.contrib.auth.context_processors.auth", 47 | "django.contrib.messages.context_processors.messages", 48 | ], 49 | }, 50 | } 51 | ] 52 | 53 | SSO_ENDPOINTS = {} 54 | for k, v in os.environ.items(): 55 | if not k.startswith("SSO_ENDPOINT_"): 56 | continue 57 | k = k[len("SSO_ENDPOINT_") :] 58 | name, _, key = k.partition("_") 59 | d = SSO_ENDPOINTS.setdefault(name, {}) 60 | d[key.lower()] = v 61 | 62 | sentry_sdk.init( 63 | dsn=os.environ.get("SENTRY_DSN"), 64 | integrations=[DjangoIntegration(), RedisIntegration()], 65 | release=fetch_git_sha(GIT_REPO_ROOT), 66 | send_default_pii=True, 67 | ) 68 | 69 | STATICFILES_STORAGE = "core.staticfiles.SourcemapManifestStaticFilesStorage" 70 | STATIC_ROOT = os.path.join(PARENT_ROOT, "public_html", "static") 71 | MEDIA_ROOT = os.path.join(PARENT_ROOT, "public_html", "media") 72 | 73 | ACCOUNTS_AVATAR_CHANGE_GROUPS = ["dummy", "Ore_Organization"] 74 | -------------------------------------------------------------------------------- /spongeauth/spongeauth/settings/test.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .base import * 4 | 5 | IS_TESTING = True 6 | 7 | for queue in RQ_QUEUES.values(): 8 | queue["ASYNC"] = False 9 | from fakeredis import FakeRedis, FakeStrictRedis 10 | import django_rq.queues 11 | 12 | django_rq.queues.get_redis_connection = lambda _, strict: FakeStrictRedis() if strict else FakeRedis() 13 | 14 | if not os.environ.get("DJANGO_SETTINGS_SKIP_LOCAL", False): 15 | try: 16 | from .local_settings import * 17 | except ImportError: 18 | pass 19 | -------------------------------------------------------------------------------- /spongeauth/spongeauth/settings/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2015 Functional Software, Inc and individual contributors. 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without modification, 6 | are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the 12 | following disclaimer in the documentation and/or other materials provided with the distribution. 13 | 14 | 3. Neither the name of the Raven nor the names of its contributors may be used to endorse or promote products 15 | derived from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, 18 | INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 20 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 21 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, 22 | WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | """ 25 | 26 | import os.path 27 | 28 | 29 | def fetch_git_sha(path, head=None): 30 | if not head: 31 | head_path = os.path.join(path, ".git", "HEAD") 32 | if not os.path.exists(head_path): 33 | raise Exception("Cannot identify HEAD for git repository at %s" % (path,)) 34 | 35 | with open(head_path, "r") as fp: 36 | head = str(fp.read()).strip() 37 | 38 | if head.startswith("ref: "): 39 | head = head[5:] 40 | revision_file = os.path.join(path, ".git", *head.split("/")) 41 | else: 42 | return head 43 | else: 44 | revision_file = os.path.join(path, ".git", "refs", "heads", head) 45 | 46 | if not os.path.exists(revision_file): 47 | if not os.path.exists(os.path.join(path, ".git")): 48 | raise Exception("%s does not seem to be the root of a git repository" % (path,)) 49 | 50 | # Check for our .git/packed-refs' file since a `git gc` may have run 51 | # https://git-scm.com/book/en/v2/Git-Internals-Maintenance-and-Data-Recovery 52 | packed_file = os.path.join(path, ".git", "packed-refs") 53 | if os.path.exists(packed_file): 54 | with open(packed_file) as fh: 55 | for line in fh: 56 | line = line.rstrip() 57 | if line and line[:1] not in ("#", "^"): 58 | try: 59 | revision, ref = line.split(" ", 1) 60 | except ValueError: 61 | continue 62 | if ref == head: 63 | return str(revision) 64 | 65 | raise Exception('Unable to find ref to head "%s" in repository' % (head,)) 66 | 67 | with open(revision_file) as fh: 68 | return str(fh.read()).strip() 69 | -------------------------------------------------------------------------------- /spongeauth/spongeauth/urls.py: -------------------------------------------------------------------------------- 1 | """spongeauth URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/1.10/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.conf.urls import url, include 14 | 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) 15 | """ 16 | from django.conf import settings 17 | from django.conf.urls.static import static 18 | from django.contrib import admin 19 | from django.urls import include, re_path 20 | 21 | import django_rq.urls 22 | 23 | import accounts.urls 24 | import api.urls 25 | import twofa.urls 26 | import sso.urls 27 | 28 | from core.views import index, admin_login_redirect 29 | 30 | from accounts.views import avatar_for_user 31 | 32 | admin.site.site_header = admin.site.site_title = admin.site.index_title = "SpongeAuth" 33 | 34 | urlpatterns = [ 35 | re_path(r"^admin/login/", admin_login_redirect), 36 | re_path(r"^admin/", admin.site.urls), 37 | re_path(r"^accounts/", include(accounts.urls, "accounts")), 38 | re_path(r"^2fa/", include(twofa.urls, "twofa")), 39 | re_path(r"^avatar/(?P[^/]+)/?$", avatar_for_user, name="avatar-for-user"), 40 | re_path(r"^sso/", include(sso.urls, "sso")), 41 | re_path(r"^$", index, name="index"), 42 | re_path(r"^api/", include(api.urls, "api")), 43 | re_path("django-rq/", include(django_rq.urls)), 44 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 45 | 46 | if settings.DEBUG: 47 | import debug_toolbar 48 | 49 | urlpatterns += [re_path(r"^__djdt__/", include(debug_toolbar.urls))] 50 | -------------------------------------------------------------------------------- /spongeauth/spongeauth/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for spongeauth project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/1.10/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "spongeauth.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /spongeauth/spongemime/__init__.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | BASE_DIR = os.path.abspath(os.path.dirname(__file__)) 4 | 5 | # mime.types from Apache HTTPd: 6 | # http://svn.apache.org/repos/asf/httpd/httpd/trunk/docs/conf/mime.types 7 | MIME_FILENAME = os.path.join(BASE_DIR, "mime.types") 8 | 9 | _fwd_cache = None 10 | _rev_cache = None 11 | 12 | 13 | def _load(): 14 | global _fwd_cache, _rev_cache 15 | if _fwd_cache and _rev_cache: 16 | return _fwd_cache, _rev_cache 17 | _fwd_cache = {} 18 | _rev_cache = {} 19 | with open(MIME_FILENAME, "r") as fh: 20 | for ln in fh: 21 | ln = ln.strip() 22 | if "#" in ln: 23 | ln = ln[: ln.index("#")].strip() 24 | if not ln: 25 | continue 26 | mime_type, extensions = ln.split(maxsplit=1) 27 | exts = extensions.split() 28 | _fwd_cache[mime_type] = exts 29 | for ext in exts: 30 | _rev_cache[ext] = mime_type 31 | return _fwd_cache, _rev_cache 32 | 33 | 34 | def mime2exts(mime): 35 | fwd_cache, _ = _load() 36 | if mime == "image/jpeg": 37 | # special case: make jpg come first 38 | return ["jpg", "jpeg", "jpe"] 39 | return fwd_cache.get(mime) 40 | 41 | 42 | def ext2mime(ext): 43 | _, rev_cache = _load() 44 | return rev_cache.get(ext) 45 | -------------------------------------------------------------------------------- /spongeauth/spongemime/test_spongemime.py: -------------------------------------------------------------------------------- 1 | import spongemime 2 | 3 | 4 | def test_mime2exts(): 5 | assert spongemime.mime2exts("image/png") == ["png"] 6 | assert set(spongemime.mime2exts("image/jpeg")) == {"jpg", "jpeg", "jpe"} 7 | assert set(spongemime.mime2exts("image/svg+xml")) == {"svg", "svgz"} 8 | 9 | 10 | def test_ext2mime(): 11 | assert spongemime.ext2mime("png") == "image/png" 12 | assert spongemime.ext2mime("jpg") == "image/jpeg" 13 | assert spongemime.ext2mime("jpeg") == "image/jpeg" 14 | -------------------------------------------------------------------------------- /spongeauth/sso/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/sso/__init__.py -------------------------------------------------------------------------------- /spongeauth/sso/admin.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/sso/admin.py -------------------------------------------------------------------------------- /spongeauth/sso/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class SsoConfig(AppConfig): 5 | name = "sso" 6 | -------------------------------------------------------------------------------- /spongeauth/sso/discourse_sso.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import hmac 3 | import secrets 4 | import base64 5 | import urllib.parse 6 | 7 | 8 | class DiscourseSigner: 9 | def __init__(self, sso_key): 10 | self.sso_key = sso_key.encode("utf8") 11 | 12 | def _sign(self, payload): 13 | m = hmac.new(self.sso_key, msg=payload, digestmod=hashlib.sha256) 14 | return m.hexdigest().encode("utf8") 15 | 16 | def _verify(self, payload, signature): 17 | good_signature = self._sign(payload) 18 | if not secrets.compare_digest(good_signature, signature): 19 | raise SignatureError("invalid signature: got {}, want {}".format(signature, good_signature)) 20 | 21 | def unsign(self, payload, signature): 22 | self._verify(payload.encode("utf8"), signature.encode("utf8")) 23 | payload_raw = base64.b64decode(payload) 24 | parsed_qs = urllib.parse.parse_qs(payload_raw) 25 | # take only the first element 26 | return {k: v[0] for k, v in parsed_qs.items()} 27 | 28 | def sign(self, payload_data): 29 | for k, v in payload_data.items(): 30 | if isinstance(v, bool): 31 | v = "true" if v else "false" 32 | payload_data[k] = v 33 | payload_raw = urllib.parse.urlencode(payload_data).encode("utf8") 34 | payload = base64.b64encode(payload_raw) 35 | signature = self._sign(payload) 36 | return payload.decode("utf8"), signature.decode("utf8") 37 | 38 | 39 | class SignatureError(Exception): 40 | pass 41 | -------------------------------------------------------------------------------- /spongeauth/sso/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/sso/management/__init__.py -------------------------------------------------------------------------------- /spongeauth/sso/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/sso/management/commands/__init__.py -------------------------------------------------------------------------------- /spongeauth/sso/management/commands/sso_ping_discourse.py: -------------------------------------------------------------------------------- 1 | from django.core.management.base import BaseCommand, CommandError 2 | 3 | from sso.utils import send_update_ping 4 | from accounts.models import User 5 | 6 | 7 | class Command(BaseCommand): 8 | help = "Update Discourse with user information" 9 | 10 | def add_arguments(self, parser): 11 | parser.add_argument("username", nargs="*", type=str) 12 | 13 | def send_update(self, user): 14 | return send_update_ping(user) 15 | 16 | def handle(self, *args, **options): 17 | if options["username"]: 18 | users = list(User.objects.filter(username__in=options["username"])) 19 | usernames = {user.username for user in users} 20 | if usernames != set(options["username"]): 21 | raise CommandError( 22 | 'User mismatch: couldn\'t find "{}"'.format('", "'.join(set(options["username"]) - usernames)) 23 | ) 24 | else: 25 | users = list(User.objects.filter(is_active=True, email_verified=True)) 26 | 27 | for user in users: 28 | self.stdout.write(user.username, ending=" ") 29 | 30 | if not user.is_active or not user.email_verified: 31 | self.stdout.write(self.style.WARNING("SKIP")) 32 | continue 33 | 34 | try: 35 | self.send_update(user) 36 | self.stdout.write(self.style.SUCCESS("OK")) 37 | except Exception as ex: 38 | self.stdout.write(self.style.ERROR("failed: {}".format(repr(ex)))) 39 | -------------------------------------------------------------------------------- /spongeauth/sso/models.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db.models.signals import post_save, m2m_changed 3 | from django.dispatch import receiver 4 | 5 | from accounts.models import User, Avatar 6 | 7 | from .utils import send_update_ping 8 | 9 | 10 | def _can_ping(): 11 | return settings.SSO_ENDPOINTS 12 | 13 | 14 | @receiver(post_save, sender=User) 15 | def on_user_save(sender, instance=None, **kwargs): 16 | if not _can_ping(): 17 | return # do nothing 18 | send_update_ping(instance) 19 | 20 | 21 | @receiver(m2m_changed, sender=User.groups.through) 22 | def on_group_change(sender, instance=None, pk_set=None, action=None, reverse=None, **kwargs): 23 | if action not in ("post_add", "post_remove"): 24 | return 25 | if not _can_ping(): 26 | return # do nothing, again 27 | if reverse: 28 | instances = User.objects.filter(pk__in=pk_set) 29 | else: 30 | instances = [instance] 31 | for instance in instances: 32 | send_update_ping(instance) 33 | 34 | 35 | @receiver(m2m_changed, sender=User.groups.through) 36 | def on_group_clear(sender, instance=None, pk_set=None, action=None, reverse=None, **kwargs): 37 | if action != "pre_clear": 38 | return 39 | if not _can_ping(): 40 | return # do nothing, again 41 | if reverse: 42 | instances = list(instance.user_set.all()) 43 | groups = [instance.id] 44 | else: 45 | instances = [instance] 46 | groups = list(instance.groups.values_list("id", flat=True)) 47 | for instance in instances: 48 | send_update_ping(instance, exclude_groups=groups) 49 | 50 | 51 | @receiver(post_save, sender=Avatar) 52 | def on_avatar_save(sender, instance=None, **kwargs): 53 | if not _can_ping(): 54 | return # do nothing 55 | if instance.user.current_avatar != instance: 56 | return # no avatar update 57 | 58 | # This shouldn't trigger, because avatars shouldn't change once they've 59 | # been saved to the database, but just in case someone messes around with 60 | # the admin panel... 61 | send_update_ping(instance.user) 62 | -------------------------------------------------------------------------------- /spongeauth/sso/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SpongePowered/SpongeAuth/6b75e71d15fd76140d970f71e85f776dc69ef432/spongeauth/sso/tests/__init__.py -------------------------------------------------------------------------------- /spongeauth/sso/tests/test_discourse_sso.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import base64 3 | 4 | from .. import discourse_sso 5 | 6 | HARDCODED_SIGNING = "slartibartfast" 7 | 8 | 9 | class SignerTestCase: 10 | def setup_method(self): 11 | self.signer = discourse_sso.DiscourseSigner(HARDCODED_SIGNING) 12 | 13 | 14 | class TestSign(SignerTestCase): 15 | def test_sign_empty(self): 16 | assert self.signer.sign({}) == ("", "54113943c5acdf489e85786046ea6f8183bcd3be9297397181194e663aba0d02") 17 | 18 | def test_sign_string(self): 19 | assert self.signer.sign({"nonce": "number used once"}) == ( 20 | "bm9uY2U9bnVtYmVyK3VzZWQrb25jZQ==", 21 | "a42c444d01993b53fe2f189bc5af05c04f4340306bca30721e049e8417372245", 22 | ) 23 | 24 | def test_sign_boolean(self): 25 | assert self.signer.sign({"avatar_force_update": True}) == ( 26 | "YXZhdGFyX2ZvcmNlX3VwZGF0ZT10cnVl", 27 | "2012b41a566916139c96a3b659013dfb8311274ce2e76988724f4149dca444cc", 28 | ) 29 | 30 | def test_sign_combo(self): 31 | assert self.signer.sign({"username": "lukegb", "avatar_force_update": True}) == ( 32 | "dXNlcm5hbWU9bHVrZWdiJmF2YXRhcl9mb3JjZV91cGRhdGU9dHJ1ZQ==", 33 | "1c10d05832df8b667414c2602dfda6b11b0bc122ca0cd23cd158622dc96976d3", 34 | ) 35 | 36 | def test_sign_with_plus(self): 37 | payload, _ = self.signer.sign({"email": "blah+plus@example.com"}) 38 | assert base64.b64decode(payload) == b"email=blah%2Bplus%40example.com" 39 | 40 | 41 | class TestUnsign(SignerTestCase): 42 | def test_unsign_good(self): 43 | in_pair = ( 44 | "dXNlcm5hbWU9bHVrZWdiJmF2YXRhcl9mb3JjZV91cGRhdGU9dHJ1ZQ==", 45 | "1c10d05832df8b667414c2602dfda6b11b0bc122ca0cd23cd158622dc96976d3", 46 | ) 47 | payload = self.signer.unsign(*in_pair) 48 | assert payload == {b"username": b"lukegb", b"avatar_force_update": b"true"} 49 | 50 | def test_unsign_bad(self): 51 | in_pair = ( 52 | "dXNlcm5hbWU9bHVrZWdiJmF2YXRhcl9mb3JjZV91cGRhdGU9dHJ1ZQ==", 53 | "1c10d05832df8b667414c2602dfda6b11b0bc122ca0cd23cd158622dc96976d4", 54 | ) 55 | with pytest.raises(discourse_sso.SignatureError): 56 | self.signer.unsign(*in_pair) 57 | 58 | 59 | class TestDiscourseSigner(SignerTestCase): 60 | def test_sign_then_unsign_roundtrip(self): 61 | input = {"username": "lukegb", "avatar_force_update": True} 62 | output = self.signer.unsign(*self.signer.sign(input)) 63 | assert output == {b"username": b"lukegb", b"avatar_force_update": b"true"} 64 | 65 | def test_unsign_then_sign_roundtrip(self): 66 | in_pair = ( 67 | "dXNlcm5hbWU9bHVrZWdiJmF2YXRhcl9mb3JjZV91cGRhdGU9dHJ1ZQ==", 68 | "1c10d05832df8b667414c2602dfda6b11b0bc122ca0cd23cd158622dc96976d3", 69 | ) 70 | out_pair = self.signer.sign(self.signer.unsign(*in_pair)) 71 | assert in_pair == out_pair 72 | -------------------------------------------------------------------------------- /spongeauth/sso/tests/test_management_update_user.py: -------------------------------------------------------------------------------- 1 | import io 2 | import unittest.mock 3 | 4 | from django.core.management import call_command, CommandError 5 | 6 | import pytest 7 | 8 | from accounts.tests.factories import UserFactory 9 | 10 | 11 | @pytest.mark.django_db 12 | def test_users_missing(settings): 13 | out = io.StringIO() 14 | 15 | user1 = UserFactory.create() 16 | user2 = UserFactory.create() 17 | 18 | with pytest.raises(CommandError) as exc: 19 | call_command("sso_ping_discourse", "bar", user1.username, "baz", stdout=out) 20 | 21 | assert user1.username not in out.getvalue() 22 | assert user2.username not in str(exc.value) 23 | assert user2.username not in out.getvalue() 24 | 25 | assert str(exc.value) in ( 26 | 'User mismatch: couldn\'t find "bar", "baz"', 27 | 'User mismatch: couldn\'t find "baz", "bar"', 28 | ) 29 | 30 | 31 | @pytest.mark.django_db 32 | @unittest.mock.patch("sso.management.commands.sso_ping_discourse.send_update_ping") 33 | def test_no_args(fake_send_ping, settings): 34 | out = io.StringIO() 35 | 36 | user1 = UserFactory.create() 37 | user2 = UserFactory.create(is_active=False) 38 | user3 = UserFactory.create(email_verified=False) 39 | 40 | call_command("sso_ping_discourse", stdout=out) 41 | 42 | assert "{} OK".format(user1.username) in out.getvalue() 43 | assert user2.username not in out.getvalue() 44 | assert user3.username not in out.getvalue() 45 | 46 | fake_send_ping.assert_called_once_with(user1) 47 | 48 | 49 | @pytest.mark.django_db 50 | @unittest.mock.patch("sso.management.commands.sso_ping_discourse.send_update_ping") 51 | def test_happy_path(fake_send_ping, settings): 52 | out = io.StringIO() 53 | 54 | user1 = UserFactory.create() 55 | user2 = UserFactory.create() 56 | 57 | def _fake_send_ping(user): 58 | if user == user1: 59 | return None 60 | raise ValueError("boo") 61 | 62 | fake_send_ping.side_effect = _fake_send_ping 63 | 64 | call_command("sso_ping_discourse", user1.username, user2.username, stdout=out) 65 | 66 | assert "{} OK\n".format(user1.username) in out.getvalue() 67 | assert "{} failed: ValueError('boo'".format(user2.username) in out.getvalue() 68 | 69 | 70 | @pytest.mark.django_db 71 | @unittest.mock.patch("sso.management.commands.sso_ping_discourse.send_update_ping") 72 | def test_skip_on_email_not_verified(fake_send_ping, settings): 73 | out = io.StringIO() 74 | 75 | user1 = UserFactory.create(email_verified=False) 76 | user2 = UserFactory.create(is_active=False) 77 | 78 | call_command("sso_ping_discourse", user1.username, user2.username, stdout=out) 79 | 80 | assert "{} SKIP\n".format(user1.username) in out.getvalue() 81 | assert "{} SKIP\n".format(user2.username) in out.getvalue() 82 | -------------------------------------------------------------------------------- /spongeauth/sso/tests/test_view_begin.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | import unittest.mock 3 | 4 | import django.test 5 | import django.shortcuts 6 | 7 | import pytest 8 | 9 | import accounts.tests.factories 10 | from .. import discourse_sso 11 | 12 | SSO_ENDPOINTS = {"foo": {"sso_secret": "foo1"}, "bar": {"sso_secret": "slartibartfast"}} 13 | 14 | 15 | @pytest.mark.django_db 16 | @django.test.override_settings(SSO_ENDPOINTS=SSO_ENDPOINTS) 17 | class TestBegin(django.test.TestCase): 18 | def setUp(self): 19 | self.user = accounts.tests.factories.UserFactory.create() 20 | self.signer = discourse_sso.DiscourseSigner("slartibartfast") 21 | 22 | self.client = django.test.Client() 23 | self.login(self.client, self.user) 24 | 25 | def login(self, c, user): 26 | assert c.login(username=user.username, password="secret") 27 | 28 | def path(self, params=None): 29 | path = django.shortcuts.reverse("sso:begin") 30 | if params: 31 | path += "?" + urllib.parse.urlencode(params) 32 | return path 33 | 34 | def test_requires_login(self): 35 | client = django.test.Client() 36 | resp = client.get(self.path()) 37 | assert resp.status_code == 302 38 | 39 | def test_no_sso_payload(self): 40 | resp = self.client.get(self.path()) 41 | assert resp.status_code == 403 42 | 43 | def test_invalid_sso_payload(self): 44 | resp = self.client.get(self.path({"sso": "blah", "sig": "nope"})) 45 | assert resp.status_code == 403 46 | 47 | @unittest.mock.patch("sso.utils.make_payload") 48 | def test_valid(self, mock_make_payload): 49 | mock_make_payload.return_value = {b"yooo": b"hooo"} 50 | 51 | sso, sig = self.signer.sign({"nonce": "123456", "return_sso_url": "/hi/i/am/sso"}) 52 | resp = self.client.get(self.path({"sso": sso, "sig": sig})) 53 | assert resp.status_code == 302 54 | assert resp["Location"].startswith("/hi/i/am/sso?") 55 | qs = resp["Location"][len("/hi/i/am/sso?") :] 56 | params = urllib.parse.parse_qs(qs) 57 | assert set(params.keys()) == {"sso", "sig"} 58 | assert all([len(x) == 1 for x in params.values()]) 59 | try: 60 | vals = self.signer.unsign(params["sso"][0], params["sig"][0]) 61 | assert vals == mock_make_payload.return_value 62 | except discourse_sso.SignatureError as exc: 63 | self.fail(exc) 64 | 65 | def test_feedback(self): 66 | sso, sig = self.signer.sign({"nonce": "123456", "return_sso_url": "/hi/i/am/sso"}) 67 | resp = self.client.get(self.path({"sso": sso, "sig": sig})) 68 | assert resp.status_code == 302 69 | assert resp["Location"].startswith("/hi/i/am/sso?") 70 | qs = resp["Location"][len("/hi/i/am/sso?") :] 71 | params = urllib.parse.parse_qs(qs) 72 | assert set(params.keys()) == {"sso", "sig"} 73 | assert all([len(x) == 1 for x in params.values()]) 74 | 75 | reresp = self.client.get(self.path({"sso": params["sso"][0], "sig": params["sig"][0]})) 76 | assert reresp.status_code == 403 77 | -------------------------------------------------------------------------------- /spongeauth/sso/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import re_path 2 | from django.views.generic.base import RedirectView 3 | 4 | import sso.views 5 | 6 | app_name = "sso" 7 | 8 | urlpatterns = [ 9 | re_path(r"^$", sso.views.begin, name="begin"), 10 | re_path(r"^sudo/$", sso.views.begin, name="sudo"), 11 | re_path(r"^signup/$", RedirectView.as_view(pattern_name="accounts:register", permanent=False), name="signup"), 12 | ] 13 | -------------------------------------------------------------------------------- /spongeauth/sso/utils.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db.models import Q 3 | 4 | import django_rq 5 | import requests 6 | 7 | from accounts.models import Group, User 8 | from . import discourse_sso 9 | 10 | 11 | def _cast_bool(b): 12 | return str(bool(b)).lower() 13 | 14 | 15 | def make_payload(user, nonce, exclude_groups=None): 16 | exclude_groups = set(exclude_groups or []) 17 | relevant_groups = Group.objects.filter(internal_only=False).order_by("internal_name") 18 | filter_q = Q(user=user) & ~Q(pk__in=exclude_groups) 19 | add_groups = relevant_groups.filter(filter_q).values_list("internal_name", flat=True) 20 | remove_groups = relevant_groups.exclude(filter_q).values_list("internal_name", flat=True) 21 | payload = { 22 | "nonce": nonce, 23 | "email": user.email, 24 | "require_activation": _cast_bool(not user.email_verified), 25 | "external_id": user.pk, 26 | "username": user.username, 27 | "name": user.full_name, 28 | "custom.user_field_1": user.mc_username, 29 | "custom.user_field_2": user.irc_nick, 30 | "custom.user_field_3": user.gh_username, 31 | "custom.user_field_4": user.discord_id, 32 | "admin": user.is_admin, 33 | "moderator": user.is_admin or user.is_staff, 34 | "add_groups": ",".join(add_groups), 35 | "remove_groups": ",".join(remove_groups), 36 | } 37 | return payload 38 | 39 | 40 | @django_rq.job 41 | def send_update_ping_to_endpoint(user_id, endpoint_name, exclude_groups): 42 | endpoint_settings = settings.SSO_ENDPOINTS.get(endpoint_name) 43 | if not endpoint_settings: 44 | return 45 | if "sync_sso_endpoint" not in endpoint_settings: 46 | return 47 | try: 48 | user = User.objects.get(pk=user_id) 49 | except User.DoesNotExist: 50 | return 51 | payload = make_payload(user, str(user.pk), exclude_groups=exclude_groups) 52 | sso = discourse_sso.DiscourseSigner(endpoint_settings["sso_secret"]) 53 | out_payload, out_signature = sso.sign(payload) 54 | headers = {"Api-Username": "system", "Api-Key": endpoint_settings["api_key"]} 55 | data = {"sso": out_payload, "sig": out_signature} 56 | resp = requests.post(endpoint_settings["sync_sso_endpoint"], headers=headers, data=data) 57 | resp.raise_for_status() 58 | 59 | 60 | def send_update_ping(user, exclude_groups=None): 61 | exclude_groups = exclude_groups or [] 62 | 63 | for endpoint_name, endpoint_settings in settings.SSO_ENDPOINTS.items(): 64 | if "sync_sso_endpoint" not in endpoint_settings: 65 | continue 66 | send_update_ping_to_endpoint.delay(user.pk, endpoint_name, exclude_groups) 67 | -------------------------------------------------------------------------------- /spongeauth/sso/views.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | from django.shortcuts import redirect 4 | from django.contrib.auth.decorators import login_required 5 | from django.http import HttpResponseForbidden 6 | from django.conf import settings 7 | 8 | from . import discourse_sso, utils 9 | 10 | 11 | @login_required 12 | def begin(request): 13 | raw_payload = request.GET.get("sso", "") 14 | raw_signature = request.GET.get("sig", "") 15 | 16 | for endpoint in settings.SSO_ENDPOINTS.values(): 17 | sso = discourse_sso.DiscourseSigner(endpoint["sso_secret"]) 18 | try: 19 | payload = sso.unsign(raw_payload, raw_signature) 20 | break 21 | except discourse_sso.SignatureError: 22 | pass 23 | else: 24 | return HttpResponseForbidden() 25 | 26 | if b"return_sso_url" not in payload: 27 | return HttpResponseForbidden() 28 | 29 | out_payload, out_signature = sso.sign(utils.make_payload(request.user, payload[b"nonce"])) 30 | redirect_to = "{}?{}".format( 31 | payload[b"return_sso_url"].decode("utf8"), urllib.parse.urlencode({"sso": out_payload, "sig": out_signature}) 32 | ) 33 | return redirect(redirect_to) 34 | -------------------------------------------------------------------------------- /spongeauth/static/images/spongie-mark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 19 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /spongeauth/static/scripts/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {!gapi.auth2.GoogleUser} googleUser 3 | */ 4 | const onGoogleSignIn = (googleUser) => { 5 | console.log("Google sign in has been finished successfully!") 6 | const form = document.querySelector('#form-glogin'); 7 | form.querySelector('input[name="google_id_token"]').value = 8 | googleUser.getAuthResponse().id_token; 9 | window.gapi.auth2.getAuthInstance().signOut(); 10 | form.submit(); 11 | }; 12 | 13 | const onGoogleSignInFailure = (err) => { 14 | console.error('Google sign in has failed:' + err.error + '!'); 15 | } 16 | 17 | window['onGoogleSignIn'] = onGoogleSignIn; 18 | window['onGoogleSignInFailure'] = onGoogleSignInFailure; 19 | 20 | (function() { 21 | // Automatically check "Uploaded avatar" radio button if user selects an avatar. 22 | Array.prototype.forEach.call( 23 | document.querySelectorAll('.avatar-image-upload'), (el) => { 24 | const form = el.closest('form'); 25 | const radioButton = form.querySelector( 26 | 'input[type=radio][name=avatar_from][value=upload]'); 27 | el.addEventListener('change', () => { 28 | if (el.value === '') 29 | return; 30 | radioButton.checked = true; 31 | }); 32 | }); 33 | })(); 34 | -------------------------------------------------------------------------------- /spongeauth/static/styles/_email.scss: -------------------------------------------------------------------------------- 1 | .email { 2 | padding: 20px; 3 | 4 | p { 5 | padding-top: 20px; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /spongeauth/static/styles/_footer.scss: -------------------------------------------------------------------------------- 1 | @import 'pallette'; 2 | @import 'utils'; 3 | 4 | /* footer */ 5 | 6 | .footer { 7 | position: absolute; 8 | bottom: 0; 9 | width: 100%; 10 | min-height: 60px; 11 | height: 60px; 12 | padding: 0 10px 0 10px; 13 | a { outline: none; } 14 | .col-md-4.col-spongie { padding: 0; } 15 | .info { display: table; } 16 | 17 | .col-md-4 { 18 | @include padding-horiz(5px, 5px); 19 | font-size: 12px; 20 | padding-top: 12px; 21 | vertical-align: middle; 22 | border-top: 1px solid $lighter; 23 | } 24 | 25 | .info > a, .info > .copyright { 26 | display: table-cell; 27 | padding-right: 10px; 28 | vertical-align: middle; 29 | } 30 | 31 | .commit { 32 | font-size: 10px; 33 | color: $mainBackground; 34 | } 35 | 36 | .commit:hover { 37 | color: $mainBackground; 38 | } 39 | } 40 | 41 | .footer > .navbar-default { 42 | background-color: $light; 43 | border: 0; 44 | border-top: 1px solid #A5A5A5; 45 | } 46 | 47 | .spongie-link { 48 | @include size(50px, 50px); 49 | display: block; 50 | margin: 0 auto; 51 | } 52 | 53 | .spongie { 54 | @include transition3(filter, 0.15s, ease-in-out); 55 | filter: grayscale(100%) contrast(30%); 56 | &:hover { 57 | filter: none; 58 | } 59 | } 60 | 61 | .copy { 62 | font-size: 11px; 63 | padding-right: 2px; 64 | } 65 | 66 | .copyright { 67 | font-family: "Lato", "Helvetica Neue", Helvetica, Arial, sans-serif; 68 | } 69 | 70 | .social { 71 | display: table; 72 | .fa-twitter:hover { color: #00aced; } 73 | .fa-facebook-official:hover { color: #3b5998; } 74 | .fa-reddit:hover { color: #3399ff; } 75 | } 76 | 77 | .social > a { 78 | display: table-cell; 79 | padding-left: 10px; 80 | vertical-align: middle; 81 | } 82 | 83 | .social > .social-icon { 84 | color: #727272; 85 | } 86 | 87 | .social > a > .fa { 88 | @include transition3(color, 0.15s, ease-in-out); 89 | } 90 | -------------------------------------------------------------------------------- /spongeauth/static/styles/_home.scss: -------------------------------------------------------------------------------- 1 | .list-group-home > a { 2 | span { float: left; } 3 | i { 4 | float: right; 5 | padding-top: 3px; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /spongeauth/static/styles/_nav.scss: -------------------------------------------------------------------------------- 1 | @import 'utils'; 2 | 3 | // left nav 4 | @import 'topbar'; 5 | 6 | #topbar { 7 | font-size: 15px; 8 | 9 | .container { 10 | max-width: 1110px; 11 | } 12 | 13 | a.logo { 14 | color: $sponge_yellow; 15 | outline: 0; 16 | 17 | &:hover, &:focus, &:active, &.active { 18 | color: $sponge_yellow; 19 | outline: 0; 20 | } 21 | } 22 | } 23 | 24 | // right nav 25 | 26 | .navbar-inverse .navbar-nav > .open > a { 27 | &, &:hover, &:focus, &:active { 28 | background: transparent; 29 | } 30 | } 31 | 32 | .navbar-nav > li > .main-dropdown, .user-dropdown { 33 | @include no-box-shadow(); 34 | border-radius: 0 0 4px 4px; 35 | border: 1px solid #e4e4e4; 36 | } 37 | 38 | .navbar-nav > li > .main-dropdown { 39 | padding: 10px 0 4px; 40 | border-top: none; 41 | } 42 | 43 | .navbar-nav > li > .user-dropdown { 44 | width: 200px; 45 | } 46 | 47 | .user-dropdown > li { 48 | position: relative; 49 | .unread { 50 | bottom: 6px; 51 | margin-left: 5px; 52 | } 53 | } 54 | 55 | .navbar-main { 56 | .navbar-right > li > a { padding: 0; } 57 | .navbar-right { 58 | @include padding-vert(9px, 9px); 59 | padding-right: 20px; 60 | } 61 | } 62 | 63 | .project-search { 64 | @include size(0, 40px); 65 | padding: 3px; 66 | overflow: hidden; 67 | } 68 | 69 | .nav-icon { 70 | @include transition2(background-color, 0.5s); 71 | cursor: pointer; 72 | padding: 7px; 73 | margin-right: 5px; 74 | text-align: center; 75 | 76 | .icon { 77 | @include transition2(color, 0.5s); 78 | cursor: pointer; 79 | font-size: 25px; 80 | color: #F6CF17; 81 | } 82 | } 83 | 84 | .nav-icon:hover { 85 | background-color: white; 86 | .icon { color: black; } 87 | } 88 | 89 | .new-icon { 90 | .caret { padding-bottom: 10px; } 91 | } 92 | 93 | .new-icon:hover { 94 | background-color: transparent; 95 | .icon { color: #F6CF17; } 96 | } 97 | 98 | .user-controls { 99 | @include padding-vert(5px, 17px); 100 | } 101 | 102 | .new-controls { 103 | @include padding-vert(10px, 19px); 104 | } 105 | 106 | .user-avatar:hover { 107 | background-color: white; 108 | } 109 | 110 | .user-dropdown > li > a { 111 | overflow: hidden; 112 | } 113 | 114 | .user-dropdown > li > a > i { 115 | transform: translateY(4px); 116 | } 117 | 118 | .btn-group-login { 119 | padding: 4px; 120 | } 121 | 122 | .btn-group-login > a { 123 | margin: 0 auto; 124 | } 125 | 126 | .unread { 127 | @include size(12px, 12px); 128 | @include circle(); 129 | position: absolute; 130 | background-image: linear-gradient(#e6b800, #FFD21A); 131 | } 132 | 133 | .user-toggle { 134 | position: relative; 135 | .unread { right: 10px; } 136 | } 137 | -------------------------------------------------------------------------------- /spongeauth/static/styles/_pallette.scss: -------------------------------------------------------------------------------- 1 | $mainBackground: #f3f2f0; 2 | $dark: #333; 3 | $light: #ccc; 4 | $lighter: #ddd; 5 | -------------------------------------------------------------------------------- /spongeauth/static/styles/_settings.scss: -------------------------------------------------------------------------------- 1 | .panel-settings { 2 | .setting:first-child { 3 | padding-top: 0; 4 | } 5 | 6 | .setting-avatar { 7 | position: relative; 8 | .setting-content > form { 9 | position: absolute; 10 | bottom: 20px; 11 | .btn-group { 12 | margin-top: 10px; 13 | } 14 | } 15 | } 16 | } 17 | 18 | .table-avatar td { 19 | padding-right: 10px; 20 | padding-top: 10px; 21 | } 22 | 23 | .avatar-view { 24 | width: 90px; 25 | height: auto; 26 | max-height: 90px; 27 | } 28 | -------------------------------------------------------------------------------- /spongeauth/static/styles/_signUp.scss: -------------------------------------------------------------------------------- 1 | .panel-signup .panel-body { 2 | form { margin: 0; } 3 | div:not(:last-child) { margin-bottom: 10px; } 4 | i { color: gray; } 5 | .user-input a { 6 | font-size: 11px; 7 | font-weight: normal; 8 | float: right; 9 | } 10 | } 11 | 12 | .qr-totp { 13 | display: block; 14 | margin: 0 auto; 15 | } 16 | 17 | .form-totp { 18 | p { max-width: 80%; } 19 | i { color: gray; } 20 | input { margin-bottom: 10px; } 21 | } 22 | 23 | .forgot-link { 24 | font-size: smaller; 25 | } 26 | -------------------------------------------------------------------------------- /spongeauth/static/styles/_sponge_variables.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This file is part of SpongeHome, licensed under the MIT License (MIT). 3 | * 4 | * Copyright (c) SpongePowered 5 | * Copyright (c) contributors 6 | * 7 | * Permission is hereby granted, free of charge, to any person obtaining a copy 8 | * of this software and associated documentation files (the "Software"), to deal 9 | * in the Software without restriction, including without limitation the rights 10 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | * copies of the Software, and to permit persons to whom the Software is 12 | * furnished to do so, subject to the following conditions: 13 | * 14 | * The above copyright notice and this permission notice shall be included in 15 | * all copies or substantial portions of the Software. 16 | * 17 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 23 | * THE SOFTWARE. 24 | */ 25 | 26 | // Brand colors 27 | $sponge_yellow: #f7cf0d; 28 | $sponge_dark_yellow: #917300; 29 | $sponge_grey: #333333; 30 | $sponge_dark_grey: #2b2b2b; 31 | 32 | $sponge_headline_font: Montserrat, "Helvetica Neue", Helvetica, Arial, sans-serif; 33 | 34 | .sponge-headline { 35 | font-family: $sponge_headline_font; 36 | font-weight: 700; 37 | text-transform: uppercase; 38 | text-decoration: none; 39 | } 40 | -------------------------------------------------------------------------------- /spongeauth/static/styles/bootstrap.scss: -------------------------------------------------------------------------------- 1 | // Used for overriding any variables. 2 | 3 | $icon-font-path: "../fonts/"; 4 | 5 | $brand-primary: #f6cf17; 6 | 7 | $link-color: #337ab7; 8 | $link-hover-color: darken($link-color, 15%); 9 | $link-hover-decoration: none; 10 | 11 | $border-radius-base: 0; 12 | $border-radius-large: 0; 13 | $border-radius-small: 0; 14 | 15 | $component-active-color: #474a54; 16 | $component-active-bg: $brand-primary; 17 | 18 | $btn-primary-color: black; 19 | $btn-primary-bg: $brand-primary; 20 | $btn-primary-border: darken($btn-primary-bg, 5%); 21 | 22 | //$navbar-height: 75px; 23 | //$navbar-inverse-bg: #3a3a3a; 24 | 25 | @import "bootstrap-sass/assets/stylesheets/_bootstrap.scss"; 26 | -------------------------------------------------------------------------------- /spongeauth/static/styles/font-awesome.scss: -------------------------------------------------------------------------------- 1 | @import "font-awesome/scss/font-awesome.scss"; 2 | -------------------------------------------------------------------------------- /spongeauth/templates/_footer.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load i18n %} 3 | 42 | -------------------------------------------------------------------------------- /spongeauth/templates/_navbar.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 51 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/_avatar_block.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load crispy_forms_tags %} 3 | 4 |
5 |
6 |
7 | 16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/_google_signin_form.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/agree_tos.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | 5 | {% block title %}{% trans "Terms of Service" %}{% endblock %} 6 | {% block main %} 7 |
8 |
9 |
10 | 36 |
37 |
38 |
39 | {% endblock main %} 40 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/change_email/confirmation_email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 |

{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %}

6 | 7 |

{% blocktrans %}Your email address has been changed to {{ new_email }}.{% endblocktrans %}

8 | 9 |

{% blocktrans %}If you did not intend to make this change, please email staff@spongepowered.org as quickly as possible, as this means that your account has been compromised.{% endblocktrans %}

10 | 11 |

{% blocktrans %}Best regards,
12 | The SpongePowered Team{% endblocktrans %}

13 | 14 | 15 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/change_email/confirmation_email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %} 2 | 3 | {% blocktrans %}Your email address has been changed to {{ new_email }}.{% endblocktrans %} 4 | 5 | {% blocktrans %}If you did not intend to make this change, please email staff@spongepowered.org as quickly as possible, as this means that your account has been compromised.{% endblocktrans %} 6 | 7 | {% blocktrans %}Best regards, 8 | The SpongePowered Team{% endblocktrans %} 9 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/change_email/email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 |

{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %}

6 | 7 |

{% blocktrans %}Welcome to SpongePowered! Click the link below to confirm your new email address.{% endblocktrans %}

8 | 9 |

{{ link }}

10 | 11 |

{% blocktrans %}Best regards,
12 | The SpongePowered Team{% endblocktrans %}

13 | 14 | 15 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/change_email/email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %} 2 | 3 | {% blocktrans %}Welcome to SpongePowered! Click the link below to confirm your new email address.{% endblocktrans %} 4 | 5 | {{ link }} 6 | 7 | {% blocktrans %}Best regards, 8 | The SpongePowered Team{% endblocktrans %} 9 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/change_email/step1.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block title %}{% trans "Change email" %}{% endblock %} 6 | {% block main %} 7 |
8 |
9 |
10 | 20 |
21 |
22 |
23 | {% endblock main %} 24 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/change_email/step1done.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block title %}{% trans "Change email" %}{% endblock %} 6 | {% block main %} 7 |
8 |
9 |
10 | 19 |
20 |
21 |
22 | {% endblock main %} 23 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/change_other_avatar.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block title %}{% blocktrans %}Change {{ for_user }}'s Avatar{% endblocktrans %}{% endblock %} 6 | {% block main %} 7 | {% with avatar_user=for_user %} 8 | {% include "accounts/_avatar_block.html" %} 9 | {% endwith %} 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/forgot/email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | {% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %} 6 | 7 | {% blocktrans %}Someone with the IP {{ ip }} asked to reset your password. If this is something you were expecting to see, then please click the link below to proceed with setting a new password.{% endblocktrans %} 8 | 9 | {{ link }} 10 | 11 | {% blocktrans %}If you did not ask for your password to be reset, please reply to this email and let us know.{% endblocktrans %} 12 | 13 | {% blocktrans %}Best regards, 14 | The SpongePowered Team{% endblocktrans %} 15 | 16 | 17 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/forgot/email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %} 2 | 3 | {% blocktrans with ip=ip %}Someone with the IP {{ ip }} asked to reset your password. If this is something you were expecting to see, then please click the link below to proceed with setting a new password.{% endblocktrans %} 4 | 5 | {{ link }} 6 | 7 | {% blocktrans %}If you did not ask for your password to be reset, please reply to this email and let us know.{% endblocktrans %} 8 | 9 | {% blocktrans %}Best regards, 10 | The SpongePowered Team{% endblocktrans %} 11 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/forgot/step1.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block title %}{% trans "Reset your password" %}{% endblock %} 6 | {% block main %} 7 |
8 |
9 |
10 | 21 |
22 |
23 |
24 | {% endblock main %} 25 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/forgot/step1done.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block title %}{% trans "Reset your password" %}{% endblock %} 6 | {% block main %} 7 |
8 |
9 |
10 | 19 |
20 |
21 |
22 | {% endblock main %} 23 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/forgot/step2.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block title %}{% trans "Reset your password" %}{% endblock %} 6 | {% block main %} 7 |
8 |
9 |
10 | 21 |
22 |
23 |
24 | {% endblock main %} 25 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block title %}{% trans "Log in" %}{% endblock %} 6 | {% block meta %} 7 | 8 | 9 | {% endblock %} 10 | {% block main %} 11 |
12 |
13 |
14 | 23 |
24 |
25 |
26 | 27 | {% include 'accounts/_google_signin_form.html' %} 28 | {% endblock main %} 29 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Log out" %}{% endblock %} 5 | {% block main %} 6 |
7 |
8 |
9 | 23 |
24 |
25 |
26 | {% endblock %} 27 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/logout_success.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block title %}{% trans "Logged out" %}{% endblock %} 5 | {% block main %} 6 |
7 |
8 |
9 | 20 |
21 |
22 |
23 | 24 | 25 | 46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block title %}{% trans "Account Settings" %}{% endblock %} 6 | {% block main %} 7 |
8 |
9 |
10 | 30 |
31 |
32 |
33 | 34 | {% with avatar_user=user %}{% include "accounts/_avatar_block.html" %}{% endwith %} 35 | 36 |
37 |
38 |
39 | 47 |
48 |
49 |
50 | 51 | {% if user.has_usable_password %} 52 |
53 |
54 |
55 | 63 |
64 |
65 |
66 | {% endif %} 67 | {% endblock %} 68 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/register.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | {% block title %}{% trans "Sign up" %}{% endblock %} 6 | {% block meta %} 7 | 8 | 9 | {% endblock %} 10 | {% block main %} 11 |
12 |
13 |
14 | 27 |
28 |
29 |
30 | 31 | {% include "accounts/_google_signin_form.html" %} 32 | {% endblock %} 33 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/verify/email.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | 4 | 5 | {% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %} 6 | 7 | {% blocktrans %}Welcome to SpongePowered! Click the link below to confirm your new account.{% endblocktrans %} 8 | 9 | {{ link }} 10 | 11 | {% blocktrans %}Best regards, 12 | The SpongePowered Team{% endblocktrans %} 13 | 14 | 15 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/verify/email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% blocktrans with username=user.username %}Hi {{ username }},{% endblocktrans %} 2 | 3 | {% blocktrans %}Welcome to SpongePowered! Click the link below to confirm your new account.{% endblocktrans %} 4 | 5 | {{ link }} 6 | 7 | {% blocktrans %}Best regards, 8 | The SpongePowered Team{% endblocktrans %} 9 | -------------------------------------------------------------------------------- /spongeauth/templates/accounts/verify/step1.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | {% load crispy_forms_tags %} 4 | 5 | 6 | {% block title %}{% trans "Email verification" %}{% endblock %} 7 | {% block main %} 8 | {% url 'accounts:change-email' as change_email_url %} 9 |
10 |
11 |
12 | 34 |
35 |
36 |
37 | {% endblock main %} 38 | -------------------------------------------------------------------------------- /spongeauth/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | {% load i18n %} 3 | 4 | 5 | 6 | {% block fulltitle %}{% block title %}{% endblock %} | SpongePowered{% endblock fulltitle %} 7 | 8 | 9 | 10 | 11 | {% block meta %}{% endblock %} 12 | 13 | 14 | 16 | 18 | 20 | 21 | 22 | 23 | {% include "_navbar.html" %} 24 | 25 | {% block messages %} 26 | {% if messages %} 27 |
28 | {% for message in messages %} 29 | {% if message.level == DEFAULT_MESSAGE_LEVELS.ERROR %} 30 |