The page where a user changes their own password and the one where an
administrator sets a user's password shared an old card: a horizontal
form, a blue Cancel and a green Change. The administrator's had Django's
"Password-based authentication: Enabled/Disabled" with a paragraph about
other backends, and its password fields stayed while Disabled was chosen.
Both pages now have the user and group forms' layout, each its own
template. The administrator chooses how the user signs in, With a
password or Without a password (such as LDAP), and the password fields
and "must change it" show only with a password; Cancel goes back to the
user. A user's own page says that their other sessions end, or, when
they must change it, that they choose one before going on, without a
Cancel. A refused password is said at its field only, without the
general "Wrong Data Provided". The section styles of these forms move
into webvirtcloud.css.
A superuser's page of a user was a heading, two unlabelled icon buttons
and three tabs: the VMs a table of check columns, the SSH keys a table
that pushed the page sideways on a phone.
Now a card at the top shows the user's name, role, Blocked when they
are, username, email, groups, last login and joining date, with Edit
user and Email OTP QR code as labelled buttons and links to the sections
below with their counts. The VMs card shows each VM with its host and
what the user may do beyond power and console as badges in the VM Users
tab's words (Change settings, Delete the VM, Change console settings),
with Change access and Remove from user in a menu, and Add VM in its
header. The SSH keys and the API tokens are cards; the key rows are now
one include with the user's own Profile, and both lists say when they
are empty. The full key shows over its fingerprint.
The tab script had no page left and goes.
The Profile page was three tabs, each a box inside a box with horizontal
labels: the SSH keys a centred bare row with a grey trash icon, a tall
empty textarea under them, and the user known only by the heading.
Now the user's name, username, role, email and last login head a card
that holds the profile form and the password link; beside it the SSH
keys and the API tokens are cards with their count, a line on what
they are for, one row each with Delete in a menu, and the form to add
one behind a button. Adding or deleting a key returns to #sshkeys.
The token rows are a list-group include that the administrator's view
of a user shares.
With one owner per VM, the VM page and a user's page counted the VM's
owners and then added one in separate steps, so two owners added at the
same moment both found the VM without one, and it got two. The check
and the insert now run in one transaction that holds the ownership lock
(PostgreSQL's advisory lock; SQLite's IMMEDIATE transactions already
serialize them): the second waits for the first and is refused. The
audit row is written after the transaction, so a failure keeps its row.
Settings > Users was a bold title, an unlabelled "+" and a table of
names with a trash icon. The owner chose this layout from three
rendered options: one list of owners, each with what it may do
(change settings, delete the VM, change console settings, or power and
console only), Edit rights and Remove, an empty state, and a labelled
"Add owner".
- Remove and Edit rights come back to the VM's Users tab: the return
address carried #users unencoded, so it was lost, and Edit rights
went to the user's account page. Both accounts views now go to a
?next page of this site.
- The add-owner list offers only users who are not owners yet and not
administrators, who can use every VM anyway; a superuser owner also
took the only place when a VM may have one owner.
- The unused del_owner view and URL are removed: the list uses the
accounts page, which asks before removing an owner.
XML:
- An XML edit not saved yet is kept: the page no longer reloads itself
when the VM's state changes, and the browser asks before leaving it.
A state change lost the edit.
- The button that opens the review dialog reads "Review changes"; it
did not save, as "Change" suggested. Closing the dialog gives focus
back to the editor.
- An SSH public key whose comment holds spaces (ssh-keygen -C "John Doe
laptop") is accepted; only the type and the key are checked.
- The created API token is shown by a GET after a redirect, so reloading
the page no longer creates a second token the user never sees.
- The OTP login page shows field errors instead of an empty error box.
- A user without a local password (an LDAP user) is not offered a password
change that could never succeed.
On a superuser's page of a user the VM permissions read True/False; they
are now check marks as on the Users list. A user without VMs gets "No VMs."
instead of an empty table. A superuser can delete one of the user's SSH
keys, as with API tokens: the same view takes the user, the row names
whose key it was.
Closes#218
- An administrator could block, delete or demote (unset Active or
Superuser on) their own account, and with it the last one, leaving the
panel without anyone to undo it. These are now refused.
- The user form showed "No password set." for users who have one: its
password field never read the user's password. Its "Set password"
link went to ../password/, a page this panel does not have. The form
now says whether a password is set and links the page that sets it.
- "Staff status" said "can log into this admin site"; here it lets a
user change and delete the template VMs given to them, and is now
called Template editor.
- A quota of 0 counted as no limit, like -1, so a user's quota could not
be taken away. Now -1 has no limit and 0 allows none; the help says
so, and the labels give the units: memory in MiB, disk in GiB.
- A user's page for a user that does not exist answered with a server
error instead of not found.
- The user list said "None" for a user who never logged in; it says
"never", and dates fit their column.
- The password form's labels no longer break mid-word.
The user page a superuser opens from the user list has an API Tokens
tab: the user's tokens with their name, first characters, creation,
expiry and last use, and a delete button for one that may have leaked.
The token itself is never shown; only its digest is kept.
The delete reuses the owner's delete view with the user's id; only a
superuser may name another user. The confirmation and the log row say
whose token it was ("API token backup of alice"); the owner's own
confirmation now reads "Delete "API token backup"?" too. The profile
and the user page share one token table, and one script that opens a
tab named in the address (#apitokens).
The API Tokens tab gets a "Last used" column, so an unused token can be
told from one a script still needs. The token's last use is written when
it authenticates, at most once a minute (accounts.auth.TokenAuthentication,
knox's class with that one addition; the API schema describes it as
knox's).
A user has at most ten tokens; a new one is refused until one is
deleted. Expired tokens do not count, and are deleted when a new token is
made.
The REST API took only the login session, so a script could not use it
without a browser. A user now makes API tokens on the profile page (API
Tokens tab): a name, and an expiry of 30, 90 or 365 days or none. The
token is shown once, on a page that is not cached, and then kept only as
a SHA-512 digest; the list shows its name, first characters, creation
and expiry, and a token is deleted there. Tools send it as
"Authorization: Token wvc_...", and act as its user, with that user's
permissions. Making and deleting a token is logged, without the token.
A password alone (HTTP Basic) still does not reach the API, as it would
skip the OTP second factor: a token is made in a session that passed the
login and its OTP. The token class comes first, so a refused request gets
401 with its challenge instead of 403.
django-rest-knox 5.1.0 provides the tokens (accounts.ApiToken adds the
name); settings.py takes "knox", KNOX_TOKEN_MODEL and REST_KNOX from the
template.
Operations on a VM now write their rows the way the admin audit does: one
row per operation, done or failed with a safe reason, with the user, IP and
host. Each row names the VM ("VM web-1: delete snapshot snap1") and carries
its UUID in the new Logs.instance_uuid column.
- audit_vm and failed_vm wrap audit() and failed() for a VM. A failure the
view turns into a message is caught outside the block, so its failed row
is written; busy claims, permission and quota refusals and invalid input
write nothing. No password, key or XML goes into a row.
- All VM page operations move from addlogmsg to audit_vm, and addlogmsg
goes. QoS changes and defining a VM from XML are logged now; a gstfsd
error reply (root password, SSH key) is a failed row, not a done one.
- Partial results are rows of their own: the VM delete, then each disk
delete; the disk detach, then the volume delete; the migration, then a
failed autostart restore, NVRAM removal or record on the target.
- Migration rows use the compute name as the host column, as every other
row does.
- VM job rows (create, clone, snapshots) and their resolutions carry the
VM's UUID; snapshot job intents now record it. The owner rows of the VM
page and the accounts pages, and the ownership system rows, carry it too.
- The Logs tab loads vm_logs/<pk>/ and shows the rows with the VM's UUID,
so a rename keeps the history, two VMs with one name are told apart and
a VM on two computes is one VM. It gains a Result column. Rows from
before this change have no UUID and stay on the Logs page only.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The Logs page now records the panel's own records too: users (add,
change with the changed fields, delete, block, unblock, an admin's
password set, a user's own password change including the first-login
one, own profile edits), groups with their members and permissions,
VM owners from the accounts pages and from the VM page, OTP QR code
mails, flavors from web and API, app settings and the theme,
removed-VM records and SSH keys. No row carries a password or a key.
- A user is created in one transaction: when a VM named in
NEW_USER_DEFAULT_INSTANCES is missing, no half-made user stays.
- The OTP mail checks the email address before it creates a device;
without one it writes a failed row and makes no device.
- The VM page's owner rows read "VM web-1 owner bob: add" and
"...: remove" instead of "Add owner: bob" and "Delete owner: 17"; a
second owner refused by the single-owner setting is a failed row.
Owner rows keep the VM's host and name, so the VM's log tab shows
them.
- A setting or theme posted with its current value is no change and
writes no row; the old free-text setting rows are gone.
Host-side objects (networks, storage, interfaces, secrets, filters)
follow in the next layer.
The Logs page shows who added, changed or deleted a compute, from web
or API, and every login, failed login, lockout and logout, with the
client address and a Result column (Done, Failed, Unknown for older
rows). A failed change is recorded with its reason and the error still
reaches the user; a refused one is recorded too.
- logs.audit.write saves each row in its own savepoint and never
raises, so a log write cannot break or undo the action it records.
The audit() context manager writes one row when the change ends.
- Logs gains success and ip; user, host and instance are wider and
date is indexed. The page orders by date then id.
- A compute edit names the changed fields; an edit that changes
nothing writes no row, and an empty password field is no change.
- A compute's VMs leaving the panel get a system row each: owners
kept on the Removed VMs page, moved to another compute, restored,
left without owners, or the removed record expired.
- Job rows carry the result. A job's log entry that cannot be
computed is logged and skipped instead of rolling back the finish
(test_service.py changes this on purpose).
- Logins are recorded by signal receivers; CustomLoginView is removed
and the plain LoginView is used. A wrong OTP code is a failed login,
counted by django-axes toward the lockout.
Panel records and host-side objects follow in the next layers.
The shared delete page asked only whether to delete the object. Each
view now gives its consequence: a deleted compute's VMs keep running
but the panel forgets them and their owners, a deleted user's VMs
stay, a group's members keep their accounts, a VM's owner loses only
the access, a removed VM's record no longer brings its owners back,
an SSH key stays on the VMs that have it, a flavor only goes as a
size template.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The login page showed its sign-in card to a user who was already signed
in, with the navbar squeezed beside it. Both login views, with and
without OTP, now send that user to the page in next, or to the default
page when next is missing or points elsewhere. Every permission check
answers 403 instead of redirecting to the login, so this cannot loop.
The page test renders the login page signed out.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
With OTP_ENABLED the login used django-otp's OTPAuthenticationForm. Under the
pinned django-otp 1.7.3 that form could not log anyone in through the shipped
page: the page posts only a 6-digit otp_token, but the form now needs the
device named explicitly, so an enrolled user got "Please select a device.",
and a user who never enrolled got "Please enter your OTP token." with no
device to produce one. Verified in an isolated test database.
SingleDeviceOTPForm makes OTP per user: a user with a confirmed TOTP device
must enter a valid code; a user without one logs in with the password alone.
The form chooses the device itself (the user's oldest confirmed TOTP device,
locked with select_for_update inside clean_otp's transaction so replay and
throttling counters cannot race) and ignores any posted otp_device, which
django-otp does not check for confirmed status or type. The login page drops
the HTML "required" on the token field (the field is optional for an
unenrolled user) and says so.
The "Lost OTP?" page (email_otp) created a confirmed device when none existed.
With per-user OTP that let anyone who knows an address turn a password-only
account into an OTP-required one and, if the mail never arrived, lock it out.
It now resends the QR only to a user who already enrolled and never creates a
device (get_existing_totp_device); admin enrollment (admin_email_otp) still
creates one. Email QR distribution and confirmed-on-create enrollment are
unchanged by user decision.
Tests: per-user login (no device -> password only; device -> valid token
passes, wrong/empty token fails), a posted or another user's device is not
used, a token cannot be replayed, and the real login page over HTTP with
OTP_ENABLED; email_otp sends nothing and creates no device for an unenrolled
user. The new tests fail on the previous code.
/accounts/email_otp/ mails a user their OTP QR code and needs no login.
Measured with the default settings (OTP disabled):
- It was reachable although OTP was off, and created OTP devices.
- Every request sent a mail: 20 requests for one address, 20 mails, so
anyone who knew an address could fill that mailbox.
- An address shared by two users, or a mail server that could not be
reached, ended in an error page (500).
- Inactive users were mailed too.
The page now exists only while OTP_ENABLED is set. It mails a single
active user whose address matches (case-insensitively), at most once
every 5 minutes per user (Django's cache: with several workers the
bound is per worker), and logs a failed delivery. The answer is the
same in every case, so it does not reveal whether an address belongs
to a user.
The QR codes come from django-qr-code (segno); both the mail and the
account page render them without the qrcode package removed earlier.
The new tests fail on the previous code.
django-login-required-middleware is dropped; Django 5.1+ ships the same
middleware. The login page is already marked login_not_required by
Django; email_otp gets the decorator, which replaces
LOGIN_REQUIRED_IGNORE_VIEW_NAMES. The datasource still requires login.
Existing settings.py files still name the old middleware. A system
check (accounts.E002) stops migrate and the server until MIDDLEWARE
uses Django's middleware, instead of an ImportError at startup; the
README has the steps.
A new matrix test walks every URL anonymously: everything outside a
short allowlist (login pages, the OpenAPI docs) sends the visitor to
the login page, and API views deny with 401/403. It passed with the old
middleware first and passes unchanged now.
Super-linter ran from the unpinned :latest image and, because of
branches-ignore: [master], never on pull requests to master. Ruff now
checks syntax errors and pyflakes rules (E9, F) on every push and PR.
Fixes the existing findings: unused imports and variables, an unused
duplicate import in instances/tests.py, and the star import in
accounts/views.py. settings-dev.py keeps its star import of the base
settings.
The forced change of the generated admin password was a session flag
set at login when the password still matched data/admin_password. A
new session after the file was gone bypassed it.
- UserAttributes.must_change_password (migration 0007) holds it; the
admin created with a generated password gets it, and a password
change clears it. ForcePasswordChangeMiddleware reads it.
- A system check (accounts.E001) stops the server unless the middleware
follows AuthenticationMiddleware; the README has the settings change.
- If the generated password cannot be stored privately, provisioning
stops without printing it and without creating the admin (one
transaction for the user and its attributes).
- The admin is provisioned on any real migrate run while no user
exists, so running migrate again after a failure creates it; flush
(plan=None) and rollbacks of accounts are skipped.
- The post_migrate receivers check the migration state for the models
they use, so a targeted migrate on a fresh database (e.g. migrate
appsettings) no longer fails with 'no such table'.
While data/admin_password exists and a user's password still matches it,
the login sets a session flag (checked once at login, not per request).
ForcePasswordChangeMiddleware then sends that user to the password-change
page for every view except change password, login and logout. Setting a
new password clears the flag and deletes data/admin_password. No schema
change; if ADMIN_PASSWORD was set, there is no file and nothing is forced.
- user_block, user_unblock and admin_email_otp changed state on GET, so an
<img> tag could lock a user out or trigger OTP emails through an
admin's session. They now require POST; the buttons are CSRF forms.
- The console token cookie is set with httponly, SameSite=Lax and, on
HTTPS, secure. Only novncd reads it; the page gets the token directly.