web: mints and grants need a sign-in from the last 15 minutes !513
40 files changed, +553 −85
Layout: unified · split
.gitbay/wiki/Architecture/05-Identity-and-Access.org +12 −1
| @@ -18,7 +18,7 @@ | |||
| 18 | | SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | optional =--ttl=, refused at auth | =keys remove= (own keys); closes its connections | | 18 | | SSH user key | user's public key | fingerprint and public blob | =full=, =git=, or =runner= | optional =--ttl=, refused at auth | =keys remove= (own keys); closes its connections | |
| 19 | | Deploy key | public key | same table, scope =deploy:<repo>:ro/rw= | one repository, read or read-write | optional =--ttl=, refused at auth | =repo deploy-key remove= (repo admin); closes its connections | | 19 | | Deploy key | public key | same table, scope =deploy:<repo>:ro/rw= | one repository, read or read-write | optional =--ttl=, refused at auth | =repo deploy-key remove= (repo admin); closes its connections | |
| 20 | | API token | =gb_= + 32 random bytes hex | SHA-256 hash | =read= (default) or =full=; with an expiry, no credential-minting command | optional =--ttl= | =token revoke [--created]= | | 20 | | API token | =gb_= + 32 random bytes hex | SHA-256 hash | =read= (default) or =full=; with an expiry, no credential-minting command | optional =--ttl= | =token revoke [--created]= | |
| 21 | | Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account | 12 h idle, 7 days absolute | logout, =web sessions revoke= | | 21 | | Web session | 32 random bytes hex, cookie =gitbay_session= | SHA-256 hash | full account; credential-minting and access-granting commands only within 15 minutes of sign-in | 12 h idle, 7 days absolute | logout, =web sessions revoke= | |
| 22 | | Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use | | 22 | | Login link | 32 random bytes hex in a URL | SHA-256 hash, single use | creates a web session | 15 min (mail), 5 min (SSH) | consumed on use | |
| 23 | | Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use | | 23 | | Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use | |
| 24 | | Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use | | 24 | | Invite | random code | SHA-256 hash, single use | one registration for one email | as issued | consumed on use | |
| @@ -118,6 +118,17 @@ button and the displayed status (=internal/control/mr.go=): | |||
| 118 | - Destructive web actions (key, email and PGP removal, release, snippet, | 118 | - Destructive web actions (key, email and PGP removal, release, snippet, |
| 119 | team and label deletion, user disable and demote) require the target's | 119 | team and label deletion, user disable and demote) require the target's |
| 120 | name typed into the form (=internal/httpd/confirm.go=). | 120 | name typed into the form (=internal/httpd/confirm.go=). |
| 121 | - Commands that create a credential (SSH, deploy and runner keys, API | ||
| 122 | tokens, email verification, login links, PGP keys, device tokens) or | ||
| 123 | grant access (repository and organization roles, teams, transfers, | ||
| 124 | admin promote and enable, webhooks, secrets, mirrors) are refused | ||
| 125 | from a browser session that signed in more than 15 minutes ago | ||
| 126 | (=control.ReauthWindow=, =Command.NeedsRecentSignIn=). The sign-in | ||
| 127 | time is =web_sessions.created_at=, which idle renewal does not move; | ||
| 128 | a request with no sign-in time is refused. SSH, API tokens and host | ||
| 129 | commands are unaffected. The refusal is audited; the form shows it | ||
| 130 | with a sign-in link, and the login returns to the page through the | ||
| 131 | server-set =gitbay_next= cookie (=internal/httpd/flash.go=). | ||
| 121 | 132 | ||
| 122 | * Rate limits | 133 | * Rate limits |
| 123 | 134 | ||
.gitbay/wiki/Architecture/09-Controls.org +1 −1
| @@ -26,7 +26,7 @@ chapter names of OWASP ASVS 4.0 where one fits. | |||
| 26 | | Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) | | 26 | | Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) | |
| 27 | | Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec | | 27 | | Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec | |
| 28 | | Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=); LFS transfer tokens are refused with their key (=internal/httpd/lfs.go=) | | 28 | | Revocation takes effect immediately | in place | removing a key or disabling an account closes its connections; every exec re-reads its key (=internal/sshd/sshd.go=); LFS transfer tokens are refused with their key (=internal/httpd/lfs.go=) | |
| 29 | | Delegation bounded by the delegating credential | partial | expiring tokens refused on =MintsCredential= commands; credentials record their creating token (=internal/control/control.go=); a web session can still mint credentials that outlive it (#297) | | 29 | | Delegation bounded by the delegating credential | in place | expiring tokens refused on =MintsCredential= commands; credentials record their creating token; a browser session runs credential-minting and access-granting commands only within 15 minutes of signing in, and the refusal is audited (=internal/control/control.go=) | |
| 30 | 30 | ||
| 31 | ** Access control (V4) | 31 | ** Access control (V4) |
| 32 | 32 | ||
.gitbay/wiki/Architecture/10-Known-Gaps.org −1
| @@ -13,7 +13,6 @@ what the 2026-09-27 review found; remove a row when its issue closes. | |||
| 13 | | #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high | | 13 | | #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high | |
| 14 | | #260 | CI network | Builds share the runner's source address; no egress policy | medium | | 14 | | #260 | CI network | Builds share the runner's source address; no egress policy | medium | |
| 15 | | #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | | 15 | | #261 | Various | Migration foreign-key check after commit; three web writes bypass dispatch; documentation drift | medium | |
| 16 | | #297 | Credentials | A browser session can mint tokens and keys that outlive it | low | | ||
| 17 | | #301 | SSRF | =repo import-issues --api-base= fetches without an address check or pin | medium | | 16 | | #301 | SSRF | =repo import-issues --api-base= fetches without an address check or pin | medium | |
| 18 | 17 | ||
| 19 | * Not filed | 18 | * Not filed |
.gitbay/wiki/Threat-Model.org +3 −2
| @@ -61,8 +61,9 @@ matrix and the open gaps are in the [[file:Architecture/00-Overview.org][Archite | |||
| 61 | rights narrowed by its credential's scope, decided in one place, so a | 61 | rights narrowed by its credential's scope, decided in one place, so a |
| 62 | bearer token is worth exactly its scope and no more, and a token or | 62 | bearer token is worth exactly its scope and no more, and a token or |
| 63 | SSH key with an expiry cannot create a credential that outlives it. | 63 | SSH key with an expiry cannot create a credential that outlives it. |
| 64 | Browser sessions are not covered yet (#297). Git transport never runs | 64 | A browser session can create one, or grant access, only within 15 |
| 65 | over the API. | 65 | minutes of signing in (=control.ReauthWindow=, #297). Git transport |
| 66 | never runs over the API. | ||
| 66 | - *Anonymous surfaces* — HTTPS clone of public repos, =git://= where | 67 | - *Anonymous surfaces* — HTTPS clone of public repos, =git://= where |
| 67 | enabled, the read-only web UI — carry no credentials and expose only | 68 | enabled, the read-only web UI — carry no credentials and expose only |
| 68 | public data. HTTP push is refused via a pkt-line =ERR=, never a 401. | 69 | public data. HTTP push is refused via a pkt-line =ERR=, never a 401. |
.gitbay/wiki/Users.org +6
| @@ -701,6 +701,12 @@ creation, expiry and last use, and =gitbay web sessions revoke <id>= | |||
| 701 | or =--all= ends them from the terminal, which is where a lost laptop is | 701 | or =--all= ends them from the terminal, which is where a lost laptop is |
| 702 | handled. | 702 | handled. |
| 703 | 703 | ||
| 704 | Actions that create a credential or grant access (adding a key, token | ||
| 705 | or email, org and repository roles, transfers) ask you to sign in again | ||
| 706 | when your web sign-in is older than 15 minutes. The form shows a "Sign | ||
| 707 | in again" link and the login returns to the page. Idle renewal does not | ||
| 708 | extend this window. | ||
| 709 | |||
| 704 | =web theme set light= or =dark= fixes the web UI's colour scheme for | 710 | =web theme set light= or =dark= fixes the web UI's colour scheme for |
| 705 | your account; =system=, the default, follows the browser's own | 711 | your account; =system=, the default, follows the browser's own |
| 706 | preference. =web theme show= prints it. The account page has the same | 712 | preference. =web theme show= prints it. The account page has the same |
CHANGELOG.org +10
| @@ -23,6 +23,16 @@ source and a =--from=/remote URL carrying a query or fragment. A | |||
| 23 | mirror or import whose host is written numerically (=127.1=, | 23 | mirror or import whose host is written numerically (=127.1=, |
| 24 | =2130706433=, =0x7f.1=) rather than as a dotted address is refused | 24 | =2130706433=, =0x7f.1=) rather than as a dotted address is refused |
| 25 | too; rewrite it before upgrading. | 25 | too; rewrite it before upgrading. |
| 26 | - A web session older than 15 minutes cannot mint a credential or grant | ||
| 27 | access — keys, PGP keys, tokens, org membership, and the admin | ||
| 28 | promote/enable actions — and the form it tried shows a sign-in link | ||
| 29 | that returns there (#297). | ||
| 30 | - A browser session creates credentials and grants access — keys, PGP | ||
| 31 | keys, tokens, verified addresses, org and repository roles, transfers, | ||
| 32 | webhooks, secrets, mirrors, and the admin promote/enable actions — | ||
| 33 | only within 15 minutes of signing in. An older session gets the form | ||
| 34 | back with a "Sign in again" link, and the login returns to it. SSH and | ||
| 35 | API tokens are unaffected (#297). | ||
| 26 | - The builds page's status badge section gives an org-mode snippet | 36 | - The builds page's status badge section gives an org-mode snippet |
| 27 | beside the Markdown one, for a README.org (#299). | 37 | beside the Markdown one, for a README.org (#299). |
| 28 | - API tokens on the settings page: create with a scope and optional | 38 | - API tokens on the settings page: create with a scope and optional |
internal/control/admin.go +5 −4
| @@ -31,10 +31,11 @@ func init() { | |||
| 31 | Examples: []string{"admin user show alice"}, | 31 | Examples: []string{"admin user show alice"}, |
| 32 | ReadOnly: true, Run: runAdminUserShow}) | 32 | ReadOnly: true, Run: runAdminUserShow}) |
| 33 | register(Command{Path: []string{"admin", "user", "promote"}, | 33 | register(Command{Path: []string{"admin", "user", "promote"}, |
| 34 | Summary: "make an account an instance admin", | 34 | NeedsRecentSignIn: true, |
| 35 | Usage: "admin user promote <username>", | 35 | Summary: "make an account an instance admin", |
| 36 | Examples: []string{"admin user promote alice"}, | 36 | Usage: "admin user promote <username>", |
| 37 | Run: runAdminUserPromote}) | 37 | Examples: []string{"admin user promote alice"}, |
| 38 | Run: runAdminUserPromote}) | ||
| 38 | register(Command{Path: []string{"admin", "user", "demote"}, | 39 | register(Command{Path: []string{"admin", "user", "demote"}, |
| 39 | Summary: "remove instance admin from an account (never the last one)", | 40 | Summary: "remove instance admin from an account (never the last one)", |
| 40 | Usage: "admin user demote <username>", | 41 | Usage: "admin user demote <username>", |
internal/control/adminhost.go +10 −9
| @@ -32,17 +32,18 @@ func init() { | |||
| 32 | }, | 32 | }, |
| 33 | Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"}, | 33 | Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"}, |
| 34 | ReadsStdin: true, | 34 | ReadsStdin: true, |
| 35 | MintsCredential: true, Run: runAdminUserCreate}) | 35 | MintsCredential: true, NeedsRecentSignIn: true, Run: runAdminUserCreate}) |
| 36 | register(Command{Path: []string{"admin", "user", "disable"}, | 36 | register(Command{Path: []string{"admin", "user", "disable"}, |
| 37 | Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled", | 37 | Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled", |
| 38 | Usage: "admin user disable <username>", | 38 | Usage: "admin user disable <username>", |
| 39 | Examples: []string{"admin user disable alice"}, | 39 | Examples: []string{"admin user disable alice"}, |
| 40 | Run: runAdminUserDisable}) | 40 | Run: runAdminUserDisable}) |
| 41 | register(Command{Path: []string{"admin", "user", "enable"}, | 41 | register(Command{Path: []string{"admin", "user", "enable"}, |
| 42 | Summary: "restore a suspended account", | 42 | NeedsRecentSignIn: true, |
| 43 | Usage: "admin user enable <username>", | 43 | Summary: "restore a suspended account", |
| 44 | Examples: []string{"admin user enable alice"}, | 44 | Usage: "admin user enable <username>", |
| 45 | Run: runAdminUserEnable}) | 45 | Examples: []string{"admin user enable alice"}, |
| 46 | Run: runAdminUserEnable}) | ||
| 46 | register(Command{Path: []string{"admin", "user", "delete"}, | 47 | register(Command{Path: []string{"admin", "user", "delete"}, |
| 47 | Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)", | 48 | Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)", |
| 48 | Usage: "admin user delete <username> --yes", | 49 | Usage: "admin user delete <username> --yes", |
| @@ -55,8 +56,8 @@ func init() { | |||
| 55 | Summary: "mark an address verified by admin assertion", | 56 | Summary: "mark an address verified by admin assertion", |
| 56 | Usage: "admin email verify <username> <address>", | 57 | Usage: "admin email verify <username> <address>", |
| 57 | Examples: []string{"admin email verify alice alice@example.org"}, | 58 | Examples: []string{"admin email verify alice alice@example.org"}, |
| 58 | MintsCredential: true, | 59 | MintsCredential: true, NeedsRecentSignIn: true, |
| 59 | Run: runAdminEmailVerify}) | 60 | Run: runAdminEmailVerify}) |
| 60 | register(Command{Path: []string{"admin", "invite"}, | 61 | register(Command{Path: []string{"admin", "invite"}, |
| 61 | Summary: "issue a registration invite and mail its code", | 62 | Summary: "issue a registration invite and mail its code", |
| 62 | Usage: "admin invite --email <address>", | 63 | Usage: "admin invite --email <address>", |
| @@ -64,8 +65,8 @@ func init() { | |||
| 64 | {"--email", "<address>", "who the invite is for", ""}, | 65 | {"--email", "<address>", "who the invite is for", ""}, |
| 65 | }, | 66 | }, |
| 66 | Examples: []string{"admin invite --email alice@example.org"}, | 67 | Examples: []string{"admin invite --email alice@example.org"}, |
| 67 | MintsCredential: true, | 68 | MintsCredential: true, NeedsRecentSignIn: true, |
| 68 | Run: runAdminInvite}) | 69 | Run: runAdminInvite}) |
| 69 | register(Command{Path: []string{"admin", "stats"}, | 70 | register(Command{Path: []string{"admin", "stats"}, |
| 70 | Summary: "instance statistics: counts and per-repository disk usage", | 71 | Summary: "instance statistics: counts and per-repository disk usage", |
| 71 | Usage: "admin stats", | 72 | Usage: "admin stats", |
internal/control/build.go +5 −4
| @@ -69,10 +69,11 @@ func init() { | |||
| 69 | // repo's builds as environment variables. Same discipline as mirror | 69 | // repo's builds as environment variables. Same discipline as mirror |
| 70 | // tokens — the value never appears in argv, logs, or output. | 70 | // tokens — the value never appears in argv, logs, or output. |
| 71 | register(Command{Path: []string{"repo", "secret", "set"}, | 71 | register(Command{Path: []string{"repo", "secret", "set"}, |
| 72 | Summary: "set a build secret", | 72 | NeedsRecentSignIn: true, |
| 73 | Usage: "repo secret set <owner/name> <NAME> (value on stdin)", | 73 | Summary: "set a build secret", |
| 74 | Examples: []string{"repo secret set krz/gitbay DEPLOY_TOKEN"}, | 74 | Usage: "repo secret set <owner/name> <NAME> (value on stdin)", |
| 75 | ReadsStdin: true, Run: runSecretSet}) | 75 | Examples: []string{"repo secret set krz/gitbay DEPLOY_TOKEN"}, |
| 76 | ReadsStdin: true, Run: runSecretSet}) | ||
| 76 | register(Command{Path: []string{"repo", "secret", "remove"}, | 77 | register(Command{Path: []string{"repo", "secret", "remove"}, |
| 77 | Summary: "remove a build secret", | 78 | Summary: "remove a build secret", |
| 78 | Usage: "repo secret remove <owner/name> <NAME>", | 79 | Usage: "repo secret remove <owner/name> <NAME>", |
internal/control/control.go +30 −1
| @@ -69,6 +69,27 @@ type Ctx struct { | |||
| 69 | Stopping <-chan struct{} | 69 | Stopping <-chan struct{} |
| 70 | } | 70 | } |
| 71 | 71 | ||
| 72 | // SourceWeb is Ctx.Source for a request from a browser session. Its | ||
| 73 | // User.SignedInAt is when that session signed in. | ||
| 74 | const SourceWeb = "web" | ||
| 75 | |||
| 76 | // ReauthWindow is how long after signing in a browser session may run a | ||
| 77 | // NeedsRecentSignIn command. A session lasts days and its cookie is a | ||
| 78 | // bearer credential; what it creates or grants must come from a recent | ||
| 79 | // sign-in (#297). | ||
| 80 | const ReauthWindow = 15 * time.Minute | ||
| 81 | |||
| 82 | // ReauthRefusal is what a web session signed in longer ago than | ||
| 83 | // ReauthWindow gets; the web shows a sign-in link beside it. | ||
| 84 | var ReauthRefusal = fmt.Sprintf("this action from the web needs a sign-in from the last %d minutes; sign in again, then submit the form again", | ||
| 85 | int(ReauthWindow/time.Minute)) | ||
| 86 | |||
| 87 | // staleSignIn reports whether a web session that signed in at at is too | ||
| 88 | // old, at now, to run a NeedsRecentSignIn command. A zero at is stale. | ||
| 89 | func staleSignIn(at, now time.Time) bool { | ||
| 90 | return now.Sub(at) > ReauthWindow | ||
| 91 | } | ||
| 92 | |||
| 72 | // usage reports a bad invocation with the command's registered usage, | 93 | // usage reports a bad invocation with the command's registered usage, |
| 73 | // the one source of it. | 94 | // the one source of it. |
| 74 | func (c *Ctx) usage() int { | 95 | func (c *Ctx) usage() int { |
| @@ -104,7 +125,12 @@ type Command struct { | |||
| 104 | // to obtain one: tokens, keys, login links, invites, accounts, | 125 | // to obtain one: tokens, keys, login links, invites, accounts, |
| 105 | // verified addresses. An expiring credential may not run it. | 126 | // verified addresses. An expiring credential may not run it. |
| 106 | MintsCredential bool | 127 | MintsCredential bool |
| 107 | Run func(c *Ctx, args []string) int | 128 | // NeedsRecentSignIn marks a command a browser session may run only |
| 129 | // within ReauthWindow of signing in: every MintsCredential command, | ||
| 130 | // and those that give an account lasting access or open a standing | ||
| 131 | // channel out of the instance. | ||
| 132 | NeedsRecentSignIn bool | ||
| 133 | Run func(c *Ctx, args []string) int | ||
| 108 | } | 134 | } |
| 109 | 135 | ||
| 110 | var registry []Command | 136 | var registry []Command |
| @@ -212,6 +238,9 @@ func runChecked(c *Ctx, cmd Command, args []string) int { | |||
| 212 | if c.User.Disabled { | 238 | if c.User.Disabled { |
| 213 | return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it") | 239 | return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it") |
| 214 | } | 240 | } |
| 241 | if cmd.NeedsRecentSignIn && c.Source == SourceWeb && staleSignIn(c.User.SignedInAt, time.Now()) { | ||
| 242 | return c.fail(protocol.ExitDenied, "%s", ReauthRefusal) | ||
| 243 | } | ||
| 215 | // The admin noun is gated here as well as in each handler, so a new | 244 | // The admin noun is gated here as well as in each handler, so a new |
| 216 | // admin command that forgets requireInstanceAdmin is still refused. | 245 | // admin command that forgets requireInstanceAdmin is still refused. |
| 217 | if cmd.Path[0] == "admin" && !c.User.IsAdmin { | 246 | if cmd.Path[0] == "admin" && !c.User.IsAdmin { |
internal/control/deploykey.go +1 −1
| @@ -23,7 +23,7 @@ func init() { | |||
| 23 | }, | 23 | }, |
| 24 | Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"}, | 24 | Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"}, |
| 25 | ReadsStdin: true, | 25 | ReadsStdin: true, |
| 26 | MintsCredential: true, Run: runDeployKeyAdd}) | 26 | MintsCredential: true, NeedsRecentSignIn: true, Run: runDeployKeyAdd}) |
| 27 | register(Command{Path: []string{"repo", "deploy-key", "list"}, | 27 | register(Command{Path: []string{"repo", "deploy-key", "list"}, |
| 28 | Summary: "list deploy keys", | 28 | Summary: "list deploy keys", |
| 29 | Usage: "repo deploy-key list <owner/name>", | 29 | Usage: "repo deploy-key list <owner/name>", |
internal/control/identity.go +2 −2
| @@ -42,8 +42,8 @@ func init() { | |||
| 42 | }, | 42 | }, |
| 43 | Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"}, | 43 | Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"}, |
| 44 | ReadsStdin: true, | 44 | ReadsStdin: true, |
| 45 | MintsCredential: true, | 45 | MintsCredential: true, NeedsRecentSignIn: true, |
| 46 | Run: runKeysAdd, | 46 | Run: runKeysAdd, |
| 47 | }) | 47 | }) |
| 48 | register(Command{ | 48 | register(Command{ |
| 49 | Path: []string{"keys", "label"}, | 49 | Path: []string{"keys", "label"}, |
internal/control/mirrorcmd.go +3 −2
| @@ -16,8 +16,9 @@ import ( | |||
| 16 | 16 | ||
| 17 | func init() { | 17 | func init() { |
| 18 | register(Command{Path: []string{"repo", "mirror", "add"}, | 18 | register(Command{Path: []string{"repo", "mirror", "add"}, |
| 19 | Summary: "mirror to or from a remote", | 19 | NeedsRecentSignIn: true, |
| 20 | Usage: "repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]", | 20 | Summary: "mirror to or from a remote", |
| 21 | Usage: "repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]", | ||
| 21 | Flags: []Flag{ | 22 | Flags: []Flag{ |
| 22 | {"--direction", "push|pull", "which way the mirror syncs", ""}, | 23 | {"--direction", "push|pull", "which way the mirror syncs", ""}, |
| 23 | {"--username", "<u>", "the remote's username", ""}, | 24 | {"--username", "<u>", "the remote's username", ""}, |
internal/control/notifications.go +3 −2
| @@ -48,8 +48,9 @@ func init() { | |||
| 48 | Examples: []string{"notifications settings watch on"}, | 48 | Examples: []string{"notifications settings watch on"}, |
| 49 | Run: runNotificationsSettingsWatch}) | 49 | Run: runNotificationsSettingsWatch}) |
| 50 | register(Command{Path: []string{"notifications", "device", "add"}, | 50 | register(Command{Path: []string{"notifications", "device", "add"}, |
| 51 | Summary: "register an Apple device for push, token on stdin", | 51 | NeedsRecentSignIn: true, |
| 52 | Usage: "notifications device add [--label <name>] < token", | 52 | Summary: "register an Apple device for push, token on stdin", |
| 53 | Usage: "notifications device add [--label <name>] < token", | ||
| 53 | Flags: []Flag{ | 54 | Flags: []Flag{ |
| 54 | {"--label", "<name>", "a name for the device", ""}, | 55 | {"--label", "<name>", "a name for the device", ""}, |
| 55 | }, | 56 | }, |
internal/control/org.go +3 −2
| @@ -37,8 +37,9 @@ func init() { | |||
| 37 | }, | 37 | }, |
| 38 | Examples: []string{"org delete krz --yes"}, Run: runOrgDelete}) | 38 | Examples: []string{"org delete krz --yes"}, Run: runOrgDelete}) |
| 39 | register(Command{Path: []string{"org", "members", "add"}, | 39 | register(Command{Path: []string{"org", "members", "add"}, |
| 40 | Summary: "add or update a member", | 40 | NeedsRecentSignIn: true, |
| 41 | Usage: "org members add <org> <user> [--role member|admin]", | 41 | Summary: "add or update a member", |
| 42 | Usage: "org members add <org> <user> [--role member|admin]", | ||
| 42 | Flags: []Flag{ | 43 | Flags: []Flag{ |
| 43 | {"--role", "member|admin", "the member's role", "member"}, | 44 | {"--role", "member|admin", "the member's role", "member"}, |
| 44 | }, | 45 | }, |
internal/control/reauth_test.go added +136
| @@ -0,0 +1,136 @@ | |||
| 1 | package control | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "slices" | ||
| 5 | "strings" | ||
| 6 | "testing" | ||
| 7 | "time" | ||
| 8 | |||
| 9 | "gitbay.org/gitbay/internal/protocol" | ||
| 10 | "gitbay.org/gitbay/internal/store" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func TestStaleSignInBoundary(t *testing.T) { | ||
| 14 | at := time.Now() | ||
| 15 | if staleSignIn(at, at.Add(ReauthWindow)) { | ||
| 16 | t.Error("exactly ReauthWindow counted as stale") | ||
| 17 | } | ||
| 18 | if !staleSignIn(at, at.Add(ReauthWindow+time.Second)) { | ||
| 19 | t.Error("ReauthWindow plus a second counted as fresh") | ||
| 20 | } | ||
| 21 | if !staleSignIn(time.Time{}, at) { | ||
| 22 | t.Error("a zero sign-in time counted as fresh") | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | // A browser session runs NeedsRecentSignIn commands only within | ||
| 27 | // ReauthWindow of signing in; SSH, the API and the host carry no | ||
| 28 | // session and are not affected (#297). | ||
| 29 | func TestRecentSignInGate(t *testing.T) { | ||
| 30 | refusals = &refusalLimiter{seen: map[int64]*refusalWindow{}} | ||
| 31 | st, repo, uid := newQueueTestRepo(t) | ||
| 32 | if _, err := st.CreateUser("bob", false); err != nil { | ||
| 33 | t.Fatal(err) | ||
| 34 | } | ||
| 35 | run := func(source string, signedIn time.Time, stdin string, argv ...string) (string, int) { | ||
| 36 | c, errOut := pruneCtx(st, t.TempDir(), store.User{ID: uid, Username: "alice", SignedInAt: signedIn}) | ||
| 37 | c.Cfg.Limits.WriteRate = -1 | ||
| 38 | c.Source = source | ||
| 39 | c.ViaAPI = source == SourceWeb || source == "api" | ||
| 40 | c.Stdin = strings.NewReader(stdin) | ||
| 41 | code := Dispatch(c, argv) | ||
| 42 | return strings.TrimSpace(errOut.String()), code | ||
| 43 | } | ||
| 44 | fresh := time.Now().Add(-time.Minute) | ||
| 45 | stale := time.Now().Add(-ReauthWindow - time.Minute) | ||
| 46 | staleKey := authorizedKey(t, "stale") | ||
| 47 | |||
| 48 | for _, tc := range []struct { | ||
| 49 | name string | ||
| 50 | signedIn time.Time | ||
| 51 | stdin string | ||
| 52 | argv []string | ||
| 53 | }{ | ||
| 54 | {"stale keys add", stale, staleKey, []string{"keys", "add"}}, | ||
| 55 | {"stale token create", stale, "", []string{"token", "create", "--name", "x"}}, | ||
| 56 | {"stale repo access grant", stale, "", []string{"repo", "access", "grant", repo.Path(), "bob", "write"}}, | ||
| 57 | {"zero sign-in time", time.Time{}, authorizedKey(t, "zero"), []string{"keys", "add"}}, | ||
| 58 | } { | ||
| 59 | if msg, code := run(SourceWeb, tc.signedIn, tc.stdin, tc.argv...); code != protocol.ExitDenied || msg != ReauthRefusal { | ||
| 60 | t.Errorf("%s: exit %d, %q", tc.name, code, msg) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | if msg, code := run(SourceWeb, fresh, authorizedKey(t, "fresh"), "keys", "add"); code != protocol.ExitOK { | ||
| 64 | t.Fatalf("fresh session: exit %d, %q", code, msg) | ||
| 65 | } | ||
| 66 | // SSH, the API and the host have no session; a zero SignedInAt is | ||
| 67 | // what they carry. | ||
| 68 | for _, source := range []string{"SHA256:abc", "api", "host"} { | ||
| 69 | if msg, code := run(source, time.Time{}, authorizedKey(t, source), "keys", "add"); code != protocol.ExitOK { | ||
| 70 | t.Fatalf("%s: exit %d, %q", source, code, msg) | ||
| 71 | } | ||
| 72 | } | ||
| 73 | // A command that grants nothing is not held back. | ||
| 74 | if msg, code := run(SourceWeb, stale, "", "keys", "list"); code != protocol.ExitOK { | ||
| 75 | t.Fatalf("keys list on a stale session: exit %d, %q", code, msg) | ||
| 76 | } | ||
| 77 | keys, err := st.ListSSHKeys(uid) | ||
| 78 | if err != nil || len(keys) != 4 { | ||
| 79 | t.Fatalf("keys: %d %v, want the fresh, ssh, api and host ones", len(keys), err) | ||
| 80 | } | ||
| 81 | got, err := st.AuditEntries(store.AuditFilter{ActionPrefix: "refused ", Limit: 10}) | ||
| 82 | if err != nil { | ||
| 83 | t.Fatal(err) | ||
| 84 | } | ||
| 85 | if len(got) != 4 { | ||
| 86 | t.Fatalf("refusal audit rows: %+v", got) | ||
| 87 | } | ||
| 88 | keyText := strings.Fields(staleKey)[1] | ||
| 89 | for _, e := range got { | ||
| 90 | if strings.Contains(e.Data, keyText) { | ||
| 91 | t.Errorf("%s kept the key: %s", e.Action, e.Data) | ||
| 92 | } | ||
| 93 | } | ||
| 94 | } | ||
| 95 | |||
| 96 | // The set of commands a stale web session is refused. Adding one is a | ||
| 97 | // decision; it shows up here. | ||
| 98 | func TestNeedsRecentSignInSet(t *testing.T) { | ||
| 99 | var got []string | ||
| 100 | for _, cmd := range Commands() { | ||
| 101 | if cmd.MintsCredential && !cmd.NeedsRecentSignIn { | ||
| 102 | t.Errorf("%s mints a credential without NeedsRecentSignIn", joinPath(cmd.Path)) | ||
| 103 | } | ||
| 104 | if cmd.NeedsRecentSignIn { | ||
| 105 | got = append(got, joinPath(cmd.Path)) | ||
| 106 | } | ||
| 107 | } | ||
| 108 | slices.Sort(got) | ||
| 109 | want := []string{ | ||
| 110 | "admin email verify", | ||
| 111 | "admin invite", | ||
| 112 | "admin user create", | ||
| 113 | "admin user enable", | ||
| 114 | "admin user promote", | ||
| 115 | "email verify", | ||
| 116 | "keys add", | ||
| 117 | "notifications device add", | ||
| 118 | "org members add", | ||
| 119 | "org settings members-role", | ||
| 120 | "org team add", | ||
| 121 | "org team grant", | ||
| 122 | "pgp add", | ||
| 123 | "repo access grant", | ||
| 124 | "repo deploy-key add", | ||
| 125 | "repo mirror add", | ||
| 126 | "repo runner add", | ||
| 127 | "repo secret set", | ||
| 128 | "repo transfer", | ||
| 129 | "token create", | ||
| 130 | "web login", | ||
| 131 | "webhook add", | ||
| 132 | } | ||
| 133 | if !slices.Equal(got, want) { | ||
| 134 | t.Fatalf("NeedsRecentSignIn commands:\n got %q\nwant %q", got, want) | ||
| 135 | } | ||
| 136 | } | ||
internal/control/register.go +2 −2
| @@ -38,8 +38,8 @@ func init() { | |||
| 38 | register(Command{Path: []string{"email", "verify"}, | 38 | register(Command{Path: []string{"email", "verify"}, |
| 39 | Summary: "confirm a verification code", | 39 | Summary: "confirm a verification code", |
| 40 | Usage: "email verify <code>", | 40 | Usage: "email verify <code>", |
| 41 | MintsCredential: true, | 41 | MintsCredential: true, NeedsRecentSignIn: true, |
| 42 | Examples: []string{"email verify abc123"}, Run: runEmailVerify}) | 42 | Examples: []string{"email verify abc123"}, Run: runEmailVerify}) |
| 43 | register(Command{Path: []string{"email", "list"}, | 43 | register(Command{Path: []string{"email", "list"}, |
| 44 | Summary: "list the addresses on your account", | 44 | Summary: "list the addresses on your account", |
| 45 | Usage: "email list", | 45 | Usage: "email list", |
internal/control/repo.go +10 −8
| @@ -50,10 +50,11 @@ func init() { | |||
| 50 | Examples: []string{"repo show krz/gitbay"}, | 50 | Examples: []string{"repo show krz/gitbay"}, |
| 51 | ReadOnly: true, Run: runRepoShow}) | 51 | ReadOnly: true, Run: runRepoShow}) |
| 52 | register(Command{Path: []string{"repo", "transfer"}, | 52 | register(Command{Path: []string{"repo", "transfer"}, |
| 53 | Summary: "move a repository to another owner", | 53 | NeedsRecentSignIn: true, |
| 54 | Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)", | 54 | Summary: "move a repository to another owner", |
| 55 | Examples: []string{"repo transfer krz/gitbay krazywarez"}, | 55 | Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)", |
| 56 | Run: runRepoTransfer}) | 56 | Examples: []string{"repo transfer krz/gitbay krazywarez"}, |
| 57 | Run: runRepoTransfer}) | ||
| 57 | register(Command{Path: []string{"repo", "rename"}, | 58 | register(Command{Path: []string{"repo", "rename"}, |
| 58 | Summary: "rename a repository", | 59 | Summary: "rename a repository", |
| 59 | Usage: "repo rename <owner/name> <new-name> (clone URLs change)", | 60 | Usage: "repo rename <owner/name> <new-name> (clone URLs change)", |
| @@ -68,10 +69,11 @@ func init() { | |||
| 68 | Examples: []string{"repo delete cmc/scratch --yes"}, | 69 | Examples: []string{"repo delete cmc/scratch --yes"}, |
| 69 | Run: runRepoDelete}) | 70 | Run: runRepoDelete}) |
| 70 | register(Command{Path: []string{"repo", "access", "grant"}, | 71 | register(Command{Path: []string{"repo", "access", "grant"}, |
| 71 | Summary: "grant access", | 72 | NeedsRecentSignIn: true, |
| 72 | Usage: "repo access grant <owner/name> <user> read|write|admin", | 73 | Summary: "grant access", |
| 73 | Examples: []string{"repo access grant krz/gitbay cmc write"}, | 74 | Usage: "repo access grant <owner/name> <user> read|write|admin", |
| 74 | Run: runAccessGrant}) | 75 | Examples: []string{"repo access grant krz/gitbay cmc write"}, |
| 76 | Run: runAccessGrant}) | ||
| 75 | register(Command{Path: []string{"repo", "access", "revoke"}, | 77 | register(Command{Path: []string{"repo", "access", "revoke"}, |
| 76 | Summary: "revoke access", | 78 | Summary: "revoke access", |
| 77 | Usage: "repo access revoke <owner/name> <user>", | 79 | Usage: "repo access revoke <owner/name> <user>", |
internal/control/runnerrepo.go +1 −1
| @@ -23,7 +23,7 @@ func init() { | |||
| 23 | Usage: "repo runner add <owner/name> < key.pub", | 23 | Usage: "repo runner add <owner/name> < key.pub", |
| 24 | Examples: []string{"repo runner add krz/gitbay < key.pub"}, | 24 | Examples: []string{"repo runner add krz/gitbay < key.pub"}, |
| 25 | ReadsStdin: true, | 25 | ReadsStdin: true, |
| 26 | MintsCredential: true, Run: runRepoRunnerAdd}) | 26 | MintsCredential: true, NeedsRecentSignIn: true, Run: runRepoRunnerAdd}) |
| 27 | register(Command{Path: []string{"repo", "runner", "list"}, | 27 | register(Command{Path: []string{"repo", "runner", "list"}, |
| 28 | Summary: "list the runners attached to a repository", | 28 | Summary: "list the runners attached to a repository", |
| 29 | Usage: "repo runner list <owner/name>", | 29 | Usage: "repo runner list <owner/name>", |
internal/control/sig.go +5 −4
| @@ -18,10 +18,11 @@ import ( | |||
| 18 | 18 | ||
| 19 | func init() { | 19 | func init() { |
| 20 | register(Command{Path: []string{"pgp", "add"}, | 20 | register(Command{Path: []string{"pgp", "add"}, |
| 21 | Summary: "register an OpenPGP public key (armored)", | 21 | NeedsRecentSignIn: true, |
| 22 | Usage: "pgp add < key.asc", | 22 | Summary: "register an OpenPGP public key (armored)", |
| 23 | Examples: []string{"pgp add < key.asc"}, | 23 | Usage: "pgp add < key.asc", |
| 24 | ReadsStdin: true, Run: runPGPAdd}) | 24 | Examples: []string{"pgp add < key.asc"}, |
| 25 | ReadsStdin: true, Run: runPGPAdd}) | ||
| 25 | register(Command{Path: []string{"pgp", "list"}, | 26 | register(Command{Path: []string{"pgp", "list"}, |
| 26 | Summary: "list registered OpenPGP keys", | 27 | Summary: "list registered OpenPGP keys", |
| 27 | Usage: "pgp list", | 28 | Usage: "pgp list", |
internal/control/teams.go +12 −9
| @@ -30,25 +30,28 @@ func init() { | |||
| 30 | Usage: "org team show <org> <team>", | 30 | Usage: "org team show <org> <team>", |
| 31 | Examples: []string{"org team show krz maintainers"}, ReadOnly: true, Run: runTeamShow}) | 31 | Examples: []string{"org team show krz maintainers"}, ReadOnly: true, Run: runTeamShow}) |
| 32 | register(Command{Path: []string{"org", "team", "add"}, | 32 | register(Command{Path: []string{"org", "team", "add"}, |
| 33 | Summary: "add org members to a team", | 33 | NeedsRecentSignIn: true, |
| 34 | Usage: "org team add <org> <team> <user>...", | 34 | Summary: "add org members to a team", |
| 35 | Examples: []string{"org team add krz maintainers cmc"}, Run: runTeamAdd}) | 35 | Usage: "org team add <org> <team> <user>...", |
| 36 | Examples: []string{"org team add krz maintainers cmc"}, Run: runTeamAdd}) | ||
| 36 | register(Command{Path: []string{"org", "team", "remove"}, | 37 | register(Command{Path: []string{"org", "team", "remove"}, |
| 37 | Summary: "remove members from a team", | 38 | Summary: "remove members from a team", |
| 38 | Usage: "org team remove <org> <team> <user>...", | 39 | Usage: "org team remove <org> <team> <user>...", |
| 39 | Examples: []string{"org team remove krz maintainers cmc"}, Run: runTeamRemove}) | 40 | Examples: []string{"org team remove krz maintainers cmc"}, Run: runTeamRemove}) |
| 40 | register(Command{Path: []string{"org", "team", "grant"}, | 41 | register(Command{Path: []string{"org", "team", "grant"}, |
| 41 | Summary: "grant a team a role on an org repo", | 42 | NeedsRecentSignIn: true, |
| 42 | Usage: "org team grant <org> <team> <owner/name> read|write|admin", | 43 | Summary: "grant a team a role on an org repo", |
| 43 | Examples: []string{"org team grant krz maintainers krz/gitbay write"}, Run: runTeamGrant}) | 44 | Usage: "org team grant <org> <team> <owner/name> read|write|admin", |
| 45 | Examples: []string{"org team grant krz maintainers krz/gitbay write"}, Run: runTeamGrant}) | ||
| 44 | register(Command{Path: []string{"org", "team", "revoke"}, | 46 | register(Command{Path: []string{"org", "team", "revoke"}, |
| 45 | Summary: "revoke a team's grant", | 47 | Summary: "revoke a team's grant", |
| 46 | Usage: "org team revoke <org> <team> <owner/name>", | 48 | Usage: "org team revoke <org> <team> <owner/name>", |
| 47 | Examples: []string{"org team revoke krz maintainers krz/gitbay"}, Run: runTeamRevoke}) | 49 | Examples: []string{"org team revoke krz maintainers krz/gitbay"}, Run: runTeamRevoke}) |
| 48 | register(Command{Path: []string{"org", "settings", "members-role"}, | 50 | register(Command{Path: []string{"org", "settings", "members-role"}, |
| 49 | Summary: "role plain membership implies on every org repo", | 51 | NeedsRecentSignIn: true, |
| 50 | Usage: "org settings members-role <org> write|read|none (default write)", | 52 | Summary: "role plain membership implies on every org repo", |
| 51 | Examples: []string{"org settings members-role krz read"}, Run: runOrgMembersRole}) | 53 | Usage: "org settings members-role <org> write|read|none (default write)", |
| 54 | Examples: []string{"org settings members-role krz read"}, Run: runOrgMembersRole}) | ||
| 52 | } | 55 | } |
| 53 | 56 | ||
| 54 | // orgAdminRef resolves an org and requires the caller to admin it. | 57 | // orgAdminRef resolves an org and requires the caller to admin it. |
internal/control/token.go +2 −2
| @@ -22,8 +22,8 @@ func init() { | |||
| 22 | {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"}, | 22 | {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"}, |
| 23 | }, | 23 | }, |
| 24 | Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"}, | 24 | Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"}, |
| 25 | MintsCredential: true, | 25 | MintsCredential: true, NeedsRecentSignIn: true, |
| 26 | Run: runTokenCreate}) | 26 | Run: runTokenCreate}) |
| 27 | register(Command{Path: []string{"token", "list"}, | 27 | register(Command{Path: []string{"token", "list"}, |
| 28 | Summary: "list API tokens", | 28 | Summary: "list API tokens", |
| 29 | Usage: "token list", | 29 | Usage: "token list", |
internal/control/web.go +2 −2
| @@ -16,8 +16,8 @@ func init() { | |||
| 16 | register(Command{Path: []string{"web", "login"}, | 16 | register(Command{Path: []string{"web", "login"}, |
| 17 | Summary: "mint a one-time browser login URL", | 17 | Summary: "mint a one-time browser login URL", |
| 18 | Usage: "web login", | 18 | Usage: "web login", |
| 19 | MintsCredential: true, | 19 | MintsCredential: true, NeedsRecentSignIn: true, |
| 20 | Examples: []string{"web login"}, Run: runWebLogin}) | 20 | Examples: []string{"web login"}, Run: runWebLogin}) |
| 21 | register(Command{Path: []string{"web", "sessions", "list"}, | 21 | register(Command{Path: []string{"web", "sessions", "list"}, |
| 22 | Summary: "list your browser sessions", | 22 | Summary: "list your browser sessions", |
| 23 | Usage: "web sessions list", | 23 | Usage: "web sessions list", |
internal/control/webhook.go +3 −2
| @@ -15,8 +15,9 @@ import ( | |||
| 15 | 15 | ||
| 16 | func init() { | 16 | func init() { |
| 17 | register(Command{Path: []string{"webhook", "add"}, | 17 | register(Command{Path: []string{"webhook", "add"}, |
| 18 | Summary: "add a webhook", | 18 | NeedsRecentSignIn: true, |
| 19 | Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]", | 19 | Summary: "add a webhook", |
| 20 | Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]", | ||
| 20 | Flags: []Flag{ | 21 | Flags: []Flag{ |
| 21 | {"--secret", "-", "read the secret that signs deliveries from stdin", ""}, | 22 | {"--secret", "-", "read the secret that signs deliveries from stdin", ""}, |
| 22 | {"--events", "push,issue.created|*", "which events to send", "*"}, | 23 | {"--events", "push,issue.created|*", "which events to send", "*"}, |
internal/httpd/account.go +6 −2
| @@ -128,6 +128,9 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U | |||
| 128 | aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath | 128 | aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath |
| 129 | } | 129 | } |
| 130 | 130 | ||
| 131 | notice := s.takeFlash(w, r) | ||
| 132 | reauth := s.reauthNotice(w, notice, "/settings") | ||
| 133 | |||
| 131 | s.render(w, "account.html", struct { | 134 | s.render(w, "account.html", struct { |
| 132 | basePage | 135 | basePage |
| 133 | Tab string // marks the rail's Settings row as current | 136 | Tab string // marks the rail's Settings row as current |
| @@ -148,10 +151,11 @@ func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, u store.U | |||
| 148 | ThemeSetting string // system, light or dark: the form's selected option | 151 | ThemeSetting string // system, light or dark: the form's selected option |
| 149 | Tokens []accountToken | 152 | Tokens []accountToken |
| 150 | TokenShown string // a token minted by this request, shown once | 153 | TokenShown string // a token minted by this request, shown once |
| 154 | Reauth bool // Notice is the stale-session refusal: link to sign in | ||
| 151 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), | 155 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), |
| 152 | aboutRepo, aboutEdit, s.cfg.SiteHost(), | 156 | aboutRepo, aboutEdit, s.cfg.SiteHost(), |
| 153 | s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme, | 157 | notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme, |
| 154 | tokens, tokenShown}) | 158 | tokens, tokenShown, reauth}) |
| 155 | } | 159 | } |
| 156 | 160 | ||
| 157 | // accountExport hands the browser the same bundle `account export` | 161 | // accountExport hands the browser the same bundle `account export` |
internal/httpd/account_test.go +2 −1
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | "strconv" | 7 | "strconv" |
| 8 | "strings" | 8 | "strings" |
| 9 | "testing" | 9 | "testing" |
| 10 | "time" | ||
| 10 | 11 | ||
| 11 | "gitbay.org/gitbay/internal/config" | 12 | "gitbay.org/gitbay/internal/config" |
| 12 | "gitbay.org/gitbay/internal/store" | 13 | "gitbay.org/gitbay/internal/store" |
| @@ -300,7 +301,7 @@ func newTokenTestServer(t *testing.T) (*Server, *store.Store, store.User) { | |||
| 300 | if err != nil { | 301 | if err != nil { |
| 301 | t.Fatal(err) | 302 | t.Fatal(err) |
| 302 | } | 303 | } |
| 303 | return New(config.Default(), st, nil), st, store.User{ID: uid, Username: "alice"} | 304 | return New(config.Default(), st, nil), st, store.User{ID: uid, Username: "alice", SignedInAt: time.Now()} |
| 304 | } | 305 | } |
| 305 | 306 | ||
| 306 | // The settings page lists a user's API tokens with scope and expiry, | 307 | // The settings page lists a user's API tokens with scope and expiry, |
internal/httpd/adminusers.go +3 −1
| @@ -62,6 +62,7 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, viewer store | |||
| 62 | } | 62 | } |
| 63 | next = "?" + q.Encode() | 63 | next = "?" + q.Encode() |
| 64 | } | 64 | } |
| 65 | notice := s.takeFlash(w, r) | ||
| 65 | s.render(w, "adminusers.html", struct { | 66 | s.render(w, "adminusers.html", struct { |
| 66 | basePage | 67 | basePage |
| 67 | Tab string | 68 | Tab string |
| @@ -69,7 +70,8 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, viewer store | |||
| 69 | Users []adminUserRow | 70 | Users []adminUserRow |
| 70 | Next string | 71 | Next string |
| 71 | Notice string | 72 | Notice string |
| 72 | }{s.baseFor(viewer), "admin", state, page.Items, next, s.takeFlash(w, r)}) | 73 | Reauth bool // Notice is the stale-session refusal: link to sign in |
| 74 | }{s.baseFor(viewer), "admin", state, page.Items, next, notice, s.reauthNotice(w, notice, r.URL.Path)}) | ||
| 73 | } | 75 | } |
| 74 | 76 | ||
| 75 | // adminUsersSubmit runs one account action. Each is the command an | 77 | // adminUsersSubmit runs one account action. Each is the command an |
internal/httpd/control.go +5 −5
| @@ -34,7 +34,7 @@ func (s *Server) runControlCode(u store.User, argv []string) (out string, msg st | |||
| 34 | var stdout, stderr bytes.Buffer | 34 | var stdout, stderr bytes.Buffer |
| 35 | ctx := &control.Ctx{ | 35 | ctx := &control.Ctx{ |
| 36 | User: u, | 36 | User: u, |
| 37 | Source: "web", | 37 | Source: control.SourceWeb, |
| 38 | Scope: "full", | 38 | Scope: "full", |
| 39 | Store: s.st, | 39 | Store: s.st, |
| 40 | Cfg: s.cfg, | 40 | Cfg: s.cfg, |
| @@ -58,7 +58,7 @@ func (s *Server) runControlStream(u store.User, argv []string, out io.Writer, do | |||
| 58 | var stderr bytes.Buffer | 58 | var stderr bytes.Buffer |
| 59 | ctx := &control.Ctx{ | 59 | ctx := &control.Ctx{ |
| 60 | User: u, | 60 | User: u, |
| 61 | Source: "web", | 61 | Source: control.SourceWeb, |
| 62 | Scope: "full", | 62 | Scope: "full", |
| 63 | Store: s.st, | 63 | Store: s.st, |
| 64 | Cfg: s.cfg, | 64 | Cfg: s.cfg, |
| @@ -104,7 +104,7 @@ func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string) | |||
| 104 | var stdout, stderr bytes.Buffer | 104 | var stdout, stderr bytes.Buffer |
| 105 | ctx := &control.Ctx{ | 105 | ctx := &control.Ctx{ |
| 106 | User: u, | 106 | User: u, |
| 107 | Source: "web", | 107 | Source: control.SourceWeb, |
| 108 | Scope: "full", | 108 | Scope: "full", |
| 109 | Store: s.st, | 109 | Store: s.st, |
| 110 | Cfg: s.cfg, | 110 | Cfg: s.cfg, |
| @@ -146,7 +146,7 @@ func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, ta | |||
| 146 | var stdout, stderr bytes.Buffer | 146 | var stdout, stderr bytes.Buffer |
| 147 | ctx := &control.Ctx{ | 147 | ctx := &control.Ctx{ |
| 148 | User: u, | 148 | User: u, |
| 149 | Source: "web", | 149 | Source: control.SourceWeb, |
| 150 | Scope: "full", | 150 | Scope: "full", |
| 151 | Store: s.st, | 151 | Store: s.st, |
| 152 | Cfg: s.cfg, | 152 | Cfg: s.cfg, |
| @@ -187,7 +187,7 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i | |||
| 187 | var stdout, stderr bytes.Buffer | 187 | var stdout, stderr bytes.Buffer |
| 188 | ctx := &control.Ctx{ | 188 | ctx := &control.Ctx{ |
| 189 | User: u, | 189 | User: u, |
| 190 | Source: "web", | 190 | Source: control.SourceWeb, |
| 191 | Scope: "full", | 191 | Scope: "full", |
| 192 | Store: s.st, | 192 | Store: s.st, |
| 193 | Cfg: s.cfg, | 193 | Cfg: s.cfg, |
internal/httpd/flash.go +23 −3
| @@ -4,6 +4,8 @@ import ( | |||
| 4 | "net/http" | 4 | "net/http" |
| 5 | "net/url" | 5 | "net/url" |
| 6 | "strings" | 6 | "strings" |
| 7 | |||
| 8 | "gitbay.org/gitbay/internal/control" | ||
| 7 | ) | 9 | ) |
| 8 | 10 | ||
| 9 | // A form action that fails redirects back to the page it came from with | 11 | // A form action that fails redirects back to the page it came from with |
| @@ -44,13 +46,31 @@ func (s *Server) takeFlash(w http.ResponseWriter, r *http.Request) string { | |||
| 44 | return msg | 46 | return msg |
| 45 | } | 47 | } |
| 46 | 48 | ||
| 49 | // reauthNotice reports whether notice is Dispatch's refusal for a session | ||
| 50 | // that signed in too long ago to mint a credential or grant access and, | ||
| 51 | // when it is, remembers path so the sign-in the page links to returns | ||
| 52 | // there (#297). | ||
| 53 | func (s *Server) reauthNotice(w http.ResponseWriter, notice, path string) bool { | ||
| 54 | if notice != control.ReauthRefusal { | ||
| 55 | return false | ||
| 56 | } | ||
| 57 | s.setNext(w, path) | ||
| 58 | return true | ||
| 59 | } | ||
| 60 | |||
| 47 | const nextCookie = "gitbay_next" | 61 | const nextCookie = "gitbay_next" |
| 48 | 62 | ||
| 63 | // localPath reports whether p is a path on this host. Browsers read a | ||
| 64 | // leading `/\` like "//", so it is refused too. | ||
| 65 | func localPath(p string) bool { | ||
| 66 | return strings.HasPrefix(p, "/") && !strings.HasPrefix(p, "//") && !strings.HasPrefix(p, "/\\") | ||
| 67 | } | ||
| 68 | |||
| 49 | // setNext remembers the local path an anonymous visitor asked for, so | 69 | // setNext remembers the local path an anonymous visitor asked for, so |
| 50 | // the login that follows can return there. Only a GET path is stored: | 70 | // the login that follows can return there. Only a GET path is stored: |
| 51 | // a POST must not be replayed. | 71 | // a POST must not be replayed. |
| 52 | func (s *Server) setNext(w http.ResponseWriter, path string) { | 72 | func (s *Server) setNext(w http.ResponseWriter, path string) { |
| 53 | if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || len(path) > 300 { | 73 | if !localPath(path) || len(path) > 300 { |
| 54 | return | 74 | return |
| 55 | } | 75 | } |
| 56 | http.SetCookie(w, &http.Cookie{ | 76 | http.SetCookie(w, &http.Cookie{ |
| @@ -69,7 +89,7 @@ func (s *Server) takeNext(w http.ResponseWriter, r *http.Request) string { | |||
| 69 | } | 89 | } |
| 70 | http.SetCookie(w, s.clearCookie(nextCookie, http.SameSiteLaxMode)) | 90 | http.SetCookie(w, s.clearCookie(nextCookie, http.SameSiteLaxMode)) |
| 71 | p, err := url.QueryUnescape(c.Value) | 91 | p, err := url.QueryUnescape(c.Value) |
| 72 | if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") { | 92 | if err != nil || !localPath(p) { |
| 73 | return "" | 93 | return "" |
| 74 | } | 94 | } |
| 75 | return p | 95 | return p |
| @@ -83,7 +103,7 @@ func (s *Server) peekNext(r *http.Request) string { | |||
| 83 | return "" | 103 | return "" |
| 84 | } | 104 | } |
| 85 | p, err := url.QueryUnescape(c.Value) | 105 | p, err := url.QueryUnescape(c.Value) |
| 86 | if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") { | 106 | if err != nil || !localPath(p) { |
| 87 | return "" | 107 | return "" |
| 88 | } | 108 | } |
| 89 | return p | 109 | return p |
internal/httpd/flash_test.go added +19
| @@ -0,0 +1,19 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import "testing" | ||
| 4 | |||
| 5 | func TestLocalPath(t *testing.T) { | ||
| 6 | for p, want := range map[string]bool{ | ||
| 7 | "/settings": true, | ||
| 8 | "/a/b?c=d": true, | ||
| 9 | "": false, | ||
| 10 | "settings": false, | ||
| 11 | "//evil.example": false, | ||
| 12 | `/\evil.example`: false, | ||
| 13 | "https://x.test/": false, | ||
| 14 | } { | ||
| 15 | if got := localPath(p); got != want { | ||
| 16 | t.Errorf("localPath(%q) = %v, want %v", p, got, want) | ||
| 17 | } | ||
| 18 | } | ||
| 19 | } | ||
internal/httpd/reauth_test.go added +159
| @@ -0,0 +1,159 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "net/http/httptest" | ||
| 6 | "net/url" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | |||
| 11 | "gitbay.org/gitbay/internal/config" | ||
| 12 | "gitbay.org/gitbay/internal/control" | ||
| 13 | "gitbay.org/gitbay/internal/store" | ||
| 14 | ) | ||
| 15 | |||
| 16 | // A session signed in longer ago than ReauthWindow cannot mint from the | ||
| 17 | // settings page: the form comes back with the refusal and a sign-in | ||
| 18 | // link, and the sign-in returns to /settings (#297). | ||
| 19 | func TestWebMintNeedsRecentSignIn(t *testing.T) { | ||
| 20 | s, st, u := newTokenTestServer(t) | ||
| 21 | stale := u | ||
| 22 | stale.SignedInAt = time.Now().Add(-control.ReauthWindow - time.Minute) | ||
| 23 | rr := submitAccountForm(t, s, stale, url.Values{"field": {"token-create"}, "name": {"laptop"}, "scope": {"full"}}) | ||
| 24 | if rr.Code != http.StatusSeeOther { | ||
| 25 | t.Fatalf("status %d, body %s", rr.Code, rr.Body.String()) | ||
| 26 | } | ||
| 27 | if list, err := st.ListAPITokens(u.ID); err != nil || len(list) != 0 { | ||
| 28 | t.Fatalf("a stale session minted %+v (%v)", list, err) | ||
| 29 | } | ||
| 30 | |||
| 31 | req := httptest.NewRequest("GET", "/settings", nil) | ||
| 32 | for _, c := range rr.Result().Cookies() { | ||
| 33 | req.AddCookie(c) | ||
| 34 | } | ||
| 35 | page := httptest.NewRecorder() | ||
| 36 | s.accountPage(page, req, stale) | ||
| 37 | body := page.Body.String() | ||
| 38 | if !strings.Contains(body, control.ReauthRefusal) { | ||
| 39 | t.Fatalf("refusal not shown: %s", body) | ||
| 40 | } | ||
| 41 | if !strings.Contains(body, `<a href="/login">Sign in again</a>`) { | ||
| 42 | t.Fatalf("no sign-in link: %s", body) | ||
| 43 | } | ||
| 44 | var next string | ||
| 45 | for _, c := range page.Result().Cookies() { | ||
| 46 | if c.Name == nextCookie { | ||
| 47 | next = c.Value | ||
| 48 | } | ||
| 49 | } | ||
| 50 | if next != url.QueryEscape("/settings") { | ||
| 51 | t.Fatalf("gitbay_next = %q, want /settings", next) | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | // An API token has no browser session: minting through the API is not | ||
| 56 | // held to the sign-in window. | ||
| 57 | func TestAPIMintIgnoresTheSignInWindow(t *testing.T) { | ||
| 58 | s, st, u := newTokenTestServer(t) | ||
| 59 | if err := st.CreateAPIToken(u.ID, "ci", store.HashToken("gb_reauthtest"), "full", nil, 0); err != nil { | ||
| 60 | t.Fatal(err) | ||
| 61 | } | ||
| 62 | req := httptest.NewRequest("POST", "/api/v1/cmd", | ||
| 63 | strings.NewReader(`{"argv":["token","create","--name","second","--scope","read"]}`)) | ||
| 64 | req.Header.Set("Authorization", "Bearer gb_reauthtest") | ||
| 65 | rr := httptest.NewRecorder() | ||
| 66 | s.apiCmd(rr, req) | ||
| 67 | if rr.Code != http.StatusOK { | ||
| 68 | t.Fatalf("status %d: %s", rr.Code, rr.Body.String()) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | |||
| 72 | // A fresh session mints without any refusal. | ||
| 73 | func TestWebMintFreshSessionSucceeds(t *testing.T) { | ||
| 74 | s, st, u := newTokenTestServer(t) | ||
| 75 | rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}, "scope": {"full"}}) | ||
| 76 | if rr.Code != http.StatusOK { | ||
| 77 | t.Fatalf("status %d, body %s", rr.Code, rr.Body.String()) | ||
| 78 | } | ||
| 79 | if list, err := st.ListAPITokens(u.ID); err != nil || len(list) != 1 { | ||
| 80 | t.Fatalf("token not minted: %+v (%v)", list, err) | ||
| 81 | } | ||
| 82 | } | ||
| 83 | |||
| 84 | // A stale session posting a grant form (org members add, on the | ||
| 85 | // organization's people page) also sees the refusal and the sign-in | ||
| 86 | // link, and the membership is not created. | ||
| 87 | func TestWebGrantNeedsRecentSignIn(t *testing.T) { | ||
| 88 | st, err := store.Open(":memory:") | ||
| 89 | if err != nil { | ||
| 90 | t.Fatal(err) | ||
| 91 | } | ||
| 92 | defer st.Close() | ||
| 93 | if err := st.MigrateUp(); err != nil { | ||
| 94 | t.Fatal(err) | ||
| 95 | } | ||
| 96 | uid, err := st.CreateUser("alice", false) | ||
| 97 | if err != nil { | ||
| 98 | t.Fatal(err) | ||
| 99 | } | ||
| 100 | if _, err := st.CreateUser("bob", false); err != nil { | ||
| 101 | t.Fatal(err) | ||
| 102 | } | ||
| 103 | fresh := store.User{ID: uid, Username: "alice", SignedInAt: time.Now()} | ||
| 104 | stale := fresh | ||
| 105 | stale.SignedInAt = time.Now().Add(-control.ReauthWindow - time.Minute) | ||
| 106 | |||
| 107 | cfg := config.Default() | ||
| 108 | cfg.Web.Mode = "accounts" | ||
| 109 | s := New(cfg, st, nil) | ||
| 110 | if _, msg, ok := s.runControl(fresh, []string{"org", "create", "krz"}); !ok { | ||
| 111 | t.Fatalf("org create: %s", msg) | ||
| 112 | } | ||
| 113 | |||
| 114 | req := httptest.NewRequest("POST", "/krz", | ||
| 115 | strings.NewReader(url.Values{"field": {"member-add"}, "user": {"bob"}}.Encode())) | ||
| 116 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||
| 117 | req.SetPathValue("owner", "krz") | ||
| 118 | rr := httptest.NewRecorder() | ||
| 119 | s.orgSubmit(rr, req, stale) | ||
| 120 | if rr.Code != http.StatusSeeOther { | ||
| 121 | t.Fatalf("status %d, body %s", rr.Code, rr.Body.String()) | ||
| 122 | } | ||
| 123 | |||
| 124 | req2 := httptest.NewRequest("GET", "/krz/-/people", nil) | ||
| 125 | req2.SetPathValue("owner", "krz") | ||
| 126 | for _, c := range rr.Result().Cookies() { | ||
| 127 | req2.AddCookie(c) | ||
| 128 | } | ||
| 129 | req2.AddCookie(sessionCookieFor(t, s, st, uid)) | ||
| 130 | page := httptest.NewRecorder() | ||
| 131 | s.ownerProfile(page, req2) | ||
| 132 | body := page.Body.String() | ||
| 133 | if !strings.Contains(body, control.ReauthRefusal) { | ||
| 134 | t.Fatalf("refusal not shown: %s", body) | ||
| 135 | } | ||
| 136 | if !strings.Contains(body, `<a href="/login">Sign in again</a>`) { | ||
| 137 | t.Fatalf("no sign-in link: %s", body) | ||
| 138 | } | ||
| 139 | var next string | ||
| 140 | for _, c := range page.Result().Cookies() { | ||
| 141 | if c.Name == nextCookie { | ||
| 142 | next = c.Value | ||
| 143 | } | ||
| 144 | } | ||
| 145 | if next != url.QueryEscape("/krz/-/people") { | ||
| 146 | t.Fatalf("gitbay_next = %q, want /krz/-/people", next) | ||
| 147 | } | ||
| 148 | |||
| 149 | org, err := st.OrgByName("krz") | ||
| 150 | if err != nil { | ||
| 151 | t.Fatal(err) | ||
| 152 | } | ||
| 153 | members, _ := st.OrgMembers(org.ID) | ||
| 154 | for _, m := range members { | ||
| 155 | if m.Username == "bob" { | ||
| 156 | t.Fatalf("a stale session added bob to the org") | ||
| 157 | } | ||
| 158 | } | ||
| 159 | } | ||
internal/httpd/settings.go +2
| @@ -26,6 +26,7 @@ type settingsPage struct { | |||
| 26 | Runners []store.RepoRunner | 26 | Runners []store.RepoRunner |
| 27 | Notice string | 27 | Notice string |
| 28 | Saved bool | 28 | Saved bool |
| 29 | Reauth bool // Notice is the stale-session refusal: link to sign in | ||
| 29 | Submitted map[string]string | 30 | Submitted map[string]string |
| 30 | } | 31 | } |
| 31 | 32 | ||
| @@ -70,6 +71,7 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor | |||
| 70 | Runners: runners, | 71 | Runners: runners, |
| 71 | Notice: notice, | 72 | Notice: notice, |
| 72 | Saved: strings.HasPrefix(notice, "Saved "), | 73 | Saved: strings.HasPrefix(notice, "Saved "), |
| 74 | Reauth: s.reauthNotice(w, notice, r.URL.Path), | ||
| 73 | Submitted: subm, | 75 | Submitted: subm, |
| 74 | }) | 76 | }) |
| 75 | } | 77 | } |
internal/httpd/web.go +4 −1
| @@ -517,6 +517,7 @@ type ownerPage struct { | |||
| 517 | Self bool | 517 | Self bool |
| 518 | Snippets int | 518 | Snippets int |
| 519 | Notice string | 519 | Notice string |
| 520 | Reauth bool // Notice is the stale-session refusal: link to sign in | ||
| 520 | Feed string | 521 | Feed string |
| 521 | } | 522 | } |
| 522 | 523 | ||
| @@ -572,6 +573,7 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) { | |||
| 572 | return | 573 | return |
| 573 | } | 574 | } |
| 574 | } | 575 | } |
| 576 | notice := s.takeFlash(w, r) | ||
| 575 | s.render(w, "owner.html", ownerPage{ | 577 | s.render(w, "owner.html", ownerPage{ |
| 576 | basePage: s.baseFor(viewer), | 578 | basePage: s.baseFor(viewer), |
| 577 | Owner: name, | 579 | Owner: name, |
| @@ -592,7 +594,8 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) { | |||
| 592 | CanAdmin: canAdmin, | 594 | CanAdmin: canAdmin, |
| 593 | Self: self, | 595 | Self: self, |
| 594 | Snippets: d.Snippets, | 596 | Snippets: d.Snippets, |
| 595 | Notice: s.takeFlash(w, r), | 597 | Notice: notice, |
| 598 | Reauth: s.reauthNotice(w, notice, r.URL.Path), | ||
| 596 | Feed: "/" + name + "/activity.atom", | 599 | Feed: "/" + name + "/activity.atom", |
| 597 | }) | 600 | }) |
| 598 | } | 601 | } |
internal/store/sessions.go +16 −6
| @@ -80,15 +80,18 @@ func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) e | |||
| 80 | return err | 80 | return err |
| 81 | } | 81 | } |
| 82 | 82 | ||
| 83 | // WebSessionUser resolves a session cookie hash to its user and renews | 83 | // WebSessionUser resolves a session cookie hash to its user, with the |
| 84 | // the session's idle expiry. A session is written at most once a | 84 | // session's sign-in time, and renews the session's idle expiry. A |
| 85 | // minute, so a burst of requests costs one UPDATE. | 85 | // session is written at most once a minute, so a burst of requests |
| 86 | // costs one UPDATE. Renewal never moves created_at: only a login | ||
| 87 | // creates a session, so created_at is when it signed in. | ||
| 86 | func (s *Store) WebSessionUser(hash string) (User, error) { | 88 | func (s *Store) WebSessionUser(hash string) (User, error) { |
| 87 | now := time.Now() | 89 | now := time.Now() |
| 88 | var userID int64 | 90 | var userID int64 |
| 91 | var created string | ||
| 89 | err := s.DB.QueryRow( | 92 | err := s.DB.QueryRow( |
| 90 | "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?", | 93 | "SELECT user_id, created_at FROM web_sessions WHERE token_hash = ? AND expires_at > ?", |
| 91 | hash, fmtTime(now)).Scan(&userID) | 94 | hash, fmtTime(now)).Scan(&userID, &created) |
| 92 | if errors.Is(err, sql.ErrNoRows) { | 95 | if errors.Is(err, sql.ErrNoRows) { |
| 93 | return User{}, ErrNotFound | 96 | return User{}, ErrNotFound |
| 94 | } | 97 | } |
| @@ -98,7 +101,14 @@ func (s *Store) WebSessionUser(hash string) (User, error) { | |||
| 98 | s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?) | 101 | s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?) |
| 99 | WHERE token_hash = ? AND last_used_at < ?`, | 102 | WHERE token_hash = ? AND last_used_at < ?`, |
| 100 | fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute))) | 103 | fmtTime(now), fmtTime(now.Add(WebSessionIdle)), hash, fmtTime(now.Add(-time.Minute))) |
| 101 | return s.UserByID(userID) | 104 | u, err := s.UserByID(userID) |
| 105 | if err != nil { | ||
| 106 | return User{}, err | ||
| 107 | } | ||
| 108 | if t := parseTime(sql.NullString{String: created, Valid: true}); t != nil { | ||
| 109 | u.SignedInAt = *t | ||
| 110 | } | ||
| 111 | return u, nil | ||
| 102 | } | 112 | } |
| 103 | 113 | ||
| 104 | func (s *Store) DeleteWebSession(hash string) error { | 114 | func (s *Store) DeleteWebSession(hash string) error { |
internal/store/sessions_test.go +40
| @@ -117,3 +117,43 @@ func TestWebSessionRenewsUpToTheCap(t *testing.T) { | |||
| 117 | t.Fatalf("list: %+v %v", list, err) | 117 | t.Fatalf("list: %+v %v", list, err) |
| 118 | } | 118 | } |
| 119 | } | 119 | } |
| 120 | |||
| 121 | // A session's sign-in time is its creation; using the session renews | ||
| 122 | // its idle expiry and leaves the sign-in time alone (#297). | ||
| 123 | func TestWebSessionUserSignedInAt(t *testing.T) { | ||
| 124 | s, uid := sessionFixture(t) | ||
| 125 | _, hash, err := NewToken() | ||
| 126 | if err != nil { | ||
| 127 | t.Fatal(err) | ||
| 128 | } | ||
| 129 | if err := s.CreateWebSession(hash, uid, 7*24*time.Hour); err != nil { | ||
| 130 | t.Fatal(err) | ||
| 131 | } | ||
| 132 | u, err := s.WebSessionUser(hash) | ||
| 133 | if err != nil { | ||
| 134 | t.Fatal(err) | ||
| 135 | } | ||
| 136 | if age := time.Since(u.SignedInAt); age < 0 || age > time.Minute { | ||
| 137 | t.Fatalf("fresh session signed in %v ago", age) | ||
| 138 | } | ||
| 139 | |||
| 140 | signedIn := time.Now().Add(-2 * time.Hour) | ||
| 141 | if _, err := s.DB.Exec("UPDATE web_sessions SET created_at = ?, last_used_at = ? WHERE token_hash = ?", | ||
| 142 | fmtTime(signedIn), fmtTime(signedIn), hash); err != nil { | ||
| 143 | t.Fatal(err) | ||
| 144 | } | ||
| 145 | u, err = s.WebSessionUser(hash) | ||
| 146 | if err != nil { | ||
| 147 | t.Fatal(err) | ||
| 148 | } | ||
| 149 | var last string | ||
| 150 | if err := s.DB.QueryRow("SELECT last_used_at FROM web_sessions WHERE token_hash = ?", hash).Scan(&last); err != nil { | ||
| 151 | t.Fatal(err) | ||
| 152 | } | ||
| 153 | if last == fmtTime(signedIn) { | ||
| 154 | t.Fatal("using the session did not renew it") | ||
| 155 | } | ||
| 156 | if want := signedIn.UTC().Truncate(time.Millisecond); !u.SignedInAt.Equal(want) { | ||
| 157 | t.Fatalf("SignedInAt = %v, want %v", u.SignedInAt, want) | ||
| 158 | } | ||
| 159 | } | ||
internal/store/users.go +3
| @@ -14,6 +14,9 @@ type User struct { | |||
| 14 | IsAdmin bool | 14 | IsAdmin bool |
| 15 | Pending bool // self-registered, email not yet verified | 15 | Pending bool // self-registered, email not yet verified |
| 16 | Disabled bool // administratively suspended | 16 | Disabled bool // administratively suspended |
| 17 | // SignedInAt is when the browser session this user came from was | ||
| 18 | // created by a login. Set by WebSessionUser only; zero elsewhere. | ||
| 19 | SignedInAt time.Time | ||
| 17 | } | 20 | } |
| 18 | 21 | ||
| 19 | type SSHKey struct { | 22 | type SSHKey struct { |
internal/web/templates/account.html +1 −1
| @@ -2,7 +2,7 @@ | |||
| 2 | {{define "title"}}account settings{{end}} | 2 | {{define "title"}}account settings{{end}} |
| 3 | {{define "content"}} | 3 | {{define "content"}} |
| 4 | <h1>Account settings</h1> | 4 | <h1>Account settings</h1> |
| 5 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | 5 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}} |
| 6 | {{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} | 6 | {{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} |
| 7 | 7 | ||
| 8 | <div class="withcol narrow"> | 8 | <div class="withcol narrow"> |
internal/web/templates/adminusers.html +1 −1
| @@ -7,7 +7,7 @@ | |||
| 7 | {{define "content"}} | 7 | {{define "content"}} |
| 8 | <h1>Accounts</h1> | 8 | <h1>Accounts</h1> |
| 9 | <p class="meta"><a href="/admin">Admin</a> · the same read as <code>gitbay admin user list</code>.</p> | 9 | <p class="meta"><a href="/admin">Admin</a> · the same read as <code>gitbay admin user list</code>.</p> |
| 10 | {{if .Notice}}<p class="notice" role="status">{{.Notice}}</p>{{end}} | 10 | {{if .Notice}}<p class="notice" role="status">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}} |
| 11 | 11 | ||
| 12 | <nav class="filters"> | 12 | <nav class="filters"> |
| 13 | <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a> | 13 | <a {{if eq .State "all"}}class="active" aria-current="page" {{end}}href="?state=all">all</a> |
internal/web/templates/owner.html +1 −1
| @@ -83,7 +83,7 @@ | |||
| 83 | 83 | ||
| 84 | {{if eq .Tab "people"}}{{$org := .Owner}} | 84 | {{if eq .Tab "people"}}{{$org := .Owner}} |
| 85 | <h2>people <span class="count">{{len .Members}}</span></h2> | 85 | <h2>people <span class="count">{{len .Members}}</span></h2> |
| 86 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} | 86 | {{if .Notice}}<p class="error" role="alert">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}} |
| 87 | <div class="tablewrap"><table class="keys"> | 87 | <div class="tablewrap"><table class="keys"> |
| 88 | <tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"><span class="vh">actions</span></th></tr> | 88 | <tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"><span class="vh">actions</span></th></tr> |
| 89 | {{range .Members}}<tr> | 89 | {{range .Members}}<tr> |
internal/web/templates/settings.html +1 −1
| @@ -3,7 +3,7 @@ | |||
| 3 | {{define "content"}} | 3 | {{define "content"}} |
| 4 | {{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} | 4 | {{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} |
| 5 | <h1>Settings</h1> | 5 | <h1>Settings</h1> |
| 6 | {{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}} | 6 | {{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}{{if .Reauth}} <a href="/login">Sign in again</a>{{end}}</p>{{end}}{{end}} |
| 7 | <div class="withcol narrow"> | 7 | <div class="withcol narrow"> |
| 8 | <nav class="sidecol" aria-label="Sections"> | 8 | <nav class="sidecol" aria-label="Sections"> |
| 9 | <details class="sidedrop"> | 9 | <details class="sidedrop"> |