samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
146 lines· 9.7 KB ·Markdown
1# Accounts and access
2
3This page explains how people get accounts, how they prove who they are on each channel (browser, SSH and HTTP git), and what they can then do with each repository.
4
5**Source files:**
6- `src/web/account.rs`: sign-up, sign-in, sessions, profile, password and SSH keys.
7- `src/web/ctx.rs`: session lookup and cookies.
8- `src/state.rs`: the permission model.
9- `src/validate.rs`: input rules.
10- `src/ssh.rs`: SSH authentication.
11- `src/web/git_http.rs`: HTTP Basic authentication.
12
13## Accounts
14
15### Registration
16
17`GET`/`POST /register` (`account::register_form`, `account::register`).
18
19- **When it's allowed.** Registration is open unless `--disable-registration` (`OPENHUB_DISABLE_REGISTRATION=true`) is set. Even then, the first account can always be created, so a new instance can't lock itself out. `account::can_register` holds the rule, and the landing and sign-in pages use it to show or hide the sign-up link.
20- **The first account becomes the site administrator** (`users.is_admin = 1`). Register it before the server is reachable by anyone else.
21- **Validation** (`src/validate.rs`):
22
23 | Field | Rule |
24 | --- | --- |
25 | Username | 1–39 ASCII letters, digits or hyphens. Can't start or end with a hyphen or contain `--`. Not reserved. Unique, case-insensitively |
26 | Email | Up to 254 characters, no whitespace, a non-empty local part, and a domain containing `.`. Unique, case-insensitively. Not verified by email |
27 | Password | At least 8 characters and at most 256 bytes |
28
29 Reserved usernames are names that would clash with top-level routes: `admin`, `api`, `assets`, `explore`, `favicon.svg`, `git`, `help`, `login`, `logout`, `new`, `register`, `root`, `settings`, `signin`, `signup`, `static`, `system`. Adding a top-level route means adding its first path segment to `RESERVED`.
30
31- **Afterwards**, the new user is signed in and sent to **Settings → SSH keys** with a welcome message.
32
33### Signing in
34
35`POST /login` (`account::login`):
36
37- The login field accepts a username or an email (`db::user_by_login`).
38- A password is verified with Argon2id even when the account doesn't exist, against `DUMMY_HASH`. That way, response timing doesn't reveal which accounts exist. The error message is always "Incorrect username or password."
39- `next` takes the user back to the page that required sign-in. `web::safe_next` only accepts same-site paths: it must start with `/`, but not `//` or `/\`.
40- There is no rate limiting, lockout, two-factor authentication or password reset (see [Security](security.md#known-gaps)).
41
42### Passwords
43
44- Hashed with `argon2` 0.5 defaults (Argon2id, 19 MiB of memory, 2 iterations) and a random salt, inside `spawn_blocking`.
45- Changing the password (`POST /settings/password`) requires the current password. It signs out every other session of that user and keeps the current one.
46
47### Sessions
48
49| Property | Value |
50| --- | --- |
51| Cookie | `openhub_session`, `HttpOnly`, `SameSite=Lax`, `Path=/`, 30-day `Max-Age`. `Secure` when `--public-url` starts with `https://` |
52| Token | 32 random bytes from the OS RNG, base64url-encoded |
53| Storage | Only `sha256(token)` is stored, in `sessions.token_hash`, so a database leak doesn't expose usable cookies |
54| Lifetime | Fixed 30 days from sign-in. There's no sliding renewal |
55| Validation | `ctx::middleware` hashes the cookie and calls `db::user_by_session`, which ignores expired rows. It skips this for git HTTP paths |
56| Sign-out | `POST /logout` deletes the session row and clears the cookie |
57
58### Flash messages
59
60One-off messages shown after a redirect, such as "Profile updated.", are set by `web::redirect_flash`. They are stored in the `openhub_flash` cookie as base64url `kind\nmessage`, where kind is `success`, `error` or `info`. The middleware decodes the cookie into `Ctx.flash` and clears it on the next response, unless that response sets a new one.
61
62### Profile
63
64`/settings/profile` edits the display name (up to 100 characters), email and bio (up to 500 characters). Avatars are generated from the username, as a coloured initial (`fmt::avatar`). No images are uploaded or fetched.
65
66## SSH keys
67
68SSH keys are the only credential that can push.
69
70**Adding a key** (`POST /settings/keys`, `account::add_key` and `validate::ssh_public_key`):
711. The input must be a single line. Input containing `PRIVATE KEY` is rejected, with a hint to paste the `.pub` file instead.
722. The line is parsed as an OpenSSH public key by the `ssh-key` crate.
733. The stored copy is normalised: re-encoded with the comment removed. The comment becomes the default title.
744. The SHA-256 fingerprint must not already be registered, by this user or anyone else. Because a key belongs to at most one account, the key alone identifies the user during SSH authentication.
75
76**Deleting a key** (`POST /settings/keys/{id}/delete`) only works for the user's own keys. Anything else returns 404.
77
78**Usage tracking.** `last_used_at` is updated each time the key authenticates, and shown on the keys page.
79
80**Key types.** Ed25519 is recommended and is what the smoke test uses. Other algorithms that both the `ssh-key` parser and russh support also work.
81
82## Identity on each channel
83
84| Channel | How the user is identified | Code |
85| --- | --- | --- |
86| Browser | Session cookie | `ctx::middleware` → `Ctx.user` |
87| SSH (`git push`, `fetch`, `clone`) | Public key fingerprint → `ssh_keys` → `users`. The SSH username, conventionally `git`, is ignored | `SshSession::auth_publickey` |
88| HTTP git, public repository | Anonymous. Credentials aren't requested | `git_http::authorize` |
89| HTTP git, private repository | HTTP Basic auth: a username or email, plus the account password, checked with Argon2id on every request | `git_http::authorize` |
90| HTTP push | Always refused (403) | `git_http::receive_pack`, `info_refs` |
91
92## Permission model
93
94All permission checks go through `AppState::access(repo, user)`, which returns `Access { read, write, admin }`:
95
96| Who | `read` | `write` | `admin` |
97| --- | --- | --- | --- |
98| Site administrator (`users.is_admin`) | ✓ | ✓ | ✓ |
99| Repository owner | ✓ | ✓ | ✓ |
100| Collaborator | ✓ | ✓ | |
101| Any other signed-in user | public repositories only | | |
102| Anonymous visitor | public repositories only | | |
103
104### What each level allows
105
106| Action | Needs | Enforced in |
107| --- | --- | --- |
108| View code, history, branches, tags and pull requests; download archives and raw files | `read` | `repo::load_repo` (every repository page) |
109| Clone and fetch over SSH or HTTP | `read` | `SshSession::exec_request`, `git_http::authorize` |
110| Push over SSH (create, update or delete branches and tags) | `write` | `SshSession::exec_request` |
111| Open a pull request, or comment on one | Signed in, plus `read` | `pulls::create`, `pulls::comment` |
112| Edit a pull request's title or body, close it or reopen it | The pull request's author, or `write` | `pulls::edit`, `pulls::close_pull`, `pulls::set_state` |
113| Merge a pull request | `write` | `pulls::merge` (`RepoCtx::require_write`) |
114| Delete a branch in the web interface (not the default branch) | `write` | `repo::delete_branch` |
115| Change the name, description, visibility or default branch | `admin` | `repo::update_settings` (`RepoCtx::require_admin`) |
116| Add or remove collaborators | `admin` | `repo::add_collaborator`, `repo::remove_collaborator` |
117| Delete the repository | `admin`, plus typing `owner/name` to confirm | `repo::delete` |
118| Create a repository | Signed in | `repo::create` (always owned by the creator) |
119
120Notes:
121
122- **Opening a pull request needs only `read`,** but both branches must already exist in the repository, and only people with `write` can push branches. So on a public repository, any signed-in user can open a pull request between existing branches and comment on it. They can't change any code.
123- **Collaborators can't be granted read-only access.** The only level is write.
124- **There are no organisations or teams.** Every repository belongs to one user.
125- **The site administrator has no special pages.** Their power is `admin` on every repository. Promoting or demoting users, or removing accounts, is done in SQL (see [Operations](operations.md#administration-without-a-ui)).
126
127### Private repositories look like missing ones
128
129A user without `read` should be unable to tell a private repository from one that doesn't exist:
130
131| Channel | Response when the repository is private and the user lacks access |
132| --- | --- |
133| Web | 404 page (`load_repo` returns `AppError::NotFound` before any other check) |
134| SSH | `ERROR: Repository not found.`, the same as for a missing repository |
135| HTTP git, with credentials | 404 `Repository not found` |
136| HTTP git, without credentials | **401** with a Basic challenge, while a missing repository gets 404. This is a known gap, listed under [Security → Known gaps](security.md#known-gaps) |
137| Profile and dashboard lists | Private repositories are only listed for the owner, collaborators and site administrators (`db::list_owner_repos`) |
138| Explore and landing pages | Public repositories only |
139
140New handlers must load repositories through `repo::load_repo`, which applies this rule, then call `require_write()` or `require_admin()` as needed.
141
142## When you change this
143
144- **New top-level route** (`/something`): add `something` to `RESERVED` in `src/validate.rs`. An existing user with that name would shadow the route, or the route would shadow the user, so check the database.
145- **New permission level**, such as read-only collaborators: change `Access`, `AppState::access`, the collaborators table (via a migration), the settings page, `db::list_owner_repos` and `db::list_member_repos`, then update both tables above.
146- **New credential type**, such as tokens: add it to the identity table, keep pushes on SSH unless a decision is made otherwise, and extend `scripts/smoke-test.sh`.