Add a backend admin migration that creates a unique index on lower(backend_users.email). Rows copied from WinterCMS may hold emails that differ only in case, so the migration refuses to run and names the clashing logins instead of choosing an account to drop.
91 lines
6.4 KiB
Markdown
91 lines
6.4 KiB
Markdown
---
|
|
title: Users and permissions
|
|
description: Sign administrators in with JWT and cookie auth, declare permissions and navigation, and manage administrators from the console.
|
|
section: backend
|
|
order: 50
|
|
---
|
|
# Users and permissions
|
|
|
|
The admin keeps WinterCMS's backend user model: the `backend_users` and `backend_user_roles` tables, roles with permission grants, and superusers who pass every check. [cabana](../../modules/cabana/README.md) signs administrators in and checks their permissions; the tables are created by the framework migrations that `migrate` runs. Administrator emails are unique regardless of case. If a table copied from WinterCMS holds two emails that differ only in case, `migrate` stops and names their logins; change or remove one of them, then run `migrate` again.
|
|
|
|
## Signing in
|
|
|
|
`POST <prefix>/api/v1/auth/login` checks the login and password against `backend_users` and issues a JWT for the admin audience, signed with `admin.jwt.secret`. Login attempts are throttled per `admin.login.max_attempts` and `admin.login.decay_minutes`. `POST .../auth/refresh` reissues a token inside the refresh window, and `POST .../auth/logout` revokes the current token by blacklisting its ID in `backend_jwt_blacklist`. Logout accepts a token whose access lifetime has run out as long as its refresh window is open, because `.../auth/refresh` would still accept it, and it always clears the session cookie.
|
|
|
|
The admin API accepts the token two ways:
|
|
|
|
- API clients send `Authorization: Bearer <token>`.
|
|
- The admin SPA sends `X-Requested-With: XMLHttpRequest` and receives the token in an HttpOnly, SameSite=Strict cookie. A cookie-authenticated request that changes state must carry that header, which blocks cross-site request forgery.
|
|
|
|
The guard is registered in [bouncer](../../modules/bouncer/README.md) under the name `backend` and is the middleware of every admin route except login, refresh and the language bundle. `cabana.Activate` always registers its own guard under that name, so a plugin that registers another guard as `backend` makes the start-up fail with an error naming the plugin instead of replacing admin authentication. See [Authentication](../services/authentication.md) for tokens and guards in general.
|
|
|
|
The admin keys go in `config/admin.yaml`, with the secret in the environment (`SUMMER_ADMIN__JWT__SECRET`):
|
|
|
|
```yaml
|
|
jwt:
|
|
ttl: 60
|
|
refresh_ttl: 20160
|
|
password:
|
|
bcrypt_cost: 12
|
|
login:
|
|
max_attempts: 5
|
|
decay_minutes: 1
|
|
```
|
|
|
|
The cookie carries the Secure attribute. `backend.cookie_secure: false` drops it for plain-HTTP development and is refused in the `production` environment.
|
|
|
|
## Permissions
|
|
|
|
A plugin declares its permissions with `pact.HasPermissions`, the Go form of `registerPermissions`, and its menu entries with `pact.HasNavigation`:
|
|
|
|
```go src=modules/cabana/example_controller_test.go#BlogPlugin.Permissions
|
|
// Permissions replaces registerPermissions().
|
|
func (p *BlogPlugin) Permissions() []pact.Permission {
|
|
return []pact.Permission{
|
|
{Code: "acme.blog.access_posts", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.posts"},
|
|
{Code: "acme.blog.access_settings", Tab: "acme.blog::lang.plugin.name", Label: "acme.blog::lang.permissions.settings"},
|
|
}
|
|
}
|
|
```
|
|
|
|
```go src=modules/cabana/example_controller_test.go#BlogPlugin.Navigation
|
|
// Navigation replaces registerNavigation().
|
|
func (p *BlogPlugin) Navigation() []pact.NavigationItem {
|
|
return []pact.NavigationItem{{
|
|
Code: "blog",
|
|
Label: "acme.blog::lang.plugin.name",
|
|
Icon: "icon-pencil",
|
|
Permissions: []string{"acme.blog.access_posts"},
|
|
Controller: "acme.blog.posts",
|
|
SideMenu: []pact.NavigationItem{
|
|
{Code: "posts", Label: "acme.blog::lang.posts.title", Controller: "acme.blog.posts"},
|
|
},
|
|
}}
|
|
}
|
|
```
|
|
|
|
A controller's `pact.AdminPermissioned.RequiredPermissions` are checked before any schema is served or query runs, and navigation and settings entries are filtered by the permissions they name, so an administrator sees only what they may open: a main menu item the administrator may not open is dropped even when one of its side-menu entries would pass, and an allowed item that links to a controller the administrator cannot open links to its first openable side-menu entry instead. `cabana.Allows` is the check and follows Winter's `hasAnyAccess`: superusers pass, an administrator needs any one of the listed codes, and an empty requirement list allows any signed-in administrator. Wildcards match on both sides: a grant ending in `.*` covers every code with that prefix, and a required code such as `acme.blog.*` is met by any grant under `acme.blog.`. The last lines of the activation example on [Admin controllers](admin-controllers.md) show it.
|
|
|
|
An administrator's grants are the role's `permissions` merged with the administrator's own `backend_users.permissions`, the way WinterCMS merges them: the administrator's value for a code replaces the role's, and only `1` grants. A `-1` (or `0`) on the administrator therefore removes a permission the role grants, so rows copied from a WinterCMS database keep their denies. As in WinterCMS the merge compares codes exactly, so denying `acme.blog.access_posts` does not take it back from a role that grants `acme.blog.*`.
|
|
|
|
Actions registered through `pact.HasAdminActions` may name extra permissions, checked on top of the controller's.
|
|
|
|
## Managing administrators
|
|
|
|
The application binary has two commands for operators:
|
|
|
|
```sh
|
|
./bin/acme admin:create --email admin@example.com --superuser
|
|
./bin/acme admin:reset-password admin@example.com
|
|
```
|
|
|
|
Both commands ask for the password at a prompt that does not echo it. In a script, pipe it on stdin so it never appears in the process list or the shell history:
|
|
|
|
```sh
|
|
printf '%s\n' "$ADMIN_PASSWORD" | ./bin/acme admin:create --email admin@example.com --superuser
|
|
```
|
|
|
|
`--password` is still accepted but deprecated: the command prints a warning, because the value is visible to other users in the process list and stays in the shell history.
|
|
|
|
`admin:create` creates an activated administrator; `--login` defaults to the lower-cased email and `--role <code>` assigns a role. It refuses a login or email that matches another administrator's login or email in either field, because a sign-in identifier that matches two administrators is answered like a wrong password. `admin:reset-password` takes a login or an email, sets the password and revokes every token issued before the reset. Passwords are hashed with bcrypt at `admin.password.bcrypt_cost`, so hashes copied from a WinterCMS database keep working.
|