mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-08 11:16:47 +00:00
Merge ce98bc85d0 into bb920b0a7f
This commit is contained in:
commit
a16e4ea02f
1309 changed files with 96979 additions and 281490 deletions
|
|
@ -16,8 +16,15 @@
|
|||
**/env*
|
||||
**/.env*
|
||||
|
||||
# Local databases and runtime data
|
||||
# Local databases and runtime data (secret key, admin password, update backups)
|
||||
backups
|
||||
**/*.sqlite3*
|
||||
data
|
||||
**/*.bak
|
||||
**/*~
|
||||
**/*.swp
|
||||
**/*.swo
|
||||
**/staticfiles
|
||||
**/*.log
|
||||
**/console/cert.pem*
|
||||
**/dhcpd.*
|
||||
|
|
|
|||
5
.github/linters/.hadolint.yaml
vendored
5
.github/linters/.hadolint.yaml
vendored
|
|
@ -1,5 +0,0 @@
|
|||
ignored:
|
||||
- DL3008
|
||||
- DL3013
|
||||
- DL3042
|
||||
- SC1091
|
||||
61
.github/workflows/codeql-analysis.yml
vendored
61
.github/workflows/codeql-analysis.yml
vendored
|
|
@ -1,61 +0,0 @@
|
|||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches-ignore: [master]
|
||||
schedule:
|
||||
- cron: '0 21 * * 3'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Override automatic language detection by changing the below list
|
||||
# Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python']
|
||||
language: ['javascript', 'python']
|
||||
# Learn more...
|
||||
# https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
100
.github/workflows/linter.yml
vendored
100
.github/workflows/linter.yml
vendored
|
|
@ -1,92 +1,32 @@
|
|||
###########################
|
||||
###########################
|
||||
## Linter GitHub Actions ##
|
||||
###########################
|
||||
###########################
|
||||
name: Lint
|
||||
|
||||
name: Lint Code Base
|
||||
|
||||
#
|
||||
# Documentation:
|
||||
# https://help.github.com/en/articles/workflow-syntax-for-github-actions
|
||||
#
|
||||
|
||||
#############################
|
||||
# Start the job on all push #
|
||||
#############################
|
||||
# a branch with a pull request (also one stacked on another) is tested by its
|
||||
# pull_request run; push runs for every branch tested each commit twice
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches-ignore: [master]
|
||||
|
||||
|
||||
###############
|
||||
# Set the Job #
|
||||
###############
|
||||
# the jobs only read the code
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Lint Code Base
|
||||
# Set the agent to run on
|
||||
ruff:
|
||||
name: Ruff
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
##################
|
||||
# Load all steps #
|
||||
###################
|
||||
steps:
|
||||
##########################
|
||||
# Checkout the code base #
|
||||
##########################
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# Full git history is needed to get a proper list of changed files within `super-linter`
|
||||
fetch-depth: 0
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Required packages
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y python3-venv python3-virtualenv libvirt-dev libldap2-dev libsasl2-dev python3-lxml zlib1g-dev libxslt1-dev pkg-config gcc libssl-dev
|
||||
|
||||
- name: Create & Activate VENV
|
||||
run: |
|
||||
python3 -m venv venv
|
||||
echo "$GITHUB_WORKSPACE/venv/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
pip3 install --upgrade pip setuptools wheel
|
||||
if [ -f dev/requirements.txt ]; then pip3 install -r dev/requirements.txt; else pip3 install -r conf/requirements.txt; fi
|
||||
################################
|
||||
# Run Linter against code base #
|
||||
################################
|
||||
- name: Lint Code Base
|
||||
uses: docker://github/super-linter:latest
|
||||
env:
|
||||
FILTER_REGEX_EXCLUDE: .*(static|scss|venv|locale|templates)/.*
|
||||
DEFAULT_BRANCH: master
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
VALIDATE_ALL_CODEBASE: false
|
||||
VALIDATE_MARKDOWN: false
|
||||
VALIDATE_HTML: false
|
||||
VALIDATE_JSCPD: false
|
||||
VALIDATE_ANSIBLE: false
|
||||
VALIDATE_CLOJURE: false
|
||||
VALIDATE_COFFEE: false
|
||||
VALIDATE_DART: false
|
||||
VALIDATE_GO: false
|
||||
VALIDATE_JSX: false
|
||||
VALIDATE_KOTLIN: false
|
||||
VALIDATE_POWERSHELL: false
|
||||
VALIDATE_PERL: false
|
||||
VALIDATE_PHP: false
|
||||
VALIDATE_RAKU: false
|
||||
VALIDATE_RUBY: false
|
||||
VALIDATE_TSX: false
|
||||
VALIDATE_TERRAFORM: false
|
||||
# Same ruff version as dev/requirements.txt
|
||||
- name: Install Ruff
|
||||
run: pip install "$(grep '^ruff==' dev/requirements.txt)"
|
||||
|
||||
- name: Run Ruff
|
||||
run: ruff check --output-format=github .
|
||||
|
|
|
|||
192
.github/workflows/test.yml
vendored
192
.github/workflows/test.yml
vendored
|
|
@ -1,19 +1,49 @@
|
|||
name: Test Suite
|
||||
|
||||
# a branch with a pull request (also one stacked on another) is tested by its
|
||||
# pull_request run; push runs for every branch tested each commit twice
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
# the jobs only read the code
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Python ${{ matrix.python-version }}
|
||||
name: Python ${{ matrix.python-version }} / ${{ matrix.database }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
|
||||
database: [sqlite]
|
||||
include:
|
||||
- python-version: '3.12'
|
||||
database: postgresql
|
||||
|
||||
# Used by the postgresql leg only; settings fall back to sqlite when
|
||||
# WEBVIRTCLOUD_DB_HOST is empty.
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
env:
|
||||
POSTGRES_USER: webvirtcloud
|
||||
POSTGRES_PASSWORD: webvirtcloud
|
||||
POSTGRES_DB: webvirtcloud
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
|
||||
env:
|
||||
WEBVIRTCLOUD_DB_HOST: ${{ matrix.database == 'postgresql' && 'localhost' || '' }}
|
||||
WEBVIRTCLOUD_DB_PASSWORD: webvirtcloud
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
|
|
@ -24,28 +54,30 @@ jobs:
|
|||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: 'pip'
|
||||
cache-dependency-path: 'conf/requirements.txt'
|
||||
cache-dependency-path: |
|
||||
conf/requirements.txt
|
||||
dev/requirements.txt
|
||||
|
||||
# Build headers for the sdist-only bindings (libvirt-python, python-ldap)
|
||||
# A runner's package mirror sometimes stops answering: time out and
|
||||
# retry instead of hanging until the job's 6-hour limit
|
||||
- name: Install System Dependencies
|
||||
timeout-minutes: 10
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
APT="-o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30"
|
||||
sudo apt-get $APT update -qq
|
||||
sudo apt-get $APT install -y --no-install-recommends \
|
||||
libvirt-dev \
|
||||
libldap2-dev \
|
||||
libsasl2-dev \
|
||||
python3-lxml \
|
||||
zlib1g-dev \
|
||||
libxslt1-dev \
|
||||
pkg-config \
|
||||
gcc \
|
||||
libssl-dev \
|
||||
libxml2-dev
|
||||
gettext
|
||||
|
||||
- name: Install Python Dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip setuptools wheel
|
||||
pip install -r conf/requirements.txt
|
||||
if [ -f dev/requirements.txt ]; then pip install -r dev/requirements.txt; fi
|
||||
python -m pip install --upgrade pip setuptools
|
||||
pip install -r dev/requirements.txt
|
||||
|
||||
- name: Configure WebVirtCloud Settings
|
||||
run: |
|
||||
|
|
@ -53,14 +85,134 @@ jobs:
|
|||
SECRET_KEY=$(python -c 'import secrets; print(secrets.token_urlsafe(50))')
|
||||
sed -i "s|^SECRET_KEY = .*|SECRET_KEY = \"${SECRET_KEY}\"|" webvirtcloud/settings.py
|
||||
|
||||
- name: Check For Missing Migrations
|
||||
run: python manage.py makemigrations --check --dry-run
|
||||
|
||||
- name: Run Django Migrations
|
||||
run: |
|
||||
python manage.py migrate --noinput
|
||||
run: python manage.py migrate --noinput
|
||||
|
||||
- name: Run Django Test Suite
|
||||
# Each catalog of LANGUAGES translates every message, and its compiled
|
||||
# .mo is the .po's: a changed text needs its translations
|
||||
- name: Check Translation Catalogs
|
||||
if: matrix.python-version == '3.12' && matrix.database == 'sqlite'
|
||||
run: |
|
||||
python manage.py test
|
||||
python manage.py makemessages -l tr -l zh --no-obsolete --ignore=staticfiles -v0
|
||||
for dir in locale/tr/LC_MESSAGES locale/zh/LC_MESSAGES; do
|
||||
msgfmt --check -o /tmp/django.mo "$dir/django.po"
|
||||
missing=$(msgattrib --untranslated "$dir/django.po"; msgattrib --only-fuzzy "$dir/django.po")
|
||||
if [ -n "$missing" ]; then echo "$missing"; echo "::error file=$dir/django.po::untranslated or fuzzy messages"; exit 1; fi
|
||||
cmp <(msgunfmt "$dir/django.mo") <(msgunfmt /tmp/django.mo) || { echo "::error file=$dir/django.mo::not compiled from django.po (compilemessages)"; exit 1; }
|
||||
done
|
||||
|
||||
- name: Run vrtManager Unit Tests
|
||||
# Includes the vrtManager tests; fails below the coverage floor in pyproject.toml
|
||||
- name: Run Test Suite
|
||||
run: |
|
||||
python -m unittest discover -s vrtManager -p "test_*.py"
|
||||
coverage run manage.py test
|
||||
coverage report --skip-covered
|
||||
|
||||
# The project test runner (webvirtcloud/test_runner.py) on the Docker
|
||||
# image's Python, whose default start method its workers need changed
|
||||
- name: Run Test Suite In Parallel
|
||||
if: matrix.python-version == '3.14' && matrix.database == 'sqlite'
|
||||
run: python manage.py test --parallel
|
||||
|
||||
# The per-file report of one leg, to see what a change left untested
|
||||
- name: Store Coverage Report
|
||||
if: always() && matrix.python-version == '3.12' && matrix.database == 'sqlite'
|
||||
run: coverage html -d coverage-html --fail-under=0
|
||||
|
||||
- name: Upload Coverage Report
|
||||
if: always() && matrix.python-version == '3.12' && matrix.database == 'sqlite'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-html
|
||||
path: coverage-html
|
||||
|
||||
- name: Validate OpenAPI Schema
|
||||
run: python manage.py spectacular --validate --fail-on-warn --file /dev/null
|
||||
|
||||
# Security warnings (HSTS, secure cookies) depend on the deployment;
|
||||
# gate on errors only.
|
||||
- name: Deployment Checks
|
||||
run: python manage.py check --deploy --fail-level ERROR
|
||||
|
||||
# What installations get, resolved for this Python; not the CI tools
|
||||
# (their fixes can need a newer Python than the leg's, as filelock's did)
|
||||
- name: Audit Shipped Packages
|
||||
run: pip-audit -r conf/requirements.txt -r conf/requirements-ldap.txt
|
||||
|
||||
docker:
|
||||
name: Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Files that exist in a used checkout and must never enter the image
|
||||
- name: Plant Secret Sentinels
|
||||
run: |
|
||||
mkdir -p data
|
||||
for f in data/secret_key data/admin_password webvirtcloud/settings.py.bak \
|
||||
'webvirtcloud/settings.py~' webvirtcloud/.settings.py.swp; do
|
||||
echo sentinel > "$f"
|
||||
done
|
||||
|
||||
- name: Build Image
|
||||
run: docker build -t webvirtcloud:ci .
|
||||
|
||||
- name: No Runtime Data In Image
|
||||
run: |
|
||||
docker run --rm --entrypoint sh webvirtcloud:ci -c '
|
||||
cd /srv/webvirtcloud
|
||||
for f in data/secret_key data/admin_password webvirtcloud/settings.py.bak \
|
||||
"webvirtcloud/settings.py~" webvirtcloud/.settings.py.swp; do
|
||||
if [ -e "$f" ]; then echo "$f is in the image"; exit 1; fi
|
||||
done'
|
||||
|
||||
# The default profile, and the HTTPS profile behind a TLS proxy
|
||||
- name: Start Containers
|
||||
run: |
|
||||
docker run -d --name wvc -p 127.0.0.1:8080:80 webvirtcloud:ci
|
||||
docker run -d --name wvc-https -e WEBVIRTCLOUD_HTTPS=1 webvirtcloud:ci
|
||||
|
||||
- name: Wait Until Healthy
|
||||
run: |
|
||||
for c in wvc wvc-https; do
|
||||
for i in $(seq 40); do
|
||||
[ "$(docker inspect -f '{{.State.Health.Status}}' $c)" = healthy ] && continue 2
|
||||
sleep 5
|
||||
done
|
||||
docker inspect -f '{{json .State.Health}}' $c || true
|
||||
docker exec $c sv status /etc/service/nginx /etc/service/webvirtcloud /etc/service/novnc || true
|
||||
docker logs $c || true
|
||||
exit 1
|
||||
done
|
||||
|
||||
- name: Import Compiled Dependencies
|
||||
run: docker exec wvc /srv/webvirtcloud/venv/bin/python3 -c "import libvirt, ldap, lxml.etree, psycopg"
|
||||
|
||||
# Static URLs carry no version: browsers must revalidate them
|
||||
- name: Static Files Revalidated
|
||||
run: |
|
||||
url=http://127.0.0.1:8080/static/css/themes/zephyr.min.css
|
||||
header() { tr -d '\r' | awk -v h="$1" 'tolower($0) ~ "^" h ":" {sub(/^[^:]*: */, ""); print}'; }
|
||||
h=$(curl -s -m 5 -D - -o /dev/null "$url")
|
||||
[ "$(echo "$h" | header cache-control)" = no-cache ] || { echo "$h"; exit 1; }
|
||||
etag=$(echo "$h" | header etag)
|
||||
[ -n "$etag" ]
|
||||
h=$(curl -s -m 5 -D - -o /dev/null -H "If-None-Match: $etag" "$url")
|
||||
echo "$h" | head -1 | grep -q " 304" || { echo "$h"; exit 1; }
|
||||
[ "$(echo "$h" | header cache-control)" = no-cache ]
|
||||
docker exec wvc sh -c 'echo "/* changed */" >> /srv/webvirtcloud/staticfiles/css/themes/zephyr.min.css'
|
||||
h=$(curl -s -m 5 -D - -o /dev/null -H "If-None-Match: $etag" "$url")
|
||||
echo "$h" | head -1 | grep -q " 200" || { echo "$h"; exit 1; }
|
||||
new=$(echo "$h" | header etag)
|
||||
[ -n "$new" ] && [ "$new" != "$etag" ]
|
||||
|
||||
# The browser reaches the console through nginx on the panel's port
|
||||
- name: Console Through Nginx Only
|
||||
run: |
|
||||
[ "$(docker inspect -f '{{json .Config.ExposedPorts}}' webvirtcloud:ci)" = '{"80/tcp":{}}' ]
|
||||
code=$(curl -s -m 5 -o /dev/null -w '%{http_code}' -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
|
||||
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGVzdGtleXRlc3RrZXk9PQ==' http://127.0.0.1:8080/novncd/ || true)
|
||||
[ "$code" = 101 ] || { echo "websocket upgrade through nginx: $code"; exit 1; }
|
||||
|
|
|
|||
18
.gitignore
vendored
18
.gitignore
vendored
|
|
@ -8,13 +8,29 @@ venv2
|
|||
.webvirtcloud
|
||||
*.pyc
|
||||
db.sqlite3*
|
||||
test_db*.sqlite3*
|
||||
console/cert.pem*
|
||||
tags
|
||||
dhcpd.*
|
||||
webvirtcloud/settings.py
|
||||
*migrations/*
|
||||
.coverage
|
||||
htmlcov
|
||||
*.log
|
||||
templates/webvirtcloud.code-workspace
|
||||
DEPENDENCY_UPGRADE_PLAN.md
|
||||
AGENTS.md
|
||||
ROADMAP.md
|
||||
.agents
|
||||
|
||||
# Runtime data (secret key, etc.)
|
||||
data/
|
||||
|
||||
# collectstatic output
|
||||
/staticfiles/
|
||||
|
||||
# Local planning and agent workspace (superpowers)
|
||||
/docs/superpowers/
|
||||
/.superpowers/
|
||||
|
||||
# Panel backups (manage.py backup)
|
||||
/backups/
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
image: gitpod/workspace-full
|
||||
|
||||
tasks:
|
||||
- init: 'echo "TODO: Replace with init/build command"'
|
||||
command: 'echo "TODO: Replace with command to start project"'
|
||||
84
Dockerfile
84
Dockerfile
|
|
@ -1,53 +1,57 @@
|
|||
FROM phusion/baseimage:noble-1.0.2
|
||||
|
||||
EXPOSE 80
|
||||
EXPOSE 6080
|
||||
|
||||
# Use baseimage-docker's init system.
|
||||
CMD ["/sbin/my_init"]
|
||||
|
||||
RUN echo 'APT::Get::Clean=always;' >> /etc/apt/apt.conf.d/99AutomaticClean
|
||||
FROM phusion/baseimage:resolute-1.0.23 AS build
|
||||
|
||||
# Compilers and headers for libvirt-python and python-ldap; they stay in this stage.
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update -qqy \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get -qyy install \
|
||||
--no-install-recommends \
|
||||
git \
|
||||
python3-venv \
|
||||
python3-pip \
|
||||
python3-dev \
|
||||
python3-lxml \
|
||||
python3-libvirt \
|
||||
libvirt-dev \
|
||||
zlib1g-dev \
|
||||
nginx \
|
||||
pkg-config \
|
||||
gcc \
|
||||
libldap2-dev \
|
||||
libssl-dev \
|
||||
libsasl2-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
WORKDIR /srv/webvirtcloud
|
||||
COPY conf/requirements.txt conf/requirements-ldap.txt conf/
|
||||
# hadolint ignore=DL3013,DL3042
|
||||
RUN python3 -m venv venv \
|
||||
&& venv/bin/pip install --no-cache-dir -U pip wheel \
|
||||
&& venv/bin/pip install --no-cache-dir -r conf/requirements.txt -r conf/requirements-ldap.txt
|
||||
|
||||
FROM phusion/baseimage:resolute-1.0.23
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
# Use baseimage-docker's init system. my_init force-stops services after
|
||||
# KILL_PROCESS_TIMEOUT seconds (default 30); the worker drains jobs for 60 s.
|
||||
ENV KILL_PROCESS_TIMEOUT=80
|
||||
CMD ["/sbin/my_init"]
|
||||
|
||||
RUN echo 'APT::Get::Clean=always;' >> /etc/apt/apt.conf.d/99AutomaticClean
|
||||
|
||||
# Shared libraries of the compiled wheels, and nginx; no compilers or headers.
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update -qqy \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get -qyy install \
|
||||
--no-install-recommends \
|
||||
python3 \
|
||||
nginx \
|
||||
libvirt0 \
|
||||
libsasl2-modules \
|
||||
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
# Setup webvirtcloud
|
||||
WORKDIR /srv/webvirtcloud
|
||||
|
||||
# Install Python dependencies first with system-site-packages to leverage prebuilt bindings
|
||||
COPY conf/requirements.txt conf/requirements.txt
|
||||
# hadolint ignore=DL3013,DL3042,SC1091
|
||||
RUN python3 -m venv --system-site-packages venv && \
|
||||
. venv/bin/activate && \
|
||||
pip3 install --no-cache-dir -U pip wheel && \
|
||||
pip3 install --no-cache-dir -r conf/requirements.txt
|
||||
|
||||
# Copy application source
|
||||
COPY --from=build /srv/webvirtcloud/venv venv
|
||||
COPY . /srv/webvirtcloud
|
||||
|
||||
# Run collectstatic with temporary dummy key, then remove temporary settings file
|
||||
# hadolint ignore=SC1091
|
||||
RUN . venv/bin/activate && \
|
||||
cp webvirtcloud/settings.py.template webvirtcloud/settings.py && \
|
||||
SECRET_KEY="build-dummy-key-only-for-collectstatic" python3 manage.py collectstatic --noinput && \
|
||||
RUN cp webvirtcloud/settings.py.template webvirtcloud/settings.py && \
|
||||
SECRET_KEY="build-dummy-key-only-for-collectstatic" venv/bin/python3 manage.py collectstatic --noinput && \
|
||||
rm -f webvirtcloud/settings.py && \
|
||||
chown -R www-data:www-data /srv/webvirtcloud
|
||||
|
||||
|
|
@ -63,19 +67,33 @@ RUN mkdir -p /etc/my_init.d \
|
|||
/etc/service/nginx \
|
||||
/etc/service/nginx-log-forwarder \
|
||||
/etc/service/webvirtcloud \
|
||||
/etc/service/novnc
|
||||
/etc/service/webvirtcloud-worker \
|
||||
/etc/service/novnc \
|
||||
/etc/service/socketiod
|
||||
COPY conf/runit/10_webvirtcloud_init.sh /etc/my_init.d/10_webvirtcloud_init.sh
|
||||
COPY conf/runit/nginx /etc/service/nginx/run
|
||||
COPY conf/runit/nginx-log-forwarder /etc/service/nginx-log-forwarder/run
|
||||
COPY conf/runit/novncd.sh /etc/service/novnc/run
|
||||
COPY conf/runit/socketiod.sh /etc/service/socketiod/run
|
||||
COPY conf/runit/webvirtcloud.sh /etc/service/webvirtcloud/run
|
||||
COPY conf/runit/webvirtcloud-worker.sh /etc/service/webvirtcloud-worker/run
|
||||
COPY --chmod=644 conf/runit/backup.cron /etc/cron.d/webvirtcloud-backup
|
||||
COPY --chmod=755 conf/runit/healthcheck /usr/local/bin/webvirtcloud-healthcheck
|
||||
RUN chmod +x /etc/my_init.d/10_webvirtcloud_init.sh \
|
||||
/etc/service/nginx/run \
|
||||
/etc/service/nginx-log-forwarder/run \
|
||||
/etc/service/novnc/run \
|
||||
/etc/service/webvirtcloud/run
|
||||
/etc/service/socketiod/run \
|
||||
/etc/service/webvirtcloud/run \
|
||||
/etc/service/webvirtcloud-worker/run
|
||||
|
||||
# Declare mountable data directory for persistent SQLite and SSH keys
|
||||
VOLUME ["/srv/webvirtcloud/data", "/var/www/.ssh"]
|
||||
# The panel's page and every service (conf/runit/healthcheck). X-Forwarded-Proto:
|
||||
# https keeps the probe from being redirected when the HTTPS profile is on
|
||||
# (nginx passes the header on; see conf/nginx).
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD /usr/local/bin/webvirtcloud-healthcheck
|
||||
|
||||
# Declare mountable data directory for persistent SQLite, SSH keys and TLS certificates, and their backups
|
||||
VOLUME ["/srv/webvirtcloud/data", "/var/www/.ssh", "/var/www/.pki", "/srv/webvirtcloud/backups"]
|
||||
|
||||
WORKDIR /srv/webvirtcloud
|
||||
|
|
|
|||
127
Vagrantfile
vendored
127
Vagrantfile
vendored
|
|
@ -1,56 +1,79 @@
|
|||
# -*- mode: ruby -*-
|
||||
# vi: set ft=ruby :
|
||||
# Two disposable compute nodes for the live tests (README: Running tests), so
|
||||
# that migration runs between them: AlmaLinux 10 with nested KVM, set up by
|
||||
# dev/libvirt-bootstrap.sh, with a SASL user for TCP and the host user's SSH
|
||||
# key for root (SASL user wvc, password wvc-test). The empty second disk
|
||||
# /dev/vdb is for the LVM tests (TEST_LIBVIRT_LVM_DEVICE); each also serves a
|
||||
# 1 GiB iSCSI LUN on 127.0.0.1 for the iSCSI tests (TEST_LIBVIRT_ISCSI_TARGET),
|
||||
# exports /srv/wvc-nfs to 127.0.0.1 for the NFS pool test (TEST_LIBVIRT_NFS_EXPORT),
|
||||
# and accepts TLS logins (SASL) with a certificate from a throwaway CA.
|
||||
|
||||
Vagrant.configure(2) do |config|
|
||||
# Default machine, if name not specified...
|
||||
config.vm.define "dev", primary: true do |dev|
|
||||
dev.vm.box = "ubuntu/bionic64"
|
||||
dev.vm.hostname = "webvirtcloud"
|
||||
dev.vm.network "private_network", ip: "192.168.33.10"
|
||||
dev.vm.provision "shell", inline: <<-SHELL
|
||||
sudo sh /vagrant/dev/libvirt-bootstrap.sh
|
||||
sudo sed -i 's/auth_tcp = \"sasl\"/auth_tcp = \"none\"/g' /etc/libvirt/libvirtd.conf
|
||||
sudo service libvirt-bin restart
|
||||
sudo adduser vagrant libvirtd
|
||||
sudo apt-get -y install python3-virtualenv virtualenv python3-pip python3-dev python3-lxml libvirt-dev zlib1g-dev python3-guestfs
|
||||
virtualenv -p python3 /vagrant/venv
|
||||
source /vagrant/venv/bin/activate
|
||||
pip3 install -r /vagrant/dev/requirements.txt
|
||||
SHELL
|
||||
end
|
||||
# To start this machine run "vagrant up prod"
|
||||
# To enter this machine run "vagrant ssh prod"
|
||||
config.vm.define "prod", autostart: false do |prod|
|
||||
prod.vm.box = "ubuntu/bionic64"
|
||||
prod.vm.hostname = "webvirtcloud"
|
||||
prod.vm.network "private_network", ip: "192.168.33.11"
|
||||
prod.vm.network "forwarded_port", guest: 80, host: 8081
|
||||
#prod.vm.synced_folder ".", "/srv/webvirtcloud"
|
||||
prod.vm.provision "shell", inline: <<-SHELL
|
||||
sudo mkdir /srv/webvirtcloud
|
||||
sudo cp -R /vagrant/* /srv/webvirtcloud
|
||||
sudo sh /srv/webvirtcloud/dev/libvirt-bootstrap.sh
|
||||
sudo sed -i 's/auth_tcp = \"sasl\"/auth_tcp = \"none\"/g' /etc/libvirt/libvirtd.conf
|
||||
sudo service libvirt-bin restart
|
||||
sudo adduser vagrant libvirtd
|
||||
sudo chown -R vagrant:vagrant /srv/webvirtcloud
|
||||
sudo apt-get -y install python3-virtualenv python3-dev python3-lxml python3-pip virtualenv libvirt-dev zlib1g-dev libxslt1-dev nginx supervisor libsasl2-modules gcc pkg-config python3-guestfs
|
||||
virtualenv -p python3 /srv/webvirtcloud/venv
|
||||
source /srv/webvirtcloud/venv/bin/activate
|
||||
pip3 install -r /srv/webvirtcloud/requirements.txt
|
||||
sudo cp /srv/webvirtcloud/conf/supervisor/webvirtcloud.conf /etc/supervisor/conf.d
|
||||
sudo cp /srv/webvirtcloud/conf/nginx/webvirtcloud.conf /etc/nginx/conf.d
|
||||
sudo cp /srv/webvirtcloud/webvirtcloud/settings.py.template /srv/webvirtcloud/webvirtcloud/settings.py
|
||||
sudo sed "s/SECRET_KEY = ''/SECRET_KEY = '"`python3 /srv/webvirtcloud/conf/runit/secret_generator.py`"'/" -i /srv/webvirtcloud/webvirtcloud/settings.py
|
||||
python3 /srv/webvirtcloud/manage.py makemigrations
|
||||
python3 /srv/webvirtcloud/manage.py migrate
|
||||
python3 /srv/webvirtcloud/manage.py collectstatic --noinput
|
||||
sudo rm /etc/nginx/sites-enabled/default
|
||||
sudo chown -R www-data:www-data /srv/webvirtcloud
|
||||
sudo service nginx restart
|
||||
sudo service supervisor restart
|
||||
SHELL
|
||||
# The CA is made here once; each compute signs its own certificate with it.
|
||||
# The client certificate sits where libvirt looks with HOME=.vagrant/tls.
|
||||
pki = File.join(__dir__, ".vagrant/tls/.pki/libvirt")
|
||||
unless File.exist?(File.join(pki, "clientcert.pem"))
|
||||
# the host's openssl, not the one Vagrant puts first on PATH
|
||||
Vagrant::Util::Env.with_original_env do
|
||||
system("sh", "-ec", <<-SH, pki, exception: true)
|
||||
mkdir -p "$0" && cd "$0"
|
||||
openssl req -x509 -newkey rsa:3072 -nodes -keyout cakey.pem -out cacert.pem -days 365 -subj /CN=wvc-test-ca
|
||||
openssl req -new -newkey rsa:3072 -nodes -keyout clientkey.pem -subj /CN=wvc-test-client |
|
||||
openssl x509 -req -CA cacert.pem -CAkey cakey.pem -days 365 -out clientcert.pem
|
||||
SH
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
Vagrant.configure(2) do |config|
|
||||
config.vm.box = "almalinux/10"
|
||||
%w[compute1 compute2].each do |name|
|
||||
config.vm.define(name) { |node| node.vm.hostname = "wvc-#{name}" }
|
||||
end
|
||||
config.vm.synced_folder ".", "/vagrant", disabled: true
|
||||
config.vm.provider :libvirt do |v|
|
||||
v.memory = 4096
|
||||
v.cpus = 2
|
||||
v.nested = true
|
||||
v.cpu_mode = "host-passthrough"
|
||||
v.video_type = "virtio" # EL10 hosts have no cirrus
|
||||
v.storage :file, size: "4G"
|
||||
end
|
||||
# the bootstrap installs this checkout's gstfsd
|
||||
config.vm.provision "file", source: "dev", destination: "/tmp/wvc/dev"
|
||||
config.vm.provision "file", source: "conf", destination: "/tmp/wvc/conf"
|
||||
config.vm.provision "file", source: "~/.ssh/id_ed25519.pub", destination: "/tmp/wvc/key.pub"
|
||||
config.vm.provision "file", source: "#{pki}/cacert.pem", destination: "/tmp/wvc/cacert.pem"
|
||||
config.vm.provision "file", source: "#{pki}/cakey.pem", destination: "/tmp/wvc/cakey.pem"
|
||||
config.vm.provision "shell", inline: <<-SHELL
|
||||
set -e
|
||||
sh /tmp/wvc/dev/libvirt-bootstrap.sh
|
||||
virsh pool-info default > /dev/null 2>&1 || { virsh pool-define-as default dir --target /var/lib/libvirt/images && virsh pool-autostart default && virsh pool-start default; }
|
||||
echo wvc-test | saslpasswd2 -a libvirt -p wvc
|
||||
install -d -m 700 /root/.ssh
|
||||
cat /tmp/wvc/key.pub >> /root/.ssh/authorized_keys
|
||||
dnf -y install targetcli
|
||||
systemctl enable --now target
|
||||
T=iqn.2026-10.test.wvc:lun
|
||||
targetcli ls /iscsi/$T > /dev/null 2>&1 || {
|
||||
targetcli /backstores/fileio create wvc /var/lib/wvc-iscsi.img 1G
|
||||
targetcli /iscsi create $T
|
||||
targetcli /iscsi/$T/tpg1/portals delete 0.0.0.0 3260
|
||||
targetcli /iscsi/$T/tpg1/portals create 127.0.0.1 3260
|
||||
targetcli /iscsi/$T/tpg1/luns create /backstores/fileio/wvc
|
||||
targetcli /iscsi/$T/tpg1 set attribute generate_node_acls=1 demo_mode_write_protect=0
|
||||
targetcli saveconfig
|
||||
}
|
||||
dnf -y install nfs-utils
|
||||
install -d /srv/wvc-nfs
|
||||
echo "/srv/wvc-nfs 127.0.0.1(rw,no_root_squash)" > /etc/exports.d/wvc.exports
|
||||
systemctl enable --now nfs-server
|
||||
exportfs -r
|
||||
install -D -m 644 /tmp/wvc/cacert.pem /etc/pki/CA/cacert.pem
|
||||
install -d /etc/pki/libvirt/private
|
||||
echo "subjectAltName=IP:$(hostname -I | cut -d' ' -f1)" > /tmp/wvc/ext
|
||||
openssl req -new -newkey rsa:3072 -nodes -keyout /etc/pki/libvirt/private/serverkey.pem -subj /CN=$(hostname) |
|
||||
openssl x509 -req -CA /tmp/wvc/cacert.pem -CAkey /tmp/wvc/cakey.pem -days 365 -extfile /tmp/wvc/ext -out /etc/pki/libvirt/servercert.pem
|
||||
sed -i 's/^#auth_tls = "none"/auth_tls = "sasl"/' /etc/libvirt/virtproxyd.conf
|
||||
# a running service would keep the old settings and refuse a new socket
|
||||
systemctl stop virtproxyd.service
|
||||
systemctl enable --now virtproxyd-tls.socket
|
||||
SHELL
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
default_app_config = 'accounts.apps.AccountsConfig'
|
||||
130
accounts/apps.py
130
accounts/apps.py
|
|
@ -1,57 +1,105 @@
|
|||
import os
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.db.models.signals import post_migrate
|
||||
from django.db import transaction
|
||||
|
||||
|
||||
def apply_change_password(sender, **kwargs):
|
||||
"""
|
||||
Apply new change_password permission for all users
|
||||
Depending on settings SHOW_PROFILE_EDIT_PASSWORD
|
||||
"""
|
||||
def admin_password_path():
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import Permission, User
|
||||
|
||||
if hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"):
|
||||
print("\033[1m! \033[92mSHOW_PROFILE_EDIT_PASSWORD is found inside settings.py\033[0m")
|
||||
print("\033[1m* \033[92mApplying permission can_change_password for all users\033[0m")
|
||||
users = User.objects.all()
|
||||
permission = Permission.objects.get(codename="change_password")
|
||||
if settings.SHOW_PROFILE_EDIT_PASSWORD:
|
||||
print("\033[1m! \033[91mWarning!!! Setting to True for all users\033[0m")
|
||||
for user in users:
|
||||
user.user_permissions.add(permission)
|
||||
else:
|
||||
print("\033[1m* \033[91mWarning!!! Setting to False for all users\033[0m")
|
||||
for user in users:
|
||||
user.user_permissions.remove(permission)
|
||||
print("\033[1m! Don`t forget to remove the option from settings.py\033[0m")
|
||||
return os.path.join(str(settings.BASE_DIR), "data", "admin_password")
|
||||
|
||||
|
||||
def _migrated(state_apps, model, field=None):
|
||||
"""post_migrate follows every migrate, also targeted ones and rollbacks:
|
||||
act only once the post-migration state has the model (and field)."""
|
||||
if state_apps is None:
|
||||
return False
|
||||
try:
|
||||
fields = state_apps.get_model(model)._meta.get_fields()
|
||||
except LookupError:
|
||||
return False
|
||||
return field is None or any(f.name == field for f in fields)
|
||||
|
||||
|
||||
def grant_change_password(sender, instance, created, raw=False, **kwargs):
|
||||
"""New users may change their own password unless an admin takes the
|
||||
permission away; the user form saves its own choice after this."""
|
||||
if not created or raw:
|
||||
return
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
permission = Permission.objects.filter(content_type__app_label="accounts", codename="change_password").first()
|
||||
if permission is not None: # created by post_migrate on a fresh database
|
||||
instance.user_permissions.add(permission)
|
||||
|
||||
|
||||
def _store_generated_password(password):
|
||||
"""
|
||||
Write the generated admin password to data/admin_password (mode 0600)
|
||||
instead of printing it, so it does not end up in install logs or
|
||||
`docker logs`. Abort provisioning if private storage fails.
|
||||
"""
|
||||
path = admin_password_path()
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
# Never write through a pre-existing file or symlink (this runs as
|
||||
# root in Docker, while data/ belongs to www-data).
|
||||
if os.path.lexists(path):
|
||||
os.unlink(path)
|
||||
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "w") as f:
|
||||
os.fchmod(f.fileno(), 0o600)
|
||||
f.write(password + "\n")
|
||||
print(f"\033[1m* \033[93mGenerated admin password written to {path}\033[0m")
|
||||
except OSError as err:
|
||||
raise RuntimeError(
|
||||
f"Cannot securely store the generated admin password at {path}. "
|
||||
"Fix the data directory permissions and run migrate again."
|
||||
) from err
|
||||
|
||||
|
||||
def create_admin(sender, **kwargs):
|
||||
"""
|
||||
Create initial admin user
|
||||
"""
|
||||
import os
|
||||
import secrets
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from accounts.models import UserAttributes
|
||||
|
||||
plan = kwargs.get("plan", [])
|
||||
for migration, rolled_back in plan:
|
||||
if (
|
||||
migration.app_label == "accounts"
|
||||
and migration.name == "0001_initial"
|
||||
and not rolled_back
|
||||
):
|
||||
if User.objects.count() == 0:
|
||||
print("\033[1m* \033[92mCreating default admin user\033[0m")
|
||||
admin = User.objects.create_superuser("admin", None, "admin")
|
||||
UserAttributes(
|
||||
user=admin,
|
||||
max_instances=-1,
|
||||
max_cpus=-1,
|
||||
max_memory=-1,
|
||||
max_disk_size=-1,
|
||||
).save()
|
||||
break
|
||||
# Any real migrate run provisions while no user exists, so a run that
|
||||
# failed to store the password can simply be repeated. flush (used by
|
||||
# tests) sends plan=None.
|
||||
if kwargs.get("plan") is None:
|
||||
return
|
||||
state_apps = kwargs.get("apps")
|
||||
if not (_migrated(state_apps, "auth.User") and _migrated(state_apps, "accounts.UserAttributes", "must_change_password")):
|
||||
return
|
||||
if User.objects.exists():
|
||||
return
|
||||
|
||||
admin_user = os.environ.get("ADMIN_USERNAME", "admin")
|
||||
admin_pass = os.environ.get("ADMIN_PASSWORD")
|
||||
generated_password = not admin_pass
|
||||
|
||||
if generated_password:
|
||||
admin_pass = secrets.token_urlsafe(16)
|
||||
_store_generated_password(admin_pass)
|
||||
|
||||
print(f"\033[1m* \033[92mCreating default admin user '{admin_user}'\033[0m")
|
||||
with transaction.atomic():
|
||||
admin = User.objects.create_superuser(admin_user, None, admin_pass)
|
||||
UserAttributes.objects.create(
|
||||
user=admin,
|
||||
must_change_password=generated_password,
|
||||
max_instances=-1,
|
||||
max_cpus=-1,
|
||||
max_memory=-1,
|
||||
max_disk_size=-1,
|
||||
)
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
|
|
@ -60,4 +108,8 @@ class AccountsConfig(AppConfig):
|
|||
|
||||
def ready(self):
|
||||
post_migrate.connect(create_admin, sender=self)
|
||||
post_migrate.connect(apply_change_password, sender=self)
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import post_save
|
||||
|
||||
post_save.connect(grant_change_password, sender=settings.AUTH_USER_MODEL)
|
||||
from . import checks # noqa: F401
|
||||
|
|
|
|||
31
accounts/auth.py
Normal file
31
accounts/auth.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""API token authentication: knox's, noting when each token was last used."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from drf_spectacular.contrib.knox_auth_token import KnoxTokenScheme
|
||||
from knox.auth import TokenAuthentication as KnoxTokenAuthentication
|
||||
from rest_framework import exceptions
|
||||
|
||||
from accounts.models import UserAttributes
|
||||
|
||||
# a token's last use is written at most once a minute, not on every request
|
||||
LAST_USED_EVERY = timedelta(minutes=1)
|
||||
|
||||
|
||||
class TokenAuthentication(KnoxTokenAuthentication):
|
||||
def validate_user(self, auth_token):
|
||||
user, token = super().validate_user(auth_token)
|
||||
# as the pages do (ForcePasswordChangeMiddleware), until the user changes the password
|
||||
if UserAttributes.objects.filter(user=user, must_change_password=True).exists():
|
||||
raise exceptions.AuthenticationFailed(_("Change your password before using the API."))
|
||||
now = timezone.now()
|
||||
if token.last_used is None or now - token.last_used >= LAST_USED_EVERY:
|
||||
type(token).objects.filter(pk=token.pk).update(last_used=now)
|
||||
return user, token
|
||||
|
||||
|
||||
class TokenScheme(KnoxTokenScheme):
|
||||
"""The API schema's description of these tokens, as of knox's."""
|
||||
target_class = "accounts.auth.TokenAuthentication"
|
||||
53
accounts/checks.py
Normal file
53
accounts/checks.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from django.conf import settings
|
||||
from django.core.checks import Error, Tags, Warning, register
|
||||
|
||||
|
||||
@register(Tags.security)
|
||||
def password_change_middleware_check(app_configs, **kwargs):
|
||||
middleware = list(settings.MIDDLEWARE)
|
||||
required = "accounts.middleware.ForcePasswordChangeMiddleware"
|
||||
authentication = "django.contrib.auth.middleware.AuthenticationMiddleware"
|
||||
if required not in middleware or (
|
||||
authentication in middleware
|
||||
and middleware.index(required) < middleware.index(authentication)
|
||||
):
|
||||
return [
|
||||
Error(
|
||||
"ForcePasswordChangeMiddleware must follow AuthenticationMiddleware.",
|
||||
hint=f"Add '{required}' to MIDDLEWARE in webvirtcloud/settings.py, as settings.py.template does.",
|
||||
id="accounts.E001",
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
@register(Tags.security)
|
||||
def login_required_middleware_check(app_configs, **kwargs):
|
||||
middleware = list(settings.MIDDLEWARE)
|
||||
required = "django.contrib.auth.middleware.LoginRequiredMiddleware"
|
||||
old = "login_required.middleware.LoginRequiredMiddleware"
|
||||
if old in middleware or required not in middleware:
|
||||
return [
|
||||
Error(
|
||||
"MIDDLEWARE must use Django's LoginRequiredMiddleware.",
|
||||
hint=f"In webvirtcloud/settings.py replace '{old}' with '{required}' "
|
||||
"and delete LOGIN_REQUIRED_IGNORE_VIEW_NAMES, as settings.py.template does.",
|
||||
id="accounts.E002",
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
@register()
|
||||
def obsolete_settings_check(app_configs, **kwargs):
|
||||
if hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"):
|
||||
return [
|
||||
Warning(
|
||||
"SHOW_PROFILE_EDIT_PASSWORD is no longer used.",
|
||||
hint="Remove it from webvirtcloud/settings.py. Whether a user may change their own "
|
||||
"password is the 'Can change password' permission, per user or group.",
|
||||
id="accounts.W001",
|
||||
)
|
||||
]
|
||||
return []
|
||||
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
from appsettings.settings import app_settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.forms import EmailField, Form, ModelForm, ValidationError
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
from django.core.validators import MaxLengthValidator
|
||||
from django.forms import CharField, EmailField, Form, ModelForm, TypedChoiceField, ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from .models import UserInstance, UserSSHKey
|
||||
|
|
@ -20,7 +22,8 @@ class UserInstanceForm(ModelForm):
|
|||
def clean_instance(self):
|
||||
instance = self.cleaned_data["instance"]
|
||||
if app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER == "False":
|
||||
exists = UserInstance.objects.filter(instance=instance)
|
||||
# the ownership being edited is not another owner
|
||||
exists = UserInstance.objects.filter(instance=instance).exclude(pk=self.instance.pk)
|
||||
if exists:
|
||||
raise ValidationError(_("Instance owned by another user"))
|
||||
|
||||
|
|
@ -29,9 +32,24 @@ class UserInstanceForm(ModelForm):
|
|||
class Meta:
|
||||
model = UserInstance
|
||||
fields = "__all__"
|
||||
# the rights beyond power and console, as the VM's Users tab names them
|
||||
labels = {"is_change": _("Change settings"), "is_delete": _("Delete the VM"),
|
||||
"is_vnc": _("Change console settings")}
|
||||
help_texts = {"is_vnc": _("Only together with Change settings")}
|
||||
|
||||
|
||||
class ProfileForm(ModelForm):
|
||||
class UniqueEmailMixin:
|
||||
""""Lost OTP?" mails only an address that is one user's: a second user
|
||||
with the same address would switch it off for both."""
|
||||
|
||||
def clean_email(self):
|
||||
email = self.cleaned_data["email"]
|
||||
if email and get_user_model().objects.filter(email__iexact=email).exclude(pk=self.instance.pk).exists():
|
||||
raise ValidationError(_("Another user has this email address"))
|
||||
return email
|
||||
|
||||
|
||||
class ProfileForm(UniqueEmailMixin, ModelForm):
|
||||
class Meta:
|
||||
model = get_user_model()
|
||||
fields = ("first_name", "last_name", "email")
|
||||
|
|
@ -71,5 +89,28 @@ class UserSSHKeyForm(ModelForm):
|
|||
fields = ("keyname", "keypublic")
|
||||
|
||||
|
||||
class ApiTokenForm(Form):
|
||||
name = CharField(label=_("Name"), max_length=64, help_text=_("What uses it, such as a script or a host"))
|
||||
days = TypedChoiceField(
|
||||
label=_("Expires"), coerce=int, empty_value=None, required=False, initial=90,
|
||||
choices=[(30, _("in 30 days")), (90, _("in 90 days")), (365, _("in a year")), ("", _("never"))],
|
||||
)
|
||||
|
||||
|
||||
class EmailOTPForm(Form):
|
||||
email = EmailField(label=_("Email"))
|
||||
|
||||
|
||||
class BoundedUsernameMixin:
|
||||
"""Django's login form gives the username its max_length after the field
|
||||
is built, so a longer one is not refused and reaches authentication;
|
||||
django-axes then cannot store it (PostgreSQL refuses it: 500)."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
username = self.fields["username"]
|
||||
username.validators.append(MaxLengthValidator(username.max_length))
|
||||
|
||||
|
||||
class LoginForm(BoundedUsernameMixin, AuthenticationForm):
|
||||
pass
|
||||
|
|
|
|||
54
accounts/lockout.py
Normal file
54
accounts/lockout.py
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
"""
|
||||
Settings for django-axes, read from the Settings page (AppSettings) on each
|
||||
use, so an administrator can change them without a restart.
|
||||
"""
|
||||
|
||||
from datetime import timedelta
|
||||
from ipaddress import ip_address, ip_network
|
||||
|
||||
from appsettings.models import AppSettings
|
||||
from django.conf import settings
|
||||
|
||||
DEFAULT_FAILURE_LIMIT = 5
|
||||
DEFAULT_LOCKOUT_MINUTES = 15
|
||||
|
||||
|
||||
def _positive_int(key, default):
|
||||
try:
|
||||
value = int(AppSettings.objects.get(key=key).value)
|
||||
except (AppSettings.DoesNotExist, ValueError):
|
||||
return default
|
||||
return value if value > 0 else default
|
||||
|
||||
|
||||
def failure_limit(request, credentials):
|
||||
"""AXES_FAILURE_LIMIT: failed logins allowed before the lock."""
|
||||
return _positive_int("LOGIN_FAILURE_LIMIT", DEFAULT_FAILURE_LIMIT)
|
||||
|
||||
|
||||
def cooloff_time(request):
|
||||
"""AXES_COOLOFF_TIME: how long a lock lasts."""
|
||||
return timedelta(minutes=_positive_int("LOGIN_LOCKOUT_MINUTES", DEFAULT_LOCKOUT_MINUTES))
|
||||
|
||||
|
||||
def _from_trusted_proxy(remote):
|
||||
try:
|
||||
address = ip_address(remote)
|
||||
except ValueError:
|
||||
return False
|
||||
if address.is_loopback: # the bundled nginx
|
||||
return True
|
||||
return any(address in ip_network(net, strict=False) for net in getattr(settings, "LOGIN_TRUSTED_PROXIES", []))
|
||||
|
||||
|
||||
def client_ip(request):
|
||||
"""
|
||||
AXES_CLIENT_IP_CALLABLE. Behind the bundled nginx every request comes from
|
||||
127.0.0.1 and nginx sets X-Real-IP to the client's address (overwriting
|
||||
any sent by the client). The header is used only for requests from that
|
||||
proxy, or from the addresses in LOGIN_TRUSTED_PROXIES; from anyone else
|
||||
it could be forged to escape a lock.
|
||||
"""
|
||||
remote = request.META.get("REMOTE_ADDR", "")
|
||||
real_ip = request.META.get("HTTP_X_REAL_IP", "").strip()
|
||||
return real_ip if real_ip and _from_trusted_proxy(remote) else remote
|
||||
30
accounts/middleware.py
Normal file
30
accounts/middleware.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext as _
|
||||
from django.urls import Resolver404, resolve
|
||||
|
||||
from accounts.models import UserAttributes
|
||||
|
||||
ALLOWED_VIEWS = {"accounts:change_password", "accounts:logout", "accounts:login"}
|
||||
|
||||
|
||||
class ForcePasswordChangeMiddleware:
|
||||
"""Send users who must change their password (the generated admin one, or one
|
||||
an administrator set) to the change form."""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if request.user.is_authenticated and UserAttributes.objects.filter(
|
||||
user=request.user, must_change_password=True
|
||||
).exists():
|
||||
try:
|
||||
view_name = resolve(request.path_info).view_name
|
||||
except Resolver404:
|
||||
view_name = None
|
||||
if view_name not in ALLOWED_VIEWS:
|
||||
if request.path_info.startswith("/api/"): # as for a token (accounts.auth)
|
||||
return JsonResponse({"detail": _("Change your password before using the API.")}, status=403)
|
||||
return redirect("accounts:change_password")
|
||||
return self.get_response(request)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Generated by Django 4.2.30 on 2026-09-27 10:18
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('accounts', '0005_auto_20200616_1039'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userattributes',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userinstance',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usersshkey',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usersshkey',
|
||||
name='keypublic',
|
||||
field=models.TextField(max_length=16384, verbose_name='public key'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='usersshkey',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def flag_legacy_generated_password(apps, schema_editor):
|
||||
"""Preserve first-login enforcement for installations with a password file."""
|
||||
path = Path(settings.BASE_DIR) / "data" / "admin_password"
|
||||
try:
|
||||
generated = path.read_text().strip()
|
||||
except FileNotFoundError:
|
||||
return
|
||||
except OSError as err:
|
||||
raise RuntimeError(
|
||||
f"Cannot read {path}; run migrate as its owner to preserve first-login enforcement."
|
||||
) from err
|
||||
if not generated:
|
||||
return
|
||||
alias = schema_editor.connection.alias
|
||||
User = apps.get_model(*settings.AUTH_USER_MODEL.split("."))
|
||||
Attributes = apps.get_model("accounts", "UserAttributes")
|
||||
for user in User.objects.using(alias).filter(is_superuser=True):
|
||||
if check_password(generated, user.password):
|
||||
attributes, _ = Attributes.objects.using(alias).get_or_create(
|
||||
user_id=user.pk
|
||||
)
|
||||
attributes.must_change_password = True
|
||||
attributes.save(using=alias, update_fields=["must_change_password"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0006_alter_userattributes_id_alter_userinstance_id_and_more")
|
||||
]
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="userattributes",
|
||||
name="must_change_password",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.RunPython(flag_legacy_generated_password, migrations.RunPython.noop),
|
||||
]
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
from django.conf import settings
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def apply_setting_once(apps, schema_editor):
|
||||
"""SHOW_PROFILE_EDIT_PASSWORD was replaced by the change_password
|
||||
permission, but its value was applied to every user on each migrate,
|
||||
undoing per-user and per-group choices. Apply it one last time."""
|
||||
if not hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"):
|
||||
return
|
||||
alias = schema_editor.connection.alias
|
||||
Permission = apps.get_model("auth", "Permission")
|
||||
User = apps.get_model(*settings.AUTH_USER_MODEL.split("."))
|
||||
permission = Permission.objects.using(alias).filter(
|
||||
content_type__app_label="accounts", codename="change_password"
|
||||
).first()
|
||||
if permission is None: # fresh database: no users and no permissions yet
|
||||
return
|
||||
for user in User.objects.using(alias).all():
|
||||
if settings.SHOW_PROFILE_EDIT_PASSWORD:
|
||||
user.user_permissions.add(permission)
|
||||
else:
|
||||
user.user_permissions.remove(permission)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("accounts", "0007_userattributes_must_change_password"),
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
operations = [migrations.RunPython(apply_setting_once, migrations.RunPython.noop)]
|
||||
30
accounts/migrations/0009_api_token.py
Normal file
30
accounts/migrations/0009_api_token.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Generated by Django 5.2.18 on 2026-10-24 19:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0008_apply_show_profile_edit_password_once'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ApiToken',
|
||||
fields=[
|
||||
('digest', models.CharField(max_length=128, primary_key=True, serialize=False)),
|
||||
('token_key', models.CharField(db_index=True, max_length=25)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('expiry', models.DateTimeField(blank=True, null=True)),
|
||||
('name', models.CharField(max_length=64, verbose_name='name')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_token_set', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created'],
|
||||
},
|
||||
),
|
||||
]
|
||||
18
accounts/migrations/0010_api_token_last_used.py
Normal file
18
accounts/migrations/0010_api_token_last_used.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 5.2.18 on 2026-10-24 20:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0009_api_token'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='apitoken',
|
||||
name='last_used',
|
||||
field=models.DateTimeField(blank=True, null=True, verbose_name='last used'),
|
||||
),
|
||||
]
|
||||
34
accounts/migrations/0011_quota_help.py
Normal file
34
accounts/migrations/0011_quota_help.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 5.2.18 on 2026-10-25 19:29
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0010_api_token_last_used'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='userattributes',
|
||||
name='max_cpus',
|
||||
field=models.IntegerField(default=2, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max CPUs'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userattributes',
|
||||
name='max_disk_size',
|
||||
field=models.IntegerField(default=20, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max disk size (GiB)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userattributes',
|
||||
name='max_instances',
|
||||
field=models.IntegerField(default=2, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max instances'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='userattributes',
|
||||
name='max_memory',
|
||||
field=models.IntegerField(default=2048, help_text='-1: no limit; 0: none allowed', validators=[django.core.validators.MinValueValidator(-1)], verbose_name='max memory (MiB)'),
|
||||
),
|
||||
]
|
||||
24
accounts/migrations/0012_user_instance_constraint.py
Normal file
24
accounts/migrations/0012_user_instance_constraint.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Generated by Django 5.2.18 on 2026-11-06 07:57
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0011_quota_help'),
|
||||
('instances', '0014_instancetombstone_host'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterUniqueTogether(
|
||||
name='userinstance',
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='userinstance',
|
||||
constraint=models.UniqueConstraint(fields=('user', 'instance'), name='unique_user_instance'),
|
||||
),
|
||||
]
|
||||
19
accounts/migrations/0013_default_admin_password.py
Normal file
19
accounts/migrations/0013_default_admin_password.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def flag_default_admin_password(apps, schema_editor):
|
||||
"""Versions up to v2.0.0 created the first user as admin/admin. An
|
||||
installation that never changed it must replace it at the next login."""
|
||||
alias = schema_editor.connection.alias
|
||||
User = apps.get_model(*settings.AUTH_USER_MODEL.split("."))
|
||||
Attributes = apps.get_model("accounts", "UserAttributes")
|
||||
for user in User.objects.using(alias).filter(username="admin"):
|
||||
if check_password("admin", user.password):
|
||||
Attributes.objects.using(alias).update_or_create(user_id=user.pk, defaults={"must_change_password": True})
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("accounts", "0012_user_instance_constraint")]
|
||||
operations = [migrations.RunPython(flag_default_admin_password, migrations.RunPython.noop)]
|
||||
|
|
@ -3,6 +3,7 @@ from django.core.validators import MinValueValidator
|
|||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from instances.models import Instance
|
||||
from knox.models import AbstractAuthToken
|
||||
|
||||
|
||||
class UserInstanceManager(models.Manager):
|
||||
|
|
@ -26,13 +27,13 @@ class UserInstance(models.Model):
|
|||
}
|
||||
|
||||
class Meta:
|
||||
unique_together = ["user", "instance"]
|
||||
constraints = [models.UniqueConstraint(fields=["user", "instance"], name="unique_user_instance")]
|
||||
|
||||
|
||||
class UserSSHKey(models.Model):
|
||||
user = models.ForeignKey(User, on_delete=models.DO_NOTHING)
|
||||
user = models.ForeignKey(User, on_delete=models.CASCADE)
|
||||
keyname = models.CharField(_("key name"), max_length=25)
|
||||
keypublic = models.CharField(_("public key"), max_length=500)
|
||||
keypublic = models.TextField(_("public key"), max_length=16384)
|
||||
|
||||
def __str__(self):
|
||||
return self.keyname
|
||||
|
|
@ -41,28 +42,29 @@ class UserSSHKey(models.Model):
|
|||
class UserAttributes(models.Model):
|
||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||
can_clone_instances = models.BooleanField(default=True)
|
||||
must_change_password = models.BooleanField(default=False)
|
||||
max_instances = models.IntegerField(
|
||||
_("max instances"),
|
||||
default=2,
|
||||
help_text=_("-1 for unlimited. Any integer value"),
|
||||
help_text=_("-1: no limit; 0: none allowed"),
|
||||
validators=[MinValueValidator(-1)],
|
||||
)
|
||||
max_cpus = models.IntegerField(
|
||||
_("max CPUs"),
|
||||
default=2,
|
||||
help_text=_("-1 for unlimited. Any integer value"),
|
||||
help_text=_("-1: no limit; 0: none allowed"),
|
||||
validators=[MinValueValidator(-1)],
|
||||
)
|
||||
max_memory = models.IntegerField(
|
||||
_("max memory"),
|
||||
_("max memory (MiB)"),
|
||||
default=2048,
|
||||
help_text=_("-1 for unlimited. Any integer value"),
|
||||
help_text=_("-1: no limit; 0: none allowed"),
|
||||
validators=[MinValueValidator(-1)],
|
||||
)
|
||||
max_disk_size = models.IntegerField(
|
||||
_("max disk size"),
|
||||
_("max disk size (GiB)"),
|
||||
default=20,
|
||||
help_text=_("-1 for unlimited. Any integer value"),
|
||||
help_text=_("-1: no limit; 0: none allowed"),
|
||||
validators=[MinValueValidator(-1)],
|
||||
)
|
||||
|
||||
|
|
@ -80,3 +82,15 @@ class PermissionSet(models.Model):
|
|||
permissions = (("change_password", _("Can change password")),)
|
||||
|
||||
managed = False
|
||||
|
||||
|
||||
class ApiToken(AbstractAuthToken):
|
||||
"""A token for the REST API, kept as a digest (django-rest-knox); the
|
||||
name says what uses it."""
|
||||
LIMIT = 10 # per user, expired ones not counted
|
||||
|
||||
name = models.CharField(_("name"), max_length=64)
|
||||
last_used = models.DateTimeField(_("last used"), null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created"]
|
||||
|
|
|
|||
45
accounts/otp.py
Normal file
45
accounts/otp.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""The login form used when OTP_ENABLED.
|
||||
|
||||
django-otp's ``OTPAuthenticationForm`` requires every user to present a token
|
||||
and, since 1.6, to name the device explicitly, so the single-field login page
|
||||
cannot satisfy it and a user who never enrolled is locked out. This form makes
|
||||
OTP per user: a user with a confirmed TOTP device must enter a valid code, one
|
||||
without a device logs in with the password alone. The device is chosen by the
|
||||
server, so the page needs only the token field and a posted device id (which
|
||||
django-otp does not check for confirmed status or ownership-by-type) is
|
||||
ignored.
|
||||
"""
|
||||
|
||||
from django.contrib.auth.signals import user_login_failed
|
||||
from django.core.exceptions import ValidationError
|
||||
from django_otp.forms import OTPAuthenticationForm
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
|
||||
from accounts.forms import BoundedUsernameMixin
|
||||
|
||||
|
||||
class SingleDeviceOTPForm(BoundedUsernameMixin, OTPAuthenticationForm):
|
||||
def _confirmed_totp(self, user, for_verify=False):
|
||||
"""The user's oldest confirmed TOTP device, or None. With for_verify
|
||||
the row is locked (select_for_update) so the token's replay and
|
||||
throttling counters cannot race; call it only inside clean_otp's
|
||||
transaction."""
|
||||
devices = TOTPDevice.objects.filter(user=user, confirmed=True)
|
||||
if for_verify:
|
||||
devices = devices.select_for_update()
|
||||
return devices.order_by("id").first()
|
||||
|
||||
def _chosen_device(self, user):
|
||||
# Ignore a posted otp_device: it is verified within clean_otp's
|
||||
# transaction, where the lock belongs.
|
||||
return self._confirmed_totp(user, for_verify=True)
|
||||
|
||||
def clean_otp(self, user):
|
||||
if user is not None and self._confirmed_totp(user) is None:
|
||||
return # no confirmed device: the password is the only factor
|
||||
try:
|
||||
return super().clean_otp(user)
|
||||
except ValidationError:
|
||||
# a wrong code is a failed login: counted by django-axes and logged
|
||||
user_login_failed.send(sender=__name__, credentials={"username": user.get_username()}, request=self.request)
|
||||
raise
|
||||
|
|
@ -2,92 +2,96 @@
|
|||
|
||||
{% load i18n %}
|
||||
{% load bootstrap_icons %}
|
||||
{% load qr_code %}
|
||||
|
||||
{% block title %}{% trans "User Profile" %} - {{ user }}{% endblock %}
|
||||
{% block page_heading %}{% trans "User Profile" %}: {{ user }}{% endblock page_heading %}
|
||||
|
||||
{% block page_heading_extra %}
|
||||
{% if otp_enabled %}
|
||||
<a href="{% url 'accounts:admin_email_otp' user.id %}" class="btn btn-secondary" title="{% trans "Email OTP QR code" %}">
|
||||
{% bs_icon 'qr-code' %}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'admin:user_update' user.id %}?next={% url 'accounts:account' user.id %}" class="btn btn-primary" title="{% trans "Edit user" %}">
|
||||
{% bs_icon 'pencil' %}
|
||||
</a>
|
||||
<a href="{% url 'accounts:user_instance_create' user.id %}" class="btn btn-success" title="{% trans "Create user instance" %}">
|
||||
{% bs_icon 'plus-circle-fill' %}
|
||||
</a>
|
||||
{% endblock page_heading_extra %}
|
||||
{% block page_heading %}{% trans "User Profile" %}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#instances" type="button" role="tab" aria-controls="instances" aria-selected="true">
|
||||
{% trans "Instances" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#public-keys" type="button" role="tab" aria-controls="public-keys" aria-selected="false">
|
||||
{% trans "Public Keys" %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane active" id="instances">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">{% trans "Instance" %}</th>
|
||||
<th scope="col">{% trans "VNC" %}</th>
|
||||
<th scope="col">{% trans "Resize" %}</th>
|
||||
<th scope="col">{% trans "Delete" %}</th>
|
||||
<th scope="colgroup" colspan="2">{% trans "Action" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for inst in user_insts %}
|
||||
<tr>
|
||||
<td>{{ forloop.counter }}</td>
|
||||
<td><a href="{% url 'instances:instance' inst.instance.id %}">{{ inst.instance.name }}</a></td>
|
||||
<td>{{ inst.is_vnc }}</td>
|
||||
<td>{{ inst.is_change }}</td>
|
||||
<td>{{ inst.is_delete }}</td>
|
||||
<td style="width:5px;">
|
||||
<a href="{% url 'accounts:user_instance_update' inst.id %}" class="btn btn-sm btn-secondary" title="{% trans "edit" %}">
|
||||
{% bs_icon 'pencil' %}
|
||||
</a>
|
||||
</td>
|
||||
<td style="width:5px;">
|
||||
<a class="btn btn-sm btn-secondary" href="{% url 'accounts:user_instance_delete' inst.id %}" title="{% trans "Delete" %}">
|
||||
{% bs_icon 'trash' %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="public-keys">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{% trans "Key name" %}</th>
|
||||
<th scope="col">{% trans "Public key" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for publickey in publickeys %}
|
||||
<tr>
|
||||
<td>{{ publickey.keyname }}</td>
|
||||
<td title="{{ publickey.keypublic }}">{{ publickey.keypublic|truncatechars:64 }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<section class="card mb-4" aria-labelledby="identity-title">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-start">
|
||||
<div class="col-lg-8">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<h2 class="h4 mb-0 text-break" id="identity-title">{{ user.get_full_name|default:user.username }}</h2>
|
||||
{% if user.is_superuser %}
|
||||
<span class="badge text-bg-secondary">{% trans "Superuser" %}</span>
|
||||
{% elif user.is_staff %}
|
||||
<span class="badge text-bg-secondary">{% trans "Template editor" %}</span>
|
||||
{% endif %}
|
||||
{% if not user.is_active %}<span class="badge text-bg-danger">{% trans "Blocked" %}</span>{% endif %}
|
||||
</div>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 small mt-2">
|
||||
<span class="text-body-secondary text-break">{{ user.username }}</span>
|
||||
{% if user.email %}<span class="text-break">{{ user.email }}</span>{% endif %}
|
||||
{% for group in user.groups.all %}<span class="badge text-bg-light border mw-100 text-wrap text-break">{{ group.name }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 gap-md-4 small text-body-secondary mt-3">
|
||||
<span>{% trans "Last login" %} {{ user.last_login|date:"Y-m-d H:i"|default:_("never") }}</span>
|
||||
<span>{% trans "Joined" %} {{ user.date_joined|date:"Y-m-d" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 d-flex flex-wrap justify-content-lg-end gap-2">
|
||||
<a href="{% url 'admin:user_update' user.id %}?next={% url 'accounts:account' user.id %}" class="btn btn-primary">{% bs_icon 'pencil' %} {% trans "Edit user" %}</a>
|
||||
{% if otp_enabled %}
|
||||
<form method="post" action="{% url 'accounts:admin_email_otp' user.id %}">{% csrf_token %}
|
||||
<button type="submit" class="btn btn-outline-secondary">{% bs_icon 'qr-code' %} {% trans "Email OTP QR code" %}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="card-footer d-flex flex-wrap gap-3 small" aria-label="{% trans 'User sections' %}">
|
||||
<a href="#instances">{% trans "VMs" %} <span class="badge text-bg-light border">{{ user_insts|length }}</span></a>
|
||||
<a href="#public-keys">{% trans "SSH Keys" %} <span class="badge text-bg-light border">{{ publickeys|length }}</span></a>
|
||||
<a href="#apitokens">{% trans "API Tokens" %} <span class="badge text-bg-light border">{{ api_tokens|length }}</span></a>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section class="card mb-4" id="instances" aria-labelledby="instances-title">
|
||||
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<h2 class="h5 mb-0" id="instances-title">{% trans "VMs" %} <span class="badge text-bg-light border">{{ user_insts|length }}</span></h2>
|
||||
<a href="{% url 'accounts:user_instance_create' user.id %}" class="btn btn-sm btn-primary">{% bs_icon 'plus-lg' %} {% trans "Add VM" %}</a>
|
||||
</div>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for inst in user_insts %}
|
||||
<div class="list-group-item py-3">
|
||||
<div class="d-flex align-items-start align-items-md-center gap-3">
|
||||
<div class="row g-2 align-items-center flex-grow-1" style="min-width: 0;">
|
||||
<div class="col-12 col-md-6">
|
||||
<a class="fw-semibold text-break" href="{% url 'instances:instance' inst.instance.id %}">{{ inst.instance.name }}</a>
|
||||
<div class="small text-body-secondary text-break">{% trans "Host" %}: {{ inst.instance.compute.name }}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 d-flex flex-wrap gap-1">
|
||||
{# the rights as the VM's Users tab names them: every owner powers the VM and opens its console #}
|
||||
{% if inst.is_change %}<span class="badge text-bg-light border">{% trans "Change settings" %}</span>{% endif %}
|
||||
{% if inst.is_delete %}<span class="badge text-bg-light border">{% trans "Delete the VM" %}</span>{% endif %}
|
||||
{% if inst.is_change and inst.is_vnc %}<span class="badge text-bg-light border">{% trans "Change console settings" %}</span>{% endif %}
|
||||
{% if not inst.is_change and not inst.is_delete %}<span class="small text-body-secondary">{% trans "Power and console only" %}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown flex-shrink-0">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
aria-label="{% blocktrans with name=inst.instance.name %}Actions for {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{% url 'accounts:user_instance_update' inst.id %}">{% trans "Change access" %}</a></li>
|
||||
<li><a class="dropdown-item text-danger" href="{% url 'accounts:user_instance_delete' inst.id %}">{% trans "Remove from user" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-body-secondary">{% trans "No VMs." %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card mb-4" id="public-keys" aria-labelledby="public-keys-title">
|
||||
<div class="card-header"><h2 class="h5 mb-0" id="public-keys-title">{% trans "SSH Keys" %} <span class="badge text-bg-light border">{{ publickeys|length }}</span></h2></div>
|
||||
<div class="list-group list-group-flush">{% include "ssh_key_list.html" with owner=user %}</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="apitokens" aria-labelledby="apitokens-title">
|
||||
<div class="card-header"><h2 class="h5 mb-0" id="apitokens-title">{% trans "API Tokens" %} <span class="badge text-bg-light border">{{ api_tokens|length }}</span></h2></div>
|
||||
<div class="list-group list-group-flush">{% include "api_token_list.html" with owner=user %}</div>
|
||||
</section>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,39 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% load django_bootstrap5 %}
|
||||
{% load i18n %}
|
||||
{% load bootstrap_icons %}
|
||||
|
||||
{% block title %}{%trans "Change Password" %}{% endblock title %}
|
||||
{% block title %}{% trans "Change Password" %}{% endblock title %}
|
||||
|
||||
{% block page_heading %}{% trans "Change Password" %}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="offset-2 col-lg-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="card-title">{%trans "Change Password" %}: {{ user }}</h4>
|
||||
{# a user changes their own password; one who must (a new or reset account) cannot go elsewhere first #}
|
||||
<form id="password-change" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form type="non_fields" %}
|
||||
|
||||
<section class="form-section">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<h2 class="h6 fw-semibold mb-1 text-break">{{ request.user.username }}</h2>
|
||||
<p class="small text-body-secondary mb-0">
|
||||
{% if must_change %}{% trans "Choose a new password before you go on." %}{% else %}{% trans "You stay signed in here; your other sessions end." %}{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" id="password-change">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form layout='horizontal' %}
|
||||
</form>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="float-end">
|
||||
<a class="btn btn-primary" href="javascript:history.back()">{% bs_icon 'x' %}
|
||||
{% trans "Cancel" %}</a>
|
||||
<button type="submit" form="password-change" class="btn btn-success">
|
||||
{% bs_icon 'check2' %} {% trans "Change" %}
|
||||
</button>
|
||||
<div class="col-lg-9 form-section-fields">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">{% bootstrap_field form.old_password %}</div>
|
||||
<div class="w-100 m-0"></div>
|
||||
<div class="col-sm-6">{% bootstrap_field form.new_password1 %}</div>
|
||||
<div class="col-sm-6">{% bootstrap_field form.new_password2 show_help=False %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-section d-flex justify-content-end gap-2">
|
||||
{% if not must_change %}<a class="btn btn-outline-secondary" href="{% url 'accounts:profile' %}">{% trans "Cancel" %}</a>{% endif %}
|
||||
<button type="submit" class="btn btn-primary">{% trans "Change password" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
{% load i18n %}
|
||||
{% load qr_code %}
|
||||
{% blocktrans %}
|
||||
Scan this QR code to get OTP for account '{{ user }}'
|
||||
{% endblocktrans %}
|
||||
<br>
|
||||
{% qr_from_text totp_url %}
|
||||
<p class="small">{% trans 'Some e-mail clients does not render SVG, also generating PNG.' %}</p>
|
||||
{% qr_from_text totp_url size="s" image_format="png" error_correction="M" %}
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="mb-0 float-end">
|
||||
<a class="btn btn-primary" href="javascript:history.back()">{% bs_icon 'arrow-left' %} {% trans "Cancel" %}</a>
|
||||
<a class="btn btn-primary" href="#" data-history-back>{% bs_icon 'arrow-left' %} {% trans "Cancel" %}</a>
|
||||
<button type="submit" form="create-update" class="btn btn-success">
|
||||
{% bs_icon 'envelope-open' %} {% trans "Send" %}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,57 +1,77 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
{% load django_bootstrap5 %}
|
||||
|
||||
{% block title %}{% trans "WebVirtCloud" %} - {% trans "Sign In with OTP" %}{% endblock title %}
|
||||
|
||||
{% block style %}
|
||||
<link href="{% static "css/signin.css" %}" rel="stylesheet">
|
||||
{% endblock style %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-box">
|
||||
<div class="page-header">
|
||||
<a href="/"><h1>WebVirtCloud</h1></a>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="col-12" role="main">
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
{% bootstrap_form_errors form %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row justify-content-center pt-5">
|
||||
<div class="col-sm-9 col-md-7 col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4 p-sm-5">
|
||||
<div class="text-center mb-4">
|
||||
<div class="d-inline-block rounded-3 bg-primary text-white fs-2 lh-1 p-3 mb-3"><i class="bi bi-shield-check"></i></div>
|
||||
<h1 class="h3 fw-bold mb-1">{% trans "Two-Factor Auth" %}</h1>
|
||||
<p class="text-body-secondary small mb-0">{% trans "Enter your authentication code to continue" %}</p>
|
||||
</div>
|
||||
|
||||
<form id="form-signin" class="form-signin" method="post" role="form" aria-label="Login form">{% csrf_token %}
|
||||
<h2 class="form-signin-heading">{% trans "Sign In" %}</h2>
|
||||
{% bootstrap_field form.username layout='inline' %}
|
||||
{% bootstrap_field form.password layout='inline' %}
|
||||
{% bootstrap_field form.otp_token layout='inline'%}
|
||||
<a href="{% url 'accounts:email_otp' %}" class="float-end">{% trans "I do not have/lost my OTP!" %}</a>
|
||||
<br>
|
||||
<div class="d-grid">
|
||||
<button id="btn-signin" class="btn btn-lg btn-success" type="submit">{% trans "Sign In" %}</button>
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger d-flex align-items-center mb-4" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-5 me-2 flex-shrink-0"></i>
|
||||
<div>{% bootstrap_form_errors form %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="form-signin" method="post" aria-label="OTP Login form">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-medium" for="id_username">{% trans "Username" %}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
<input type="text" id="id_username" class="form-control" name="username" value="{{ form.username.value|default:'' }}" placeholder="{% trans 'Enter username' %}" autocapitalize="none" autocorrect="off" required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-medium" for="id_password">{% trans "Password" %}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-shield-lock"></i></span>
|
||||
<input type="password" id="id_password" class="form-control" name="password" placeholder="{% trans 'Enter password' %}" required>
|
||||
<button class="btn btn-outline-secondary" type="button" id="btn-toggle-pwd" tabindex="-1" aria-label="{% trans 'Toggle password visibility' %}">
|
||||
<i class="bi bi-eye" id="toggle-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<label class="form-label small fw-medium mb-0" for="id_otp_token">{% trans "Security Code" %}</label>
|
||||
<a href="{% url 'accounts:email_otp' %}" class="small">{% trans "Lost OTP?" %}</a>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||
<input type="text" id="id_otp_token" class="form-control font-monospace" name="otp_token" placeholder="000000" inputmode="numeric" pattern="[0-9]*" maxlength="6" autofocus>
|
||||
</div>
|
||||
<div class="form-text">{% trans "Leave empty if you have not set up two-factor authentication." %}</div>
|
||||
</div>
|
||||
|
||||
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit" data-busy-text="{% trans "Verifying..." %}">
|
||||
<i class="bi bi-shield-lock-fill me-1"></i>
|
||||
<span id="btn-text">{% trans "Verify & Sign In" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="d-flex align-items-center small text-body-secondary border-top mt-4 pt-3">
|
||||
<span class="d-inline-block rounded-circle p-1 bg-success me-2" aria-hidden="true"></span>
|
||||
{% trans "2FA Protection Active" %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$("#btn-signin").click(function() {
|
||||
// disable button
|
||||
$(this).prop("disabled", true);
|
||||
// add spinner to button
|
||||
$(this).html(
|
||||
`
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
{% trans "Loading" %}...
|
||||
`
|
||||
);
|
||||
$("#form-signin").submit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock script%}
|
||||
<script src="{% static 'js/login_form.js' %}"></script>
|
||||
{% endblock script %}
|
||||
|
|
|
|||
15
accounts/templates/accounts/otp_qr.html
Normal file
15
accounts/templates/accounts/otp_qr.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "OTP QR Code" %}{% endblock %}
|
||||
|
||||
{% block page_heading %}{% trans "OTP QR Code" %}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<p>{% blocktrans %}Scan this QR code to get OTP for account '{{ user }}'{% endblocktrans %}</p>
|
||||
{{ qr_svg }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
16
accounts/templates/api_token_created.html
Normal file
16
accounts/templates/api_token_created.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "API token created" %}{% endblock %}
|
||||
{% block page_heading %}{% trans "API token created" %}: {{ name }}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
{% trans "Copy the token now: it is not shown again. Anyone who has it can use the REST API as you." %}
|
||||
</div>
|
||||
<label class="form-label" for="api-token">{% trans "Token" %}</label>
|
||||
<input type="text" class="form-control font-monospace mb-3" id="api-token" value="{{ key }}" readonly>
|
||||
<p>{% trans "Use it like this:" %}</p>
|
||||
<pre class="bg-body-tertiary p-2 text-break" style="white-space: pre-wrap"><code>curl -H "Authorization: Token {{ key }}" {{ request.scheme }}://{{ request.get_host }}{% url 'instance-list' %}</code></pre>
|
||||
<a class="btn btn-primary" href="{% url 'accounts:profile' %}#apitokens">{% trans "Back to the profile" %}</a>
|
||||
{% endblock content %}
|
||||
25
accounts/templates/api_token_list.html
Normal file
25
accounts/templates/api_token_list.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{% load i18n %}
|
||||
{% load bootstrap_icons %}
|
||||
{# the tokens of the signed-in user, or of owner on a superuser's view of a user: the items of a list-group #}
|
||||
{% for token in api_tokens %}
|
||||
<div class="list-group-item py-3">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||
<span class="fw-semibold text-break">{{ token.name }}</span>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
aria-label="{% blocktrans with name=token.name %}Actions for API token {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item text-danger" href="{% if owner %}{% url 'accounts:account_api_token_delete' owner.id token.token_key %}{% else %}{% url 'accounts:api_token_delete' token.token_key %}{% endif %}">{% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small mt-1">{% trans "Starts with" %} <code>{{ token.token_key }}</code></div>
|
||||
<div class="row g-2 small mt-1">
|
||||
<div class="col-12 col-md-4"><span class="text-body-secondary">{% trans "Created" %}</span> <span class="text-nowrap">{{ token.created|date:"Y-m-d H:i" }}</span></div>
|
||||
<div class="col-12 col-md-4"><span class="text-body-secondary">{% trans "Expires" %}</span> <span class="text-nowrap">{% if token.expiry %}{{ token.expiry|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %}</span></div>
|
||||
<div class="col-12 col-md-4"><span class="text-body-secondary">{% trans "Last used" %}</span> <span class="text-nowrap">{% if token.last_used %}{{ token.last_used|date:"Y-m-d H:i" }}{% else %}{% trans "never" %}{% endif %}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-body-secondary">{% trans "No API tokens." %}</div>
|
||||
{% endfor %}
|
||||
|
|
@ -1,55 +1,66 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "WebVirtCloud" %} - {% trans "Sign In" %}{% endblock title %}
|
||||
|
||||
{% block style %}
|
||||
<link href="{% static "css/signin.css" %}" rel="stylesheet">
|
||||
{% endblock style %}
|
||||
|
||||
{% block content %}
|
||||
<div class="login-box">
|
||||
<div class="page-header">
|
||||
<a href="/"><h1>WebVirtCloud</h1></a>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="col-12" role="main">
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger" role="alert">
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
{% trans "Incorrect username or password." %}
|
||||
<div class="row justify-content-center pt-5">
|
||||
<div class="col-sm-9 col-md-7 col-lg-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-4 p-sm-5">
|
||||
<div class="text-center mb-4">
|
||||
<div class="d-inline-block rounded-3 bg-primary text-white fs-2 lh-1 p-3 mb-3"><i class="bi bi-hdd-network"></i></div>
|
||||
<h1 class="h3 fw-bold mb-1">WebVirtCloud</h1>
|
||||
<p class="text-body-secondary small mb-0">{% trans "Virtualization Management Console" %}</p>
|
||||
</div>
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alert alert-danger d-flex align-items-center mb-4" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-5 me-2 flex-shrink-0"></i>
|
||||
<div>{% trans "Incorrect username or password." %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form id="form-signin" method="post" aria-label="Login form">
|
||||
{% csrf_token %}
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-medium" for="id_username">{% trans "Username" %}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-person"></i></span>
|
||||
<input type="text" id="id_username" class="form-control" name="username" placeholder="{% trans 'Enter username' %}" autocapitalize="none" autocorrect="off" autofocus required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label small fw-medium" for="id_password">{% trans "Password" %}</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-shield-lock"></i></span>
|
||||
<input type="password" id="id_password" class="form-control" name="password" placeholder="{% trans 'Enter password' %}" required>
|
||||
<button class="btn btn-outline-secondary" type="button" id="btn-toggle-pwd" tabindex="-1" aria-label="{% trans 'Toggle password visibility' %}">
|
||||
<i class="bi bi-eye" id="toggle-icon"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
|
||||
<button id="btn-signin" class="btn btn-primary w-100 py-2" type="submit" data-busy-text="{% trans "Signing in..." %}">
|
||||
<i class="bi bi-box-arrow-in-right me-1"></i>
|
||||
<span id="btn-text">{% trans "Sign In" %}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="d-flex align-items-center small text-body-secondary border-top mt-4 pt-3">
|
||||
<span class="d-inline-block rounded-circle p-1 bg-success me-2" aria-hidden="true"></span>
|
||||
KVM / Libvirt
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form id="form-signin" class="form-signin" method="post" role="form" aria-label="Login form">{% csrf_token %}
|
||||
<h2 class="form-signin-heading">{% trans "Sign In" %}</h2>
|
||||
<input type="text" class="form-control" name="username" placeholder="{% trans 'User' %}" autocapitalize="none" autocorrect="off" autofocus required>
|
||||
<input type="password" class="form-control" name="password" placeholder="{% trans 'Password' %}" required>
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<div class="d-grid">
|
||||
<button id="btn-signin" class="btn btn-lg btn-success" type="submit">{% trans "Sign In" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script>
|
||||
$(document).ready(function() {
|
||||
$("#btn-signin").click(function() {
|
||||
// disable button
|
||||
$(this).prop("disabled", true);
|
||||
// add spinner to button
|
||||
$(this).html(
|
||||
`
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
{% trans "Loading" %}...
|
||||
`
|
||||
);
|
||||
$("#form-signin").submit();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock script%}
|
||||
<script src="{% static 'js/login_form.js' %}"></script>
|
||||
{% endblock script %}
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% block title %}
|
||||
{% trans "WebVirtCloud" %} - {% trans "Sign Out"%}
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<a href="/"><h1>WebVirtCloud</h1></a>
|
||||
</div>
|
||||
<div class="col-12" role="main">
|
||||
<div class="logout">
|
||||
<h2>{% trans "Successful log out" %}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -2,83 +2,78 @@
|
|||
{% load i18n %}
|
||||
{% load django_bootstrap5 %}
|
||||
{% load bootstrap_icons %}
|
||||
{% load tags_fingerprint %}
|
||||
|
||||
{% block title %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock %}
|
||||
{% block title %}{% trans "Profile" %}{% endblock %}
|
||||
|
||||
{% block page_heading %}{% trans "Profile" %}: {{ request.user.first_name }} {{ request.user.last_name}}{% endblock page_heading %}
|
||||
{% block page_heading %}{% trans "Profile" %}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#editprofile" type="button" role="tab" aria-controls="editprofile" aria-selected="true">
|
||||
{% trans "Edit Profile" %}
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#sshkeys" type="button" role="tab" aria-controls="sshkeys" aria-selected="false">
|
||||
{% trans "SSH Keys" %}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane tab-pane-bordered active" id="editprofile">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="" role="form" aria-label="Edit user info form">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form profile_form layout='horizontal' %}
|
||||
{% if perms.accounts.change_password %}
|
||||
<a href="{% url 'accounts:change_password' %}" class="btn btn-primary">
|
||||
{% bs_icon 'lock' %} {% trans "Change Password" %}
|
||||
</a>
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-4">
|
||||
<section class="card" aria-labelledby="identity-title">
|
||||
<div class="card-body border-bottom">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-1">
|
||||
<h2 class="h4 mb-0 text-break" id="identity-title">{{ user.get_full_name|default:user.username }}</h2>
|
||||
{% if user.is_superuser %}
|
||||
<span class="badge text-bg-secondary">{% trans "Superuser" %}</span>
|
||||
{% elif user.is_staff %}
|
||||
<span class="badge text-bg-secondary">{% trans "Template editor" %}</span>
|
||||
{% endif %}
|
||||
<div class="mb-0 float-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% bs_icon 'pencil' %} {% trans "Update" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="text-body-secondary text-break">{{ user.username }}</div>
|
||||
{% if user.email %}<div class="small text-break mt-2">{{ user.email }}</div>{% endif %}
|
||||
<div class="small text-body-secondary mt-2">{% trans "Last login" %} {{ user.last_login|date:"Y-m-d H:i"|default:_("never") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" class="card-body" aria-label="{% trans 'Edit user info form' %}">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form profile_form %}
|
||||
<button type="submit" class="btn btn-primary w-100">{% trans "Save profile" %}</button>
|
||||
</form>
|
||||
{# an LDAP user's password is changed in LDAP #}
|
||||
{% if perms.accounts.change_password and user.has_usable_password %}
|
||||
<div class="card-footer">
|
||||
<a href="{% url 'accounts:change_password' %}" class="btn btn-sm btn-outline-secondary">{% bs_icon 'lock' %} {% trans "Change Password" %}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
<div class="tab-pane tab-pane-bordered fade" id="sshkeys">
|
||||
{% if publickeys %}
|
||||
<div class="col-lg-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover">
|
||||
<tbody class="text-center">
|
||||
{% for key in publickeys %}
|
||||
<tr>
|
||||
<td>{{ key.keyname }} ({% ssh_to_fingerprint key.keypublic %})</td>
|
||||
<td>
|
||||
<a href="{% url 'accounts:ssh_key_delete' key.id %}" title="{% trans "Delete" %}" class="btn btn-sm btn-secondary">
|
||||
{% bs_icon 'trash' %}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="col-lg-8">
|
||||
<section class="card mb-4" id="sshkeys" aria-labelledby="sshkeys-title">
|
||||
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<h2 class="h5 mb-0" id="sshkeys-title">{% trans "SSH Keys" %} <span class="badge text-bg-light border">{{ publickeys|length }}</span></h2>
|
||||
<button class="btn btn-sm btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#add-ssh-key" aria-expanded="false" aria-controls="add-ssh-key">
|
||||
{% bs_icon 'plus-lg' %} {% trans "Add SSH Key" %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
{%trans "Add SSH Key" %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{% url 'accounts:ssh_key_create' %}" role="form" aria-label="Add key to user form">
|
||||
<div class="card-body small text-body-secondary">{% trans "Offered when you create a VM and on a VM's Access tab." %}</div>
|
||||
<div class="list-group list-group-flush">{% include "ssh_key_list.html" %}</div>
|
||||
<div class="collapse" id="add-ssh-key">
|
||||
<form method="post" action="{% url 'accounts:ssh_key_create' %}" class="card-body border-top" aria-label="{% trans 'Add key to user form' %}">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form ssh_key_form layout='horizontal' %}
|
||||
<div class="mb-0 float-end">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
{% bs_icon 'plus-circle-fill' %} {% trans "Add" %}
|
||||
</button>
|
||||
</div>
|
||||
{% bootstrap_form ssh_key_form %}
|
||||
<button type="submit" class="btn btn-primary">{% trans "Add" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" id="apitokens" aria-labelledby="apitokens-title">
|
||||
<div class="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
|
||||
<h2 class="h5 mb-0" id="apitokens-title">{% trans "API Tokens" %} <span class="badge text-bg-light border">{{ api_tokens|length }}</span></h2>
|
||||
<button class="btn btn-sm btn-primary" type="button" data-bs-toggle="collapse" data-bs-target="#create-api-token" aria-expanded="false" aria-controls="create-api-token">
|
||||
{% bs_icon 'plus-lg' %} {% trans "Create an API token" %}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body small text-body-secondary">{% blocktrans %}A token lets a script or a tool use the REST API as you, without your password or OTP: send it as <code>Authorization: Token <token></code>. A password change does not delete tokens: delete a token you no longer use, or one that may have leaked.{% endblocktrans %}</div>
|
||||
<div class="list-group list-group-flush">{% include "api_token_list.html" %}</div>
|
||||
<div class="collapse" id="create-api-token">
|
||||
<form method="post" action="{% url 'accounts:api_token_create' %}" class="card-body border-top" aria-label="{% trans 'Create an API token' %}">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form api_token_form %}
|
||||
<button type="submit" class="btn btn-primary">{% trans "Create" %}</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
21
accounts/templates/ssh_key_list.html
Normal file
21
accounts/templates/ssh_key_list.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{% load i18n %}
|
||||
{% load bootstrap_icons %}
|
||||
{% load tags_fingerprint %}
|
||||
{# the keys of the signed-in user, or of owner on a superuser's view of a user: the items of a list-group #}
|
||||
{% for key in publickeys %}
|
||||
<div class="list-group-item py-3">
|
||||
<div class="d-flex justify-content-between align-items-center gap-2">
|
||||
<span class="fw-semibold text-break">{{ key.keyname }}</span>
|
||||
<div class="dropdown">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
aria-label="{% blocktrans with name=key.keyname %}Actions for SSH key {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item text-danger" href="{% if owner %}{% url 'accounts:account_ssh_key_delete' owner.id key.id %}{% else %}{% url 'accounts:ssh_key_delete' key.id %}{% endif %}">{% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-monospace small text-break mt-1" title="{{ key.keypublic }}">{% ssh_to_fingerprint key.keypublic %}</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-body-secondary">{% trans "No SSH keys." %}</div>
|
||||
{% endfor %}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import base64
|
||||
import struct
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.test import TestCase
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
|
||||
from accounts.utils import get_user_totp_device, send_email_with_otp, validate_ssh_key
|
||||
|
||||
|
||||
class AccountsUtilsTestCase(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="test_otp_user", email="user@example.com", password="password123"
|
||||
)
|
||||
|
||||
def test_validate_ssh_key_valid(self):
|
||||
blob = (
|
||||
struct.pack(">I", 7)
|
||||
+ b"ssh-rsa"
|
||||
+ struct.pack(">I", 3)
|
||||
+ b"\x01\x00\x01"
|
||||
+ struct.pack(">I", 8)
|
||||
+ b"\x00" * 8
|
||||
)
|
||||
b64 = base64.b64encode(blob).decode()
|
||||
valid_key = f"ssh-rsa {b64} user@example.com"
|
||||
self.assertTrue(validate_ssh_key(valid_key))
|
||||
|
||||
def test_validate_ssh_key_invalid_parts(self):
|
||||
self.assertFalse(validate_ssh_key("only_two parts"))
|
||||
self.assertFalse(validate_ssh_key("one_part"))
|
||||
self.assertFalse(validate_ssh_key("four parts key here now"))
|
||||
|
||||
def test_validate_ssh_key_invalid_base64(self):
|
||||
self.assertFalse(validate_ssh_key("ssh-rsa not-valid-b64! user@example.com"))
|
||||
|
||||
def test_validate_ssh_key_struct_error(self):
|
||||
# Base64 with less than 4 bytes cannot unpack length
|
||||
short_b64 = base64.b64encode(b"ab").decode()
|
||||
self.assertFalse(validate_ssh_key(f"ssh-rsa {short_b64} user@example.com"))
|
||||
|
||||
def test_validate_ssh_key_type_mismatch(self):
|
||||
blob = struct.pack(">I", 7) + b"ssh-rsa" + b"\x00" * 8
|
||||
b64 = base64.b64encode(blob).decode()
|
||||
# header says ssh-ed25519 but payload has ssh-rsa
|
||||
self.assertFalse(validate_ssh_key(f"ssh-ed25519 {b64} user@example.com"))
|
||||
|
||||
def test_get_user_totp_device_creates_new(self):
|
||||
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 0)
|
||||
device = get_user_totp_device(self.user)
|
||||
self.assertIsInstance(device, TOTPDevice)
|
||||
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_get_user_totp_device_retrieves_existing(self):
|
||||
device1 = get_user_totp_device(self.user)
|
||||
device2 = get_user_totp_device(self.user)
|
||||
self.assertEqual(device1.id, device2.id)
|
||||
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_send_email_with_otp(self):
|
||||
device = get_user_totp_device(self.user)
|
||||
send_email_with_otp(self.user, device)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn("OTP QR Code", mail.outbox[0].subject)
|
||||
self.assertEqual(mail.outbox[0].to, ["user@example.com"])
|
||||
82
accounts/tests/test_account_page.py
Normal file
82
accounts/tests/test_account_page.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""A superuser's page of a user: the VMs and their permissions, the SSH keys."""
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from accounts.models import UserInstance, UserSSHKey
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
from logs.models import Logs
|
||||
|
||||
KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGbXMJlKTOlUOvwzdjDs3ac3ivHQoOxAUZEwi3WsJHfs alice@example"
|
||||
|
||||
|
||||
class AccountPageTests(TestCase):
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_superuser("ap-admin", "ap@example.com", "pw")
|
||||
self.alice = User.objects.create_user("alice", password="pw")
|
||||
self.key = UserSSHKey.objects.create(user=self.alice, keyname="laptop", keypublic=KEY)
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
def page(self):
|
||||
return self.client.get(reverse("accounts:account", args=[self.alice.pk]))
|
||||
|
||||
def test_the_permissions_are_marks_not_true_or_false(self):
|
||||
compute = Compute.objects.create(name="ap-host", hostname="localhost", login="", password="", details="", type=4)
|
||||
vm = Instance.objects.create(compute=compute, name="ap-vm", uuid="00000000-0000-0000-0000-000000000001")
|
||||
UserInstance.objects.create(user=self.alice, instance=vm, is_vnc=True, is_change=False, is_delete=False)
|
||||
response = self.page()
|
||||
self.assertContains(response, "ap-vm")
|
||||
self.assertNotContains(response, "<td>True</td>", html=True)
|
||||
self.assertNotContains(response, "<td>False</td>", html=True)
|
||||
# what the user may do beyond power and console, in the VM Users tab's words, and the VM's host
|
||||
self.assertContains(response, "Power and console only")
|
||||
self.assertNotContains(response, "Change settings")
|
||||
self.assertContains(response, "Host: ap-host")
|
||||
# the console settings come with the right to change the VM
|
||||
UserInstance.objects.filter(user=self.alice).update(is_change=True)
|
||||
response = self.page()
|
||||
self.assertContains(response, "Change settings")
|
||||
self.assertContains(response, "Change console settings")
|
||||
self.assertNotContains(response, "Power and console only")
|
||||
|
||||
def test_a_blocked_user_is_marked(self):
|
||||
self.assertNotContains(self.page(), "Blocked")
|
||||
self.alice.is_active = False
|
||||
self.alice.save()
|
||||
self.assertContains(self.page(), '<span class="badge text-bg-danger">Blocked</span>', html=True)
|
||||
|
||||
def test_a_user_without_vms_or_tokens_is_told_so(self):
|
||||
response = self.page()
|
||||
self.assertContains(response, "No VMs.")
|
||||
self.assertContains(response, "No API tokens.")
|
||||
# the whole key shows over its fingerprint
|
||||
self.assertContains(response, f'title="{KEY}"')
|
||||
|
||||
def test_a_superuser_deletes_a_users_key(self):
|
||||
url = reverse("accounts:account_ssh_key_delete", args=[self.alice.pk, self.key.pk])
|
||||
self.assertContains(self.page(), f'href="{url}"')
|
||||
self.assertContains(self.client.get(url), "laptop")
|
||||
response = self.client.post(url)
|
||||
self.assertRedirects(response, reverse("accounts:account", args=[self.alice.pk]) + "#public-keys",
|
||||
fetch_redirect_response=False)
|
||||
self.assertFalse(UserSSHKey.objects.exists())
|
||||
self.assertTrue(Logs.objects.filter(message="SSH key laptop of alice: delete").exists())
|
||||
|
||||
def test_only_a_superuser_and_only_that_users_key(self):
|
||||
bob = User.objects.create_user("bob", password="pw")
|
||||
self.assertEqual(self.client.post(reverse("accounts:account_ssh_key_delete", args=[bob.pk, self.key.pk])).status_code, 404)
|
||||
self.client.force_login(bob)
|
||||
url = reverse("accounts:account_ssh_key_delete", args=[self.alice.pk, self.key.pk])
|
||||
self.assertEqual(self.client.post(url).status_code, 403)
|
||||
self.assertTrue(UserSSHKey.objects.exists())
|
||||
|
||||
def test_the_owner_form_names_the_rights_as_the_vm_page_does(self):
|
||||
compute = Compute.objects.create(name="ap-host2", hostname="localhost", login="", password="", details="", type=4)
|
||||
vm = Instance.objects.create(compute=compute, name="ap-vm2", uuid="00000000-0000-0000-0000-000000000002")
|
||||
owner = UserInstance.objects.create(user=self.alice, instance=vm)
|
||||
response = self.client.get(reverse("accounts:user_instance_update", args=[owner.pk]))
|
||||
for label in ("Change settings", "Delete the VM", "Change console settings"):
|
||||
self.assertContains(response, label)
|
||||
self.assertNotContains(response, "Is vnc")
|
||||
38
accounts/tests/test_admin_email_otp.py
Normal file
38
accounts/tests/test_admin_email_otp.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class AdminEmailOtpTestCase(TestCase):
|
||||
"""Sending the OTP QR code by email must not happen on GET."""
|
||||
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.superuser = User.objects.create_superuser(
|
||||
username="otp_super", email="otp_super@example.com", password="x"
|
||||
)
|
||||
self.user = User.objects.create_user(
|
||||
username="otp_user", email="otp_user@example.com", password="x"
|
||||
)
|
||||
self.client.force_login(self.superuser)
|
||||
self.url = reverse("accounts:admin_email_otp", args=[self.user.id])
|
||||
|
||||
@patch("accounts.views.send_email_with_otp")
|
||||
def test_get_does_not_send_email(self, mock_send):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch("accounts.views.send_email_with_otp")
|
||||
def test_post_sends_email(self, mock_send):
|
||||
response = self.client.post(self.url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
mock_send.assert_called_once()
|
||||
|
||||
def test_the_smtp_connection_waits_a_bounded_time(self):
|
||||
from django.core.mail.backends.smtp import EmailBackend
|
||||
|
||||
# None would wait for a server that stopped answering forever
|
||||
self.assertIsNotNone(EmailBackend().timeout)
|
||||
92
accounts/tests/test_admin_password.py
Normal file
92
accounts/tests/test_admin_password.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""The generated admin password must not be printed to the log."""
|
||||
import io
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth import authenticate, get_user_model
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
|
||||
from accounts.apps import create_admin
|
||||
from accounts.models import UserAttributes
|
||||
|
||||
|
||||
class GeneratedAdminPasswordTestCase(TestCase):
|
||||
def _run_create_admin(self, base_dir, plan=None):
|
||||
get_user_model().objects.all().delete()
|
||||
if plan is None:
|
||||
migration = MagicMock(app_label="accounts")
|
||||
migration.name = "0001_initial"
|
||||
plan = [(migration, False)]
|
||||
out = io.StringIO()
|
||||
env = {k: v for k, v in os.environ.items() if k not in ("ADMIN_PASSWORD", "ADMIN_USERNAME")}
|
||||
with override_settings(BASE_DIR=Path(base_dir)), patch.dict(
|
||||
os.environ, env, clear=True
|
||||
), redirect_stdout(out):
|
||||
create_admin(sender=None, plan=plan, apps=apps)
|
||||
return out.getvalue()
|
||||
|
||||
def test_password_is_written_to_a_private_file_not_printed(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / "data").mkdir()
|
||||
output = self._run_create_admin(tmp)
|
||||
password_file = Path(tmp) / "data" / "admin_password"
|
||||
|
||||
self.assertTrue(password_file.exists())
|
||||
password = password_file.read_text().strip()
|
||||
self.assertEqual(stat.S_IMODE(password_file.stat().st_mode), 0o600)
|
||||
self.assertNotIn(password, output)
|
||||
self.assertIn(str(password_file), output)
|
||||
self.assertIsNotNone(authenticate(RequestFactory().get("/"), username="admin", password=password))
|
||||
self.assertTrue(UserAttributes.objects.get(user__username="admin").must_change_password)
|
||||
|
||||
def test_does_not_follow_a_planted_symlink(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
(Path(tmp) / "data").mkdir()
|
||||
victim = Path(tmp) / "victim"
|
||||
victim.write_text("original\n")
|
||||
(Path(tmp) / "data" / "admin_password").symlink_to(victim)
|
||||
|
||||
self._run_create_admin(tmp)
|
||||
|
||||
password_file = Path(tmp) / "data" / "admin_password"
|
||||
self.assertEqual(victim.read_text(), "original\n")
|
||||
self.assertFalse(password_file.is_symlink())
|
||||
self.assertEqual(stat.S_IMODE(password_file.stat().st_mode), 0o600)
|
||||
|
||||
def test_storage_failure_does_not_print_password_or_create_an_admin(self):
|
||||
password = "review-dummy-generated-password"
|
||||
output = io.StringIO()
|
||||
with tempfile.TemporaryDirectory() as tmp, patch(
|
||||
"secrets.token_urlsafe", return_value=password
|
||||
), patch("accounts.apps.os.open", side_effect=PermissionError("read-only")), redirect_stdout(output):
|
||||
with self.assertRaisesRegex(RuntimeError, "Cannot securely store"):
|
||||
self._run_create_admin(tmp)
|
||||
self.assertNotIn(password, output.getvalue())
|
||||
self.assertFalse(get_user_model().objects.exists())
|
||||
|
||||
def test_rerunning_migrate_after_a_storage_failure_creates_the_admin(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with patch("accounts.apps.os.open", side_effect=PermissionError("read-only")), redirect_stdout(io.StringIO()):
|
||||
with self.assertRaises(RuntimeError):
|
||||
self._run_create_admin(tmp)
|
||||
# The second migrate has nothing left to apply: an empty plan.
|
||||
(Path(tmp) / "data").mkdir(exist_ok=True)
|
||||
self._run_create_admin(tmp, plan=[])
|
||||
self.assertTrue(get_user_model().objects.filter(username="admin").exists())
|
||||
|
||||
def test_flush_does_not_create_an_admin(self):
|
||||
get_user_model().objects.all().delete()
|
||||
create_admin(sender=None, plan=None, apps=apps)
|
||||
self.assertFalse(get_user_model().objects.exists())
|
||||
|
||||
def test_migrating_another_app_before_auth_creates_nothing(self):
|
||||
get_user_model().objects.all().delete()
|
||||
state_without_auth = MagicMock()
|
||||
state_without_auth.get_model.side_effect = LookupError
|
||||
create_admin(sender=None, plan=[], apps=state_without_auth)
|
||||
self.assertFalse(get_user_model().objects.exists())
|
||||
216
accounts/tests/test_api_tokens.py
Normal file
216
accounts/tests/test_api_tokens.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"""API tokens: a user makes them on the profile page, in a session that
|
||||
passed the login (and its OTP), and scripts use them on the REST API."""
|
||||
|
||||
import re
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from accounts.models import ApiToken, UserAttributes
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from logs.models import Logs
|
||||
from lxml import html
|
||||
|
||||
|
||||
class ApiTokenTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user("tok-user", password="pw")
|
||||
self.other = get_user_model().objects.create_user("tok-other", password="pw")
|
||||
|
||||
def make(self, name="ansible", days="90"):
|
||||
"""The token the creation page shows once."""
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(reverse("accounts:api_token_create"), {"name": name, "days": days}, follow=True)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return re.search(r"wvc_[0-9a-f]{64}", response.content.decode()).group(0)
|
||||
|
||||
def api(self, token):
|
||||
self.client.logout()
|
||||
return self.client.get(reverse("instance-list"), HTTP_AUTHORIZATION=f"Token {token}")
|
||||
|
||||
def test_a_token_opens_the_api_without_a_session(self):
|
||||
token = self.make()
|
||||
response = self.api(token)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), []) # the user's own VMs: none
|
||||
|
||||
def test_a_token_is_kept_only_as_a_hash_and_shown_once(self):
|
||||
token = self.make(name="ci")
|
||||
(stored,) = ApiToken.objects.filter(user=self.user)
|
||||
self.assertEqual((stored.name, stored.token_key), ("ci", token[:15]))
|
||||
self.assertNotIn(token, stored.digest)
|
||||
page = self.client.get(reverse("accounts:profile")).content.decode()
|
||||
self.assertIn("ci", page)
|
||||
self.assertNotIn(token, page)
|
||||
|
||||
def test_the_page_showing_the_token_is_not_cached(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(reverse("accounts:api_token_create"), {"name": "ci", "days": "30"}, follow=True)
|
||||
self.assertIn("no-store", response["Cache-Control"])
|
||||
|
||||
def test_reloading_the_page_creates_no_second_token(self):
|
||||
token = self.make(name="ci")
|
||||
response = self.client.get(reverse("accounts:api_token_created"))
|
||||
self.assertRedirects(response, reverse("accounts:profile") + "#apitokens", fetch_redirect_response=False)
|
||||
self.assertNotIn(token, response.content.decode())
|
||||
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_a_token_lasts_the_days_chosen_or_until_deleted(self):
|
||||
self.make(name="month", days="30")
|
||||
self.make(name="always", days="")
|
||||
expiry = dict(ApiToken.objects.values_list("name", "expiry"))
|
||||
self.assertAlmostEqual(expiry["month"], timezone.now() + timedelta(days=30), delta=timedelta(minutes=1))
|
||||
self.assertIsNone(expiry["always"])
|
||||
|
||||
def test_an_expired_token_or_a_wrong_one_is_refused(self):
|
||||
token = self.make()
|
||||
ApiToken.objects.update(expiry=timezone.now() - timedelta(seconds=1))
|
||||
self.assertEqual(self.api(token).status_code, 401)
|
||||
self.assertEqual(self.api("wvc_" + "0" * 64).status_code, 401)
|
||||
|
||||
def test_an_inactive_users_token_is_refused(self):
|
||||
token = self.make()
|
||||
self.user.is_active = False
|
||||
self.user.save()
|
||||
self.assertEqual(self.api(token).status_code, 401)
|
||||
|
||||
def test_a_deleted_token_stops_working_and_only_its_owner_deletes_it(self):
|
||||
token = self.make()
|
||||
stored = ApiToken.objects.get(user=self.user)
|
||||
url = reverse("accounts:api_token_delete", args=[stored.token_key])
|
||||
self.client.force_login(self.other)
|
||||
self.assertEqual(self.client.post(url).status_code, 404)
|
||||
self.client.force_login(self.user)
|
||||
self.assertRedirects(self.client.post(url), reverse("accounts:profile") + "#apitokens",
|
||||
fetch_redirect_response=False)
|
||||
self.assertEqual(self.api(token).status_code, 401)
|
||||
|
||||
def test_making_and_deleting_a_token_is_logged_without_the_token(self):
|
||||
token = self.make(name="backup")
|
||||
stored = ApiToken.objects.get(user=self.user)
|
||||
self.client.post(reverse("accounts:api_token_delete", args=[stored.token_key]))
|
||||
rows = list(Logs.objects.order_by("id").values_list("message", "success"))
|
||||
self.assertEqual(rows[-2:], [("API token backup: add", True), ("API token backup: delete", True)])
|
||||
self.assertFalse(Logs.objects.filter(message__contains=token[4:20]).exists())
|
||||
|
||||
def test_a_token_needs_a_name(self):
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(reverse("accounts:api_token_create"), {"name": " ", "days": "30"})
|
||||
self.assertFalse(ApiToken.objects.exists())
|
||||
self.assertNotRegex(response.content.decode(), r"wvc_[0-9a-f]{64}")
|
||||
|
||||
def test_knox_makes_no_table_of_its_own(self):
|
||||
# accounts.ApiToken replaces knox's AuthToken; a knox_authtoken table
|
||||
# would reference auth_user unseen, and PostgreSQL could not empty
|
||||
# auth_user between tests
|
||||
self.assertNotIn("knox_authtoken", connection.introspection.table_names())
|
||||
def test_a_token_shows_when_it_was_last_used(self):
|
||||
token = self.make()
|
||||
self.assertIsNone(ApiToken.objects.get().last_used)
|
||||
self.api(token)
|
||||
first = ApiToken.objects.get().last_used
|
||||
self.assertAlmostEqual(first, timezone.now(), delta=timedelta(seconds=5))
|
||||
# within a minute a request writes nothing
|
||||
self.api(token)
|
||||
self.assertEqual(ApiToken.objects.get().last_used, first)
|
||||
later = timezone.now() + timedelta(minutes=2)
|
||||
with patch("accounts.auth.timezone.now", return_value=later):
|
||||
self.api(token)
|
||||
self.assertEqual(ApiToken.objects.get().last_used, later)
|
||||
self.client.force_login(self.user)
|
||||
self.assertIn("Last used", self.client.get(reverse("accounts:profile")).content.decode())
|
||||
|
||||
def test_a_token_stops_while_its_user_must_change_the_password(self):
|
||||
token = self.make()
|
||||
UserAttributes.objects.update_or_create(user=self.user, defaults={"must_change_password": True})
|
||||
self.assertEqual(self.api(token).status_code, 401)
|
||||
UserAttributes.objects.filter(user=self.user).update(must_change_password=False)
|
||||
self.assertEqual(self.api(token).status_code, 200)
|
||||
|
||||
def test_a_user_has_at_most_ten_tokens(self):
|
||||
for number in range(10):
|
||||
self.make(name=f"t{number}")
|
||||
response = self.client.post(reverse("accounts:api_token_create"), {"name": "one more", "days": "30"})
|
||||
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), 10)
|
||||
self.assertNotRegex(response.content.decode(), r"wvc_[0-9a-f]{64}")
|
||||
self.assertIn("at most 10", response.content.decode())
|
||||
|
||||
def test_a_form_sent_twice_makes_one_token(self):
|
||||
self.make("ci")
|
||||
response = self.client.post(reverse("accounts:api_token_create"), {"name": "ci", "days": "90"})
|
||||
self.assertContains(response, "You have a token named ci: choose another name.")
|
||||
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), 1)
|
||||
self.make("ci-2") # another name is fine
|
||||
self.assertEqual(ApiToken.objects.filter(user=self.user).count(), 2)
|
||||
|
||||
def test_expired_tokens_do_not_count_toward_the_limit(self):
|
||||
for number in range(10):
|
||||
self.make(name=f"t{number}")
|
||||
ApiToken.objects.filter(name="t0").update(expiry=timezone.now() - timedelta(days=1))
|
||||
self.make(name="new")
|
||||
self.assertEqual(sorted(ApiToken.objects.values_list("name", flat=True))[:2], ["new", "t1"])
|
||||
|
||||
|
||||
class AdminApiTokenTests(TestCase):
|
||||
"""A superuser sees a user's tokens on the user's page and deletes one
|
||||
that may have leaked."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user("tok-user", password="pw")
|
||||
self.admin = get_user_model().objects.create_superuser("tok-admin", "admin@example.com", "pw")
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.post(reverse("accounts:api_token_create"), {"name": "ansible", "days": "90"}, follow=True)
|
||||
self.token = re.search(r"wvc_[0-9a-f]{64}", response.content.decode()).group(0)
|
||||
self.stored = ApiToken.objects.get(user=self.user)
|
||||
|
||||
def delete_url(self, user):
|
||||
return reverse("accounts:account_api_token_delete", args=[user.id, self.stored.token_key])
|
||||
|
||||
def test_the_users_page_lists_the_tokens_without_them(self):
|
||||
self.client.force_login(self.admin)
|
||||
page = self.client.get(reverse("accounts:account", args=[self.user.id])).content.decode()
|
||||
self.assertIn("ansible", page)
|
||||
self.assertIn(self.stored.token_key, page)
|
||||
self.assertIn(self.delete_url(self.user), page)
|
||||
self.assertNotIn(self.token, page)
|
||||
|
||||
def test_a_superuser_deletes_a_users_token(self):
|
||||
self.client.force_login(self.admin)
|
||||
# the confirmation names whose token it is
|
||||
self.assertIn("API token ansible of tok-user", self.client.get(self.delete_url(self.user)).content.decode())
|
||||
self.assertRedirects(self.client.post(self.delete_url(self.user)),
|
||||
reverse("accounts:account", args=[self.user.id]) + "#apitokens",
|
||||
fetch_redirect_response=False)
|
||||
self.assertFalse(ApiToken.objects.exists())
|
||||
self.assertEqual(Logs.objects.order_by("id").values_list("message", "user").last(),
|
||||
("API token ansible of tok-user: delete", "tok-admin"))
|
||||
self.client.logout()
|
||||
self.assertEqual(self.client.get(reverse("instance-list"), HTTP_AUTHORIZATION=f"Token {self.token}").status_code, 401)
|
||||
|
||||
def test_only_a_superuser_deletes_another_users_token(self):
|
||||
other = get_user_model().objects.create_user("tok-other", password="pw")
|
||||
self.client.force_login(other)
|
||||
self.assertEqual(self.client.post(self.delete_url(self.user)).status_code, 403)
|
||||
# nor through the owner's own address: it names the caller's tokens only
|
||||
self.assertEqual(self.client.post(reverse("accounts:api_token_delete", args=[self.stored.token_key])).status_code, 404)
|
||||
self.client.force_login(self.admin)
|
||||
self.assertEqual(self.client.post(self.delete_url(other)).status_code, 404)
|
||||
self.assertTrue(ApiToken.objects.exists())
|
||||
|
||||
def test_the_user_list_counts_each_users_live_tokens(self):
|
||||
self.client.force_login(self.user)
|
||||
self.client.post(reverse("accounts:api_token_create"), {"name": "old", "days": "30"})
|
||||
ApiToken.objects.filter(name="old").update(expiry=timezone.now() - timedelta(days=1))
|
||||
self.client.force_login(self.admin)
|
||||
page = html.fromstring(self.client.get(reverse("admin:user_list")).content)
|
||||
def tokens(name):
|
||||
return page.xpath(f"//div[contains(@class, 'list-group-item')][.//a[normalize-space()='{name}']]"
|
||||
"//a[contains(@href, '#apitokens')]")
|
||||
# the expired one is not counted; the count opens the user's tokens
|
||||
(link,) = tokens("tok-user")
|
||||
self.assertEqual(link.text_content().strip(), "1 API token")
|
||||
self.assertEqual(link.get("href"), reverse("accounts:account", args=[self.user.id]) + "#apitokens")
|
||||
self.assertEqual(tokens("tok-admin"), [])
|
||||
114
accounts/tests/test_audit_rows.py
Normal file
114
accounts/tests/test_audit_rows.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
"""Changes to a user's own account, VM ownership, OTP mails and SSH keys write rows on the Logs page."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from accounts.models import UserAttributes
|
||||
from logs.models import Logs
|
||||
|
||||
|
||||
def rows():
|
||||
return list(Logs.objects.order_by("id").values_list("message", "success"))
|
||||
|
||||
|
||||
class OwnAccountRowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.bob = get_user_model().objects.create_user("bob", password="Bob-pass-2026")
|
||||
self.client.force_login(self.bob)
|
||||
Logs.objects.all().delete() # the login's row
|
||||
|
||||
def test_own_profile_and_password(self):
|
||||
self.client.post(reverse("accounts:profile"), {"username": "bob", "email": "bob@example.com"})
|
||||
self.client.post(reverse("accounts:change_password"), {
|
||||
"old_password": "Bob-pass-2026", "new_password1": "Bob-new-pass-1", "new_password2": "Bob-new-pass-1"})
|
||||
self.assertEqual(rows(), [("User bob: change email", True), ("User bob: change password", True)])
|
||||
self.assertFalse(Logs.objects.filter(message__contains="pass-").exists())
|
||||
|
||||
def test_a_wrong_old_password_is_a_failed_row(self):
|
||||
# someone at an open session guessing the password
|
||||
self.client.post(reverse("accounts:change_password"), {
|
||||
"old_password": "guess-2026", "new_password1": "Bob-new-pass-1", "new_password2": "Bob-new-pass-1"})
|
||||
self.client.get(reverse("accounts:change_password"))
|
||||
self.client.post(reverse("accounts:change_password"), { # none given: not a guess
|
||||
"new_password1": "Bob-new-pass-1", "new_password2": "Bob-new-pass-1"})
|
||||
self.assertEqual(rows(), [("User bob: change password: wrong old password", False)])
|
||||
self.assertFalse(Logs.objects.filter(message__contains="pass-").exists())
|
||||
|
||||
def test_the_first_login_password_change(self):
|
||||
self.bob.user_permissions.clear()
|
||||
UserAttributes.objects.update_or_create(user=self.bob, defaults={"must_change_password": True})
|
||||
self.client.post(reverse("accounts:change_password"), {
|
||||
"old_password": "Bob-pass-2026", "new_password1": "Bob-new-pass-1", "new_password2": "Bob-new-pass-1"})
|
||||
self.assertEqual(rows(), [("User bob: change password", True)])
|
||||
|
||||
|
||||
SSH_KEY = ("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfx"
|
||||
"YHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw=="
|
||||
" test@test")
|
||||
|
||||
|
||||
class AdminAccountRowTests(TestCase):
|
||||
def setUp(self):
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
|
||||
self.admin = get_user_model().objects.create_superuser("audit-admin", "audit@example.com", "pw")
|
||||
self.bob = get_user_model().objects.create_user("bob", password="Bob-pass-2026")
|
||||
compute = Compute.objects.create(name="c1", hostname="127.0.0.1", login="", password="", type=4)
|
||||
self.vm = Instance.objects.create(compute=compute, name="vm", uuid="66666666-6666-6666-6666-666666666666")
|
||||
self.client.force_login(self.admin)
|
||||
Logs.objects.all().delete() # the login's row
|
||||
|
||||
def test_ownership_from_the_accounts_pages(self):
|
||||
from accounts.models import UserInstance
|
||||
|
||||
self.client.post(reverse("accounts:user_instance_create", args=[self.bob.pk]),
|
||||
{"user": self.bob.pk, "instance": self.vm.pk})
|
||||
ui = UserInstance.objects.get(user=self.bob, instance=self.vm)
|
||||
url = reverse("accounts:user_instance_update", args=[ui.pk])
|
||||
self.client.post(url, {"is_vnc": "on"})
|
||||
self.client.post(url, {"is_vnc": "on"}) # no change
|
||||
self.client.post(reverse("accounts:user_instance_delete", args=[ui.pk]))
|
||||
self.assertEqual(rows(), [
|
||||
("VM vm: add owner bob", True),
|
||||
("VM vm: change owner bob: is_vnc", True),
|
||||
("VM vm: remove owner bob", True),
|
||||
])
|
||||
self.assertEqual(set(Logs.objects.values_list("user", "host", "instance", "instance_uuid")),
|
||||
{("audit-admin", "c1", "vm", self.vm.uuid)})
|
||||
|
||||
def test_otp_mail_without_an_email(self):
|
||||
from smtplib import SMTPException
|
||||
from unittest.mock import patch
|
||||
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
|
||||
url = reverse("accounts:admin_email_otp", args=[self.bob.pk])
|
||||
with patch("accounts.views.send_email_with_otp") as send:
|
||||
self.client.post(url)
|
||||
send.assert_not_called()
|
||||
self.assertFalse(TOTPDevice.objects.filter(user=self.bob).exists())
|
||||
|
||||
self.bob.email = "bob@example.com"
|
||||
self.bob.save()
|
||||
with patch("accounts.views.send_email_with_otp") as send:
|
||||
self.client.post(url)
|
||||
send.assert_called_once()
|
||||
with patch("accounts.views.send_email_with_otp", side_effect=SMTPException("refused")):
|
||||
response = self.client.post(url, follow=True)
|
||||
self.assertContains(response, "The OTP QR code could not be mailed to bob: refused")
|
||||
self.assertEqual(rows(), [
|
||||
("User bob: mail OTP QR code: no email address", False),
|
||||
("User bob: mail OTP QR code", True),
|
||||
("User bob: mail OTP QR code: SMTPException", False),
|
||||
])
|
||||
|
||||
def test_ssh_keys(self):
|
||||
from accounts.models import UserSSHKey
|
||||
|
||||
self.client.post(reverse("accounts:ssh_key_create"), {"keyname": "laptop", "keypublic": SSH_KEY})
|
||||
key = UserSSHKey.objects.get(keyname="laptop")
|
||||
self.client.post(reverse("accounts:ssh_key_delete", args=[key.pk]))
|
||||
self.assertEqual(rows(), [("SSH key laptop: add", True), ("SSH key laptop: delete", True)])
|
||||
self.assertFalse(Logs.objects.filter(message__contains="AAAA").exists())
|
||||
99
accounts/tests/test_change_password_permission.py
Normal file
99
accounts/tests/test_change_password_permission.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Who may change their own password is the change_password permission: new
|
||||
users get it, admins may take it away, and later migrate runs keep their choice."""
|
||||
|
||||
import importlib
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from django.apps import apps
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from admin.forms import UserCreateForm
|
||||
|
||||
migration = importlib.import_module("accounts.migrations.0008_apply_show_profile_edit_password_once")
|
||||
|
||||
|
||||
def permission():
|
||||
return Permission.objects.get(content_type__app_label="accounts", codename="change_password")
|
||||
|
||||
|
||||
def can_change(user):
|
||||
return get_user_model().objects.get(pk=user.pk).has_perm("accounts.change_password")
|
||||
|
||||
|
||||
class ChangePasswordPermissionTestCase(TestCase):
|
||||
def test_new_users_may_change_their_password(self):
|
||||
self.assertTrue(can_change(get_user_model().objects.create_user("new", password="pw")))
|
||||
|
||||
def test_user_without_the_permission_cannot_open_the_page(self):
|
||||
user = get_user_model().objects.create_user("limited", password="pw")
|
||||
user.user_permissions.remove(permission())
|
||||
self.client.force_login(user)
|
||||
self.assertEqual(self.client.get(reverse("accounts:change_password")).status_code, 403)
|
||||
|
||||
def test_a_user_who_must_change_it_has_no_cancel(self):
|
||||
from accounts.models import UserAttributes
|
||||
|
||||
user = get_user_model().objects.create_user("forced", password="pw")
|
||||
self.client.force_login(user)
|
||||
page = self.client.get(reverse("accounts:change_password"))
|
||||
self.assertContains(page, "your other sessions end")
|
||||
self.assertContains(page, f'href="{reverse("accounts:profile")}">Cancel')
|
||||
UserAttributes.objects.create(user=user, must_change_password=True)
|
||||
page = self.client.get(reverse("accounts:change_password"))
|
||||
self.assertContains(page, "Choose a new password before you go on.")
|
||||
self.assertNotContains(page, f'href="{reverse("accounts:profile")}">Cancel')
|
||||
|
||||
def test_a_user_without_a_local_password_cannot_change_it(self):
|
||||
user = get_user_model().objects.create_user("ldap-user") # as django-auth-ldap makes them
|
||||
self.client.force_login(user)
|
||||
self.assertEqual(self.client.get(reverse("accounts:change_password")).status_code, 403)
|
||||
self.assertNotContains(self.client.get(reverse("accounts:profile")), reverse("accounts:change_password"))
|
||||
|
||||
def test_create_form_ticks_it_and_the_admin_choice_wins(self):
|
||||
self.assertIn(permission(), UserCreateForm().fields["user_permissions"].initial)
|
||||
form = UserCreateForm(data={"username": "unticked", "password": "Kq7-panel-pass", "is_active": True, "role": "user"})
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
self.assertFalse(can_change(form.save()))
|
||||
|
||||
@override_settings(SHOW_PROFILE_EDIT_PASSWORD=True)
|
||||
def test_migrate_keeps_a_removed_permission(self):
|
||||
user = get_user_model().objects.create_user("kept", password="pw")
|
||||
user.user_permissions.remove(permission())
|
||||
|
||||
call_command("migrate", verbosity=0)
|
||||
|
||||
self.assertFalse(can_change(user))
|
||||
|
||||
|
||||
class ApplySettingOnceTestCase(TestCase):
|
||||
def run_migration(self):
|
||||
migration.apply_setting_once(apps, MagicMock(connection=MagicMock(alias="default")))
|
||||
|
||||
def setUp(self):
|
||||
self.with_perm = get_user_model().objects.create_user("with", password="pw")
|
||||
self.without = get_user_model().objects.create_user("without", password="pw")
|
||||
self.without.user_permissions.remove(permission())
|
||||
|
||||
@override_settings(SHOW_PROFILE_EDIT_PASSWORD=True)
|
||||
def test_true_grants_to_everyone(self):
|
||||
self.run_migration()
|
||||
self.assertTrue(can_change(self.with_perm) and can_change(self.without))
|
||||
|
||||
@override_settings(SHOW_PROFILE_EDIT_PASSWORD=False)
|
||||
def test_false_revokes_from_everyone(self):
|
||||
self.run_migration()
|
||||
self.assertFalse(can_change(self.with_perm) or can_change(self.without))
|
||||
|
||||
def test_without_the_setting_nothing_changes(self):
|
||||
with self.settings():
|
||||
from django.conf import settings
|
||||
|
||||
if hasattr(settings, "SHOW_PROFILE_EDIT_PASSWORD"):
|
||||
del settings.SHOW_PROFILE_EDIT_PASSWORD
|
||||
self.run_migration()
|
||||
self.assertTrue(can_change(self.with_perm))
|
||||
self.assertFalse(can_change(self.without))
|
||||
23
accounts/tests/test_checks.py
Normal file
23
accounts/tests/test_checks.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from django.conf import settings
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
|
||||
from accounts.checks import login_required_middleware_check
|
||||
|
||||
OLD = "login_required.middleware.LoginRequiredMiddleware"
|
||||
NEW = "django.contrib.auth.middleware.LoginRequiredMiddleware"
|
||||
|
||||
|
||||
class LoginRequiredMiddlewareCheckTestCase(SimpleTestCase):
|
||||
def ids(self, middleware):
|
||||
with override_settings(MIDDLEWARE=middleware):
|
||||
return [e.id for e in login_required_middleware_check(None)]
|
||||
|
||||
def test_current_settings_pass(self):
|
||||
self.assertEqual(self.ids(settings.MIDDLEWARE), [])
|
||||
|
||||
def test_settings_from_before_django_5_2_are_rejected(self):
|
||||
old = [OLD if m == NEW else m for m in settings.MIDDLEWARE]
|
||||
self.assertEqual(self.ids(old), ["accounts.E002"])
|
||||
|
||||
def test_missing_login_middleware_is_rejected(self):
|
||||
self.assertEqual(self.ids([m for m in settings.MIDDLEWARE if m != NEW]), ["accounts.E002"])
|
||||
80
accounts/tests/test_email_otp.py
Normal file
80
accounts/tests/test_email_otp.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""The "Lost OTP?" page mails a user their OTP QR code without a login. It
|
||||
exists only while OTP is enabled; it resends the QR only to a user who
|
||||
has already enrolled (it never creates a device), mails one exact match at
|
||||
most once per interval, and never fails with an error page."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from django.core import mail
|
||||
from django.core.cache import cache
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from logs.models import Logs
|
||||
|
||||
URL = reverse("accounts:email_otp")
|
||||
|
||||
|
||||
@override_settings(
|
||||
OTP_ENABLED=True,
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache", "LOCATION": "email-otp-tests"}},
|
||||
)
|
||||
class EmailOTPTests(TestCase):
|
||||
def setUp(self):
|
||||
cache.clear()
|
||||
self.user = get_user_model().objects.create_user("otp-user", email="otp.user@example.com", password="x")
|
||||
|
||||
def enroll(self, user=None):
|
||||
return TOTPDevice.objects.create(user=user or self.user, confirmed=True)
|
||||
|
||||
def post(self, email):
|
||||
response = self.client.post(URL, {"email": email})
|
||||
self.assertRedirects(response, reverse("accounts:login"), fetch_redirect_response=False)
|
||||
return response
|
||||
|
||||
def test_one_mail_to_an_enrolled_user(self):
|
||||
self.enroll()
|
||||
self.post("OTP.User@Example.com") # addresses match case-insensitively
|
||||
self.assertEqual([m.to for m in mail.outbox], [["otp.user@example.com"]])
|
||||
# the requester is anonymous: the row does not make it the user
|
||||
self.assertEqual(list(Logs.objects.values_list("user", "message", "success", "ip")),
|
||||
[("", "User otp-user: mail OTP QR code (lost OTP)", True, "127.0.0.1")])
|
||||
|
||||
def test_an_unenrolled_user_gets_no_mail_and_no_device(self):
|
||||
self.post("otp.user@example.com")
|
||||
self.assertEqual(mail.outbox, [])
|
||||
self.assertFalse(TOTPDevice.objects.filter(user=self.user).exists())
|
||||
|
||||
def test_repeated_requests_send_one_mail_per_interval(self):
|
||||
self.enroll()
|
||||
for _ in range(20):
|
||||
self.post("otp.user@example.com")
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
|
||||
def test_an_address_of_two_users_sends_nothing(self):
|
||||
get_user_model().objects.create_user("otp-twin", email="otp.user@example.com", password="x")
|
||||
self.post("otp.user@example.com")
|
||||
self.assertEqual(mail.outbox, [])
|
||||
|
||||
def test_unknown_and_inactive_addresses_send_nothing(self):
|
||||
get_user_model().objects.create_user("otp-gone", email="gone@example.com", password="x", is_active=False)
|
||||
self.post("nobody@example.com")
|
||||
self.post("gone@example.com")
|
||||
self.assertEqual(mail.outbox, [])
|
||||
|
||||
@override_settings(EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend", EMAIL_HOST="127.0.0.1", EMAIL_PORT=1)
|
||||
def test_a_mail_server_that_is_down_is_no_error_page(self):
|
||||
self.enroll()
|
||||
with self.assertLogs("accounts.views", level="ERROR"):
|
||||
self.post("otp.user@example.com")
|
||||
self.assertIs(Logs.objects.get(message__startswith="User otp-user: mail OTP QR code (lost OTP):").success, False)
|
||||
|
||||
|
||||
@override_settings(OTP_ENABLED=False)
|
||||
class EmailOTPDisabledTests(TestCase):
|
||||
def test_the_page_does_not_exist_without_otp(self):
|
||||
get_user_model().objects.create_user("otp-off", email="off@example.com", password="x")
|
||||
self.assertEqual(self.client.get(URL).status_code, 404)
|
||||
self.assertEqual(self.client.post(URL, {"email": "off@example.com"}).status_code, 404)
|
||||
self.assertEqual(mail.outbox, [])
|
||||
155
accounts/tests/test_first_login_password.py
Normal file
155
accounts/tests/test_first_login_password.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""The generated admin password must be replaced at first login."""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from unittest.mock import patch
|
||||
|
||||
from accounts.models import UserAttributes
|
||||
|
||||
GENERATED = "Generated-Pass-123"
|
||||
|
||||
|
||||
class FirstLoginPasswordChangeTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.tmp.cleanup)
|
||||
self.password_file = Path(self.tmp.name) / "data" / "admin_password"
|
||||
self.password_file.parent.mkdir()
|
||||
self.password_file.write_text(GENERATED + "\n")
|
||||
settings_override = override_settings(BASE_DIR=Path(self.tmp.name))
|
||||
settings_override.enable()
|
||||
self.addCleanup(settings_override.disable)
|
||||
|
||||
self.admin = get_user_model().objects.create_superuser(
|
||||
username="firstadmin", email="firstadmin@example.com", password=GENERATED
|
||||
)
|
||||
UserAttributes.objects.create(user=self.admin, must_change_password=True)
|
||||
|
||||
def test_login_with_generated_password_redirects_every_page_to_change_form(self):
|
||||
self.client.login(request=RequestFactory().get("/"), username="firstadmin", password=GENERATED)
|
||||
for url in (reverse("index"), reverse("instances:index"), reverse("accounts:profile")):
|
||||
with self.subTest(url=url):
|
||||
response = self.client.get(url)
|
||||
self.assertRedirects(
|
||||
response, reverse("accounts:change_password"), fetch_redirect_response=False
|
||||
)
|
||||
self.assertEqual(self.client.get(reverse("accounts:change_password")).status_code, 200)
|
||||
|
||||
def test_an_api_request_of_the_session_gets_json_not_the_form(self):
|
||||
# as a token of that user does (accounts.auth)
|
||||
self.client.login(request=RequestFactory().get("/"), username="firstadmin", password=GENERATED)
|
||||
response = self.client.get("/api/v1/instances/")
|
||||
self.assertEqual((response.status_code, response.json()), (403, {"detail": "Change your password before using the API."}))
|
||||
|
||||
def test_changing_the_password_lifts_the_redirect_and_deletes_the_file(self):
|
||||
self.client.login(request=RequestFactory().get("/"), username="firstadmin", password=GENERATED)
|
||||
self.client.post(
|
||||
reverse("accounts:change_password"),
|
||||
{
|
||||
"old_password": GENERATED,
|
||||
"new_password1": "A-new-strong-pass-456",
|
||||
"new_password2": "A-new-strong-pass-456",
|
||||
},
|
||||
)
|
||||
self.assertFalse(self.password_file.exists())
|
||||
self.assertFalse(UserAttributes.objects.get(user=self.admin).must_change_password)
|
||||
response = self.client.get(reverse("instances:index"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_user_with_another_password_is_not_forced(self):
|
||||
get_user_model().objects.create_superuser(
|
||||
username="otheradmin", email="otheradmin@example.com", password="Other-pass-789"
|
||||
)
|
||||
self.client.login(request=RequestFactory().get("/"), username="otheradmin", password="Other-pass-789")
|
||||
self.assertEqual(self.client.get(reverse("instances:index")).status_code, 200)
|
||||
self.assertTrue(self.password_file.exists())
|
||||
|
||||
def test_missing_file_and_a_new_session_do_not_bypass_enforcement(self):
|
||||
self.password_file.unlink()
|
||||
self.client.force_login(self.admin)
|
||||
self.assertRedirects(
|
||||
self.client.get(reverse("accounts:profile")),
|
||||
reverse("accounts:change_password"), fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
def test_unreadable_password_file_is_not_used_during_login(self):
|
||||
with patch("accounts.apps.admin_password_path", side_effect=AssertionError("must not read file")):
|
||||
self.client.login(request=RequestFactory().get("/"), username="firstadmin", password=GENERATED)
|
||||
self.assertRedirects(
|
||||
self.client.get(reverse("accounts:profile")),
|
||||
reverse("accounts:change_password"), fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
def test_cleanup_failure_does_not_undo_a_successful_password_change(self):
|
||||
self.client.force_login(self.admin)
|
||||
with patch("accounts.views.os.remove", side_effect=PermissionError("read-only")):
|
||||
response = self.client.post(reverse("accounts:change_password"), {
|
||||
"old_password": GENERATED, "new_password1": "A-new-strong-pass-456",
|
||||
"new_password2": "A-new-strong-pass-456",
|
||||
})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertFalse(UserAttributes.objects.get(user=self.admin).must_change_password)
|
||||
self.admin.refresh_from_db()
|
||||
self.assertTrue(self.admin.check_password("A-new-strong-pass-456"))
|
||||
self.assertEqual(self.client.get(reverse("accounts:profile")).status_code, 200)
|
||||
|
||||
def test_missing_middleware_is_reported_by_the_upgrade_check(self):
|
||||
from accounts.checks import password_change_middleware_check
|
||||
with override_settings(MIDDLEWARE=["django.contrib.auth.middleware.AuthenticationMiddleware"]):
|
||||
errors = password_change_middleware_check(None)
|
||||
self.assertEqual([error.id for error in errors], ["accounts.E001"])
|
||||
|
||||
def test_legacy_migration_flags_only_the_matching_account(self):
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from django.apps import apps
|
||||
from django.db import connection
|
||||
migration = importlib.import_module("accounts.migrations.0007_userattributes_must_change_password")
|
||||
UserAttributes.objects.filter(user=self.admin).update(must_change_password=False)
|
||||
other = get_user_model().objects.create_superuser("legacy_other", "", "other-password")
|
||||
migration.flag_legacy_generated_password(apps, SimpleNamespace(connection=connection))
|
||||
self.assertTrue(UserAttributes.objects.get(user=self.admin).must_change_password)
|
||||
self.assertFalse(UserAttributes.objects.filter(user=other, must_change_password=True).exists())
|
||||
|
||||
def test_legacy_migration_fails_if_password_file_cannot_be_read(self):
|
||||
import importlib
|
||||
migration = importlib.import_module("accounts.migrations.0007_userattributes_must_change_password")
|
||||
with patch("pathlib.Path.read_text", side_effect=PermissionError("unreadable")):
|
||||
with self.assertRaisesRegex(RuntimeError, "run migrate as its owner"):
|
||||
migration.flag_legacy_generated_password(None, None)
|
||||
|
||||
|
||||
class DefaultAdminPasswordMigrationTestCase(TestCase):
|
||||
"""An installation of a version up to v2.0.0 still signed in as admin/admin."""
|
||||
|
||||
def admin_with(self, password):
|
||||
admin, _ = get_user_model().objects.get_or_create(username="admin", defaults={"is_superuser": True})
|
||||
admin.set_password(password)
|
||||
admin.save()
|
||||
UserAttributes.objects.filter(user=admin).delete() # the migration also makes them
|
||||
return admin
|
||||
|
||||
def migrate(self):
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
from django.apps import apps
|
||||
from django.db import connection
|
||||
migration = importlib.import_module("accounts.migrations.0013_default_admin_password")
|
||||
migration.flag_default_admin_password(apps, SimpleNamespace(connection=connection))
|
||||
|
||||
def test_the_default_admin_password_must_be_changed(self):
|
||||
admin = self.admin_with("admin")
|
||||
self.migrate()
|
||||
self.assertTrue(UserAttributes.objects.get(user=admin).must_change_password)
|
||||
self.client.force_login(admin)
|
||||
self.assertRedirects(self.client.get(reverse("instances:index")), reverse("accounts:change_password"),
|
||||
fetch_redirect_response=False)
|
||||
|
||||
def test_a_changed_admin_password_is_left_alone(self):
|
||||
admin = self.admin_with("Changed-pw-2026")
|
||||
UserAttributes.objects.create(user=admin)
|
||||
self.migrate()
|
||||
self.assertFalse(UserAttributes.objects.get(user=admin).must_change_password)
|
||||
20
accounts/tests/test_ldap_timeouts.py
Normal file
20
accounts/tests/test_ldap_timeouts.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
"""A login through LDAP waits a bounded time for the directory."""
|
||||
|
||||
import unittest
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
try:
|
||||
import ldap
|
||||
from django_auth_ldap.backend import LDAPBackend
|
||||
except ImportError: # LDAP is optional (conf/requirements-ldap.txt)
|
||||
ldap = None
|
||||
|
||||
|
||||
@unittest.skipIf(ldap is None, "python-ldap is not installed")
|
||||
class LdapTimeoutTests(SimpleTestCase):
|
||||
def test_the_connection_and_each_answer_have_a_timeout(self):
|
||||
# python-ldap's default for both is to wait without end
|
||||
options = LDAPBackend().settings.CONNECTION_OPTIONS
|
||||
self.assertGreater(options.get(ldap.OPT_NETWORK_TIMEOUT, -1), 0)
|
||||
self.assertGreater(options.get(ldap.OPT_TIMEOUT, -1), 0)
|
||||
99
accounts/tests/test_login_lockout.py
Normal file
99
accounts/tests/test_login_lockout.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
"""Failed logins lock a username for the address it is tried from
|
||||
(django-axes). The number of attempts and the lock time come from the
|
||||
Settings page."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from appsettings.models import AppSettings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from accounts.lockout import client_ip, cooloff_time, failure_limit
|
||||
|
||||
|
||||
@override_settings(OTP_ENABLED=False)
|
||||
class LoginLockoutTests(TestCase):
|
||||
def setUp(self):
|
||||
get_user_model().objects.create_user("lo-user", password="right-pass-1")
|
||||
AppSettings.objects.filter(key="LOGIN_FAILURE_LIMIT").update(value="3")
|
||||
|
||||
def login(self, password, ip="203.0.113.10", via_proxy=True):
|
||||
meta = {"REMOTE_ADDR": "127.0.0.1", "HTTP_X_REAL_IP": ip} if via_proxy else {"REMOTE_ADDR": ip}
|
||||
return self.client.post(reverse("accounts:login"), {"username": "lo-user", "password": password}, **meta)
|
||||
|
||||
def test_the_limit_from_the_settings_page_locks_the_user_at_that_address(self):
|
||||
for _ in range(3):
|
||||
self.login("wrong")
|
||||
self.assertEqual(self.login("right-pass-1").status_code, 429)
|
||||
# another address is not locked: an attacker cannot lock someone out
|
||||
self.assertEqual(self.login("right-pass-1", ip="198.51.100.7").status_code, 302)
|
||||
|
||||
def test_a_successful_login_starts_the_count_again(self):
|
||||
self.login("wrong")
|
||||
self.login("wrong")
|
||||
self.assertEqual(self.login("right-pass-1").status_code, 302)
|
||||
self.client.logout()
|
||||
self.login("wrong")
|
||||
self.login("wrong")
|
||||
self.assertEqual(self.login("right-pass-1").status_code, 302)
|
||||
|
||||
def test_a_successful_login_is_logged_on_the_logs_page_only(self):
|
||||
from axes.models import AccessLog
|
||||
from logs.models import Logs
|
||||
|
||||
self.assertEqual(self.login("right-pass-1").status_code, 302)
|
||||
self.assertTrue(Logs.objects.filter(user="lo-user").exists())
|
||||
self.assertFalse(AccessLog.objects.exists())
|
||||
|
||||
def test_the_proxy_header_is_trusted_only_from_the_local_proxy(self):
|
||||
# direct requests: X-Real-IP is ignored, so changing it does not escape the lock
|
||||
for n in range(3):
|
||||
self.client.post(reverse("accounts:login"), {"username": "lo-user", "password": "wrong"},
|
||||
REMOTE_ADDR="203.0.113.20", HTTP_X_REAL_IP=f"10.0.0.{n}")
|
||||
response = self.client.post(reverse("accounts:login"), {"username": "lo-user", "password": "right-pass-1"},
|
||||
REMOTE_ADDR="203.0.113.20", HTTP_X_REAL_IP="10.0.0.99")
|
||||
self.assertEqual(response.status_code, 429)
|
||||
|
||||
|
||||
class LockoutSettingsTests(TestCase):
|
||||
def request(self, remote, real_ip=None):
|
||||
meta = {"REMOTE_ADDR": remote}
|
||||
if real_ip:
|
||||
meta["HTTP_X_REAL_IP"] = real_ip
|
||||
return RequestFactory().post("/", **meta)
|
||||
|
||||
def test_values_from_the_settings_page(self):
|
||||
AppSettings.objects.filter(key="LOGIN_FAILURE_LIMIT").update(value="7")
|
||||
AppSettings.objects.filter(key="LOGIN_LOCKOUT_MINUTES").update(value="30")
|
||||
self.assertEqual(failure_limit(None, {}), 7)
|
||||
self.assertEqual(cooloff_time(None), timedelta(minutes=30))
|
||||
|
||||
def test_defaults_and_invalid_values(self):
|
||||
for value in ("", "abc", "0", "-3"):
|
||||
with self.subTest(value=value):
|
||||
AppSettings.objects.filter(key__in=["LOGIN_FAILURE_LIMIT", "LOGIN_LOCKOUT_MINUTES"]).update(value=value)
|
||||
self.assertEqual(failure_limit(None, {}), 5)
|
||||
self.assertEqual(cooloff_time(None), timedelta(minutes=15))
|
||||
|
||||
def test_client_ip(self):
|
||||
self.assertEqual(client_ip(self.request("127.0.0.1", "203.0.113.5")), "203.0.113.5")
|
||||
self.assertEqual(client_ip(self.request("::1", "203.0.113.5")), "203.0.113.5")
|
||||
self.assertEqual(client_ip(self.request("127.0.0.1")), "127.0.0.1")
|
||||
self.assertEqual(client_ip(self.request("198.51.100.2", "203.0.113.5")), "198.51.100.2")
|
||||
|
||||
def test_client_ip_behind_a_configured_proxy(self):
|
||||
with self.settings(LOGIN_TRUSTED_PROXIES=["172.18.0.0/16", "10.0.0.5"]):
|
||||
self.assertEqual(client_ip(self.request("172.18.3.4", "203.0.113.5")), "203.0.113.5")
|
||||
self.assertEqual(client_ip(self.request("10.0.0.5", "203.0.113.6")), "203.0.113.6")
|
||||
self.assertEqual(client_ip(self.request("10.0.0.6", "203.0.113.6")), "10.0.0.6")
|
||||
|
||||
|
||||
class SettingsPageTests(TestCase):
|
||||
def test_both_values_are_on_the_settings_page(self):
|
||||
self.client.force_login(get_user_model().objects.create_superuser("lo-admin", "lo@example.com", "pw"))
|
||||
page = self.client.get(reverse("appsettings")).content.decode()
|
||||
self.assertIn('name="LOGIN_FAILURE_LIMIT"', page)
|
||||
self.assertIn('name="LOGIN_LOCKOUT_MINUTES"', page)
|
||||
self.client.post(reverse("appsettings"), {"LOGIN_LOCKOUT_MINUTES": "45"})
|
||||
self.assertEqual(cooloff_time(None), timedelta(minutes=45))
|
||||
181
accounts/tests/test_otp_login.py
Normal file
181
accounts/tests/test_otp_login.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
"""OTP login (SingleDeviceOTPForm): a user with a confirmed TOTP device must
|
||||
enter a valid code; a user without one logs in with the password alone. The
|
||||
device is chosen by the server (a posted device id is ignored), so the single
|
||||
token field of the login page works and no one else's device can be used."""
|
||||
|
||||
import importlib
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from django.urls import clear_url_caches
|
||||
from django_otp.oath import totp
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
|
||||
import accounts.urls
|
||||
import webvirtcloud.urls
|
||||
from accounts.otp import SingleDeviceOTPForm
|
||||
|
||||
|
||||
def token_for(device, offset=0):
|
||||
return format(
|
||||
totp(device.bin_key, step=device.step, t0=device.t0, digits=device.digits, drift=offset),
|
||||
"0{}d".format(device.digits),
|
||||
)
|
||||
|
||||
|
||||
def wrong_token(device):
|
||||
"""A code that is invalid now: it differs from the tokens of the current
|
||||
step and its neighbours (which TOTP accepts within its tolerance), so the
|
||||
test never flakes on a code that happens to match."""
|
||||
valid = {token_for(device, d) for d in (-1, 0, 1)}
|
||||
return next("%06d" % i for i in range(1000000) if "%06d" % i not in valid)
|
||||
|
||||
|
||||
class SingleDeviceOTPFormTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.User = get_user_model()
|
||||
self.user = self.User.objects.create_user("alice", password="pw-correct-horse")
|
||||
|
||||
def _form(self, **extra):
|
||||
data = {"username": "alice", "password": "pw-correct-horse", **extra}
|
||||
return SingleDeviceOTPForm(RequestFactory().post("/accounts/login/"), data=data)
|
||||
|
||||
def _device(self, user=None, confirmed=True):
|
||||
return TOTPDevice.objects.create(user=user or self.user, confirmed=confirmed)
|
||||
|
||||
def test_user_without_device_logs_in_with_password_only(self):
|
||||
self.assertTrue(self._form(otp_token="").is_valid())
|
||||
|
||||
def test_user_with_device_needs_a_token(self):
|
||||
self._device()
|
||||
form = self._form(otp_token="")
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
def test_user_with_device_valid_token(self):
|
||||
device = self._device()
|
||||
self.assertTrue(self._form(otp_token=token_for(device)).is_valid())
|
||||
|
||||
def test_user_with_device_invalid_token(self):
|
||||
device = self._device()
|
||||
self.assertFalse(self._form(otp_token=wrong_token(device)).is_valid())
|
||||
|
||||
def test_single_token_field_without_device_id(self):
|
||||
# the login page posts only otp_token; the form must still find the device
|
||||
device = self._device()
|
||||
form = self._form(otp_token=token_for(device))
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
|
||||
def test_an_unconfirmed_device_does_not_require_otp(self):
|
||||
self._device(confirmed=False)
|
||||
self.assertTrue(self._form(otp_token="").is_valid())
|
||||
|
||||
def test_another_users_device_is_not_used(self):
|
||||
other = self.User.objects.create_user("mallory", password="x")
|
||||
other_device = self._device(user=other)
|
||||
# alice has no device of her own: a token from mallory's device is ignored
|
||||
self.assertTrue(self._form(otp_token=token_for(other_device)).is_valid())
|
||||
|
||||
def test_a_posted_device_id_is_ignored(self):
|
||||
other = self.User.objects.create_user("mallory", password="x")
|
||||
alice_device = self._device()
|
||||
other_device = self._device(user=other)
|
||||
form = self._form(otp_token=token_for(alice_device), otp_device=other_device.persistent_id)
|
||||
self.assertTrue(form.is_valid(), form.errors)
|
||||
|
||||
def test_token_cannot_be_replayed(self):
|
||||
device = self._device()
|
||||
code = token_for(device)
|
||||
self.assertTrue(self._form(otp_token=code).is_valid())
|
||||
self.assertFalse(self._form(otp_token=code).is_valid())
|
||||
|
||||
|
||||
def reload_urls():
|
||||
importlib.reload(accounts.urls)
|
||||
importlib.reload(webvirtcloud.urls)
|
||||
clear_url_caches()
|
||||
|
||||
|
||||
@override_settings(OTP_ENABLED=True)
|
||||
class OTPLoginHTTPTestCase(TestCase):
|
||||
"""The actual login page (OTP_ENABLED): the single token field logs an
|
||||
enrolled user in, and a user without a device needs only the password.
|
||||
Reloads the URLconf because accounts/urls.py picks the login form from
|
||||
OTP_ENABLED at import time."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# class cleanups run last first: the URLs reload after the setting is off again
|
||||
cls.addClassCleanup(reload_urls)
|
||||
super().setUpClass() # turns OTP_ENABLED on
|
||||
reload_urls()
|
||||
|
||||
def setUp(self):
|
||||
self.user = get_user_model().objects.create_user("bob", password="pw-correct-horse")
|
||||
|
||||
def _post(self, **extra):
|
||||
from django.urls import reverse
|
||||
return self.client.post(reverse("accounts:login"), {
|
||||
"username": "bob", "password": "pw-correct-horse", **extra,
|
||||
})
|
||||
|
||||
def test_user_without_device_logs_in(self):
|
||||
response = self._post(otp_token="")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_enrolled_user_logs_in_with_only_the_token(self):
|
||||
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||
response = self._post(otp_token=token_for(device))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_enrolled_user_wrong_token_stays(self):
|
||||
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||
response = self._post(otp_token=wrong_token(device))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def messages(self):
|
||||
from logs.models import Logs
|
||||
return list(Logs.objects.order_by("id").values_list("message", flat=True))
|
||||
|
||||
def test_a_wrong_otp_code_is_a_failed_login(self):
|
||||
from axes.models import AccessAttempt
|
||||
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||
self._post(otp_token=wrong_token(device))
|
||||
self.assertEqual(self.messages(), ["Login failed"])
|
||||
self.assertEqual(AccessAttempt.objects.get(username="bob").failures_since_start, 1)
|
||||
|
||||
def test_wrong_otp_codes_reach_the_lockout(self):
|
||||
from appsettings.models import AppSettings
|
||||
AppSettings.objects.filter(key="LOGIN_FAILURE_LIMIT").update(value="3")
|
||||
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||
self._post(otp_token=wrong_token(device))
|
||||
self._post(otp_token=wrong_token(device))
|
||||
self.assertEqual(self._post(otp_token=wrong_token(device)).status_code, 429)
|
||||
self.assertEqual(self.messages(), ["Login failed", "Login failed", "Locked out", "Login failed"])
|
||||
|
||||
def test_a_right_code_logs_in(self):
|
||||
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||
self._post(otp_token=token_for(device))
|
||||
self.assertEqual(self.messages(), ["Login"])
|
||||
|
||||
def test_an_oversized_username_is_refused_by_the_form(self):
|
||||
response = self._post(username="x" * 300, otp_token="")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("username", response.context["form"].errors)
|
||||
self.assertEqual(self.messages(), [])
|
||||
|
||||
def test_a_signed_in_user_is_sent_on_from_the_login_page(self):
|
||||
from django.urls import reverse
|
||||
self.client.force_login(self.user)
|
||||
response = self.client.get(f"{reverse('accounts:login')}?next={reverse('computes')}")
|
||||
self.assertRedirects(response, reverse("computes"), fetch_redirect_response=False)
|
||||
|
||||
|
||||
class OtpLoginPageTestCase(TestCase):
|
||||
def test_a_field_error_is_shown_in_the_error_box(self):
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
request = RequestFactory().post("/accounts/login/")
|
||||
form = SingleDeviceOTPForm(request, data={"username": "u" * 200, "password": "pw"})
|
||||
self.assertFalse(form.is_valid())
|
||||
page = render_to_string("accounts/otp_login.html", {"form": form}, request=request)
|
||||
self.assertIn(str(form.errors["username"][0]), page)
|
||||
35
accounts/tests/test_ssh_keys.py
Normal file
35
accounts/tests/test_ssh_keys.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""A user's SSH keys: they go with the user, and a key is valid with or without a comment."""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase
|
||||
|
||||
from accounts.models import UserSSHKey
|
||||
from accounts.utils import validate_ssh_key
|
||||
|
||||
|
||||
class SshKeyTestCase(TestCase):
|
||||
def test_ssh_key_cascade_deletion(self):
|
||||
user = get_user_model().objects.create_user(username="sec_acc_user", password="password")
|
||||
key = UserSSHKey.objects.create(
|
||||
user=user,
|
||||
keyname="my-key",
|
||||
keypublic="ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGabcdef123456 test@example.com",
|
||||
)
|
||||
self.assertTrue(UserSSHKey.objects.filter(id=key.id).exists())
|
||||
|
||||
# When user is deleted, key should be deleted via CASCADE (not left orphan)
|
||||
user.delete()
|
||||
self.assertFalse(UserSSHKey.objects.filter(id=key.id).exists())
|
||||
|
||||
def test_validate_ssh_key_supports_keys_with_and_without_comment(self):
|
||||
rsa_base = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfxYHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw=="
|
||||
# Key with comment
|
||||
key_with_comment = f"{rsa_base} test@test"
|
||||
self.assertTrue(validate_ssh_key(key_with_comment))
|
||||
|
||||
# Key without comment
|
||||
key_without_comment = rsa_base
|
||||
self.assertTrue(validate_ssh_key(key_without_comment))
|
||||
|
||||
# Invalid key
|
||||
self.assertFalse(validate_ssh_key("not a valid key string"))
|
||||
31
accounts/tests/test_unique_email.py
Normal file
31
accounts/tests/test_unique_email.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""An email address belongs to one user: "Lost OTP?" mails only an exact,
|
||||
single match, so a second user with the same address would switch it off for both."""
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from accounts.forms import ProfileForm
|
||||
from admin.forms import UserForm
|
||||
|
||||
|
||||
class UniqueEmailTests(TestCase):
|
||||
def setUp(self):
|
||||
self.alice = User.objects.create_user("alice", "alice@example.com", "pw")
|
||||
self.bob = User.objects.create_user("bob", "", "pw")
|
||||
|
||||
def test_the_profile_refuses_another_users_address(self):
|
||||
self.client.force_login(self.bob)
|
||||
response = self.client.post(reverse("accounts:profile"), {"email": "Alice@Example.com"})
|
||||
self.assertContains(response, "Another user has this email address")
|
||||
self.bob.refresh_from_db()
|
||||
self.assertEqual(self.bob.email, "")
|
||||
|
||||
def test_the_admin_form_refuses_another_users_address(self):
|
||||
form = UserForm({"username": "bob", "email": "alice@example.com", "is_active": "on"}, instance=self.bob)
|
||||
self.assertEqual(form.errors["email"], ["Another user has this email address"])
|
||||
|
||||
def test_own_address_and_no_address_are_kept(self):
|
||||
User.objects.create_user("carol", "", "pw")
|
||||
self.assertTrue(ProfileForm({"email": ""}, instance=self.bob).is_valid())
|
||||
self.assertTrue(ProfileForm({"email": "alice@example.com"}, instance=self.alice).is_valid())
|
||||
119
accounts/tests/test_utils.py
Normal file
119
accounts/tests/test_utils.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import re
|
||||
import struct
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import mail
|
||||
from django.test import RequestFactory, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
from lxml import html
|
||||
|
||||
from accounts.utils import (OTP_LINK, OTP_LINK_MAX_AGE, get_existing_totp_device, get_user_totp_device, otp_qr_svg,
|
||||
send_email_with_otp, validate_ssh_key)
|
||||
|
||||
|
||||
class AccountsUtilsTestCase(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.user = User.objects.create_user(
|
||||
username="test_otp_user", email="user@example.com", password="password123"
|
||||
)
|
||||
|
||||
def test_validate_ssh_key_valid(self):
|
||||
blob = (
|
||||
struct.pack(">I", 7)
|
||||
+ b"ssh-rsa"
|
||||
+ struct.pack(">I", 3)
|
||||
+ b"\x01\x00\x01"
|
||||
+ struct.pack(">I", 8)
|
||||
+ b"\x00" * 8
|
||||
)
|
||||
b64 = base64.b64encode(blob).decode()
|
||||
valid_key = f"ssh-rsa {b64} user@example.com"
|
||||
self.assertTrue(validate_ssh_key(valid_key))
|
||||
# ssh-keygen -C "John Doe laptop": the comment holds spaces
|
||||
self.assertTrue(validate_ssh_key(f"ssh-rsa {b64} John Doe laptop"))
|
||||
self.assertTrue(validate_ssh_key(f"ssh-rsa {b64}"))
|
||||
|
||||
def test_validate_ssh_key_invalid_parts(self):
|
||||
self.assertFalse(validate_ssh_key("only_two parts"))
|
||||
self.assertFalse(validate_ssh_key("one_part"))
|
||||
|
||||
def test_validate_ssh_key_invalid_base64(self):
|
||||
self.assertFalse(validate_ssh_key("ssh-rsa not-valid-b64! user@example.com"))
|
||||
|
||||
def test_validate_ssh_key_struct_error(self):
|
||||
# Base64 with less than 4 bytes cannot unpack length
|
||||
short_b64 = base64.b64encode(b"ab").decode()
|
||||
self.assertFalse(validate_ssh_key(f"ssh-rsa {short_b64} user@example.com"))
|
||||
|
||||
def test_validate_ssh_key_type_mismatch(self):
|
||||
blob = struct.pack(">I", 7) + b"ssh-rsa" + b"\x00" * 8
|
||||
b64 = base64.b64encode(blob).decode()
|
||||
# header says ssh-ed25519 but payload has ssh-rsa
|
||||
self.assertFalse(validate_ssh_key(f"ssh-ed25519 {b64} user@example.com"))
|
||||
|
||||
def test_get_user_totp_device_creates_new(self):
|
||||
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 0)
|
||||
device = get_user_totp_device(self.user)
|
||||
self.assertIsInstance(device, TOTPDevice)
|
||||
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_get_user_totp_device_retrieves_existing(self):
|
||||
device1 = get_user_totp_device(self.user)
|
||||
device2 = get_user_totp_device(self.user)
|
||||
self.assertEqual(device1.id, device2.id)
|
||||
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 1)
|
||||
|
||||
def test_get_existing_totp_device_returns_none_without_a_device(self):
|
||||
self.assertIsNone(get_existing_totp_device(self.user))
|
||||
self.assertEqual(TOTPDevice.objects.filter(user=self.user).count(), 0)
|
||||
|
||||
def test_get_existing_totp_device_ignores_unconfirmed(self):
|
||||
TOTPDevice.objects.create(user=self.user, confirmed=False)
|
||||
self.assertIsNone(get_existing_totp_device(self.user))
|
||||
|
||||
def test_get_existing_totp_device_returns_confirmed(self):
|
||||
device = TOTPDevice.objects.create(user=self.user, confirmed=True)
|
||||
self.assertEqual(get_existing_totp_device(self.user).id, device.id)
|
||||
|
||||
def test_the_device_is_made_under_a_lock_on_the_user(self):
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
with patch.object(QuerySet, "select_for_update", autospec=True, side_effect=QuerySet.select_for_update) as lock:
|
||||
device = get_user_totp_device(self.user)
|
||||
lock.assert_called_once()
|
||||
self.assertEqual(get_user_totp_device(self.user), device)
|
||||
|
||||
@override_settings(OTP_ENABLED=True)
|
||||
def test_the_mail_links_to_the_qr_code_and_holds_no_secret(self):
|
||||
device = get_user_totp_device(self.user)
|
||||
send_email_with_otp(RequestFactory().get("/"), self.user)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
message = mail.outbox[0]
|
||||
self.assertEqual((message.subject, message.to, message.alternatives), ("OTP QR Code", ["user@example.com"], []))
|
||||
self.assertNotIn(device.bin_key.hex(), message.body)
|
||||
self.assertNotIn("otpauth", message.body)
|
||||
link = re.search(r"http://testserver(/\S+)", message.body).group(1)
|
||||
page = self.client.get(link)
|
||||
self.assertEqual(page["Cache-Control"], "no-store")
|
||||
self.assertEqual(len(html.fromstring(page.content).xpath("//svg")), 1)
|
||||
|
||||
@override_settings(OTP_ENABLED=True)
|
||||
def test_a_link_that_is_old_or_not_signed_shows_nothing(self):
|
||||
get_user_totp_device(self.user)
|
||||
token = OTP_LINK.sign(str(self.user.pk))
|
||||
with patch("django.core.signing.time.time", return_value=time.time() + OTP_LINK_MAX_AGE + 1):
|
||||
self.assertEqual(self.client.get(reverse("accounts:otp_qr", args=[token])).status_code, 404)
|
||||
self.assertEqual(self.client.get(reverse("accounts:otp_qr", args=[f"{self.user.pk}:x:y"])).status_code, 404)
|
||||
|
||||
def test_the_qr_code_is_what_django_qr_code_drew(self):
|
||||
# sha256 of the SVG django-qr-code 4.2.0 made for this URL
|
||||
url = "otpauth://totp/WebVirtCloud:alice?secret=JBSWY3DPEHPK3PXP&issuer=WebVirtCloud"
|
||||
svg = str(otp_qr_svg(SimpleNamespace(config_url=url)))
|
||||
self.assertEqual(hashlib.sha256(svg.encode()).hexdigest(), "b57323be725891af13d862a3a3351cac3ac64a493793c0258cf3e4a5886467c2")
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
from appsettings.models import AppSettings
|
||||
from appsettings.settings import app_settings
|
||||
from computes.models import Compute
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.shortcuts import reverse
|
||||
from django.test import Client, TestCase
|
||||
from django.test import Client, RequestFactory, TestCase, override_settings
|
||||
from instances.models import Instance
|
||||
|
||||
|
||||
|
|
@ -15,12 +16,9 @@ from accounts.utils import validate_ssh_key
|
|||
|
||||
class AccountsTestCase(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
# Add users for testing purposes
|
||||
def setUpTestData(cls):
|
||||
User = get_user_model()
|
||||
cls.admin_user = User.objects.get(pk=1)
|
||||
cls.admin_user = User.objects.create_superuser("accounts-admin", "admin@example.com", "pw")
|
||||
cls.test_user = User.objects.create_user(username="test", password="test")
|
||||
|
||||
# Add compute
|
||||
|
|
@ -37,16 +35,8 @@ class AccountsTestCase(TestCase):
|
|||
name="test-vm",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
if getattr(cls, "instance", None):
|
||||
cls.instance.delete()
|
||||
if getattr(cls, "compute", None):
|
||||
cls.compute.delete()
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
self.client.login(username="admin", password="admin")
|
||||
self.client.force_login(self.admin_user)
|
||||
permission = Permission.objects.get(codename="change_password")
|
||||
self.test_user.user_permissions.add(permission)
|
||||
self.rsa_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6OOdbfv27QVnSC6sKxGaHb6YFc+3gxCkyVR3cTSXE/n5BEGf8aOgBpepULWa1RZfxYHY14PlKULDygdXSdrrR2kNSwoKz/Oo4d+3EE92L7ocl1+djZbptzgWgtw1OseLwbFik+iKlIdqPsH+IUQvX7yV545ZQtAP8Qj1R+uCqkw== test@test"
|
||||
|
|
@ -61,8 +51,8 @@ class AccountsTestCase(TestCase):
|
|||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@override_settings(OTP_ENABLED=True)
|
||||
def test_account_with_otp(self):
|
||||
settings.OTP_ENABLED = True
|
||||
response = self.client.get(
|
||||
reverse("accounts:account", args=[self.test_user.id])
|
||||
)
|
||||
|
|
@ -76,9 +66,17 @@ class AccountsTestCase(TestCase):
|
|||
)
|
||||
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL)
|
||||
|
||||
response = client.get(reverse("accounts:logout"))
|
||||
response = client.post(reverse("accounts:logout"))
|
||||
self.assertRedirects(response, reverse("accounts:login"))
|
||||
|
||||
def test_a_signed_in_user_is_sent_on_from_the_login_page(self):
|
||||
self.client.force_login(self.test_user)
|
||||
login = reverse("accounts:login")
|
||||
response = self.client.get(f"{login}?next={reverse('computes')}")
|
||||
self.assertRedirects(response, reverse("computes"), fetch_redirect_response=False)
|
||||
response = self.client.get(f"{login}?next=https://example.com/")
|
||||
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL, fetch_redirect_response=False)
|
||||
|
||||
def test_change_password(self):
|
||||
self.client.force_login(self.test_user)
|
||||
|
||||
|
|
@ -89,8 +87,8 @@ class AccountsTestCase(TestCase):
|
|||
reverse("accounts:change_password"),
|
||||
{
|
||||
"old_password": "wrongpass",
|
||||
"new_password1": "newpw",
|
||||
"new_password2": "newpw",
|
||||
"new_password1": "Wvc-changed-9",
|
||||
"new_password2": "Wvc-changed-9",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
|
@ -99,15 +97,15 @@ class AccountsTestCase(TestCase):
|
|||
reverse("accounts:change_password"),
|
||||
{
|
||||
"old_password": "test",
|
||||
"new_password1": "newpw",
|
||||
"new_password2": "newpw",
|
||||
"new_password1": "Wvc-changed-9",
|
||||
"new_password2": "Wvc-changed-9",
|
||||
},
|
||||
)
|
||||
self.assertRedirects(response, reverse("accounts:profile"))
|
||||
|
||||
self.client.logout()
|
||||
|
||||
logged_in = self.client.login(username="test", password="newpw")
|
||||
logged_in = self.client.login(request=RequestFactory().get("/"), username="test", password="Wvc-changed-9")
|
||||
self.assertTrue(logged_in)
|
||||
|
||||
def test_user_instance_create_update_delete(self):
|
||||
|
|
@ -227,7 +225,7 @@ class AccountsTestCase(TestCase):
|
|||
"keypublic": self.rsa_key,
|
||||
},
|
||||
)
|
||||
self.assertRedirects(response, reverse("accounts:profile"))
|
||||
self.assertRedirects(response, reverse("accounts:profile") + "#sshkeys")
|
||||
|
||||
key = UserSSHKey.objects.get(keyname="keyname")
|
||||
self.assertEqual(key.keyname, "keyname")
|
||||
|
|
@ -237,7 +235,22 @@ class AccountsTestCase(TestCase):
|
|||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse("accounts:ssh_key_delete", args=[key.id]))
|
||||
self.assertRedirects(response, reverse("accounts:profile"))
|
||||
self.assertRedirects(response, reverse("accounts:profile") + "#sshkeys")
|
||||
|
||||
def test_the_profile_lists_keys_and_tokens_where_links_land(self):
|
||||
admin = self.admin_user
|
||||
key = UserSSHKey.objects.create(user=admin, keyname="laptop", keypublic=self.rsa_key)
|
||||
response = self.client.get(reverse("accounts:profile"))
|
||||
# other pages link to #sshkeys and #apitokens
|
||||
self.assertContains(response, 'id="sshkeys"')
|
||||
self.assertContains(response, 'id="apitokens"')
|
||||
self.assertContains(response, f'href="{reverse("accounts:ssh_key_delete", args=[key.id])}"')
|
||||
self.assertContains(response, f'href="{reverse("accounts:change_password")}"')
|
||||
# an LDAP user's password is changed in LDAP
|
||||
admin.set_unusable_password()
|
||||
admin.save()
|
||||
self.client.force_login(admin)
|
||||
self.assertNotContains(self.client.get(reverse("accounts:profile")), f'href="{reverse("accounts:change_password")}"')
|
||||
|
||||
def test_validate_ssh_key(self):
|
||||
self.assertFalse(validate_ssh_key(""))
|
||||
|
|
@ -264,6 +277,7 @@ class AccountsTestCase(TestCase):
|
|||
)
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
self.addCleanup(setattr, app_settings, "ALLOW_INSTANCE_MULTIPLE_OWNER", app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER)
|
||||
app_settings.ALLOW_INSTANCE_MULTIPLE_OWNER = "False"
|
||||
form = UserInstanceForm(
|
||||
{
|
||||
|
|
@ -285,3 +299,58 @@ class AccountsTestCase(TestCase):
|
|||
}
|
||||
)
|
||||
self.assertFalse(form.is_valid())
|
||||
|
||||
|
||||
class SingleOwnerEditTests(TestCase):
|
||||
"""With one owner per VM, the owner's own rights can still be changed."""
|
||||
|
||||
def test_the_only_owner_is_edited(self):
|
||||
AppSettings.objects.filter(key="ALLOW_INSTANCE_MULTIPLE_OWNER").update(value="False")
|
||||
admin = get_user_model().objects.create_superuser("audit-admin", "audit@example.com", "pw")
|
||||
bob = get_user_model().objects.create_user("bob")
|
||||
compute = Compute.objects.create(name="c1", hostname="127.0.0.1", login="", password="", type=4)
|
||||
vm = Instance.objects.create(compute=compute, name="vm", uuid="99999999-9999-9999-9999-999999999999")
|
||||
ui = UserInstance.objects.create(user=bob, instance=vm)
|
||||
self.client.force_login(admin)
|
||||
response = self.client.post(reverse("accounts:user_instance_update", args=[ui.pk]), {"is_vnc": "on"})
|
||||
self.assertRedirects(response, reverse("accounts:account", args=[bob.pk]), fetch_redirect_response=False)
|
||||
ui.refresh_from_db()
|
||||
self.assertTrue(ui.is_vnc)
|
||||
|
||||
|
||||
class OwnerRightsReturnTestCase(TestCase):
|
||||
"""Edit rights on a VM's Users tab comes back to that tab; a page of
|
||||
another site does not count."""
|
||||
|
||||
def test_an_owner_s_rights_come_back_to_the_vm_page(self):
|
||||
admin = get_user_model().objects.create_superuser("rights-admin", "rights@example.com", "x")
|
||||
owner = get_user_model().objects.create_user("rights-owner")
|
||||
compute = Compute.objects.create(name="rights", hostname="192.0.2.30", login="u", password="p", type=1)
|
||||
vm = Instance.objects.create(compute=compute, name="rights-vm", uuid="12121212-0000-0000-0000-000000000000")
|
||||
user_instance = UserInstance.objects.create(user=owner, instance=vm)
|
||||
self.client.force_login(admin)
|
||||
update = reverse("accounts:user_instance_update", args=[user_instance.id])
|
||||
for next_url, back in ((f"/instances/{vm.id}/%23users", f"/instances/{vm.id}/#users"),
|
||||
("https://example.com/", reverse("accounts:account", args=[owner.id]))):
|
||||
with self.subTest(next_url):
|
||||
response = self.client.post(f"{update}?next={next_url}", {"is_change": True})
|
||||
self.assertRedirects(response, back, fetch_redirect_response=False)
|
||||
|
||||
|
||||
class UserInstanceDeleteTestCase(TestCase):
|
||||
def test_open_redirect_prevented_in_user_instance_delete(self):
|
||||
User = get_user_model()
|
||||
admin = User.objects.create_superuser(username="sec_acc_admin", email="admin@example.com", password="password")
|
||||
user = User.objects.create_user(username="sec_acc_user", password="password")
|
||||
compute = Compute.objects.create(name="acc-comp", hostname="127.0.0.1", login="root", type=1)
|
||||
instance = Instance.objects.create(compute=compute, name="acc-vm", uuid="99999999-9999-9999-9999-999999999999")
|
||||
user_inst = UserInstance.objects.create(instance=instance, user=user)
|
||||
self.client.force_login(admin)
|
||||
url = reverse("accounts:user_instance_delete", args=[user_inst.id])
|
||||
|
||||
# Attempt external malicious redirect
|
||||
res = self.client.post(f"{url}?next=https://malicious-attacker.com")
|
||||
self.assertEqual(res.status_code, 302)
|
||||
# Should redirect to safe internal URL (user account page), NOT external site
|
||||
self.assertNotIn("malicious-attacker.com", res.url)
|
||||
self.assertEqual(res.url, reverse("accounts:account", args=[user.id]))
|
||||
|
|
@ -1,17 +1,21 @@
|
|||
from django.conf import settings
|
||||
from django.contrib.auth.views import LoginView, LogoutView
|
||||
from django.urls import path
|
||||
from django_otp.forms import OTPAuthenticationForm
|
||||
|
||||
from . import views
|
||||
from .views import CustomLoginView
|
||||
from .forms import LoginForm
|
||||
from .otp import SingleDeviceOTPForm
|
||||
|
||||
app_name = "accounts"
|
||||
|
||||
urlpatterns = [
|
||||
path("logout/", LogoutView.as_view(template_name="logout.html"), name="logout"),
|
||||
# LOGOUT_REDIRECT_URL sends a signed-out user to the sign-in page
|
||||
path("logout/", LogoutView.as_view(), name="logout"),
|
||||
path("profile/", views.profile, name="profile"),
|
||||
path("profile/<int:user_id>/", views.account, name="account"),
|
||||
path("profile/<int:user_id>/api_token/<str:token_key>/delete/", views.api_token_delete,
|
||||
name="account_api_token_delete"),
|
||||
path("profile/<int:user_id>/ssh_key/<int:pk>/delete/", views.ssh_key_delete, name="account_ssh_key_delete"),
|
||||
path("change_password/", views.change_password, name="change_password"),
|
||||
path(
|
||||
"user_instance/create/<int:user_id>/",
|
||||
|
|
@ -30,7 +34,11 @@ urlpatterns = [
|
|||
),
|
||||
path("ssh_key/create/", views.ssh_key_create, name="ssh_key_create"),
|
||||
path("ssh_key/<int:pk>/delete/", views.ssh_key_delete, name="ssh_key_delete"),
|
||||
path("api_token/create/", views.api_token_create, name="api_token_create"),
|
||||
path("api_token/created/", views.api_token_created, name="api_token_created"),
|
||||
path("api_token/<str:token_key>/delete/", views.api_token_delete, name="api_token_delete"),
|
||||
path("email_otp/", views.email_otp, name="email_otp"),
|
||||
path("otp_qr/<str:token>/", views.otp_qr, name="otp_qr"),
|
||||
path(
|
||||
"admin_email_otp/<int:user_id>/",
|
||||
views.admin_email_otp,
|
||||
|
|
@ -44,12 +52,14 @@ if settings.OTP_ENABLED:
|
|||
"login/",
|
||||
LoginView.as_view(
|
||||
template_name="accounts/otp_login.html",
|
||||
authentication_form=OTPAuthenticationForm,
|
||||
authentication_form=SingleDeviceOTPForm,
|
||||
redirect_authenticated_user=True,
|
||||
),
|
||||
name="login",
|
||||
),
|
||||
]
|
||||
else:
|
||||
urlpatterns += (
|
||||
path("login/", CustomLoginView.as_view(template_name="login.html"), name="login"),
|
||||
path("login/", LoginView.as_view(template_name="login.html", authentication_form=LoginForm,
|
||||
redirect_authenticated_user=True), name="login"),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,29 +1,46 @@
|
|||
import base64
|
||||
import binascii
|
||||
import struct
|
||||
from decimal import Decimal
|
||||
|
||||
import segno
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.core.signing import TimestampSigner
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django_otp import devices_for_user
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
|
||||
|
||||
def get_user_totp_device(user):
|
||||
devices = devices_for_user(user)
|
||||
for device in devices:
|
||||
def get_existing_totp_device(user):
|
||||
"""The user's confirmed TOTP device, or None. Unlike get_user_totp_device
|
||||
it never creates one, so an unauthenticated caller cannot turn a
|
||||
password-only account into an OTP-required one."""
|
||||
for device in devices_for_user(user, confirmed=True):
|
||||
if isinstance(device, TOTPDevice):
|
||||
return device
|
||||
return None
|
||||
|
||||
device = user.totpdevice_set.create()
|
||||
return device
|
||||
|
||||
def get_user_totp_device(user):
|
||||
"""The user's TOTP device, made if they have none. The user's row is
|
||||
locked meanwhile: two requests at once (a double click) make one device,
|
||||
not two that both log in."""
|
||||
with transaction.atomic():
|
||||
get_user_model().objects.select_for_update().get(pk=user.pk)
|
||||
for device in devices_for_user(user):
|
||||
if isinstance(device, TOTPDevice):
|
||||
return device
|
||||
return user.totpdevice_set.create()
|
||||
|
||||
|
||||
def validate_ssh_key(key):
|
||||
array = key.encode().split()
|
||||
# Each rsa-ssh key has 3 different strings in it, first one being
|
||||
# typeofkey second one being keystring third one being username .
|
||||
if len(array) != 3:
|
||||
# An OpenSSH public key is a type, a key and an optional comment, which may hold spaces
|
||||
array = key.strip().encode().split(maxsplit=2)
|
||||
if len(array) < 2:
|
||||
return False
|
||||
typeofkey = array[0]
|
||||
string = array[1]
|
||||
|
|
@ -44,18 +61,26 @@ def validate_ssh_key(key):
|
|||
return True
|
||||
|
||||
|
||||
def send_email_with_otp(user, device):
|
||||
# the link a mail carries to its user's OTP QR code: the mail holds no secret
|
||||
OTP_LINK = TimestampSigner(salt="accounts.otp_qr")
|
||||
OTP_LINK_MAX_AGE = 3600
|
||||
|
||||
|
||||
def send_email_with_otp(request, user):
|
||||
"""Mail the user a link to the page of their OTP QR code, good for
|
||||
OTP_LINK_MAX_AGE: the QR code's secret is never in a mailbox."""
|
||||
url = request.build_absolute_uri(reverse("accounts:otp_qr", args=[OTP_LINK.sign(str(user.pk))]))
|
||||
send_mail(
|
||||
_("OTP QR Code"),
|
||||
_("Please view HTML version of this message."),
|
||||
_("Open this link within an hour to see the OTP QR code of account '%(user)s':\n\n%(url)s")
|
||||
% {"user": user, "url": url},
|
||||
None,
|
||||
[user.email],
|
||||
html_message=render_to_string(
|
||||
"accounts/email/otp.html",
|
||||
{
|
||||
"totp_url": device.config_url,
|
||||
"user": user,
|
||||
},
|
||||
),
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
|
||||
def otp_qr_svg(device):
|
||||
"""The device's QR code as inline SVG: 1.8 mm modules, error correction M, a 4-module border."""
|
||||
qr = segno.make(device.config_url, error="m", mode="byte", encoding="utf-8", micro=False)
|
||||
return mark_safe(qr.svg_inline(scale=Decimal("1.8"), unit="mm", border=4, dark="black", light="white"))
|
||||
|
|
|
|||
|
|
@ -1,42 +1,50 @@
|
|||
import logging
|
||||
import os
|
||||
from datetime import timedelta
|
||||
|
||||
from admin.decorators import superuser_only
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import get_user_model, update_session_auth_hash, login as auth_login
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.contrib.auth import get_user_model, update_session_auth_hash
|
||||
from django.contrib.auth.decorators import login_not_required, login_required
|
||||
from django.contrib.auth.forms import PasswordChangeForm
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.signing import BadSignature
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.core.cache import cache
|
||||
from django.http import Http404
|
||||
from django.utils import timezone
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from instances.models import Instance
|
||||
from django.views.decorators.cache import never_cache
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
|
||||
from accounts.forms import EmailOTPForm, ProfileForm, UserSSHKeyForm
|
||||
from accounts.models import *
|
||||
from accounts.apps import admin_password_path
|
||||
from computes.utils import lock_ownership
|
||||
from logs.audit import audit, audit_vm, changed_fields, failed, snapshot
|
||||
|
||||
from accounts.forms import ApiTokenForm, EmailOTPForm, ProfileForm, UserSSHKeyForm
|
||||
from accounts.models import ApiToken, User, UserAttributes, UserInstance, UserSSHKey
|
||||
|
||||
from . import forms
|
||||
from .utils import get_user_totp_device, send_email_with_otp
|
||||
from django.contrib.auth.views import LoginView
|
||||
from logs.views import addlogmsg
|
||||
from .utils import OTP_LINK, OTP_LINK_MAX_AGE, get_existing_totp_device, get_user_totp_device, otp_qr_svg, send_email_with_otp
|
||||
|
||||
class CustomLoginView(LoginView):
|
||||
def form_valid(self, form):
|
||||
username = form.cleaned_data['username']
|
||||
addlogmsg(username, "-", "-", "Logged In")
|
||||
auth_login(self.request, form.get_user())
|
||||
return HttpResponseRedirect(self.get_success_url())
|
||||
|
||||
def form_invalid(self, form):
|
||||
username = form.cleaned_data['username']
|
||||
addlogmsg(username, "-", "-", "Failed Login Attempt")
|
||||
return self.render_to_response(self.get_context_data(form=form))
|
||||
|
||||
def profile(request):
|
||||
publickeys = UserSSHKey.objects.filter(user_id=request.user.id)
|
||||
before = snapshot(request.user)
|
||||
profile_form = ProfileForm(request.POST or None, instance=request.user)
|
||||
ssh_key_form = UserSSHKeyForm()
|
||||
|
||||
if profile_form.is_valid():
|
||||
profile_form.save()
|
||||
with audit(request, None, f"User {request.user.username}", "change") as entry:
|
||||
profile_form.save()
|
||||
fields = changed_fields(before, snapshot(request.user))
|
||||
entry.action = "change " + ", ".join(fields)
|
||||
entry.skip = not fields
|
||||
messages.success(request, _("Profile updated"))
|
||||
return redirect("accounts:profile")
|
||||
|
||||
|
|
@ -47,6 +55,75 @@ def profile(request):
|
|||
"publickeys": publickeys,
|
||||
"profile_form": profile_form,
|
||||
"ssh_key_form": ssh_key_form,
|
||||
"api_tokens": ApiToken.objects.filter(user=request.user),
|
||||
"api_token_form": ApiTokenForm(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
@never_cache
|
||||
def api_token_create(request):
|
||||
"""A REST API token, shown once: only its digest is kept. It is made in
|
||||
a session that passed the login, and its OTP when OTP is on."""
|
||||
form = ApiTokenForm(request.POST)
|
||||
if not form.is_valid():
|
||||
return render(request, "common/form.html", {"form": form, "title": _("Create an API token")})
|
||||
name, days = form.cleaned_data["name"], form.cleaned_data["days"]
|
||||
with transaction.atomic():
|
||||
# the user's row: two forms sent at once (a double click) take their turns
|
||||
get_user_model().objects.select_for_update().get(pk=request.user.pk)
|
||||
ApiToken.objects.filter(user=request.user, expiry__lt=timezone.now()).delete()
|
||||
if ApiToken.objects.filter(user=request.user).count() >= ApiToken.LIMIT:
|
||||
form.add_error(None, _("A user has at most %(limit)s API tokens: delete one first.") % {"limit": ApiToken.LIMIT})
|
||||
elif ApiToken.objects.filter(user=request.user, name=name).exists():
|
||||
form.add_error("name", _("You have a token named %(name)s: choose another name.") % {"name": name})
|
||||
else:
|
||||
with audit(request, None, f"API token {name}", "add"):
|
||||
_token, key = ApiToken.objects.create(request.user, expiry=timedelta(days=days) if days else None,
|
||||
name=name)
|
||||
if form.errors:
|
||||
return render(request, "common/form.html", {"form": form, "title": _("Create an API token")})
|
||||
# shown by a GET, so reloading the page does not create another token
|
||||
request.session["api_token_created"] = {"name": name, "key": key}
|
||||
return redirect("accounts:api_token_created")
|
||||
|
||||
|
||||
@never_cache
|
||||
def api_token_created(request):
|
||||
"""The token just created, shown once."""
|
||||
created = request.session.pop("api_token_created", None)
|
||||
if created is None:
|
||||
return redirect(reverse("accounts:profile") + "#apitokens")
|
||||
return render(request, "api_token_created.html", created)
|
||||
|
||||
|
||||
def api_token_delete(request, token_key, user_id=None):
|
||||
"""Delete one of the user's own tokens or, given user_id, a superuser
|
||||
deletes one of that user's."""
|
||||
if user_id is None:
|
||||
token = get_object_or_404(ApiToken, token_key=token_key, user=request.user)
|
||||
subject = f"API token {token.name}"
|
||||
back = reverse("accounts:profile")
|
||||
elif request.user.is_superuser:
|
||||
token = get_object_or_404(ApiToken, token_key=token_key, user_id=user_id)
|
||||
subject = f"API token {token.name} of {token.user.username}"
|
||||
back = reverse("accounts:account", args=[user_id])
|
||||
else:
|
||||
raise PermissionDenied
|
||||
if request.method == "POST":
|
||||
with audit(request, None, subject, "delete"):
|
||||
token.delete()
|
||||
messages.success(request, _("API token deleted"))
|
||||
return redirect(back + "#apitokens")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"common/confirm_delete.html",
|
||||
{
|
||||
"object": subject,
|
||||
"title": _("Delete API token"),
|
||||
"consequence": _("Scripts and tools that use it can no longer reach the API."),
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -54,9 +131,10 @@ def profile(request):
|
|||
def ssh_key_create(request):
|
||||
key_form = UserSSHKeyForm(request.POST or None, user=request.user)
|
||||
if key_form.is_valid():
|
||||
key_form.save()
|
||||
with audit(request, None, f"SSH key {key_form.cleaned_data['keyname']}", "add"):
|
||||
key_form.save()
|
||||
messages.success(request, _("SSH key added"))
|
||||
return redirect("accounts:profile")
|
||||
return redirect(reverse("accounts:profile") + "#sshkeys")
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
|
@ -68,28 +146,40 @@ def ssh_key_create(request):
|
|||
)
|
||||
|
||||
|
||||
def ssh_key_delete(request, pk):
|
||||
ssh_key = get_object_or_404(UserSSHKey, pk=pk, user=request.user)
|
||||
def ssh_key_delete(request, pk, user_id=None):
|
||||
"""Delete one of the user's own keys or, given user_id, a superuser
|
||||
deletes one of that user's."""
|
||||
if user_id is None:
|
||||
ssh_key = get_object_or_404(UserSSHKey, pk=pk, user=request.user)
|
||||
subject = f"SSH key {ssh_key.keyname}"
|
||||
back = reverse("accounts:profile") + "#sshkeys"
|
||||
elif request.user.is_superuser:
|
||||
ssh_key = get_object_or_404(UserSSHKey, pk=pk, user_id=user_id)
|
||||
subject = f"SSH key {ssh_key.keyname} of {ssh_key.user.username}"
|
||||
back = reverse("accounts:account", args=[user_id]) + "#public-keys"
|
||||
else:
|
||||
raise PermissionDenied
|
||||
if request.method == "POST":
|
||||
ssh_key.delete()
|
||||
with audit(request, None, subject, "delete"):
|
||||
ssh_key.delete()
|
||||
messages.success(request, _("SSH key deleted"))
|
||||
return redirect("accounts:profile")
|
||||
return redirect(back)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"common/confirm_delete.html",
|
||||
{
|
||||
"object": ssh_key,
|
||||
"object": subject,
|
||||
"title": _("Delete SSH key"),
|
||||
"consequence": _("VMs that already have it keep it; VMs created from now on do not get it."),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@superuser_only
|
||||
def account(request, user_id):
|
||||
user = User.objects.get(id=user_id)
|
||||
user_insts = UserInstance.objects.filter(user_id=user_id)
|
||||
instances = Instance.objects.all().order_by("name")
|
||||
user = get_object_or_404(User, id=user_id)
|
||||
user_insts = UserInstance.objects.filter(user_id=user_id).select_related("instance__compute")
|
||||
publickeys = UserSSHKey.objects.filter(user_id=user_id)
|
||||
|
||||
return render(
|
||||
|
|
@ -98,24 +188,44 @@ def account(request, user_id):
|
|||
{
|
||||
"user": user,
|
||||
"user_insts": user_insts,
|
||||
"instances": instances,
|
||||
"publickeys": publickeys,
|
||||
"api_tokens": ApiToken.objects.filter(user_id=user_id),
|
||||
"otp_enabled": settings.OTP_ENABLED,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@permission_required("accounts.change_password", raise_exception=True)
|
||||
@sensitive_post_parameters()
|
||||
@login_required
|
||||
def change_password(request):
|
||||
must_change = UserAttributes.objects.filter(
|
||||
user=request.user, must_change_password=True
|
||||
).exists()
|
||||
if not must_change and not request.user.has_perm("accounts.change_password"):
|
||||
raise PermissionDenied
|
||||
if not request.user.has_usable_password(): # an LDAP user: the password is changed in LDAP
|
||||
raise PermissionDenied
|
||||
form = PasswordChangeForm(request.user, request.POST or None)
|
||||
|
||||
if form.is_valid():
|
||||
user = form.save()
|
||||
update_session_auth_hash(request, user) # Important!
|
||||
with audit(request, None, f"User {request.user.username}", "change password"), transaction.atomic():
|
||||
user = form.save()
|
||||
UserAttributes.objects.filter(user=user).update(must_change_password=False)
|
||||
update_session_auth_hash(request, user)
|
||||
request.session.pop("must_change_password", None)
|
||||
if must_change and not UserAttributes.objects.filter(must_change_password=True).exists():
|
||||
try:
|
||||
os.remove(admin_password_path())
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
logging.getLogger(__name__).warning("Could not remove the generated admin password file")
|
||||
messages.success(request, _("Password Changed"))
|
||||
return redirect("accounts:profile")
|
||||
if form.has_error("old_password", "password_incorrect"): # someone at an open session guessing it
|
||||
failed(request, None, f"User {request.user.username}", "change password", "wrong old password")
|
||||
|
||||
return render(request, "accounts/change_password_form.html", {"form": form})
|
||||
return render(request, "accounts/change_password_form.html", {"form": form, "must_change": must_change})
|
||||
|
||||
|
||||
@superuser_only
|
||||
|
|
@ -124,8 +234,17 @@ def user_instance_create(request, user_id):
|
|||
|
||||
form = forms.UserInstanceForm(request.POST or None, initial={"user": user})
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect(reverse("accounts:account", args=[user.id]))
|
||||
instance = form.cleaned_data["instance"]
|
||||
# the form's one-owner check again, with the insert in one step, as on the VM page
|
||||
with audit_vm(request, instance, f"add owner {form.cleaned_data['user'].username}") as entry, \
|
||||
transaction.atomic():
|
||||
lock_ownership()
|
||||
form.full_clean()
|
||||
entry.skip = not form.is_valid()
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
if form.is_valid():
|
||||
return redirect(reverse("accounts:account", args=[user.id]))
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
|
@ -137,13 +256,29 @@ def user_instance_create(request, user_id):
|
|||
)
|
||||
|
||||
|
||||
def _next_or(request, default):
|
||||
"""To the ?next page, the VM page an owner was changed from, when it is
|
||||
on this site; else to default."""
|
||||
next_url = request.GET.get("next")
|
||||
if next_url and url_has_allowed_host_and_scheme(
|
||||
url=next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()
|
||||
):
|
||||
return redirect(next_url)
|
||||
return redirect(default)
|
||||
|
||||
|
||||
@superuser_only
|
||||
def user_instance_update(request, pk):
|
||||
user_instance = get_object_or_404(UserInstance, pk=pk)
|
||||
instance, before = user_instance.instance, snapshot(user_instance)
|
||||
form = forms.UserInstanceForm(request.POST or None, instance=user_instance)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect(reverse("accounts:account", args=[user_instance.user.id]))
|
||||
with audit_vm(request, instance, "change owner") as entry:
|
||||
form.save()
|
||||
fields = changed_fields(before, snapshot(user_instance))
|
||||
entry.action = f"change owner {user_instance.user.username}: " + ", ".join(fields)
|
||||
entry.skip = not fields
|
||||
return _next_or(request, reverse("accounts:account", args=[user_instance.user.id]))
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
|
@ -159,32 +294,48 @@ def user_instance_update(request, pk):
|
|||
def user_instance_delete(request, pk):
|
||||
user_instance = get_object_or_404(UserInstance, pk=pk)
|
||||
if request.method == "POST":
|
||||
user = user_instance.user
|
||||
user_instance.delete()
|
||||
next = request.GET.get("next", None)
|
||||
if next:
|
||||
return redirect(next)
|
||||
else:
|
||||
return redirect(reverse("accounts:account", args=[user.id]))
|
||||
user, instance = user_instance.user, user_instance.instance
|
||||
with audit_vm(request, instance, f"remove owner {user.username}"):
|
||||
user_instance.delete()
|
||||
return _next_or(request, reverse("accounts:account", args=[user.id]))
|
||||
|
||||
return render(
|
||||
request,
|
||||
"common/confirm_delete.html",
|
||||
{"object": user_instance},
|
||||
{"object": user_instance,
|
||||
"consequence": _("The user loses access to this VM; the VM itself is not changed.")},
|
||||
)
|
||||
|
||||
|
||||
# a user gets at most one OTP mail per this many seconds
|
||||
EMAIL_OTP_INTERVAL = 300
|
||||
|
||||
|
||||
@login_not_required
|
||||
def email_otp(request):
|
||||
"""
|
||||
"Lost OTP?": mail a link to the OTP QR code to the user with this address. Open to
|
||||
anyone, so it mails only one exact, active match, at most once per
|
||||
EMAIL_OTP_INTERVAL, and answers the same in every case.
|
||||
"""
|
||||
if not settings.OTP_ENABLED:
|
||||
raise Http404
|
||||
form = EmailOTPForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
UserModel = get_user_model()
|
||||
try:
|
||||
user = UserModel.objects.get(email=form.cleaned_data["email"])
|
||||
except UserModel.DoesNotExist:
|
||||
pass
|
||||
else:
|
||||
device = get_user_totp_device(user)
|
||||
send_email_with_otp(user, device)
|
||||
users = list(get_user_model().objects.filter(email__iexact=form.cleaned_data["email"], is_active=True)[:2])
|
||||
# the cache is per process: with several workers the bound is per worker
|
||||
if len(users) == 1 and cache.add(f"email_otp:{users[0].pk}", True, EMAIL_OTP_INTERVAL):
|
||||
user = users[0]
|
||||
# resend only to a user who already enrolled: creating a device here
|
||||
# would let anyone who knows an email force OTP on that account
|
||||
device = get_existing_totp_device(user)
|
||||
if device is not None:
|
||||
try:
|
||||
# the row's user is the requester: anonymous
|
||||
with audit(request, None, f"User {user.username}", "mail OTP QR code (lost OTP)"):
|
||||
send_email_with_otp(request, user)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception("Could not mail the OTP QR code to user %s", user.pk)
|
||||
|
||||
messages.success(
|
||||
request, _("OTP Sent to %(email)s") % {"email": form.cleaned_data["email"]}
|
||||
|
|
@ -201,15 +352,42 @@ def email_otp(request):
|
|||
)
|
||||
|
||||
|
||||
@login_not_required
|
||||
def otp_qr(request, token):
|
||||
"""The OTP QR code of the user a mailed link (send_email_with_otp) names."""
|
||||
if not settings.OTP_ENABLED:
|
||||
raise Http404
|
||||
try:
|
||||
user_id = OTP_LINK.unsign(token, max_age=OTP_LINK_MAX_AGE)
|
||||
except BadSignature:
|
||||
raise Http404
|
||||
user = get_object_or_404(get_user_model(), pk=user_id, is_active=True)
|
||||
device = get_existing_totp_device(user)
|
||||
if device is None:
|
||||
raise Http404
|
||||
response = render(request, "accounts/otp_qr.html", {"user": user, "qr_svg": otp_qr_svg(device)})
|
||||
response["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
@require_POST
|
||||
@superuser_only
|
||||
def admin_email_otp(request, user_id):
|
||||
user = get_object_or_404(get_user_model(), pk=user_id)
|
||||
device = get_user_totp_device(user)
|
||||
subject = f"User {user.username}"
|
||||
if user.email != "":
|
||||
send_email_with_otp(user, device)
|
||||
messages.success(
|
||||
request, _("OTP QR code was emailed to user %(user)s") % {"user": user}
|
||||
)
|
||||
try:
|
||||
with audit(request, None, subject, "mail OTP QR code"):
|
||||
get_user_totp_device(user)
|
||||
send_email_with_otp(request, user)
|
||||
except OSError as err: # the SMTP server refused it, or did not answer within EMAIL_TIMEOUT
|
||||
messages.error(request, _("The OTP QR code could not be mailed to %(user)s: %(error)s") % {
|
||||
"user": user, "error": err})
|
||||
else:
|
||||
messages.success(
|
||||
request, _("OTP QR code was emailed to user %(user)s") % {"user": user}
|
||||
)
|
||||
else:
|
||||
failed(request, None, subject, "mail OTP QR code", "no email address")
|
||||
messages.error(request, _("User email not set, failed to send QR code"))
|
||||
return redirect("accounts:account", user.id)
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
default_app_config = 'admin.apps.AdminConfig'
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
from functools import wraps
|
||||
from django.core.exceptions import PermissionDenied
|
||||
|
||||
|
||||
def superuser_only(function):
|
||||
@wraps(function)
|
||||
def _inner(request, *args, **kwargs):
|
||||
if not request.user.is_superuser:
|
||||
raise PermissionDenied
|
||||
|
|
|
|||
185
admin/forms.py
185
admin/forms.py
|
|
@ -1,15 +1,36 @@
|
|||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth.forms import ReadOnlyPasswordHashField
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import password_validation
|
||||
from django.contrib.auth.forms import AdminPasswordChangeForm
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.text import format_lazy
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.forms import UniqueEmailMixin
|
||||
from accounts.models import UserAttributes
|
||||
from instances.models import Instance
|
||||
|
||||
from .models import Permission
|
||||
|
||||
|
||||
# what each permission lets a user do, shown beside its switch
|
||||
PERMISSION_HELP = {
|
||||
"change_password": _("Change their own password on their profile page"),
|
||||
"clone_instances": _("Clone the VMs they may change and the templates they may view, within their quota"),
|
||||
"passwordless_console": _("Open a console without the VM's console password"),
|
||||
"snapshot_instances": _("Take, revert and delete snapshots of the VMs they may change"),
|
||||
"view_instances": _("See every VM and host in the lists, read-only"),
|
||||
}
|
||||
|
||||
|
||||
def permission_rows(field):
|
||||
"""Each permission's checkbox of a bound field with what it lets a user do."""
|
||||
return [(box, PERMISSION_HELP.get(box.data["value"].instance.codename, "")) for box in field]
|
||||
|
||||
|
||||
class GroupForm(forms.ModelForm):
|
||||
permissions = forms.ModelMultipleChoiceField(
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
|
|
@ -19,7 +40,7 @@ class GroupForm(forms.ModelForm):
|
|||
|
||||
users = forms.ModelMultipleChoiceField(
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
queryset=User.objects.all(),
|
||||
queryset=User.objects.order_by("username"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
|
|
@ -29,6 +50,9 @@ class GroupForm(forms.ModelForm):
|
|||
if instance and instance.id:
|
||||
self.fields["users"].initial = self.instance.user_set.all()
|
||||
|
||||
def permission_rows(self):
|
||||
return permission_rows(self["permissions"])
|
||||
|
||||
def save_m2m(self):
|
||||
self.instance.user_set.set(self.cleaned_data["users"])
|
||||
|
||||
|
|
@ -42,7 +66,15 @@ class GroupForm(forms.ModelForm):
|
|||
fields = "__all__"
|
||||
|
||||
|
||||
class UserForm(forms.ModelForm):
|
||||
class UserForm(UniqueEmailMixin, forms.ModelForm):
|
||||
# one choice for is_staff and is_superuser: a superuser keeps the
|
||||
# is_staff it had (it adds nothing to every permission)
|
||||
role = forms.ChoiceField(
|
||||
widget=forms.RadioSelect,
|
||||
choices=[("user", _("User")), ("template_editor", _("Template editor")), ("superuser", _("Superuser"))],
|
||||
initial="user",
|
||||
)
|
||||
|
||||
user_permissions = forms.ModelMultipleChoiceField(
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
queryset=Permission.objects.filter(content_type__model="permissionset"),
|
||||
|
|
@ -66,37 +98,73 @@ class UserForm(forms.ModelForm):
|
|||
"last_name",
|
||||
"email",
|
||||
"user_permissions",
|
||||
"is_staff",
|
||||
"is_active",
|
||||
"is_superuser",
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(UserForm, self).__init__(*args, **kwargs)
|
||||
if self.instance.id:
|
||||
password = ReadOnlyPasswordHashField(
|
||||
label=_("Password"),
|
||||
help_text=format_lazy(
|
||||
_(
|
||||
"""Raw passwords are not stored, so there is no way to see this user's password,
|
||||
but you can change the password using <a href='{}'>this form</a>."""
|
||||
),
|
||||
reverse_lazy(
|
||||
"admin:user_update_password",
|
||||
args=[
|
||||
self.instance.id,
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
self.fields["Password"] = password
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.is_superuser:
|
||||
self.initial["role"] = "superuser"
|
||||
elif self.instance.is_staff:
|
||||
self.initial["role"] = "template_editor"
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
role = cleaned.get("role")
|
||||
if role:
|
||||
cleaned["is_superuser"] = self.instance.is_superuser = role == "superuser"
|
||||
if role != "superuser":
|
||||
self.instance.is_staff = role == "template_editor"
|
||||
return cleaned
|
||||
|
||||
def permission_rows(self):
|
||||
return permission_rows(self["user_permissions"])
|
||||
|
||||
class UserCreateForm(UserForm):
|
||||
password = forms.CharField(widget=forms.PasswordInput)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# New users may change their own password and open their VMs' consoles
|
||||
# (whose VNC password the console page sends) unless unticked here
|
||||
self.fields["user_permissions"].initial = Permission.objects.filter(
|
||||
Q(content_type__app_label="accounts", codename="change_password")
|
||||
| Q(content_type__app_label="instances", codename="passwordless_console")
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
# the VMs a new user owns, by name: a name may repeat across hosts
|
||||
cleaned = super().clean()
|
||||
self.default_instances = []
|
||||
for name in settings.NEW_USER_DEFAULT_INSTANCES:
|
||||
found = list(Instance.objects.filter(name=name)[:2])
|
||||
if len(found) != 1:
|
||||
raise ValidationError(_("NEW_USER_DEFAULT_INSTANCES: %(name)s is not exactly one VM") % {"name": name})
|
||||
self.default_instances += found
|
||||
return cleaned
|
||||
|
||||
def _post_clean(self):
|
||||
# The validators in AUTH_PASSWORD_VALIDATORS, as on the password change
|
||||
# forms; after the instance has the form's values (username, email).
|
||||
super()._post_clean()
|
||||
password = self.cleaned_data.get("password")
|
||||
if password:
|
||||
try:
|
||||
password_validation.validate_password(password, self.instance)
|
||||
except ValidationError as error:
|
||||
self.add_error("password", error)
|
||||
|
||||
def save(self, commit=True):
|
||||
# hashed before the user is first written: the raw password never
|
||||
# reaches the database
|
||||
user = super().save(commit=False)
|
||||
user.set_password(self.cleaned_data["password"])
|
||||
if commit:
|
||||
user.save()
|
||||
self.save_m2m()
|
||||
return user
|
||||
|
||||
class Meta(UserForm.Meta):
|
||||
fields = [
|
||||
"username",
|
||||
"password",
|
||||
|
|
@ -105,13 +173,70 @@ class UserCreateForm(UserForm):
|
|||
"last_name",
|
||||
"email",
|
||||
"user_permissions",
|
||||
"is_staff",
|
||||
"is_active",
|
||||
"is_superuser",
|
||||
]
|
||||
|
||||
|
||||
# the quota's limits, with the unit each is entered in (memory in GiB, kept in MiB)
|
||||
QUOTA_UNITS = {"max_instances": "", "max_cpus": "", "max_memory": "GiB", "max_disk_size": "GiB"}
|
||||
|
||||
|
||||
class UserAttributesForm(forms.ModelForm):
|
||||
"""The quota: each limit a number from 0 (none allowed) or No limit,
|
||||
which is kept as -1; memory is entered in GiB and kept in MiB."""
|
||||
|
||||
max_instances = forms.IntegerField(label=_("VMs"), min_value=0, required=False)
|
||||
max_cpus = forms.IntegerField(label=_("vCPUs"), min_value=0, required=False)
|
||||
max_memory = forms.DecimalField(label=_("Memory"), min_value=0, required=False)
|
||||
max_disk_size = forms.IntegerField(label=_("Disk"), min_value=0, required=False)
|
||||
|
||||
class Meta:
|
||||
model = UserAttributes
|
||||
exclude = ["user", "can_clone_instances"]
|
||||
fields = list(QUOTA_UNITS)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for name in QUOTA_UNITS:
|
||||
limit = getattr(self.instance, name) # a new user's: the model's default
|
||||
self.fields[f"{name}_unlimited"] = forms.BooleanField(label=_("No limit"), required=False, initial=limit == -1)
|
||||
if limit == -1:
|
||||
self.initial[name] = None
|
||||
elif name == "max_memory":
|
||||
# exact: a MiB count over 1024 always ends, so saving it again changes nothing
|
||||
self.initial[name] = format(Decimal(limit) / 1024, "f")
|
||||
else:
|
||||
self.initial[name] = limit
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
for name in QUOTA_UNITS:
|
||||
if cleaned.get(f"{name}_unlimited"):
|
||||
cleaned[name] = -1
|
||||
elif cleaned.get(name) is None:
|
||||
if name not in self.errors:
|
||||
self.add_error(name, _("Enter a limit or choose No limit."))
|
||||
elif name == "max_memory":
|
||||
cleaned[name] = int((cleaned[name] * 1024).to_integral_value(ROUND_HALF_UP))
|
||||
return cleaned
|
||||
|
||||
def quota_rows(self):
|
||||
"""Each limit's field, its No limit box and its unit, for the form page."""
|
||||
return [(self[name], self[f"{name}_unlimited"], unit) for name, unit in QUOTA_UNITS.items()]
|
||||
|
||||
|
||||
class AdminPasswordForm(AdminPasswordChangeForm):
|
||||
"""A password an administrator sets; the user may have to change it at their next login."""
|
||||
must_change_password = forms.BooleanField(label=_("The user must change it at their next login"), required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["must_change_password"] = self.fields.pop("must_change_password") # last, after Django's own
|
||||
# Django's "Password-based authentication: Enabled/Disabled", said as how the user signs in
|
||||
self.fields["usable_password"].choices = [("true", _("With a password")), ("false", _("Without a password"))]
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
# the change form asks for the old password: a user without one could never pass it
|
||||
if cleaned.get("must_change_password") and not cleaned.get("set_usable_password", True):
|
||||
raise ValidationError(_("A user without a password cannot be made to change it"))
|
||||
return cleaned
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
from django.db import models, migrations
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def apply_migration(apps, schema_editor):
|
||||
Group = apps.get_model('auth', 'Group')
|
||||
Group.objects.create(name='Technicians')
|
||||
Group = apps.get_model("auth", "Group")
|
||||
Group.objects.get_or_create(name="Technicians")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('admin', '0002_auto_20200609_0830'),
|
||||
]
|
||||
("admin", "0002_auto_20200609_0830"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(apply_migration)
|
||||
]
|
||||
migrations.RunPython(apply_migration, migrations.RunPython.noop),
|
||||
]
|
||||
|
|
|
|||
15
admin/permissions.py
Normal file
15
admin/permissions.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from rest_framework.permissions import BasePermission
|
||||
|
||||
|
||||
class IsSuperUser(BasePermission):
|
||||
"""
|
||||
Allows access only to active superusers.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return bool(
|
||||
request.user
|
||||
and request.user.is_authenticated
|
||||
and request.user.is_active
|
||||
and request.user.is_superuser
|
||||
)
|
||||
80
admin/templates/admin/group_form.html
Normal file
80
admin/templates/admin/group_form.html
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
{% extends "base.html" %}
|
||||
{% load django_bootstrap5 %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block page_heading %}{{ title }}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<form id="create-update" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form type="non_fields" %}
|
||||
|
||||
<section class="form-section">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<h2 class="h6 fw-semibold mb-1">{% trans "Group" %}</h2>
|
||||
<p class="small text-body-secondary mb-0">{% trans "Users who share a set of permissions." %}</p>
|
||||
</div>
|
||||
<div class="col-lg-9 form-section-fields">
|
||||
<div class="row"><div class="col-sm-6">{% bootstrap_field form.name %}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<h2 class="h6 fw-semibold mb-1">{% trans "Permissions" %}</h2>
|
||||
<p class="small text-body-secondary mb-0">{% trans "Each member has these permissions besides their own." %}</p>
|
||||
</div>
|
||||
<div class="col-lg-9 form-section-fields">
|
||||
<div class="border-top">
|
||||
{% for box, help in form.permission_rows %}
|
||||
<div class="form-check form-switch border-bottom py-2 ps-5">
|
||||
<input id="{{ box.id_for_label }}" class="form-check-input" type="checkbox" role="switch" name="permissions"
|
||||
value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ box.id_for_label }}">
|
||||
<span class="d-block">{{ box.data.value.instance.name }}</span>
|
||||
<span class="small text-body-secondary">{{ help }}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for error in form.permissions.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<h2 class="h6 fw-semibold mb-1">{% trans "Members" %}</h2>
|
||||
<p class="small text-body-secondary mb-0">{% trans "Who gets these permissions." %}</p>
|
||||
</div>
|
||||
<fieldset class="col-lg-9 form-section-fields">
|
||||
<legend class="visually-hidden">{% trans "Members" %}</legend>
|
||||
<input id="filter" class="form-control form-control-sm mb-2" style="max-width: 20rem" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search users" %}" data-forget>
|
||||
{# the members first, then everyone else; each by username #}
|
||||
<div class="d-flex flex-wrap gap-2 searchable">
|
||||
{% for box in form.users %}{% if box.data.selected %}{% include "admin/group_member.html" %}{% endif %}{% endfor %}
|
||||
{% for box in form.users %}{% if not box.data.selected %}{% include "admin/group_member.html" %}{% endif %}{% endfor %}
|
||||
</div>
|
||||
<p id="no-match" class="small text-body-secondary mt-2 mb-0" hidden>{% trans "No user matches the search." %}</p>
|
||||
{% for error in form.users.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||||
</fieldset>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-section d-flex justify-content-end gap-2">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'admin:group_list' %}">{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-primary">{% if group %}{% trans "Save changes" %}{% else %}{% trans "Create group" %}{% endif %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
<script src="{% static 'js/table_filter.js' %}"></script>
|
||||
{% endblock script %}
|
||||
|
|
@ -2,58 +2,60 @@
|
|||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load bootstrap_icons %}
|
||||
{% block title %}{% trans "Users" %}{% endblock %}
|
||||
|
||||
{% block title %}{% trans "Groups" %}{% endblock %}
|
||||
|
||||
{% block page_heading %}{% trans "Groups" %}{% endblock page_heading %}
|
||||
|
||||
{% block page_heading_extra %}
|
||||
<a href="{% url 'admin:group_create' %}" class="btn btn-primary">
|
||||
<span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New group" %}
|
||||
</a>
|
||||
{% endblock page_heading_extra %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<a href="{% url 'admin:group_create' %}" class="btn btn-success btn-header float-end">
|
||||
{% bs_icon 'plus-circle-fill' %}
|
||||
</a>
|
||||
{% include 'search_block.html' %}
|
||||
<h1 class="page-header">{% trans "Groups" %}</h1>
|
||||
{% if not groups %}
|
||||
<div class="alert alert-warning shadow-sm">
|
||||
{% bs_icon 'exclamation-triangle' %} {% trans "There are no groups yet." %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
{% if not groups %}
|
||||
<div class="col-lg-12">
|
||||
<div class="alert alert-warning shadow-sm">
|
||||
{% bs_icon 'exclamation-triangle '%} <strong>{% trans "Warning" %}:</strong> {% trans "You don't have any groups" %}
|
||||
{% else %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="me-auto">{% trans "Groups" %} <span class="badge text-bg-light border">{{ groups|length }}</span></span>
|
||||
<input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search groups" %}">
|
||||
</div>
|
||||
<div class="list-group list-group-flush searchable">
|
||||
{% for group in groups %}
|
||||
<div class="list-group-item py-3" data-search="{{ group.name }}">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-12 col-md-4">
|
||||
<a href="{% url 'admin:group_update' group.id %}" class="fw-semibold text-break">{{ group.name }}</a>
|
||||
</div>
|
||||
<div class="col-12 col-md-5 small text-body-secondary">
|
||||
{% blocktrans count count=group.members %}{{ count }} member{% plural %}{{ count }} members{% endblocktrans %}
|
||||
· {% blocktrans count count=group.perms %}{{ count }} permission{% plural %}{{ count }} permissions{% endblocktrans %}
|
||||
</div>
|
||||
<div class="col-12 col-md-3 text-md-end">
|
||||
<div class="btn-group">
|
||||
<a href="{% url 'admin:group_update' group.id %}" class="btn btn-sm btn-outline-secondary">{% trans "Edit" %}</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
data-bs-popper-config='{"strategy": "fixed"}' aria-label="{% blocktrans with name=group.name %}More actions for {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow">
|
||||
<li><a class="dropdown-item text-danger" href="{% url 'admin:group_delete' group.id %}">{% bs_icon 'trash' %} {% trans "Delete" %}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-lg-12">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th span="col">{% trans "Group Name" %}</th>
|
||||
<th span="col">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="searchable">
|
||||
{% for group in groups %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href=""><strong>{{ group.name }}</strong></a>
|
||||
</td>
|
||||
<td>
|
||||
<div class="float-end btn-group">
|
||||
<a class="btn btn-primary" href="{% url 'admin:group_update' group.id %}" title="{%trans "Edit" %}">
|
||||
{% bs_icon 'pencil' %}
|
||||
</a>
|
||||
<a class="btn btn-danger" href="{% url 'admin:group_delete' group.id %}" title="{%trans "Delete" %}">
|
||||
{% bs_icon 'x' %}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
<div role="status">
|
||||
<div id="no-match" class="text-center text-body-secondary py-3" hidden>{% trans "No group matches the search." %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
<script src="{% static 'js/filter-table.js' %}"></script>
|
||||
{% if groups %}<script src="{% static 'js/table_filter.js' %}"></script>{% endif %}
|
||||
{% endblock script %}
|
||||
|
|
|
|||
7
admin/templates/admin/group_member.html
Normal file
7
admin/templates/admin/group_member.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{# one user of the group form's Members: box is a checkbox of GroupForm.users #}
|
||||
{% with user=box.data.value.instance %}
|
||||
<label class="border rounded px-2 py-1 text-break" data-search="{{ user.username }} {{ user.get_full_name }} {{ user.email }}">
|
||||
<input id="{{ box.id_for_label }}" class="form-check-input me-1" type="checkbox" name="users" value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
|
||||
{{ user.username }}{% if user.get_full_name %} <span class="small text-body-secondary">{{ user.get_full_name }}</span>{% endif %}
|
||||
</label>
|
||||
{% endwith %}
|
||||
|
|
@ -8,52 +8,59 @@
|
|||
{% block page_heading %}{% trans "Logs" %}{% endblock page_heading %}
|
||||
|
||||
{% block page_heading_extra %}
|
||||
{% include 'search_block.html' %}
|
||||
<a class="btn btn-outline-primary" href="{% url 'admin:logs_export' %}{% if query %}?{{ query }}{% endif %}"
|
||||
title="{% trans 'Download these rows as an Excel file' %}"><span aria-hidden="true">{% bs_icon 'download' %}</span> {% trans "Excel" %}</a>
|
||||
{% endblock page_heading_extra %}
|
||||
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
{% if not logs %}
|
||||
<div class="col-lg-12">
|
||||
<div class="alert alert-warning shadow-sm">
|
||||
{% bs_icon 'exclamation-triangle'%} <strong>{% trans "Warning" %}:</strong> {% trans "You don't have any Logs" %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped">
|
||||
<thead>
|
||||
<div class="card mb-3">
|
||||
{# the search runs on the server over every row; the download takes the same rows #}
|
||||
<form method="get" class="card-header d-flex flex-wrap align-items-center gap-2" role="search">
|
||||
<span class="me-auto">{% trans "Logs" %} <span class="badge text-bg-light border">{{ logs.paginator.count }}</span></span>
|
||||
<input name="q" value="{{ q }}" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans 'Search' %}"
|
||||
aria-label="{% trans 'Search the logs' %}">
|
||||
<div class="form-check mb-0">
|
||||
<input id="logs-failed" class="form-check-input" type="checkbox" name="failed" value="1"{% if failed %} checked{% endif %}>
|
||||
<label class="form-check-label small" for="logs-failed">{% trans "Failed only" %}</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-primary">{% trans "Search" %}</button>
|
||||
</form>
|
||||
{% if not logs %}
|
||||
<div class="card-body text-center text-body-secondary">
|
||||
{% if q or failed %}{% trans "No log rows match." %}{% else %}{% trans "There are no log rows yet." %}{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive position-relative">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">{% blocktrans %}Date ({{ time_zone }}){% endblocktrans %}</th>
|
||||
<th scope="col">{% trans "User" %}</th>
|
||||
<th scope="col">{% trans "Host" %}</th>
|
||||
<th scope="col">{% trans "Instance" %}</th>
|
||||
<th scope="col">{% trans "Message" %}</th>
|
||||
<th scope="col">{% trans "Result" %}</th>
|
||||
<th scope="col">{% trans "IP" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">{% trans "Date" %}</th>
|
||||
<th scope="col">{% trans "User" %}</th>
|
||||
<th scope="col">{% trans "Host" %}</th>
|
||||
<th scope="col">{% trans "Instance" %}</th>
|
||||
<th scope="col">{% trans "Message" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="searchable">
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td>{{ log.id }}</td>
|
||||
<td style="width:130px;">{{ log.date|date:"M d H:i:s" }}</td>
|
||||
<td>{{ log.user }}</td>
|
||||
<td>{{ log.host }}</td>
|
||||
<td>{{ log.instance }}</td>
|
||||
<td>{{ log.message }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% bootstrap_pagination logs %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<td class="small text-body-secondary">{{ log.id }}</td>
|
||||
<td class="small text-nowrap">{{ log.date|date:"Y-m-d H:i:s" }}</td>
|
||||
<td>{{ log.user }}</td>
|
||||
<td>{{ log.host }}</td>
|
||||
<td>{% if log.vm_pk %}<a href="{% url 'instances:instance' log.vm_pk %}">{{ log.instance }}</a>{% else %}{{ log.instance }}{% endif %}</td>
|
||||
<td class="log-message">{{ log.message }}</td>
|
||||
<td>{% if log.success is None %}<span class="small text-body-secondary">{% trans "Unknown" %}</span>{% elif log.success %}<span class="badge text-bg-light border">{% trans "Done" %}</span>{% else %}<span class="badge text-bg-danger">{% trans "Failed" %}</span>{% endif %}</td>
|
||||
<td class="small font-monospace">{{ log.ip|default:"" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if logs %}{% bootstrap_pagination logs extra=query %}{% endif %}
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script src="/static/js/filter-table.js"></script>
|
||||
{% endblock script %}
|
||||
|
|
|
|||
57
admin/templates/admin/tombstone_list.html
Normal file
57
admin/templates/admin/tombstone_list.html
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load bootstrap_icons %}
|
||||
|
||||
{% block title %}{% trans "Removed VMs" %}{% endblock %}
|
||||
|
||||
{% block page_heading %}{% trans "Removed VMs" %}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<p class="text-body-secondary">{% trans "VMs that vanished from their compute. Their owners come back if the same UUID shows up again before the record expires." %}</p>
|
||||
{% if not tombstones %}
|
||||
<div class="alert alert-info shadow-sm">
|
||||
{% bs_icon 'info-circle' %} {% trans "No removed VMs" %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="me-auto">{% trans "Removed VMs" %} <span class="badge text-bg-light border">{{ tombstones|length }}</span></span>
|
||||
<input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search removed VMs" %}">
|
||||
</div>
|
||||
<div class="list-group list-group-flush searchable">
|
||||
{% for tombstone in tombstones %}
|
||||
<div class="list-group-item py-3" data-search="{{ tombstone.name }} {{ tombstone.uuid }} {{ tombstone.host }} {{ tombstone.owner_names|join:' ' }}">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-12 col-md-4">
|
||||
<span class="fw-semibold text-break">{{ tombstone.name }}</span>
|
||||
{% if tombstone.is_template %}<span class="badge text-bg-light border">{% trans "Template" %}</span>{% endif %}
|
||||
<div class="font-monospace small text-body-secondary text-break mt-1">{{ tombstone.uuid }}</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-5 small">
|
||||
<div>
|
||||
{% trans "Left" %} <span class="fw-semibold">{{ tombstone.host }}</span>
|
||||
<span class="text-body-secondary">· {% trans "Owners" %}:</span> {{ tombstone.owner_names|join:", "|default:"–" }}
|
||||
</div>
|
||||
<div class="text-body-secondary mt-1">
|
||||
{% trans "Removed" %} <span class="text-nowrap">{{ tombstone.removed|date:"Y-m-d H:i" }}</span>
|
||||
· {% trans "expires" %} <span class="text-nowrap">{{ tombstone.expires|date:"Y-m-d H:i" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 text-md-end">
|
||||
<a class="btn btn-sm btn-outline-danger" href="{% url 'admin:tombstone_delete' tombstone.id %}">{% trans "Delete" %}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div role="status">
|
||||
<div id="no-match" class="text-center text-body-secondary py-3" hidden>{% trans "No removed VM matches the search." %}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
{% if tombstones %}<script src="{% static 'js/table_filter.js' %}"></script>{% endif %}
|
||||
{% endblock script %}
|
||||
|
|
@ -1,27 +1,167 @@
|
|||
{% extends "base.html" %}
|
||||
{% load django_bootstrap5 %}
|
||||
{% load bootstrap_icons %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
||||
{% block page_heading %}{{ title }}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card col-sm-10 offset-1">
|
||||
<div class="card-body">
|
||||
<form id="create-update" action="" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form user_form layout='horizontal' %}
|
||||
{% bootstrap_form attributes_form layout='horizontal' %}
|
||||
</form>
|
||||
<div class="float-end">
|
||||
<a class="btn btn-primary" href="javascript:history.back()">
|
||||
{% bs_icon 'x-square-fill' %} {% trans "Cancel" %}</a>
|
||||
<button type="submit" form="create-update" class="btn btn-success">
|
||||
{% bs_icon 'check2' %} {% trans "Save" %}
|
||||
</button>
|
||||
{# who the user is, what they manage, what they may use, then what more they may do #}
|
||||
<form id="create-update" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors user_form type="non_fields" %}
|
||||
|
||||
<section class="form-section">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<h2 class="h6 fw-semibold mb-1">{% trans "Account" %}</h2>
|
||||
{% if edited_user %}
|
||||
<p class="small text-body-secondary mb-0">{% trans "Last login" %}: {{ edited_user.last_login|date:"Y-m-d H:i"|default:_("never") }}</p>
|
||||
{% else %}
|
||||
<p class="small text-body-secondary mb-0">{% trans "What the user signs in with." %}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-lg-9 form-section-fields">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">{% bootstrap_field user_form.username %}</div>
|
||||
<div class="col-sm-6">
|
||||
{% if edited_user %}
|
||||
<span class="form-label d-block">{% trans "Password" %}</span>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
<span>{% if edited_user.has_usable_password %}{% trans "Set" %}{% else %}{% trans "Not set" %}{% endif %}</span>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="{% url 'admin:user_update_password' edited_user.id %}">{% trans "Change password" %}</a>
|
||||
</div>
|
||||
{% else %}
|
||||
{% bootstrap_field user_form.password %}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="col-sm-6">{% bootstrap_field user_form.first_name %}</div>
|
||||
<div class="col-sm-6">{% bootstrap_field user_form.last_name %}</div>
|
||||
<div class="col-12">{% bootstrap_field user_form.email %}</div>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input id="{{ user_form.is_active.id_for_label }}" class="form-check-input" type="checkbox" role="switch" name="is_active"
|
||||
aria-describedby="active-help"{% if user_form.is_active.value %} checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ user_form.is_active.id_for_label }}">{% trans "May sign in" %}</label>
|
||||
<div id="active-help" class="small text-body-secondary">{% trans "Turn off to block the user while keeping the account." %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<h2 class="h6 fw-semibold mb-1" id="role-heading">{% trans "Role" %}</h2>
|
||||
<p class="small text-body-secondary mb-0">{% trans "What the user manages." %}</p>
|
||||
</div>
|
||||
<div class="col-lg-9 form-section-fields">
|
||||
{% trans "Uses the VMs given to them, within their quota." as user_help %}
|
||||
{% trans "May change and delete the template VMs given to them." as editor_help %}
|
||||
{% trans "Manages the panel: every permission, no quota." as superuser_help %}
|
||||
<div class="row row-cols-1 row-cols-md-3 g-2" role="radiogroup" aria-labelledby="role-heading">
|
||||
{% for choice in user_form.role %}
|
||||
<div class="col">
|
||||
<label class="form-choice border rounded p-3 d-flex gap-2 h-100">
|
||||
<input class="form-check-input flex-shrink-0 mt-1" type="radio" name="role" value="{{ choice.data.value }}"{% if choice.data.selected %} checked{% endif %}>
|
||||
<span><span class="d-block fw-semibold">{{ choice.choice_label }}</span>
|
||||
<span class="small text-body-secondary">{% if choice.data.value == "superuser" %}{{ superuser_help }}{% elif choice.data.value == "template_editor" %}{{ editor_help }}{% else %}{{ user_help }}{% endif %}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for error in user_form.role.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{# user_form.js hides the sections a superuser does not need while Superuser is chosen #}
|
||||
{% with superuser=user_form.role.value %}
|
||||
<p id="superuser-note" class="form-section small text-body-secondary mb-0"{% if superuser != "superuser" %} hidden{% endif %}>
|
||||
{% trans "A superuser has every permission and no quota: there is nothing more to set." %}
|
||||
</p>
|
||||
|
||||
<section class="form-section" data-not-superuser{% if superuser == "superuser" %} hidden{% endif %}>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<h2 class="h6 fw-semibold mb-1">{% trans "Quota" %}</h2>
|
||||
<p class="small text-body-secondary mb-0">{% trans "The most the user's VMs may have in total." %}</p>
|
||||
</div>
|
||||
<div class="col-lg-9 form-section-fields">
|
||||
<div class="row row-cols-2 row-cols-md-4 g-3">
|
||||
{% for field, unlimited, unit in attributes_form.quota_rows %}
|
||||
<div class="col">
|
||||
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
<div class="input-group{% if field.errors %} has-validation{% endif %}">
|
||||
<input id="{{ field.id_for_label }}" class="form-control{% if field.errors %} is-invalid{% endif %}" type="number" name="{{ field.html_name }}"
|
||||
min="0" step="{% if field.name == 'max_memory' %}any{% else %}1{% endif %}" value="{{ field.value|default_if_none:'' }}"{% if unlimited.value %} disabled{% endif %}>
|
||||
{% if unit %}<span class="input-group-text">{{ unit }}</span>{% endif %}
|
||||
{% for error in field.errors %}<div class="invalid-feedback">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
<div class="form-check mt-1">
|
||||
<input id="{{ unlimited.id_for_label }}" class="form-check-input" type="checkbox" name="{{ unlimited.html_name }}"
|
||||
data-no-limit="{{ field.id_for_label }}"{% if unlimited.value %} checked{% endif %}>
|
||||
<label class="form-check-label small" for="{{ unlimited.id_for_label }}">{{ unlimited.label }}</label>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="form-section" data-not-superuser{% if superuser == "superuser" %} hidden{% endif %}>
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<h2 class="h6 fw-semibold mb-1">{% trans "Groups and permissions" %}</h2>
|
||||
<p class="small text-body-secondary mb-0">{% trans "The permissions of the user's groups apply too." %}</p>
|
||||
</div>
|
||||
<div class="col-lg-9 form-section-fields">
|
||||
<fieldset class="mb-3">
|
||||
<legend class="visually-hidden">{% trans "Groups" %}</legend>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{% for box in user_form.groups %}
|
||||
<label class="border rounded px-2 py-1 text-break">
|
||||
<input id="{{ box.id_for_label }}" class="form-check-input me-1" type="checkbox" name="groups" value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
|
||||
{{ box.choice_label }}
|
||||
</label>
|
||||
{% empty %}
|
||||
<span class="small text-body-secondary">{% trans "There are no groups yet." %}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for error in user_form.groups.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend class="visually-hidden">{% trans "Own permissions" %}</legend>
|
||||
<p class="small text-body-secondary mb-2">{% trans "Off grants nothing here; it takes away nothing a group grants." %}</p>
|
||||
<div class="border-top">
|
||||
{% for box, help in user_form.permission_rows %}
|
||||
<div class="form-check form-switch border-bottom py-2 ps-5">
|
||||
<input id="{{ box.id_for_label }}" class="form-check-input" type="checkbox" role="switch" name="user_permissions"
|
||||
value="{{ box.data.value }}"{% if box.data.selected %} checked{% endif %}>
|
||||
<label class="form-check-label" for="{{ box.id_for_label }}">
|
||||
<span class="d-block">{{ box.data.value.instance.name }}</span>
|
||||
<span class="small text-body-secondary">{{ help }}</span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for error in user_form.user_permissions.errors %}<div class="invalid-feedback d-block">{{ error }}</div>{% endfor %}
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endwith %}
|
||||
|
||||
<div class="form-section d-flex justify-content-end gap-2">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'admin:user_list' %}">{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-primary">{% if edited_user %}{% trans "Save changes" %}{% else %}{% trans "Create user" %}{% endif %}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
<script src="{% static 'js/user_form.js' %}"></script>
|
||||
{% endblock script %}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
{% load static %}
|
||||
{% load common_tags %}
|
||||
{% load bootstrap_icons %}
|
||||
|
||||
{% block title %}{{ title }}{% endblock %}
|
||||
|
|
@ -9,73 +8,86 @@
|
|||
{% block page_heading %}{{ title }}{% endblock page_heading %}
|
||||
|
||||
{% block page_heading_extra %}
|
||||
<a href="{% url 'admin:user_create' %}" class="btn btn-success btn-header float-end">
|
||||
{% bs_icon 'plus-circle-fill' %}
|
||||
<a href="{% url 'admin:user_create' %}" class="btn btn-primary">
|
||||
<span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New user" %}
|
||||
</a>
|
||||
{% include 'search_block.html' %}
|
||||
{% endblock page_heading_extra %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
{% if not users %}
|
||||
<div class="col-lg-12">
|
||||
<div class="alert alert-warning shadow-sm">
|
||||
{% bs_icon 'exclamation-triangle '%} <strong>{% trans "Warning" %}:</strong> {% trans "You don't have any user" %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header d-flex flex-wrap align-items-center gap-2">
|
||||
<span class="me-auto">{% trans "Users" %} <span class="badge text-bg-light border">{{ users|length }}</span></span>
|
||||
<input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search users" %}">
|
||||
</div>
|
||||
<div class="list-group list-group-flush searchable">
|
||||
{% for user in users %}
|
||||
<div class="list-group-item py-3" data-search="{{ user.username }} {{ user.email }}{% for group in user.groups.all %} {{ group.name }}{% endfor %} {% if user.is_superuser %}{% trans "Superuser" %}{% elif user.is_staff %}{% trans "Template editor" %}{% else %}{% trans "User" %}{% endif %} {% if user.is_active %}{% trans "Active" %}{% else %}{% trans "Blocked" %}{% endif %}">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-12 col-md-4">
|
||||
<a href="{% url 'accounts:account' user.id %}" class="fw-semibold text-break">{{ user.username }}</a>
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mt-1 small text-body-secondary">
|
||||
{% if user.is_superuser %}
|
||||
<span class="badge text-bg-secondary">{% trans "Superuser" %}</span>
|
||||
{% elif user.is_staff %}
|
||||
<span class="badge text-bg-light border">{% trans "Template editor" %}</span>
|
||||
{% else %}
|
||||
<span>{% trans "User" %}</span>
|
||||
{% endif %}
|
||||
{% if user.is_active %}
|
||||
<span>{% trans "Active" %}</span>
|
||||
{% else %}
|
||||
<span class="badge text-bg-danger"><span aria-hidden="true">{% bs_icon 'slash-circle' %}</span> {% trans "Blocked" %}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-5 small">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2">
|
||||
{% for group in user.groups.all %}<span class="badge text-bg-light border mw-100 text-wrap text-break">{{ group.name }}</span>{% endfor %}
|
||||
{# a superuser has every permission: only a granted clone permission is news #}
|
||||
{% if user.is_active and user.can_clone and not user.is_superuser %}<span>{% trans "Can clone" %}</span>{% endif %}
|
||||
{% if user.api_tokens %}
|
||||
<a href="{% url 'accounts:account' user.id %}#apitokens">{% blocktrans count count=user.api_tokens %}{{ count }} API token{% plural %}{{ count }} API tokens{% endblocktrans %}</a>
|
||||
{% endif %}
|
||||
{% if not user.groups.all and not user.api_tokens %}{% if user.is_superuser or not user.is_active or not user.can_clone %}
|
||||
<span class="text-body-secondary">{% trans "No groups or API tokens" %}</span>
|
||||
{% endif %}{% endif %}
|
||||
</div>
|
||||
<div class="text-body-secondary mt-1">
|
||||
{% trans "Last login" %}: <span class="text-nowrap">{{ user.last_login|date:"Y-m-d H:i"|default:_("never") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-3 text-md-end">
|
||||
<div class="btn-group">
|
||||
<a href="{% url 'admin:user_update' user.id %}" class="btn btn-sm btn-outline-secondary">{% trans "Edit" %}</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false"
|
||||
data-bs-popper-config='{"strategy": "fixed"}' aria-label="{% blocktrans with name=user.username %}More actions for {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end shadow">
|
||||
<li><a class="dropdown-item" href="{% url 'accounts:account' user.id %}">{% bs_icon 'person' %} {% trans "View profile" %}</a></li>
|
||||
{# an administrator does not block or delete their own account #}
|
||||
<li><form method="post" action="{% if user.is_active %}{% url 'admin:user_block' user.id %}{% else %}{% url 'admin:user_unblock' user.id %}{% endif %}">{% csrf_token %}
|
||||
<button type="submit" class="dropdown-item"{% if user == request.user %} disabled{% endif %}>
|
||||
{% if user.is_active %}{% bs_icon 'slash-circle' %} {% trans "Block" %}{% else %}{% bs_icon 'play' %} {% trans "Unblock" %}{% endif %}
|
||||
</button>
|
||||
</form></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li>{% if user == request.user %}
|
||||
<button type="button" class="dropdown-item text-danger" disabled>{% bs_icon 'trash' %} {% trans "Delete" %}</button>
|
||||
{% else %}
|
||||
<a class="dropdown-item text-danger" href="{% url 'admin:user_delete' user.id %}">{% bs_icon 'trash' %} {% trans "Delete" %}</a>
|
||||
{% endif %}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-lg-12">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th span="col">{% trans "Username" %}</th>
|
||||
<th span="col">{% trans "Status" %}</th>
|
||||
<th span="col">{% trans "Last Login" %}</th>
|
||||
<th span="col">{% trans "Staff" %}</th>
|
||||
<th span="col">{% trans "Superuser" %}</th>
|
||||
<th span="col">{% trans "Can Clone" %}</th>
|
||||
<th span="col">{% trans "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="searchable">
|
||||
{% for user in users %}
|
||||
{% has_perm user 'instances.clone_instances' as can_clone %}
|
||||
<tr class="{% if not user.is_active %}danger{% endif %}">
|
||||
<td>
|
||||
{{ user.username }}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
{% trans "Active" %}
|
||||
{% else %}
|
||||
{% trans "Blocked" %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.last_login }}</td>
|
||||
<td>{% if user.is_staff %}{% bs_icon 'check' %}{% endif %}</td>
|
||||
<td>{% if user.is_superuser %}{% bs_icon 'check' %}</span>{% endif %}</td>
|
||||
<td>{% if can_clone %}{% bs_icon 'check' %}{% endif %}</td>
|
||||
<td>
|
||||
<div class="float-end btn-group">
|
||||
<a class="btn btn-success" title="{%trans "View Profile" %}" href="{% url 'accounts:account' user.id %}">{% bs_icon 'eye-fill' %}</a>
|
||||
<a class="btn btn-primary" title="{%trans "Edit" %}" href="{% url 'admin:user_update' user.id %}">{% bs_icon 'pencil-fill' %}</a>
|
||||
{% if user.is_active %}
|
||||
<a class="btn btn-warning" title="{%trans "Block" %}" href="{% url 'admin:user_block' user.id %}">{% bs_icon 'stop-fill' %}</a>
|
||||
{% else %}
|
||||
<a class="btn btn-success" title="{%trans "Unblock" %}" href="{% url 'admin:user_unblock' user.id %}">{% bs_icon 'play-fill' %}</a>
|
||||
{% endif %}
|
||||
<a class="btn btn-danger" title="{%trans "Delete" %}" href="{% url 'admin:user_delete' user.id %}">{% bs_icon 'x-circle-fill' %}</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div role="status">
|
||||
<div id="no-match" class="text-center text-body-secondary py-3" hidden>{% trans "No user matches the search." %}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block script %}
|
||||
<script src="{% static "js/filter-table.js" %}"></script>
|
||||
<script src="{% static 'js/table_filter.js' %}"></script>
|
||||
{% endblock script %}
|
||||
|
|
|
|||
52
admin/templates/admin/user_password_form.html
Normal file
52
admin/templates/admin/user_password_form.html
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
{% extends "base.html" %}
|
||||
{% load django_bootstrap5 %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "Change Password" %}{% endblock %}
|
||||
|
||||
{% block page_heading %}{% trans "Change Password" %}{% endblock page_heading %}
|
||||
|
||||
{% block content %}
|
||||
{# an administrator sets another user's password, or takes it away (sign-in by LDAP, for one) #}
|
||||
<form id="password-change" method="post">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form_errors form type="non_fields" %}
|
||||
|
||||
<section class="form-section">
|
||||
<div class="row g-3">
|
||||
<div class="col-lg-3">
|
||||
<h2 class="h6 fw-semibold mb-1 text-break">{{ edited_user.username }}</h2>
|
||||
<p class="small text-body-secondary mb-0">{% trans "How the user signs in." %} {% trans "Changing it deletes the user's API tokens." %}</p>
|
||||
</div>
|
||||
<div class="col-lg-9 form-section-fields">
|
||||
{% trans "Signs in with the password set here." as with_help %}
|
||||
{% trans "Signs in another way, such as LDAP. The current password is removed." as without_help %}
|
||||
<div class="row row-cols-1 row-cols-md-2 g-2 mb-3" role="radiogroup" aria-label="{% trans "How the user signs in." %}">
|
||||
{% for choice in form.usable_password %}
|
||||
<div class="col">
|
||||
<label class="form-choice border rounded p-3 d-flex gap-2 h-100">
|
||||
<input class="form-check-input flex-shrink-0 mt-1" type="radio" name="usable_password" value="{{ choice.data.value }}"{% if choice.data.selected %} checked{% endif %}>
|
||||
<span><span class="d-block fw-semibold">{{ choice.choice_label }}</span>
|
||||
<span class="small text-body-secondary">{% if choice.data.value == "true" %}{{ with_help }}{% else %}{{ without_help }}{% endif %}</span></span>
|
||||
</label>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{# hidden while Without a password is chosen (webvirtcloud.css) #}
|
||||
<div class="with-password">
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">{% bootstrap_field form.password1 %}</div>
|
||||
<div class="col-sm-6">{% bootstrap_field form.password2 show_help=False %}</div>
|
||||
</div>
|
||||
{% bootstrap_field form.must_change_password %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="form-section d-flex justify-content-end gap-2">
|
||||
<a class="btn btn-outline-secondary" href="{% url 'admin:user_update' edited_user.id %}">{% trans "Cancel" %}</a>
|
||||
<button type="submit" class="btn btn-primary">{% trans "Change password" %}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
124
admin/tests.py
124
admin/tests.py
|
|
@ -1,124 +0,0 @@
|
|||
from django.contrib.auth.models import Group, User
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.shortcuts import reverse
|
||||
from django.test import TestCase
|
||||
|
||||
from accounts.models import UserAttributes
|
||||
|
||||
|
||||
class AdminTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.client.login(username="admin", password="admin")
|
||||
|
||||
def test_group_list(self):
|
||||
response = self.client.get(reverse("admin:group_list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_groups(self):
|
||||
response = self.client.get(reverse("admin:group_create"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:group_create"), {"name": "Test Group"}
|
||||
)
|
||||
self.assertRedirects(response, reverse("admin:group_list"))
|
||||
|
||||
group = Group.objects.get(name="Test Group")
|
||||
group_id = group.id
|
||||
|
||||
response = self.client.get(reverse("admin:group_update", args=[group_id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:group_update", args=[group_id]), {"name": "Updated Group Test"}
|
||||
)
|
||||
self.assertRedirects(response, reverse("admin:group_list"))
|
||||
|
||||
group = Group.objects.get(id=group_id)
|
||||
self.assertEqual(group.name, "Updated Group Test")
|
||||
|
||||
response = self.client.get(reverse("admin:group_delete", args=[group_id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse("admin:group_delete", args=[group_id]))
|
||||
self.assertRedirects(response, reverse("admin:group_list"))
|
||||
|
||||
with self.assertRaises(ObjectDoesNotExist):
|
||||
Group.objects.get(id=group_id)
|
||||
|
||||
def test_user_list(self):
|
||||
response = self.client.get(reverse("admin:user_list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_users(self):
|
||||
response = self.client.get(reverse("admin:user_create"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:user_create"),
|
||||
{
|
||||
"username": "test",
|
||||
"password": "test",
|
||||
"max_instances": 1,
|
||||
"max_cpus": 1,
|
||||
"max_memory": 1024,
|
||||
"max_disk_size": 4,
|
||||
},
|
||||
)
|
||||
self.assertRedirects(response, reverse("admin:user_list"))
|
||||
|
||||
user = User.objects.get(username="test")
|
||||
user_id = user.id
|
||||
|
||||
ua: UserAttributes = UserAttributes.objects.get(user_id=user_id)
|
||||
self.assertEqual(ua.user_id, user_id)
|
||||
self.assertEqual(ua.max_instances, 1)
|
||||
self.assertEqual(ua.max_cpus, 1)
|
||||
self.assertEqual(ua.max_memory, 1024)
|
||||
self.assertEqual(ua.max_disk_size, 4)
|
||||
|
||||
response = self.client.get(reverse("admin:user_update", args=[user_id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:user_update", args=[user_id]),
|
||||
{
|
||||
"username": "utest",
|
||||
"max_instances": 2,
|
||||
"max_cpus": 2,
|
||||
"max_memory": 2048,
|
||||
"max_disk_size": 8,
|
||||
},
|
||||
)
|
||||
self.assertRedirects(response, reverse("admin:user_list"))
|
||||
|
||||
user = User.objects.get(id=user_id)
|
||||
self.assertEqual(user.username, "utest")
|
||||
|
||||
ua = UserAttributes.objects.get(user_id=user_id)
|
||||
self.assertEqual(ua.user_id, user_id)
|
||||
self.assertEqual(ua.max_instances, 2)
|
||||
self.assertEqual(ua.max_cpus, 2)
|
||||
self.assertEqual(ua.max_memory, 2048)
|
||||
self.assertEqual(ua.max_disk_size, 8)
|
||||
|
||||
response = self.client.get(reverse("admin:user_block", args=[user_id]))
|
||||
user = User.objects.get(id=user_id)
|
||||
self.assertFalse(user.is_active)
|
||||
|
||||
response = self.client.get(reverse("admin:user_unblock", args=[user_id]))
|
||||
user = User.objects.get(id=user_id)
|
||||
self.assertTrue(user.is_active)
|
||||
|
||||
response = self.client.get(reverse("admin:user_delete", args=[user_id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse("admin:user_delete", args=[user_id]))
|
||||
self.assertRedirects(response, reverse("admin:user_list"))
|
||||
|
||||
with self.assertRaises(ObjectDoesNotExist):
|
||||
User.objects.get(id=user_id)
|
||||
|
||||
def test_logs(self):
|
||||
response = self.client.get(reverse("admin:logs"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
0
admin/tests/__init__.py
Normal file
0
admin/tests/__init__.py
Normal file
115
admin/tests/test_audit_rows.py
Normal file
115
admin/tests/test_audit_rows.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Changes to users and groups write rows on the Logs page."""
|
||||
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from logs.models import Logs
|
||||
|
||||
ATTRIBUTES = {"role": "user", "max_instances": 2, "max_cpus": 2, "max_memory": 2, "max_disk_size": 20}
|
||||
|
||||
|
||||
def rows():
|
||||
return list(Logs.objects.order_by("id").values_list("message", "success"))
|
||||
|
||||
|
||||
class UserRowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.admin = User.objects.create_superuser("audit-admin", "audit@example.com", "pw")
|
||||
self.client.force_login(self.admin)
|
||||
Logs.objects.all().delete() # the login's row
|
||||
|
||||
def create(self, username="carol", **extra):
|
||||
return self.client.post(reverse("admin:user_create"),
|
||||
{"username": username, "password": "Carol-pass-2026", "is_active": "on", **ATTRIBUTES, **extra})
|
||||
|
||||
def edit(self, user, **changes):
|
||||
data = {"username": user.username, "email": user.email, "is_active": "on", **ATTRIBUTES,
|
||||
"user_permissions": [p.pk for p in user.user_permissions.all()],
|
||||
"groups": [g.pk for g in user.groups.all()], **changes}
|
||||
return self.client.post(reverse("admin:user_update", args=[user.pk]), data)
|
||||
|
||||
def test_user_add_change_delete(self):
|
||||
self.create()
|
||||
carol = User.objects.get(username="carol")
|
||||
self.edit(carol, email="carol@example.com")
|
||||
self.edit(User.objects.get(pk=carol.pk)) # nothing changed
|
||||
self.client.post(reverse("admin:user_delete", args=[carol.pk]))
|
||||
self.assertEqual(rows(), [
|
||||
("User carol: add", True),
|
||||
("User carol: change email", True),
|
||||
("User carol: delete", True),
|
||||
])
|
||||
|
||||
def test_attributes_and_groups_are_named(self):
|
||||
self.create()
|
||||
carol = User.objects.get(username="carol")
|
||||
permission = Permission.objects.get(content_type__app_label="accounts", codename="change_password")
|
||||
carol.user_permissions.remove(permission)
|
||||
Logs.objects.all().delete()
|
||||
|
||||
self.edit(carol, max_instances=5)
|
||||
group = Group.objects.create(name="ops")
|
||||
self.edit(carol, max_instances=5, groups=[group.pk], user_permissions=[permission.pk])
|
||||
self.edit(carol, max_instances=5, groups=[group.pk], user_permissions=[permission.pk], username="carla")
|
||||
self.edit(User.objects.get(pk=carol.pk), username="") # invalid: no row
|
||||
self.assertEqual([m for m, _ in rows()], [
|
||||
"User carol: change max_instances",
|
||||
"User carol: change groups, user_permissions",
|
||||
"User carol: change username",
|
||||
])
|
||||
self.assertTrue(User.objects.get(pk=carol.pk).user_permissions.filter(pk=permission.pk).exists())
|
||||
|
||||
def test_block_unblock_and_password(self):
|
||||
self.create()
|
||||
carol = User.objects.get(username="carol")
|
||||
Logs.objects.all().delete()
|
||||
self.client.post(reverse("admin:user_block", args=[carol.pk]))
|
||||
self.client.post(reverse("admin:user_block", args=[carol.pk])) # already blocked: nothing changes
|
||||
self.client.post(reverse("admin:user_unblock", args=[carol.pk]))
|
||||
self.client.post(reverse("admin:user_unblock", args=[carol.pk]))
|
||||
self.client.post(reverse("admin:user_update_password", args=[carol.pk]),
|
||||
{"password1": "New-carol-pass-1", "password2": "New-carol-pass-1"})
|
||||
self.assertEqual(rows(), [
|
||||
("User carol: block", True),
|
||||
("User carol: unblock", True),
|
||||
("User carol: set password", True),
|
||||
])
|
||||
self.assertFalse(Logs.objects.filter(message__contains="New-carol-pass-1").exists())
|
||||
|
||||
def test_the_row_has_actor_and_ip(self):
|
||||
self.client.post(
|
||||
reverse("admin:user_create"), {"username": "carol", "password": "Carol-pass-2026", **ATTRIBUTES},
|
||||
REMOTE_ADDR="192.0.2.9")
|
||||
row = Logs.objects.get()
|
||||
self.assertEqual((row.user, row.host, row.instance, row.ip), ("audit-admin", "-", "", "192.0.2.9"))
|
||||
|
||||
|
||||
class GroupAndRemovedVMRowTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client.force_login(User.objects.create_superuser("audit-admin", "audit@example.com", "pw"))
|
||||
Logs.objects.all().delete() # the login's row
|
||||
|
||||
def test_group_add_change_delete(self):
|
||||
member = User.objects.create_user("dave")
|
||||
permission = Permission.objects.get(content_type__app_label="accounts", codename="change_password")
|
||||
self.client.post(reverse("admin:group_create"), {"name": "ops"})
|
||||
group = Group.objects.get(name="ops")
|
||||
url = reverse("admin:group_update", args=[group.pk])
|
||||
self.client.post(url, {"name": "ops", "users": [member.pk]})
|
||||
self.client.post(url, {"name": "ops", "users": [member.pk], "permissions": [permission.pk]})
|
||||
self.client.post(url, {"name": "ops", "users": [member.pk], "permissions": [permission.pk]}) # no change
|
||||
self.client.post(reverse("admin:group_delete", args=[group.pk]))
|
||||
self.assertEqual(rows(), [
|
||||
("Group ops: add", True),
|
||||
("Group ops: change users", True),
|
||||
("Group ops: change permissions", True),
|
||||
("Group ops: delete", True),
|
||||
])
|
||||
|
||||
def test_a_removed_vm_record_delete(self):
|
||||
from instances.models import InstanceTombstone
|
||||
uuid = "55555555-5555-5555-5555-555555555555"
|
||||
tombstone = InstanceTombstone.objects.create(uuid=uuid, name="vm", owners=[])
|
||||
self.client.post(reverse("admin:tombstone_delete", args=[tombstone.pk]))
|
||||
self.assertEqual(rows(), [(f"Removed VM vm ({uuid}): delete", True)])
|
||||
113
admin/tests/test_logs_page.py
Normal file
113
admin/tests/test_logs_page.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
"""The Logs page searches every row on the server, can show the failed ones
|
||||
only, says the year and time zone, links a VM's rows to the VM, and downloads
|
||||
what it shows as an Excel workbook."""
|
||||
|
||||
from io import BytesIO
|
||||
from unittest.mock import patch
|
||||
|
||||
from computes.models import Compute
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from instances.models import Instance
|
||||
from logs.models import Logs
|
||||
from lxml import html
|
||||
from openpyxl import load_workbook
|
||||
|
||||
UUID = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
|
||||
@override_settings(TIME_ZONE="UTC")
|
||||
class LogsPageTests(TestCase):
|
||||
def setUp(self):
|
||||
self.admin = get_user_model().objects.create_superuser("lg-admin", "lg@example.com", "pw")
|
||||
self.client.force_login(self.admin)
|
||||
Logs.objects.all().delete()
|
||||
# the row to find is the oldest: past the first page
|
||||
Logs.objects.create(user="alice", host="host1", instance="web", instance_uuid=UUID,
|
||||
message="VM web: delete", success=False, ip="192.0.2.7")
|
||||
Logs.objects.bulk_create(Logs(user="bob", instance="db", message=f"VM db: power on {n}", success=True)
|
||||
for n in range(150))
|
||||
compute = Compute.objects.create(name="host1", hostname="192.0.2.1", login="u", password="p", type=1)
|
||||
self.vm = Instance.objects.create(compute=compute, name="web", uuid=UUID)
|
||||
|
||||
def page(self, **query):
|
||||
return html.fromstring(self.client.get(reverse("admin:logs"), query).content)
|
||||
|
||||
def rows(self, page):
|
||||
return [[" ".join(td.text_content().split()) for td in tr.xpath("td")] for tr in page.xpath("//tbody/tr")]
|
||||
|
||||
def test_a_search_finds_a_row_on_any_page(self):
|
||||
for query in ("alice", "WEB: DELETE", "192.0.2.7", "host1"):
|
||||
with self.subTest(query=query):
|
||||
rows = self.rows(self.page(q=query))
|
||||
self.assertEqual([row[2] for row in rows], ["alice"])
|
||||
|
||||
def test_a_search_is_text_not_a_pattern(self):
|
||||
Logs.objects.create(user="carol", instance="", message="disk (vda) [full]", success=True)
|
||||
self.assertEqual([row[2] for row in self.rows(self.page(q="(vda) ["))], ["carol"])
|
||||
|
||||
def test_the_failed_rows_only_and_marked(self):
|
||||
page = self.page(failed="1")
|
||||
self.assertEqual([row[2] for row in self.rows(page)], ["alice"])
|
||||
self.assertTrue(page.xpath("//tbody/tr[1]//span[contains(@class, 'text-bg-danger')]"))
|
||||
|
||||
def test_the_pages_keep_the_search(self):
|
||||
page = self.page(q="bob")
|
||||
self.assertTrue(page.xpath("//a[contains(@href, 'page=2') and contains(@href, 'q=bob')]"))
|
||||
|
||||
def test_dates_have_the_year_and_the_time_zone(self):
|
||||
page = self.page(q="alice")
|
||||
self.assertIn("Date (UTC)", [" ".join(th.text_content().split()) for th in page.xpath("//thead//th")])
|
||||
log = Logs.objects.get(user="alice")
|
||||
self.assertEqual(self.rows(page)[0][1], log.date.strftime("%Y-%m-%d %H:%M:%S"))
|
||||
|
||||
def test_a_vm_row_links_the_vm_while_it_is_there(self):
|
||||
page = self.page(q="alice")
|
||||
self.assertEqual(page.xpath("//tbody/tr/td/a/@href"), [reverse("instances:instance", args=[self.vm.pk])])
|
||||
self.vm.delete()
|
||||
self.assertEqual(self.page(q="alice").xpath("//tbody/tr/td/a"), [])
|
||||
|
||||
def test_the_rows_shown_download_as_a_workbook(self):
|
||||
Logs.objects.create(user="mallory", instance="", message="=HYPERLINK(1)", success=False)
|
||||
response = self.client.get(reverse("admin:logs_export"), {"failed": "1"})
|
||||
self.assertEqual(response["Content-Type"], "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
self.assertIn("attachment", response["Content-Disposition"])
|
||||
sheet = load_workbook(BytesIO(response.content)).active
|
||||
values = list(sheet.values)
|
||||
self.assertEqual(values[0], ("Date (UTC)", "User", "Host", "VM", "VM UUID", "Message", "Result", "IP"))
|
||||
self.assertEqual([row[1] for row in values[1:]], ["mallory", "alice"])
|
||||
# a message starting with = stays text
|
||||
self.assertEqual(sheet.cell(row=2, column=6).data_type, "s")
|
||||
# a bold header that stays in view, with filters over every row
|
||||
self.assertEqual((sheet.freeze_panes, sheet.auto_filter.ref, sheet["A1"].font.b), ("A2", "A1:H3", True))
|
||||
|
||||
def test_the_workbook_is_written_row_by_row(self):
|
||||
# openpyxl's write-only mode: a large download does not keep every cell in memory
|
||||
from openpyxl import Workbook
|
||||
|
||||
with patch("instances.report.Workbook", wraps=Workbook) as book:
|
||||
self.client.get(reverse("admin:logs_export"))
|
||||
book.assert_called_once_with(write_only=True)
|
||||
page = self.page(q="alice", failed="1")
|
||||
self.assertEqual(page.xpath("//a[contains(@href, 'export')]/@href"),
|
||||
[reverse("admin:logs_export") + "?q=alice&failed=1"])
|
||||
|
||||
def test_only_an_administrator_downloads(self):
|
||||
self.client.force_login(get_user_model().objects.create_user("lg-user", password="pw"))
|
||||
self.assertEqual(self.client.get(reverse("admin:logs_export")).status_code, 403)
|
||||
|
||||
def test_a_control_character_in_a_row_is_shown_not_a_failure(self):
|
||||
# a failed login writes the name it was tried with, as typed
|
||||
Logs.objects.create(user="eve\x01", instance="", message="Login", success=False)
|
||||
response = self.client.get(reverse("admin:logs_export"), {"q": "eve"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(load_workbook(BytesIO(response.content)).active.cell(row=2, column=2).value, "eve\\x01")
|
||||
|
||||
def test_the_download_stops_at_its_row_limit_and_says_so(self):
|
||||
with patch("admin.views.EXPORT_ROWS", 3):
|
||||
book = load_workbook(BytesIO(self.client.get(reverse("admin:logs_export")).content))
|
||||
self.assertEqual(book.active.max_row, 4) # the header and three rows
|
||||
notes = [row[0] for row in book["About"].values]
|
||||
self.assertIn("Only the newest 3 rows: narrow the search for the others", notes)
|
||||
|
||||
63
admin/tests/test_tombstones.py
Normal file
63
admin/tests/test_tombstones.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
"""Superusers list the ownership kept for VMs that vanished and can remove it."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from instances.models import InstanceTombstone
|
||||
from logs.models import Logs
|
||||
|
||||
UUID = "00000000-0000-4000-8000-000000000001"
|
||||
|
||||
|
||||
class TombstoneTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client.force_login(User.objects.create_superuser("ts-admin", "ts@example.com", "pw"))
|
||||
self.owner = User.objects.create_user("ts-owner", password="pw")
|
||||
self.tombstone = InstanceTombstone.objects.create(
|
||||
uuid=UUID,
|
||||
name="ts-vm",
|
||||
host="ts-host",
|
||||
owners=[
|
||||
{"user": self.owner.id, "is_change": True, "is_delete": False, "is_vnc": True},
|
||||
{"user": 999999, "is_change": False, "is_delete": False, "is_vnc": False},
|
||||
],
|
||||
)
|
||||
|
||||
@override_settings(INSTANCE_OWNERSHIP_RETENTION_DAYS=7)
|
||||
def test_the_list_shows_the_vm_its_owners_and_when_it_expires(self):
|
||||
removed = timezone.now() - timedelta(days=2)
|
||||
InstanceTombstone.objects.filter(pk=self.tombstone.pk).update(removed=removed)
|
||||
response = self.client.get(reverse("admin:tombstone_list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
row = response.context["tombstones"][0]
|
||||
self.assertEqual(row.owner_names, ["ts-owner", "deleted user"])
|
||||
self.assertEqual(row.expires, removed + timedelta(days=7))
|
||||
self.assertContains(response, "ts-vm")
|
||||
self.assertContains(response, UUID)
|
||||
self.assertContains(response, "ts-owner")
|
||||
self.assertContains(response, '<span class="fw-semibold">ts-host</span>', html=True)
|
||||
|
||||
def test_only_superusers_see_or_remove_them(self):
|
||||
self.client.force_login(self.owner)
|
||||
self.assertEqual(self.client.get(reverse("admin:tombstone_list")).status_code, 403)
|
||||
url = reverse("admin:tombstone_delete", args=[self.tombstone.pk])
|
||||
self.assertEqual(self.client.post(url).status_code, 403)
|
||||
self.assertTrue(InstanceTombstone.objects.exists())
|
||||
|
||||
def test_a_get_only_asks_for_confirmation(self):
|
||||
response = self.client.get(reverse("admin:tombstone_delete", args=[self.tombstone.pk]))
|
||||
self.assertContains(response, "ts-vm")
|
||||
self.assertTrue(InstanceTombstone.objects.exists())
|
||||
|
||||
def test_a_post_removes_it(self):
|
||||
response = self.client.post(reverse("admin:tombstone_delete", args=[self.tombstone.pk]), follow=True)
|
||||
self.assertRedirects(response, reverse("admin:tombstone_list"))
|
||||
self.assertContains(response, "Removed VM ts-vm deleted")
|
||||
self.assertFalse(InstanceTombstone.objects.exists())
|
||||
# the VM's history (found by UUID) has it
|
||||
self.assertEqual(list(Logs.objects.filter(message__startswith="Removed VM").values_list("host", "instance", "instance_uuid")),
|
||||
[("ts-host", "ts-vm", UUID)])
|
||||
104
admin/tests/test_user_create.py
Normal file
104
admin/tests/test_user_create.py
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"""Creating a user in the admin pages: the password reaches the database only
|
||||
hashed, and the configured password validators apply as on the change forms."""
|
||||
|
||||
import re
|
||||
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.db import connection
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from accounts.models import UserInstance
|
||||
from computes.models import Compute
|
||||
from instances.models import Instance
|
||||
|
||||
RAW = "Raw-Passw0rd-in-the-form"
|
||||
LIMITS = {"role": "user", "max_instances": 1, "max_cpus": 1, "max_memory": 1, "max_disk_size": 4}
|
||||
|
||||
|
||||
class UserCreateTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client.force_login(User.objects.create_superuser("uc-admin", "uc@example.com", "pw"))
|
||||
self.change_password = Permission.objects.get(content_type__app_label="accounts", codename="change_password")
|
||||
|
||||
def create(self, **data):
|
||||
return self.client.post(reverse("admin:user_create"), {"username": "newbie", "password": RAW, **LIMITS, **data})
|
||||
|
||||
def test_the_raw_password_is_never_written(self):
|
||||
written = []
|
||||
|
||||
def record(execute, sql, params, many, context):
|
||||
if RAW in repr(params):
|
||||
written.append(sql)
|
||||
return execute(sql, params, many, context)
|
||||
|
||||
with connection.execute_wrapper(record):
|
||||
response = self.create()
|
||||
self.assertRedirects(response, reverse("admin:user_list"))
|
||||
self.assertEqual(written, [])
|
||||
self.assertTrue(User.objects.get(username="newbie").check_password(RAW))
|
||||
|
||||
def test_a_new_user_may_change_the_password_and_open_consoles_unless_unticked(self):
|
||||
# new VMs have a VNC password, which the console page sends only with passwordless_console
|
||||
page = self.client.get(reverse("admin:user_create")).content.decode()
|
||||
ticked = set(re.findall(r'<input[^>]*name="user_permissions"[^>]*value="(\d+)"[^>]*checked', page))
|
||||
self.assertEqual(ticked, {str(self.change_password.pk), str(Permission.objects.get(codename="passwordless_console").pk)})
|
||||
|
||||
def test_groups_and_permissions_are_saved(self):
|
||||
group = Group.objects.create(name="uc-group")
|
||||
self.create(groups=[group.pk], user_permissions=[self.change_password.pk])
|
||||
user = User.objects.get(username="newbie")
|
||||
self.assertEqual(list(user.groups.all()), [group])
|
||||
self.assertEqual(list(user.user_permissions.all()), [self.change_password])
|
||||
|
||||
def test_an_unticked_permission_stays_off(self):
|
||||
self.create()
|
||||
self.assertFalse(User.objects.get(username="newbie").user_permissions.exists())
|
||||
|
||||
@override_settings(
|
||||
AUTH_PASSWORD_VALIDATORS=[
|
||||
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", "OPTIONS": {"min_length": 30}}
|
||||
]
|
||||
)
|
||||
def test_configured_password_validators_apply(self):
|
||||
response = self.create()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "at least 30 characters")
|
||||
self.assertFalse(User.objects.filter(username="newbie").exists())
|
||||
|
||||
@override_settings(
|
||||
AUTH_PASSWORD_VALIDATORS=[{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}]
|
||||
)
|
||||
def test_the_password_is_checked_against_the_new_username(self):
|
||||
response = self.client.post(
|
||||
reverse("admin:user_create"), {"username": "rumpelstiltskin", "password": "rumpelstiltskin1", **LIMITS}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "too similar to the username")
|
||||
|
||||
|
||||
class DefaultInstancesTests(TestCase):
|
||||
"""NEW_USER_DEFAULT_INSTANCES names VMs, and a name may repeat across hosts."""
|
||||
|
||||
def setUp(self):
|
||||
self.client.force_login(User.objects.create_superuser("uc-admin", "uc@example.com", "pw"))
|
||||
one, two = (Compute.objects.create(name=name, hostname="localhost", login="", password="", details="", type=4)
|
||||
for name in ("uc-one", "uc-two"))
|
||||
self.web = Instance.objects.create(compute=one, name="web", uuid="00000000-0000-0000-0000-000000000001")
|
||||
for compute, uuid in ((one, "00000000-0000-0000-0000-000000000002"), (two, "00000000-0000-0000-0000-000000000003")):
|
||||
Instance.objects.create(compute=compute, name="db", uuid=uuid)
|
||||
|
||||
def create(self):
|
||||
return self.client.post(reverse("admin:user_create"), {"username": "newbie", "password": RAW, **LIMITS})
|
||||
|
||||
@override_settings(NEW_USER_DEFAULT_INSTANCES=["web"])
|
||||
def test_the_new_user_owns_the_named_vm(self):
|
||||
self.assertRedirects(self.create(), reverse("admin:user_list"))
|
||||
self.assertEqual([o.instance for o in UserInstance.objects.filter(user__username="newbie")], [self.web])
|
||||
|
||||
def test_a_name_that_is_not_one_vm_is_refused_on_the_form(self):
|
||||
for name in ("no-such-vm", "db"):
|
||||
with self.subTest(name), override_settings(NEW_USER_DEFAULT_INSTANCES=["web", name]):
|
||||
response = self.create()
|
||||
self.assertContains(response, f"NEW_USER_DEFAULT_INSTANCES: {name} is not exactly one VM")
|
||||
self.assertFalse(User.objects.filter(username="newbie").exists())
|
||||
166
admin/tests/test_user_pages.py
Normal file
166
admin/tests/test_user_pages.py
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
"""The Users pages: an administrator cannot lock their own account out, the
|
||||
user form says what its fields mean, and a password set by an administrator
|
||||
can be one the user must change."""
|
||||
|
||||
from accounts.models import ApiToken, UserAttributes
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from lxml import html
|
||||
|
||||
|
||||
class UserPagesTests(TestCase):
|
||||
def setUp(self):
|
||||
User = get_user_model()
|
||||
self.admin = User.objects.create_superuser("up-admin", "up@example.com", "pw")
|
||||
self.alice = User.objects.create_user("up-alice", password="Alice-pw-123")
|
||||
UserAttributes.objects.get_or_create(user=self.alice)
|
||||
self.client.force_login(self.admin)
|
||||
|
||||
def messages(self, response):
|
||||
return [str(m) for m in get_messages(response.wsgi_request)]
|
||||
|
||||
def form_data(self, user, **changes):
|
||||
data = {"username": user.username, "is_active": "on", "role": "superuser" if user.is_superuser else "user",
|
||||
"max_instances": "2", "max_cpus": "2", "max_memory": "2", "max_disk_size": "20", **changes}
|
||||
return {key: value for key, value in data.items() if value != ""}
|
||||
|
||||
def test_an_administrator_cannot_block_delete_or_demote_themselves(self):
|
||||
own = self.admin.pk
|
||||
for response in (self.client.post(reverse("admin:user_block", args=[own])),
|
||||
self.client.post(reverse("admin:user_delete", args=[own]))):
|
||||
self.assertIn("You cannot block, delete or demote your own account", self.messages(response))
|
||||
for change in ({"role": "user"}, {"is_active": ""}):
|
||||
response = self.client.post(reverse("admin:user_update", args=[own]), self.form_data(self.admin, **change))
|
||||
self.assertContains(response, "You cannot block, delete or demote your own account")
|
||||
self.admin.refresh_from_db()
|
||||
self.assertTrue(self.admin.is_active and self.admin.is_superuser)
|
||||
# another user's account is theirs to change
|
||||
self.client.post(reverse("admin:user_block", args=[self.alice.pk]))
|
||||
self.alice.refresh_from_db()
|
||||
self.assertFalse(self.alice.is_active)
|
||||
|
||||
def test_the_form_tells_the_password_state_and_links_its_page(self):
|
||||
page = html.fromstring(self.client.get(reverse("admin:user_update", args=[self.alice.pk])).content)
|
||||
text = " ".join(page.text_content().split())
|
||||
self.assertIn("Password Set Change password", text)
|
||||
self.assertNotIn("No password set", text)
|
||||
self.assertEqual(page.xpath("//a[normalize-space()='Change password']/@href"),
|
||||
[reverse("admin:user_update_password", args=[self.alice.pk])])
|
||||
self.assertEqual(page.xpath("//a[@href='../password/']"), [])
|
||||
|
||||
def test_roles_say_what_they_mean(self):
|
||||
page = self.client.get(reverse("admin:user_update", args=[self.alice.pk])).content.decode()
|
||||
self.assertIn("Template editor", page)
|
||||
self.assertIn("May change and delete the template VMs given to them", page)
|
||||
self.assertIn("Manages the panel: every permission, no quota.", page)
|
||||
self.assertNotIn("log into this admin site", page)
|
||||
|
||||
def test_the_password_page_says_how_the_user_signs_in(self):
|
||||
url = reverse("admin:user_update_password", args=[self.alice.pk])
|
||||
page = self.client.get(url).content.decode()
|
||||
self.assertIn("With a password", page)
|
||||
self.assertIn("Signs in another way, such as LDAP. The current password is removed.", page)
|
||||
self.assertIn(f'href="{reverse("admin:user_update", args=[self.alice.pk])}">Cancel', page)
|
||||
# a mismatch is said at its field only
|
||||
response = self.client.post(url, {"usable_password": "true", "password1": "Ab-123456x", "password2": "other"})
|
||||
self.assertContains(response, "The two password fields didn’t match.")
|
||||
self.assertNotIn("Wrong Data Provided", self.messages(response))
|
||||
|
||||
def test_a_refused_choice_is_said_beside_its_field(self):
|
||||
url = reverse("admin:user_update", args=[self.alice.pk])
|
||||
response = self.client.post(url, {"username": "up-alice", "is_active": "on", "groups": "99999", "user_permissions": "99999",
|
||||
"role": "user", "max_instances": 1, "max_cpus": 1, "max_memory": 1, "max_disk_size": 1})
|
||||
page = html.fromstring(response.content)
|
||||
self.assertEqual(len(page.xpath("//fieldset//div[contains(@class, 'invalid-feedback')]")), 2)
|
||||
|
||||
def test_the_quota_shows_no_limit_and_memory_in_gib(self):
|
||||
UserAttributes.objects.filter(user=self.alice).update(max_instances=-1, max_cpus=1, max_memory=1536, max_disk_size=-1)
|
||||
page = html.fromstring(self.client.get(reverse("admin:user_update", args=[self.alice.pk])).content)
|
||||
shown = {box.get("name"): (box.get("value"), box.get("disabled") is not None) for box in page.xpath("//input[@type='number']")}
|
||||
self.assertEqual(shown, {"max_instances": ("", True), "max_cpus": ("1", False), "max_memory": ("1.5", False),
|
||||
"max_disk_size": ("", True)})
|
||||
self.assertEqual(page.xpath("//input[@checked][@data-no-limit]/@name"), ["max_instances_unlimited", "max_disk_size_unlimited"])
|
||||
|
||||
def test_a_new_user_starts_with_the_default_quota(self):
|
||||
page = html.fromstring(self.client.get(reverse("admin:user_create")).content)
|
||||
shown = {box.get("name"): box.get("value") for box in page.xpath("//input[@type='number']")}
|
||||
self.assertEqual(shown, {"max_instances": "2", "max_cpus": "2", "max_memory": "2", "max_disk_size": "20"})
|
||||
self.assertEqual(page.xpath("//input[@name='role'][@checked]/@value"), ["user"])
|
||||
|
||||
def test_no_limit_is_kept_as_minus_one_and_gib_as_mib(self):
|
||||
response = self.client.post(reverse("admin:user_update", args=[self.alice.pk]),
|
||||
self.form_data(self.alice, max_cpus_unlimited="on", max_memory="0.75"))
|
||||
self.assertRedirects(response, reverse("admin:user_list"))
|
||||
quota = UserAttributes.objects.get(user=self.alice)
|
||||
self.assertEqual((quota.max_instances, quota.max_cpus, quota.max_memory, quota.max_disk_size), (2, -1, 768, 20))
|
||||
|
||||
def test_a_limit_needs_a_number_or_no_limit(self):
|
||||
for value in ("", "-1"):
|
||||
with self.subTest(value=value):
|
||||
response = self.client.post(reverse("admin:user_update", args=[self.alice.pk]),
|
||||
self.form_data(self.alice, max_cpus=value) | {"max_cpus": value})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(html.fromstring(response.content).xpath("//input[@name='max_cpus'][contains(@class, 'is-invalid')]")), 1)
|
||||
|
||||
def test_a_role_sets_staff_and_superuser(self):
|
||||
url = reverse("admin:user_update", args=[self.alice.pk])
|
||||
for role, flags in (("template_editor", (True, False)), ("superuser", (True, True)), ("user", (False, False))):
|
||||
with self.subTest(role=role):
|
||||
self.assertRedirects(self.client.post(url, self.form_data(self.alice, role=role)), reverse("admin:user_list"))
|
||||
self.alice.refresh_from_db()
|
||||
self.assertEqual((self.alice.is_staff, self.alice.is_superuser), flags)
|
||||
|
||||
def test_a_missing_users_profile_is_not_found(self):
|
||||
self.assertEqual(self.client.get(reverse("accounts:account", args=[99999])).status_code, 404)
|
||||
|
||||
def test_a_user_who_never_logged_in_says_never(self):
|
||||
page = html.fromstring(self.client.get(reverse("admin:user_list")).content)
|
||||
(row,) = page.xpath("//div[contains(@class, 'list-group-item')][.//a[normalize-space()='up-alice']]")
|
||||
self.assertIn("Last login: never", " ".join(row.text_content().split()))
|
||||
|
||||
def test_the_search_finds_a_user_by_role_and_status(self):
|
||||
self.alice.is_active = False
|
||||
self.alice.save()
|
||||
page = html.fromstring(self.client.get(reverse("admin:user_list")).content)
|
||||
(row,) = page.xpath("//div[contains(@class, 'list-group-item')][.//a[normalize-space()='up-alice']]")
|
||||
self.assertIn("User Blocked", row.get("data-search"))
|
||||
|
||||
def test_a_password_set_by_an_administrator_can_be_one_to_change(self):
|
||||
url = reverse("admin:user_update_password", args=[self.alice.pk])
|
||||
self.client.post(url, {"password1": "New-pw-4567x", "password2": "New-pw-4567x", "must_change_password": "on"})
|
||||
self.assertTrue(UserAttributes.objects.get(user=self.alice).must_change_password)
|
||||
# the user is sent to the change form until they choose their own
|
||||
self.alice.refresh_from_db() # the new password's hash, or the session is not hers
|
||||
self.client.force_login(self.alice)
|
||||
self.assertRedirects(self.client.get(reverse("instances:index")), reverse("accounts:change_password"),
|
||||
fetch_redirect_response=False)
|
||||
self.client.force_login(self.admin)
|
||||
self.client.post(url, {"password1": "New-pw-4567y", "password2": "New-pw-4567y"})
|
||||
self.assertFalse(UserAttributes.objects.get(user=self.alice).must_change_password)
|
||||
|
||||
def test_a_password_set_by_an_administrator_ends_the_users_api_tokens(self):
|
||||
# a reset closes a taken-over account: a stolen token must not outlive it
|
||||
ApiToken.objects.create(self.alice, expiry=None, name="script")
|
||||
ApiToken.objects.create(self.admin, expiry=None, name="mine")
|
||||
url = reverse("admin:user_update_password", args=[self.alice.pk])
|
||||
self.client.post(url, {"password1": "New-pw-4567x", "password2": "New-pw-4567x"})
|
||||
self.assertFalse(ApiToken.objects.filter(user=self.alice).exists())
|
||||
self.assertTrue(ApiToken.objects.filter(user=self.admin).exists())
|
||||
|
||||
def test_a_user_without_a_password_cannot_be_made_to_change_it(self):
|
||||
# the change form asks for the old password: one they never had
|
||||
url = reverse("admin:user_update_password", args=[self.alice.pk])
|
||||
response = self.client.post(url, {"usable_password": "false", "must_change_password": "on"})
|
||||
self.assertContains(response, "A user without a password cannot be made to change it")
|
||||
self.alice.refresh_from_db()
|
||||
self.assertTrue(self.alice.has_usable_password())
|
||||
|
||||
def test_an_administrator_keeps_their_own_password(self):
|
||||
url = reverse("admin:user_update_password", args=[self.admin.pk])
|
||||
response = self.client.post(url, {"usable_password": "false"})
|
||||
self.assertContains(response, "You cannot block, delete or demote your own account")
|
||||
self.admin.refresh_from_db()
|
||||
self.assertTrue(self.admin.has_usable_password())
|
||||
|
||||
26
admin/tests/test_user_update_next.py
Normal file
26
admin/tests/test_user_update_next.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""After saving a user, the edit page goes back to ?next= only on this site."""
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
LIMITS = {"role": "user", "max_instances": 1, "max_cpus": 1, "max_memory": 1, "max_disk_size": 4}
|
||||
|
||||
|
||||
class UserUpdateNextTests(TestCase):
|
||||
def setUp(self):
|
||||
self.client.force_login(User.objects.create_superuser("un-admin", "un@example.com", "pw"))
|
||||
self.user = User.objects.create_user("un-user", password="pw")
|
||||
|
||||
def save(self, next_url):
|
||||
url = reverse("admin:user_update", args=[self.user.id]) + "?next=" + next_url
|
||||
return self.client.post(url, {"username": "un-user", "is_active": "on", **LIMITS})
|
||||
|
||||
def test_a_page_of_this_site(self):
|
||||
account = reverse("accounts:account", args=[self.user.id])
|
||||
self.assertRedirects(self.save(account), account, fetch_redirect_response=False)
|
||||
|
||||
def test_another_site_is_not_followed(self):
|
||||
for next_url in ("https://evil.example/", "//evil.example/", "/\\evil.example/"):
|
||||
with self.subTest(next_url=next_url):
|
||||
self.assertRedirects(self.save(next_url), reverse("admin:user_list"), fetch_redirect_response=False)
|
||||
211
admin/tests/test_views.py
Normal file
211
admin/tests/test_views.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import connection
|
||||
from django.shortcuts import reverse
|
||||
from django.test import TestCase
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from lxml import html
|
||||
|
||||
from accounts.models import UserAttributes
|
||||
from logs.models import Logs
|
||||
|
||||
|
||||
class AdminTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.admin = get_user_model().objects.create_superuser("tests-admin", "admin@example.com", "pw")
|
||||
self.client.force_login(self.admin)
|
||||
Logs.objects.all().delete() # the login's row
|
||||
|
||||
def test_group_list(self):
|
||||
response = self.client.get(reverse("admin:group_list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_groups(self):
|
||||
response = self.client.get(reverse("admin:group_create"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:group_create"), {"name": "Test Group"}
|
||||
)
|
||||
self.assertRedirects(response, reverse("admin:group_list"))
|
||||
|
||||
group = Group.objects.get(name="Test Group")
|
||||
group_id = group.id
|
||||
|
||||
response = self.client.get(reverse("admin:group_update", args=[group_id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:group_update", args=[group_id]), {"name": "Updated Group Test"}
|
||||
)
|
||||
self.assertRedirects(response, reverse("admin:group_list"))
|
||||
|
||||
group = Group.objects.get(id=group_id)
|
||||
self.assertEqual(group.name, "Updated Group Test")
|
||||
|
||||
response = self.client.get(reverse("admin:group_delete", args=[group_id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# the shared confirmation page names what it deletes
|
||||
self.assertContains(response, "<title>Delete Updated Group Test</title>", html=True)
|
||||
|
||||
response = self.client.post(reverse("admin:group_delete", args=[group_id]))
|
||||
self.assertRedirects(response, reverse("admin:group_list"))
|
||||
|
||||
with self.assertRaises(ObjectDoesNotExist):
|
||||
Group.objects.get(id=group_id)
|
||||
|
||||
def test_group_form_lists_its_members_first(self):
|
||||
for name in ("zed", "bob", "amy"):
|
||||
User.objects.create_user(name)
|
||||
User.objects.filter(username="amy").update(first_name="Amy", last_name="Adams")
|
||||
group = Group.objects.create(name="Ops")
|
||||
group.user_set.add(User.objects.get(username="zed"))
|
||||
|
||||
page = html.fromstring(self.client.get(reverse("admin:group_update", args=[group.id])).content)
|
||||
boxes = page.xpath("//div[contains(@class, 'searchable')]/label")
|
||||
names = [box.text_content().split()[0] for box in boxes]
|
||||
self.assertEqual(names, ["zed", "admin", "amy", "bob", "tests-admin"])
|
||||
self.assertIn("Amy Adams", boxes[2].get("data-search"))
|
||||
self.assertEqual(page.xpath("//input[@id='filter']/@data-forget"), [""])
|
||||
self.assertEqual(page.xpath("//form[@id='create-update']//button[@type='submit']/text()"), ["Save changes"])
|
||||
page = html.fromstring(self.client.get(reverse("admin:group_create")).content)
|
||||
self.assertEqual(page.xpath("//form[@id='create-update']//button[@type='submit']/text()"), ["Create group"])
|
||||
|
||||
def test_user_list(self):
|
||||
response = self.client.get(reverse("admin:user_list"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_user_list_shows_who_can_clone_in_queries_independent_of_the_users(self):
|
||||
clone = Permission.objects.get(content_type__app_label="instances", codename="clone_instances")
|
||||
User.objects.create_user("direct").user_permissions.add(clone)
|
||||
group = Group.objects.create(name="Cloners")
|
||||
group.permissions.add(clone)
|
||||
User.objects.create_user("by-group").groups.add(group)
|
||||
blocked = User.objects.create_user("blocked", is_active=False)
|
||||
blocked.user_permissions.add(clone)
|
||||
User.objects.create_user("plain")
|
||||
|
||||
def rows():
|
||||
with CaptureQueriesContext(connection) as queries:
|
||||
page = html.fromstring(self.client.get(reverse("admin:user_list")).content)
|
||||
return {item.xpath(".//a[contains(@class, 'fw-semibold')]")[0].text: "Can clone" in item.text_content()
|
||||
for item in page.xpath("//div[contains(@class, 'list-group-item')]")}, len(queries)
|
||||
|
||||
shown, count = rows()
|
||||
self.assertEqual({name: shown[name] for name in ("direct", "by-group", "blocked", "plain", "admin")},
|
||||
{"direct": True, "by-group": True, "blocked": False, "plain": False, "admin": False})
|
||||
for i in range(5):
|
||||
User.objects.create_user(f"more-{i}").user_permissions.add(clone)
|
||||
self.assertEqual(rows()[1], count)
|
||||
|
||||
def test_users(self):
|
||||
response = self.client.get(reverse("admin:user_create"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:user_create"),
|
||||
{
|
||||
"username": "test",
|
||||
"password": "Wvc-created-9",
|
||||
"role": "user",
|
||||
"max_instances": 1,
|
||||
"max_cpus": 1,
|
||||
"max_memory": 1, # GiB
|
||||
"max_disk_size": 4,
|
||||
},
|
||||
)
|
||||
self.assertRedirects(response, reverse("admin:user_list"))
|
||||
|
||||
user = User.objects.get(username="test")
|
||||
user_id = user.id
|
||||
|
||||
ua: UserAttributes = UserAttributes.objects.get(user_id=user_id)
|
||||
self.assertEqual(ua.user_id, user_id)
|
||||
self.assertEqual(ua.max_instances, 1)
|
||||
self.assertEqual(ua.max_cpus, 1)
|
||||
self.assertEqual(ua.max_memory, 1024)
|
||||
self.assertEqual(ua.max_disk_size, 4)
|
||||
|
||||
response = self.client.get(reverse("admin:user_update", args=[user_id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(
|
||||
reverse("admin:user_update", args=[user_id]),
|
||||
{
|
||||
"username": "utest",
|
||||
"role": "user",
|
||||
"max_instances": 2,
|
||||
"max_cpus": 2,
|
||||
"max_memory": 2, # GiB
|
||||
"max_disk_size": 8,
|
||||
},
|
||||
)
|
||||
self.assertRedirects(response, reverse("admin:user_list"))
|
||||
|
||||
user = User.objects.get(id=user_id)
|
||||
self.assertEqual(user.username, "utest")
|
||||
|
||||
ua = UserAttributes.objects.get(user_id=user_id)
|
||||
self.assertEqual(ua.user_id, user_id)
|
||||
self.assertEqual(ua.max_instances, 2)
|
||||
self.assertEqual(ua.max_cpus, 2)
|
||||
self.assertEqual(ua.max_memory, 2048)
|
||||
self.assertEqual(ua.max_disk_size, 8)
|
||||
|
||||
# Blocking changes state, so a GET (e.g. an <img> tag) must not do it.
|
||||
User.objects.filter(id=user_id).update(is_active=True)
|
||||
response = self.client.get(reverse("admin:user_block", args=[user_id]))
|
||||
self.assertEqual(response.status_code, 405)
|
||||
self.assertTrue(User.objects.get(id=user_id).is_active)
|
||||
|
||||
response = self.client.post(reverse("admin:user_block", args=[user_id]))
|
||||
user = User.objects.get(id=user_id)
|
||||
self.assertFalse(user.is_active)
|
||||
|
||||
response = self.client.get(reverse("admin:user_unblock", args=[user_id]))
|
||||
self.assertEqual(response.status_code, 405)
|
||||
self.assertFalse(User.objects.get(id=user_id).is_active)
|
||||
|
||||
response = self.client.post(reverse("admin:user_unblock", args=[user_id]))
|
||||
user = User.objects.get(id=user_id)
|
||||
self.assertTrue(user.is_active)
|
||||
|
||||
response = self.client.get(reverse("admin:user_delete", args=[user_id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
response = self.client.post(reverse("admin:user_delete", args=[user_id]))
|
||||
self.assertRedirects(response, reverse("admin:user_list"))
|
||||
|
||||
with self.assertRaises(ObjectDoesNotExist):
|
||||
User.objects.get(id=user_id)
|
||||
|
||||
def test_logs(self):
|
||||
response = self.client.get(reverse("admin:logs"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_logs_show_result_and_ip(self):
|
||||
Logs.objects.create(message="first", success=True, ip="192.0.2.7")
|
||||
Logs.objects.create(message="second", success=False)
|
||||
Logs.objects.create(message="third")
|
||||
response = self.client.get(reverse("admin:logs"))
|
||||
self.assertContains(response, "Done")
|
||||
self.assertContains(response, "Failed")
|
||||
self.assertContains(response, "Unknown")
|
||||
self.assertContains(response, "192.0.2.7")
|
||||
|
||||
def test_logs_order_ties_by_id(self):
|
||||
first = Logs.objects.create(message="first")
|
||||
second = Logs.objects.create(message="second")
|
||||
Logs.objects.filter(id__in=[first.id, second.id]).update(date=first.date)
|
||||
response = self.client.get(reverse("admin:logs"))
|
||||
self.assertEqual([log.id for log in response.context["logs"]], [second.id, first.id])
|
||||
|
||||
def test_logs_bad_page_numbers(self):
|
||||
Logs.objects.create(message="entry")
|
||||
response = self.client.get(reverse("admin:logs"), {"page": "abc"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context["logs"].number, 1)
|
||||
response = self.client.get(reverse("admin:logs"), {"page": "999"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.context["logs"].number, response.context["logs"].paginator.num_pages)
|
||||
|
|
@ -15,4 +15,7 @@ urlpatterns = [
|
|||
path("users/<int:pk>/block/", views.user_block, name="user_block"),
|
||||
path("users/<int:pk>/unblock/", views.user_unblock, name="user_unblock"),
|
||||
path("logs/", views.logs, name="logs"),
|
||||
path("logs/export.xlsx", views.logs_export, name="logs_export"),
|
||||
path("removed-vms/", views.tombstone_list, name="tombstone_list"),
|
||||
path("removed-vms/<int:pk>/delete/", views.tombstone_delete, name="tombstone_delete"),
|
||||
]
|
||||
|
|
|
|||
259
admin/views.py
259
admin/views.py
|
|
@ -1,22 +1,38 @@
|
|||
from accounts.models import Instance, UserAttributes, UserInstance
|
||||
from datetime import timedelta
|
||||
|
||||
from accounts.models import ApiToken, Instance, UserAttributes, UserInstance
|
||||
from appsettings.settings import app_settings
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.forms import AdminPasswordChangeForm
|
||||
from django.contrib.auth.models import Group, User
|
||||
from django.contrib.auth.models import Group, Permission, User
|
||||
from django.core.paginator import Paginator
|
||||
from django.http import HttpResponse
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Exists, OuterRef, Q
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.decorators.debug import sensitive_post_parameters
|
||||
from instances.models import InstanceTombstone
|
||||
from instances.report import workbook
|
||||
from logs.audit import audit, changed_fields, snapshot
|
||||
from logs.models import Logs
|
||||
|
||||
from . import forms
|
||||
from .decorators import superuser_only
|
||||
|
||||
|
||||
def group_snapshot(group):
|
||||
"""snapshot() of a group with its members, which are a reverse relation snapshot() does not see."""
|
||||
return snapshot(group) | {"users": frozenset(group.user_set.values_list("pk", flat=True))}
|
||||
|
||||
|
||||
@superuser_only
|
||||
def group_list(request):
|
||||
groups = Group.objects.all()
|
||||
groups = Group.objects.annotate(members=Count("user", distinct=True), perms=Count("permissions", distinct=True)).order_by("name")
|
||||
return render(
|
||||
request,
|
||||
"admin/group_list.html",
|
||||
|
|
@ -30,12 +46,13 @@ def group_list(request):
|
|||
def group_create(request):
|
||||
form = forms.GroupForm(request.POST or None)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
with audit(request, None, f"Group {form.cleaned_data['name']}", "add"):
|
||||
form.save()
|
||||
return redirect("admin:group_list")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"common/form.html",
|
||||
"admin/group_form.html",
|
||||
{
|
||||
"form": form,
|
||||
"title": _("Create Group"),
|
||||
|
|
@ -46,16 +63,22 @@ def group_create(request):
|
|||
@superuser_only
|
||||
def group_update(request, pk):
|
||||
group = get_object_or_404(Group, pk=pk)
|
||||
subject, before = f"Group {group.name}", group_snapshot(group)
|
||||
form = forms.GroupForm(request.POST or None, instance=group)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
with audit(request, None, subject, "change") as entry:
|
||||
form.save()
|
||||
fields = changed_fields(before, group_snapshot(group))
|
||||
entry.action = "change " + ", ".join(fields)
|
||||
entry.skip = not fields
|
||||
return redirect("admin:group_list")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"common/form.html",
|
||||
"admin/group_form.html",
|
||||
{
|
||||
"form": form,
|
||||
"group": group,
|
||||
"title": _("Update Group"),
|
||||
},
|
||||
)
|
||||
|
|
@ -65,19 +88,33 @@ def group_update(request, pk):
|
|||
def group_delete(request, pk):
|
||||
group = get_object_or_404(Group, pk=pk)
|
||||
if request.method == "POST":
|
||||
group.delete()
|
||||
with audit(request, None, f"Group {group.name}", "delete"):
|
||||
group.delete()
|
||||
return redirect("admin:group_list")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"common/confirm_delete.html",
|
||||
{"object": group},
|
||||
{"object": group, "consequence": _("Its members stay but lose the permissions it gave them.")},
|
||||
)
|
||||
|
||||
|
||||
# an administrator who blocks, deletes or demotes their own account is out of
|
||||
# these pages at once, and may have been the last one
|
||||
OWN_ACCOUNT = _("You cannot block, delete or demote your own account")
|
||||
|
||||
|
||||
@superuser_only
|
||||
def user_list(request):
|
||||
users = User.objects.all()
|
||||
# API tokens that still work: an expired one is deleted only at the user's next create
|
||||
live = Q(auth_token_set__expiry__isnull=True) | Q(auth_token_set__expiry__gt=timezone.now())
|
||||
# the clone permission, given to the user or to a group of the user: one
|
||||
# query for the list, not a has_perm (two queries) a user
|
||||
clone = Permission.objects.filter(content_type__app_label="instances", codename="clone_instances")
|
||||
users = User.objects.annotate(
|
||||
api_tokens=Count("auth_token_set", filter=live),
|
||||
can_clone=Exists(clone.filter(Q(user=OuterRef("pk")) | Q(group__user=OuterRef("pk")))),
|
||||
).prefetch_related("groups")
|
||||
return render(
|
||||
request,
|
||||
"admin/user_list.html",
|
||||
|
|
@ -88,19 +125,19 @@ def user_list(request):
|
|||
)
|
||||
|
||||
|
||||
@sensitive_post_parameters()
|
||||
@superuser_only
|
||||
def user_create(request):
|
||||
user_form = forms.UserCreateForm(request.POST or None)
|
||||
attributes_form = forms.UserAttributesForm(request.POST or None)
|
||||
if user_form.is_valid() and attributes_form.is_valid():
|
||||
user = user_form.save()
|
||||
password = user_form.cleaned_data["password"]
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
attributes = attributes_form.save(commit=False)
|
||||
attributes.user = user
|
||||
attributes.save()
|
||||
add_default_instances(user)
|
||||
with audit(request, None, f"User {user_form.cleaned_data['username']}", "add"), transaction.atomic():
|
||||
user = user_form.save()
|
||||
attributes = attributes_form.save(commit=False)
|
||||
attributes.user = user
|
||||
attributes.save()
|
||||
UserInstance.objects.bulk_create(UserInstance(user=user, instance=instance)
|
||||
for instance in user_form.default_instances)
|
||||
return redirect("admin:user_list")
|
||||
|
||||
return render(
|
||||
|
|
@ -114,19 +151,35 @@ def user_create(request):
|
|||
)
|
||||
|
||||
|
||||
@sensitive_post_parameters()
|
||||
@superuser_only
|
||||
def user_update(request, pk):
|
||||
user = get_object_or_404(User, pk=pk)
|
||||
attributes, attributes_created = UserAttributes.objects.get_or_create(user=user)
|
||||
subject = f"User {user.username}"
|
||||
before, before_attributes = snapshot(user), snapshot(attributes)
|
||||
user_form = forms.UserForm(request.POST or None, instance=user)
|
||||
attributes_form = forms.UserAttributesForm(
|
||||
request.POST or None, instance=attributes
|
||||
)
|
||||
if user_form.is_valid() and attributes_form.is_valid():
|
||||
user_form.save()
|
||||
attributes_form.save()
|
||||
next = request.GET.get("next")
|
||||
return redirect(next or "admin:user_list")
|
||||
valid = user_form.is_valid() and attributes_form.is_valid()
|
||||
if valid and user == request.user and not (user_form.cleaned_data["is_active"] and user_form.cleaned_data["is_superuser"]):
|
||||
user_form.add_error(None, OWN_ACCOUNT)
|
||||
valid = False
|
||||
if valid:
|
||||
with audit(request, None, subject, "change") as entry:
|
||||
user_form.save()
|
||||
attributes_form.save()
|
||||
fields = set(changed_fields(before, snapshot(user))) | set(changed_fields(before_attributes, snapshot(attributes)))
|
||||
fields = sorted(fields - {"password", "last_login"})
|
||||
entry.action = "change " + ", ".join(fields)
|
||||
entry.skip = not fields
|
||||
next_url = request.GET.get("next")
|
||||
if next_url and url_has_allowed_host_and_scheme(
|
||||
url=next_url, allowed_hosts={request.get_host()}, require_https=request.is_secure()
|
||||
):
|
||||
return redirect(next_url)
|
||||
return redirect("admin:user_list")
|
||||
|
||||
return render(
|
||||
request,
|
||||
|
|
@ -134,84 +187,172 @@ def user_update(request, pk):
|
|||
{
|
||||
"user_form": user_form,
|
||||
"attributes_form": attributes_form,
|
||||
"edited_user": user,
|
||||
"title": _("Update User"),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@sensitive_post_parameters()
|
||||
@superuser_only
|
||||
def user_update_password(request, pk):
|
||||
user = get_object_or_404(User, pk=pk)
|
||||
if request.method == "POST":
|
||||
form = AdminPasswordChangeForm(user, request.POST)
|
||||
if form.is_valid():
|
||||
user = form.save()
|
||||
form = forms.AdminPasswordForm(user, request.POST)
|
||||
valid = form.is_valid()
|
||||
if valid and user == request.user and not form.cleaned_data["set_usable_password"]:
|
||||
form.add_error(None, OWN_ACCOUNT)
|
||||
valid = False
|
||||
if valid:
|
||||
with audit(request, None, f"User {user.username}", "set password"), transaction.atomic():
|
||||
user = form.save()
|
||||
UserAttributes.objects.update_or_create(
|
||||
user=user, defaults={"must_change_password": form.cleaned_data["must_change_password"]})
|
||||
# a reset closes a taken-over account: its tokens would outlive the old password
|
||||
ApiToken.objects.filter(user=user).delete()
|
||||
update_session_auth_hash(request, user) # Important!
|
||||
messages.success(
|
||||
request, _("Password changed for %(user)s") % {"user": user.username}
|
||||
)
|
||||
return redirect("admin:user_list")
|
||||
else:
|
||||
messages.error(request, _("Wrong Data Provided"))
|
||||
else:
|
||||
form = AdminPasswordChangeForm(user)
|
||||
form = forms.AdminPasswordForm(user)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"accounts/change_password_form.html",
|
||||
{
|
||||
"form": form,
|
||||
"user": user.username,
|
||||
},
|
||||
)
|
||||
return render(request, "admin/user_password_form.html", {"form": form, "edited_user": user})
|
||||
|
||||
|
||||
@superuser_only
|
||||
def user_delete(request, pk):
|
||||
user = get_object_or_404(User, pk=pk)
|
||||
if user == request.user:
|
||||
messages.error(request, OWN_ACCOUNT)
|
||||
return redirect("admin:user_list")
|
||||
if request.method == "POST":
|
||||
user.delete()
|
||||
with audit(request, None, f"User {user.username}", "delete"):
|
||||
user.delete()
|
||||
return redirect("admin:user_list")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"common/confirm_delete.html",
|
||||
{"object": user},
|
||||
{"object": user,
|
||||
"consequence": _("Their access to VMs, SSH keys and settings go; the VMs themselves stay.")},
|
||||
)
|
||||
|
||||
|
||||
@require_POST
|
||||
@superuser_only
|
||||
def user_block(request, pk):
|
||||
user: User = get_object_or_404(User, pk=pk)
|
||||
user.is_active = False
|
||||
user.save()
|
||||
if user == request.user:
|
||||
messages.error(request, OWN_ACCOUNT)
|
||||
return redirect("admin:user_list")
|
||||
with audit(request, None, f"User {user.username}", "block") as entry:
|
||||
entry.skip = not user.is_active # already blocked
|
||||
user.is_active = False
|
||||
user.save()
|
||||
return redirect("admin:user_list")
|
||||
|
||||
|
||||
@require_POST
|
||||
@superuser_only
|
||||
def user_unblock(request, pk):
|
||||
user: User = get_object_or_404(User, pk=pk)
|
||||
user.is_active = True
|
||||
user.save()
|
||||
with audit(request, None, f"User {user.username}", "unblock") as entry:
|
||||
entry.skip = user.is_active # not blocked
|
||||
user.is_active = True
|
||||
user.save()
|
||||
return redirect("admin:user_list")
|
||||
|
||||
|
||||
# rows in one Logs download: the workbook is built in memory
|
||||
EXPORT_ROWS = 100000
|
||||
|
||||
|
||||
def _logs(request):
|
||||
"""The Logs rows the search and the failed-only box choose, newest first,
|
||||
and the query that chooses them again."""
|
||||
rows = Logs.objects.order_by("-date", "-id")
|
||||
text = request.GET.get("q", "").strip()
|
||||
failed = bool(request.GET.get("failed"))
|
||||
if text:
|
||||
rows = rows.filter(Q(user__icontains=text) | Q(host__icontains=text) | Q(instance__icontains=text)
|
||||
| Q(instance_uuid__icontains=text) | Q(message__icontains=text) | Q(ip__icontains=text))
|
||||
if failed:
|
||||
rows = rows.filter(success=False)
|
||||
query = urlencode([(key, value) for key, value in (("q", text), ("failed", "1" if failed else "")) if value])
|
||||
return rows, text, failed, query
|
||||
|
||||
|
||||
def _result(success):
|
||||
return _("Unknown") if success is None else _("Done") if success else _("Failed")
|
||||
|
||||
|
||||
@superuser_only
|
||||
def logs(request):
|
||||
l = Logs.objects.order_by("-date")
|
||||
paginator = Paginator(l, int(app_settings.LOGS_PER_PAGE))
|
||||
page = request.GET.get("page", 1)
|
||||
logs = paginator.page(page)
|
||||
return render(request, "admin/logs.html", {"logs": logs})
|
||||
rows, text, failed, query = _logs(request)
|
||||
logs = Paginator(rows, int(app_settings.LOGS_PER_PAGE)).get_page(request.GET.get("page"))
|
||||
# a VM's rows link to it while it is in the panel
|
||||
vms = dict(Instance.objects.filter(uuid__in={log.instance_uuid for log in logs if log.instance_uuid})
|
||||
.values_list("uuid", "pk"))
|
||||
for log in logs:
|
||||
log.vm_pk = vms.get(log.instance_uuid)
|
||||
return render(request, "admin/logs.html", {"logs": logs, "q": text, "failed": failed, "query": query,
|
||||
"time_zone": timezone.get_current_timezone_name()})
|
||||
|
||||
|
||||
def add_default_instances(user):
|
||||
"""
|
||||
Adds instances listed in NEW_USER_DEFAULT_INSTANCES to user
|
||||
"""
|
||||
existing_instances = UserInstance.objects.filter(user=user)
|
||||
if not existing_instances:
|
||||
for instance_name in settings.NEW_USER_DEFAULT_INSTANCES:
|
||||
instance = Instance.objects.get(name=instance_name)
|
||||
user_instance = UserInstance(user=user, instance=instance)
|
||||
user_instance.save()
|
||||
@superuser_only
|
||||
def logs_export(request):
|
||||
"""The rows the Logs page shows for the same search, every page of them,
|
||||
as an Excel workbook."""
|
||||
rows, text, failed, _query = _logs(request)
|
||||
zone = timezone.get_current_timezone_name()
|
||||
header = [f"Date ({zone})", "User", "Host", "VM", "VM UUID", "Message", "Result", "IP"]
|
||||
# tuples, not model objects: up to EXPORT_ROWS of them are held at once
|
||||
logs = list(rows.values_list("date", "user", "host", "instance", "instance_uuid", "message", "success", "ip")
|
||||
[:EXPORT_ROWS + 1])
|
||||
data = [[timezone.localtime(date).strftime("%Y-%m-%d %H:%M:%S"), user, host, instance, uuid, message,
|
||||
str(_result(success)), ip or ""]
|
||||
for date, user, host, instance, uuid, message, success, ip in logs[:EXPORT_ROWS]]
|
||||
notes = [f"Search: {text}" if text else "Search: none", "Failed only" if failed else "All results",
|
||||
f"Rows: {len(data)}", f"Times in {zone}"]
|
||||
if len(logs) > EXPORT_ROWS:
|
||||
notes.append(f"Only the newest {EXPORT_ROWS} rows: narrow the search for the others")
|
||||
response = HttpResponse(workbook("Logs", header, data, "About", notes),
|
||||
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||
name = f"webvirtcloud-logs-{timezone.localtime():%Y%m%d-%H%M%S}.xlsx"
|
||||
response["Content-Disposition"] = f'attachment; filename="{name}"'
|
||||
return response
|
||||
|
||||
|
||||
@superuser_only
|
||||
def tombstone_list(request):
|
||||
"""The owners kept for VMs that vanished from their compute, in case the
|
||||
same UUID comes back."""
|
||||
tombstones = list(InstanceTombstone.objects.order_by("-removed"))
|
||||
ids = {owner["user"] for tombstone in tombstones for owner in tombstone.owners}
|
||||
names = dict(User.objects.filter(id__in=ids).values_list("id", "username"))
|
||||
retention = timedelta(days=getattr(settings, "INSTANCE_OWNERSHIP_RETENTION_DAYS", 30))
|
||||
for tombstone in tombstones:
|
||||
tombstone.owner_names = [names.get(owner["user"], _("deleted user")) for owner in tombstone.owners]
|
||||
tombstone.expires = tombstone.removed + retention
|
||||
return render(request, "admin/tombstone_list.html", {"tombstones": tombstones})
|
||||
|
||||
|
||||
@superuser_only
|
||||
def tombstone_delete(request, pk):
|
||||
tombstone = get_object_or_404(InstanceTombstone, pk=pk)
|
||||
if request.method == "POST":
|
||||
with audit(request, tombstone.host or None, f"Removed VM {tombstone.name} ({tombstone.uuid})", "delete",
|
||||
instance=tombstone.name, uuid=tombstone.uuid):
|
||||
tombstone.delete()
|
||||
messages.success(request, _("Removed VM %(name)s deleted") % {"name": tombstone.name})
|
||||
return redirect("admin:tombstone_list")
|
||||
|
||||
return render(
|
||||
request,
|
||||
"common/confirm_delete.html",
|
||||
{"object": tombstone,
|
||||
"consequence": _("Its owners no longer get the VM back if its UUID shows up again.")},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,3 +3,4 @@ from django.apps import AppConfig
|
|||
|
||||
class AppsettingsConfig(AppConfig):
|
||||
name = "appsettings"
|
||||
default_auto_field = "django.db.models.AutoField"
|
||||
|
|
|
|||
0
appsettings/management/__init__.py
Normal file
0
appsettings/management/__init__.py
Normal file
0
appsettings/management/commands/__init__.py
Normal file
0
appsettings/management/commands/__init__.py
Normal file
83
appsettings/management/commands/backup.py
Normal file
83
appsettings/management/commands/backup.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import fcntl
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
from contextlib import closing
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import connection
|
||||
|
||||
PREFIX = "webvirtcloud-backup-"
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = ("Copy the database, secret key, worker id, settings and the SSH keys and TLS certificates of the "
|
||||
"panel's user into a new directory in backups/, then delete all but the newest --keep backups.")
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("--keep", type=int, default=7, help="how many backups to keep (default 7)")
|
||||
|
||||
def handle(self, *args, keep, **options):
|
||||
if keep < 1:
|
||||
raise CommandError("--keep must be at least 1")
|
||||
os.umask(0o077) # the database holds compute passwords and keys
|
||||
backups = settings.BASE_DIR / "backups"
|
||||
backups.mkdir(mode=0o700, exist_ok=True)
|
||||
with open(backups / ".lock", "w") as lock:
|
||||
try:
|
||||
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
raise CommandError("Another backup is running") from None
|
||||
self.stdout.write(str(self.backup(backups, keep)))
|
||||
|
||||
def backup(self, backups, keep):
|
||||
name = PREFIX + datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
partial = backups / f".{name}"
|
||||
partial.mkdir()
|
||||
try:
|
||||
self.copy(partial)
|
||||
partial.rename(backups / name)
|
||||
except BaseException:
|
||||
shutil.rmtree(partial, ignore_errors=True)
|
||||
raise
|
||||
# only after a good backup: a failing one never costs an older one;
|
||||
# the partial ones are what a killed run left behind
|
||||
for old in [*sorted(backups.glob(PREFIX + "*"))[:-keep], *backups.glob(f".{PREFIX}*")]:
|
||||
shutil.rmtree(old)
|
||||
return backups / name
|
||||
|
||||
def copy(self, to):
|
||||
database = connection.settings_dict
|
||||
if database["ENGINE"] == "django.db.backends.sqlite3":
|
||||
# the backup API copies a consistent state while the panel writes
|
||||
read_only = Path(database["NAME"]).resolve().as_uri() + "?mode=ro"
|
||||
with closing(sqlite3.connect(read_only, uri=True, timeout=30)) as source, \
|
||||
closing(sqlite3.connect(to / "db.sqlite3")) as copy:
|
||||
source.backup(copy)
|
||||
result = copy.execute("PRAGMA integrity_check").fetchone()[0]
|
||||
if result != "ok":
|
||||
raise CommandError(f"The copy of the database is damaged: {result}")
|
||||
else:
|
||||
self.stderr.write("The database is not sqlite and is not in the backup: back it up with its own tools.")
|
||||
data = settings.BASE_DIR / "data"
|
||||
for source in (data / "secret_key", data / "node_id", settings.BASE_DIR / "webvirtcloud" / "settings.py"):
|
||||
if source.is_file():
|
||||
shutil.copyfile(source, to / source.name)
|
||||
ssh = Path.home() / ".ssh"
|
||||
if ssh.is_dir():
|
||||
(to / "ssh").mkdir()
|
||||
for source in ssh.iterdir():
|
||||
if source.is_file() and not source.is_symlink():
|
||||
shutil.copyfile(source, to / "ssh" / source.name)
|
||||
# the TLS client certificates libvirt reads (~/.pki/libvirt); a link stays a link
|
||||
pki = Path.home() / ".pki"
|
||||
if pki.is_dir():
|
||||
shutil.copytree(pki, to / "pki", symlinks=True)
|
||||
if (settings.BASE_DIR / ".git").exists():
|
||||
commit = subprocess.run(["git", "rev-parse", "HEAD"], cwd=settings.BASE_DIR, capture_output=True, text=True)
|
||||
if commit.returncode == 0:
|
||||
(to / "commit").write_text(commit.stdout)
|
||||
27
appsettings/management/commands/build_themes.py
Normal file
27
appsettings/management/commands/build_themes.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from pathlib import Path
|
||||
|
||||
import sass
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from appsettings.views import THEMES_DIR
|
||||
|
||||
SCSS = Path(settings.BASE_DIR, "dev", "scss")
|
||||
|
||||
|
||||
def compile_theme(theme):
|
||||
return sass.compile(
|
||||
string=f"@import '{SCSS}/wvc-themes/{theme}/variables';\n"
|
||||
f"@import '{SCSS}/bootstrap-overrides.scss';\n"
|
||||
f"@import '{SCSS}/wvc-themes/{theme}/bootswatch';",
|
||||
output_style="compressed",
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Compile the themes in dev/scss/wvc-themes into static/css/themes (needs libsass, dev/requirements.txt)."
|
||||
|
||||
def handle(self, *args, **options):
|
||||
THEMES_DIR.mkdir(exist_ok=True)
|
||||
for theme in sorted(p.name for p in (SCSS / "wvc-themes").iterdir() if p.is_dir()):
|
||||
(THEMES_DIR / f"{theme}.min.css").write_text(compile_theme(theme))
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from .settings import app_settings, get_settings
|
||||
from .settings import get_settings
|
||||
|
||||
|
||||
class AppSettingsMiddleware(object):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,141 @@
|
|||
"""The settings table and its rows as migrations 0001 to 0024 left them,
|
||||
which this migration replaces."""
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
# (name, key, value, choices, description) of each setting, in their old order
|
||||
SETTINGS = [
|
||||
('Theme', 'BOOTSTRAP_THEME', 'zephyr', '',
|
||||
'Bootstrap CSS & Bootswatch Theme'),
|
||||
('All Instances View Style', 'VIEW_INSTANCES_LIST_STYLE', 'grouped', 'grouped,nongrouped',
|
||||
'Instances page for administrators: VMs under their host, or one list'),
|
||||
('Logs per Page', 'LOGS_PER_PAGE', '100', '',
|
||||
'Rows per page on the Logs page'),
|
||||
('Multiple Owner for VM', 'ALLOW_INSTANCE_MULTIPLE_OWNER', 'True', 'True,False',
|
||||
'A VM can be given to more than one user'),
|
||||
('Quota Debug', 'QUOTA_DEBUG', 'True', 'True,False',
|
||||
'A quota refusal shows the numbers: what would be used and what is allowed'),
|
||||
('Disk Format', 'INSTANCE_VOLUME_DEFAULT_FORMAT', 'qcow2', 'raw,qcow,qcow2',
|
||||
'Format of new disk images in directory pools'),
|
||||
('Disk Bus', 'INSTANCE_VOLUME_DEFAULT_BUS', 'virtio', 'virtio,scsi,ide,usb,sata',
|
||||
'Bus of new disks'),
|
||||
('Disk SCSI Controller', 'INSTANCE_VOLUME_DEFAULT_SCSI_CONTROLLER', 'virtio-scsi', 'virtio-scsi,lsilogic',
|
||||
'Controller model for disks on the SCSI bus'),
|
||||
('Disk Cache', 'INSTANCE_VOLUME_DEFAULT_CACHE', 'directsync', 'default,directsync,none,unsafe,writeback,writethrough',
|
||||
'Cache mode of new disks'),
|
||||
('Disk IO Type', 'INSTANCE_VOLUME_DEFAULT_IO', 'default', 'default,native,threads',
|
||||
'I/O mode of new disks (native needs cache none or directsync)'),
|
||||
('Disk Detect Zeroes', 'INSTANCE_VOLUME_DEFAULT_DETECT_ZEROES', 'default', 'default,on,off,unmap',
|
||||
'Detect zeroes of new disks: how blocks of zeros the guest writes are stored'),
|
||||
('Disk Discard', 'INSTANCE_VOLUME_DEFAULT_DISCARD', 'default', 'default,unmap,ignore',
|
||||
"Discard of new disks (unmap: the guest's TRIM frees space in the image)"),
|
||||
('Disk Owner UID', 'INSTANCE_VOLUME_DEFAULT_OWNER_UID', '0', '',
|
||||
'Owner of new disk image files (0: root; qemu is often 107)'),
|
||||
('Disk Owner GID', 'INSTANCE_VOLUME_DEFAULT_OWNER_GID', '0', '',
|
||||
'Group of new disk image files (0: root; qemu is often 107)'),
|
||||
('VM CPU Mode', 'INSTANCE_CPU_DEFAULT_MODE', 'host-model', 'host-model,host-passthrough,maximum',
|
||||
'CPU mode chosen first for a new VM'),
|
||||
('VM Machine Type', 'INSTANCE_MACHINE_DEFAULT_TYPE', 'q35', 'q35,pc,virt',
|
||||
'Machine type chosen first on the new VM pages'),
|
||||
('VM Firmware Type', 'INSTANCE_FIRMWARE_DEFAULT_TYPE', 'BIOS', 'BIOS,UEFI',
|
||||
'Firmware chosen first for a new x86_64 VM'),
|
||||
('VM Architecture Type', 'INSTANCE_ARCH_DEFAULT_TYPE', 'x86_64', 'x86_64,i686,aarch64',
|
||||
'Architecture chosen first on the new VM pages'),
|
||||
('VM Clone Name Prefix', 'CLONE_INSTANCE_DEFAULT_PREFIX', 'instance', '',
|
||||
'Prefix of the names made for clones: prefix1, prefix2, ...'),
|
||||
('VM Clone Auto Name', 'CLONE_INSTANCE_AUTO_NAME', 'False', 'True,False',
|
||||
'Users who are not administrators get clone names made from the prefix'),
|
||||
('Show Access Root Pass', 'SHOW_ACCESS_ROOT_PASSWORD', 'False', 'True,False',
|
||||
"Show Root Password on a VM's Access tab"),
|
||||
('Show Access SSH Keys', 'SHOW_ACCESS_SSH_KEYS', 'False', 'True,False',
|
||||
"Show SSH Keys on a VM's Access tab"),
|
||||
('Console Scale', 'CONSOLE_SCALE', 'False', 'True,False',
|
||||
'The console starts scaled to its window'),
|
||||
('Console View-Only', 'CONSOLE_VIEW_ONLY', 'False', 'True,False',
|
||||
'The console starts view-only: no keyboard or mouse input'),
|
||||
('Console Resize Session', 'CONSOLE_RESIZE_SESSION', 'False', 'True,False',
|
||||
'The console starts asking the guest to fit its screen to the window'),
|
||||
('Console Clip Viewport', 'CONSOLE_CLIP_VIEWPORT', 'False', 'True,False',
|
||||
'The console starts clipped to its window, without scaling'),
|
||||
('VM DRBD Status', 'VM_DRBD_STATUS', 'False', 'True,False',
|
||||
"The Instances page shows each VM's DRBD state"),
|
||||
('VM CD-ROM Device', 'INSTANCE_CDROM_ADD', 'sata', 'None,ide,sata,scsi',
|
||||
'Bus of the CD-ROM a new VM gets (None: no CD-ROM)'),
|
||||
('VM Video Type', 'INSTANCE_VIDEO_DEFAULT_TYPE', 'vga', 'None,virtio,vga,cirrus,vmvga,bochs,ramfb',
|
||||
'Video device chosen first for a new VM'),
|
||||
('VM Input Device', 'INSTANCE_INPUT_DEFAULT_DEVICE', 'default', 'None,default,virtio,usb',
|
||||
'Input device chosen first for a new VM (None: no input device)'),
|
||||
('VM NIC Type', 'INSTANCE_NIC_DEFAULT_TYPE', 'default', 'default,e1000,e1000e,rtl8139,virtio',
|
||||
"Network card model chosen first for a new VM (default: libvirt's choice)"),
|
||||
('Login Failure Limit', 'LOGIN_FAILURE_LIMIT', '5', '',
|
||||
'Failed logins of a user from one address before it is locked'),
|
||||
('Login Lockout Minutes', 'LOGIN_LOCKOUT_MINUTES', '15', '',
|
||||
'How long a locked login stays locked, in minutes'),
|
||||
('Snapshot Free Percent', 'SNAPSHOT_FREE_SPACE_PERCENT', '10', '',
|
||||
"Free space a storage pool keeps for a snapshot, in percent of the VM's disks in it (0: none)"),
|
||||
('Snapshot Free Min GiB', 'SNAPSHOT_FREE_SPACE_MIN_GIB', '1', '',
|
||||
'Free space a storage pool keeps for a snapshot at least, in GiB (0: none)'),
|
||||
('Worker Concurrency', 'WORKER_CONCURRENCY', '4', '',
|
||||
'Jobs the worker runs at the same time (1-16)'),
|
||||
('Job Retention Days', 'JOB_RETENTION_DAYS', '30', '',
|
||||
'Days finished jobs stay in the job history (1-3650)'),
|
||||
('Log Retention Days', 'LOG_RETENTION_DAYS', '365', '',
|
||||
'Days the Logs page keeps its rows (1-3650)'),
|
||||
]
|
||||
|
||||
|
||||
def add_settings(apps, schema_editor):
|
||||
AppSettings = apps.get_model("appsettings", "AppSettings")
|
||||
AppSettings.objects.using(schema_editor.connection.alias).bulk_create(
|
||||
AppSettings(name=name, key=key, value=value, choices=choices, description=description)
|
||||
for name, key, value, choices, description in SETTINGS
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
replaces = [
|
||||
("appsettings", "0001_initial"),
|
||||
("appsettings", "0002_auto_20200527_1603"),
|
||||
("appsettings", "0003_auto_20200615_0637"),
|
||||
("appsettings", "0004_auto_20200716_0637"),
|
||||
("appsettings", "0005_auto_20200911_1233"),
|
||||
("appsettings", "0006_auto_20220630_0717"),
|
||||
("appsettings", "0007_auto_20220905_0918"),
|
||||
("appsettings", "0008_auto_20220905_1459"),
|
||||
("appsettings", "0009_alter_appsettings_id"),
|
||||
("appsettings", "0010_auto_20231030_1305"),
|
||||
("appsettings", "0011_alter_appsettings_id"),
|
||||
("appsettings", "0012_remove_spice_qemu_console_type"),
|
||||
("appsettings", "0013_remove_qemu_console_default_type"),
|
||||
("appsettings", "0014_login_lockout_settings"),
|
||||
("appsettings", "0015_remove_clone_instance_auto_migrate"),
|
||||
("appsettings", "0016_nic_type_rtl8139"),
|
||||
("appsettings", "0017_snapshot_free_space_settings"),
|
||||
("appsettings", "0018_worker_settings"),
|
||||
("appsettings", "0019_prebuilt_themes"),
|
||||
("appsettings", "0020_fix_setting_choices"),
|
||||
("appsettings", "0021_setting_descriptions"),
|
||||
("appsettings", "0022_cpu_mode_choices"),
|
||||
("appsettings", "0023_remove_bottom_bar"),
|
||||
("appsettings", "0024_log_retention_setting"),
|
||||
]
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="AppSettings",
|
||||
fields=[
|
||||
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("name", models.CharField(max_length=25, verbose_name="name")),
|
||||
("key", models.CharField(db_index=True, max_length=50, unique=True, verbose_name="key")),
|
||||
("value", models.CharField(max_length=25, verbose_name="value")),
|
||||
("choices", models.CharField(max_length=70, verbose_name="choices")),
|
||||
("description", models.CharField(max_length=100, null=True, verbose_name="description")),
|
||||
],
|
||||
),
|
||||
migrations.RunPython(add_settings, migrations.RunPython.noop),
|
||||
]
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
# Generated by Django 3.2.13 on 2022-06-30 07:17
|
||||
|
||||
from django.db import migrations
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
def update_default_settings(apps, schema_editor):
|
||||
setting = apps.get_model("appsettings", "AppSettings")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
def update_console_type(apps, schema_editor):
|
||||
AppSettings = apps.get_model("appsettings", "AppSettings")
|
||||
db_alias = schema_editor.connection.alias
|
||||
setting = (
|
||||
AppSettings.objects.using(db_alias)
|
||||
.filter(key="QEMU_CONSOLE_DEFAULT_TYPE")
|
||||
.first()
|
||||
)
|
||||
if setting:
|
||||
setting.value = "vnc"
|
||||
setting.choices = "vnc"
|
||||
setting.save(using=db_alias)
|
||||
|
||||
|
||||
def reverse_console_type(apps, schema_editor):
|
||||
AppSettings = apps.get_model("appsettings", "AppSettings")
|
||||
db_alias = schema_editor.connection.alias
|
||||
setting = (
|
||||
AppSettings.objects.using(db_alias)
|
||||
.filter(key="QEMU_CONSOLE_DEFAULT_TYPE")
|
||||
.first()
|
||||
)
|
||||
if setting:
|
||||
setting.choices = "vnc,spice"
|
||||
setting.save(using=db_alias)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("appsettings", "0011_alter_appsettings_id"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(update_console_type, reverse_console_type),
|
||||
]
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
def remove_console_type(apps, schema_editor):
|
||||
"""VNC is the only console type; the setting has nothing left to choose."""
|
||||
AppSettings = apps.get_model("appsettings", "AppSettings")
|
||||
AppSettings.objects.using(schema_editor.connection.alias).filter(key="QEMU_CONSOLE_DEFAULT_TYPE").delete()
|
||||
|
||||
|
||||
def restore_console_type(apps, schema_editor):
|
||||
AppSettings = apps.get_model("appsettings", "AppSettings")
|
||||
AppSettings.objects.using(schema_editor.connection.alias).get_or_create(
|
||||
key="QEMU_CONSOLE_DEFAULT_TYPE",
|
||||
defaults={"name": "VM Console Type", "value": "vnc", "choices": "vnc", "description": "Default console type"},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("appsettings", "0012_remove_spice_qemu_console_type"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(remove_console_type, restore_console_type),
|
||||
]
|
||||
38
appsettings/migrations/0014_login_lockout_settings.py
Normal file
38
appsettings/migrations/0014_login_lockout_settings.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from django.core.management.color import no_style
|
||||
from django.db import migrations
|
||||
from django.utils.translation import gettext_noop as _
|
||||
|
||||
|
||||
SETTINGS = [
|
||||
(_("Login Failure Limit"), "LOGIN_FAILURE_LIMIT", "5",
|
||||
_("Failed logins of a user from one address before it is locked")),
|
||||
(_("Login Lockout Minutes"), "LOGIN_LOCKOUT_MINUTES", "15",
|
||||
_("How long a locked login stays locked, in minutes")),
|
||||
]
|
||||
|
||||
|
||||
def add_settings(apps, schema_editor):
|
||||
setting = apps.get_model("appsettings", "AppSettings")
|
||||
db_alias = schema_editor.connection.alias
|
||||
# Earlier migrations inserted the default rows with fixed ids, which a
|
||||
# PostgreSQL id sequence does not see: move it past them before inserting
|
||||
# without an id (a no-op on sqlite).
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
for sql in schema_editor.connection.ops.sequence_reset_sql(no_style(), [setting]):
|
||||
cursor.execute(sql)
|
||||
for name, key, value, description in SETTINGS:
|
||||
setting.objects.using(db_alias).get_or_create(
|
||||
key=key, defaults={"name": name, "value": value, "choices": "", "description": description}
|
||||
)
|
||||
|
||||
|
||||
def del_settings(apps, schema_editor):
|
||||
setting = apps.get_model("appsettings", "AppSettings")
|
||||
db_alias = schema_editor.connection.alias
|
||||
setting.objects.using(db_alias).filter(key__in=[key for _, key, _, _ in SETTINGS]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("appsettings", "0013_remove_qemu_console_default_type")]
|
||||
|
||||
operations = [migrations.RunPython(add_settings, del_settings)]
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
def remove_auto_migrate(apps, schema_editor):
|
||||
"""A clone stays on the host of the VM it was made from: the setting sent
|
||||
it to a random host, whatever storage, CPU or network that host had."""
|
||||
AppSettings = apps.get_model("appsettings", "AppSettings")
|
||||
AppSettings.objects.using(schema_editor.connection.alias).filter(key="CLONE_INSTANCE_AUTO_MIGRATE").delete()
|
||||
|
||||
|
||||
def restore_auto_migrate(apps, schema_editor):
|
||||
AppSettings = apps.get_model("appsettings", "AppSettings")
|
||||
AppSettings.objects.using(schema_editor.connection.alias).get_or_create(
|
||||
key="CLONE_INSTANCE_AUTO_MIGRATE",
|
||||
defaults={
|
||||
"name": "VM Clone Auto Migrate",
|
||||
"value": "False",
|
||||
"choices": "True,False",
|
||||
"description": "Auto migrate instance after clone",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("appsettings", "0014_login_lockout_settings"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(remove_auto_migrate, restore_auto_migrate),
|
||||
]
|
||||
22
appsettings/migrations/0016_nic_type_rtl8139.py
Normal file
22
appsettings/migrations/0016_nic_type_rtl8139.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
def fix_rtl8139(apps, schema_editor):
|
||||
"""The NIC type is rtl8139: a VM with model rt18139 does not start. Not
|
||||
undone on the way back, as nothing needs the misspelling."""
|
||||
AppSettings = apps.get_model("appsettings", "AppSettings")
|
||||
for setting in AppSettings.objects.using(schema_editor.connection.alias).filter(key="INSTANCE_NIC_DEFAULT_TYPE"):
|
||||
setting.choices = setting.choices.replace("rt18139", "rtl8139")
|
||||
if setting.value == "rt18139":
|
||||
setting.value = "rtl8139"
|
||||
setting.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("appsettings", "0015_remove_clone_instance_auto_migrate"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_rtl8139, migrations.RunPython.noop),
|
||||
]
|
||||
36
appsettings/migrations/0017_snapshot_free_space_settings.py
Normal file
36
appsettings/migrations/0017_snapshot_free_space_settings.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from django.core.management.color import no_style
|
||||
from django.db import migrations
|
||||
from django.utils.translation import gettext_noop as _
|
||||
|
||||
|
||||
SETTINGS = [
|
||||
(_("Snapshot Free Percent"), "SNAPSHOT_FREE_SPACE_PERCENT", "10",
|
||||
_("Free space a storage pool keeps for a snapshot, in percent of the VM's disks in it (0: none)")),
|
||||
(_("Snapshot Free Min GiB"), "SNAPSHOT_FREE_SPACE_MIN_GIB", "1",
|
||||
_("Free space a storage pool keeps for a snapshot at least, in GiB (0: none)")),
|
||||
]
|
||||
|
||||
|
||||
def add_settings(apps, schema_editor):
|
||||
setting = apps.get_model("appsettings", "AppSettings")
|
||||
db_alias = schema_editor.connection.alias
|
||||
# see 0014: move a PostgreSQL id sequence past the fixed ids first
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
for sql in schema_editor.connection.ops.sequence_reset_sql(no_style(), [setting]):
|
||||
cursor.execute(sql)
|
||||
for name, key, value, description in SETTINGS:
|
||||
setting.objects.using(db_alias).get_or_create(
|
||||
key=key, defaults={"name": name, "value": value, "choices": "", "description": description}
|
||||
)
|
||||
|
||||
|
||||
def del_settings(apps, schema_editor):
|
||||
setting = apps.get_model("appsettings", "AppSettings")
|
||||
db_alias = schema_editor.connection.alias
|
||||
setting.objects.using(db_alias).filter(key__in=[key for _, key, _, _ in SETTINGS]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("appsettings", "0016_nic_type_rtl8139")]
|
||||
|
||||
operations = [migrations.RunPython(add_settings, del_settings)]
|
||||
34
appsettings/migrations/0018_worker_settings.py
Normal file
34
appsettings/migrations/0018_worker_settings.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from django.core.management.color import no_style
|
||||
from django.db import migrations
|
||||
from django.utils.translation import gettext_noop as _
|
||||
|
||||
|
||||
SETTINGS = [
|
||||
(_("Worker Concurrency"), "WORKER_CONCURRENCY", "4", _("Jobs the worker runs at the same time (1-16)")),
|
||||
(_("Job Retention Days"), "JOB_RETENTION_DAYS", "30", _("Days finished jobs stay in the job history (1-3650)")),
|
||||
]
|
||||
|
||||
|
||||
def add_settings(apps, schema_editor):
|
||||
setting = apps.get_model("appsettings", "AppSettings")
|
||||
db_alias = schema_editor.connection.alias
|
||||
# see 0014: move a PostgreSQL id sequence past the fixed ids first
|
||||
with schema_editor.connection.cursor() as cursor:
|
||||
for sql in schema_editor.connection.ops.sequence_reset_sql(no_style(), [setting]):
|
||||
cursor.execute(sql)
|
||||
for name, key, value, description in SETTINGS:
|
||||
setting.objects.using(db_alias).get_or_create(
|
||||
key=key, defaults={"name": name, "value": value, "choices": "", "description": description}
|
||||
)
|
||||
|
||||
|
||||
def del_settings(apps, schema_editor):
|
||||
setting = apps.get_model("appsettings", "AppSettings")
|
||||
db_alias = schema_editor.connection.alias
|
||||
setting.objects.using(db_alias).filter(key__in=[key for _, key, _, _ in SETTINGS]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("appsettings", "0017_snapshot_free_space_settings")]
|
||||
|
||||
operations = [migrations.RunPython(add_settings, del_settings)]
|
||||
15
appsettings/migrations/0019_prebuilt_themes.py
Normal file
15
appsettings/migrations/0019_prebuilt_themes.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from django.db import migrations
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
setting = apps.get_model("appsettings", "AppSettings")
|
||||
db_alias = schema_editor.connection.alias
|
||||
setting.objects.using(db_alias).filter(key="SASS_DIR").delete()
|
||||
# the default was a typo that never applied: these installs showed the shipped zephyr
|
||||
setting.objects.using(db_alias).filter(key="BOOTSTRAP_THEME", value="flaty").update(value="zephyr")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [("appsettings", "0018_worker_settings")]
|
||||
|
||||
operations = [migrations.RunPython(forward, migrations.RunPython.noop)]
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue