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 | 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 | 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 | 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 | 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 | 23 | | Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use | |
| 24 | 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 | 118 | - Destructive web actions (key, email and PGP removal, release, snippet, |
| 119 | 119 | team and label deletion, user disable and demote) require the target's |
| 120 | 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 | 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 | 26 | | Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) | |
| 27 | 27 | | Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec | |
| 28 | 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 | 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 | 13 | | #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high | |
| 14 | 14 | | #260 | CI network | Builds share the runner's source address; no egress policy | medium | |
| 15 | 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 | 16 | | #301 | SSRF | =repo import-issues --api-base= fetches without an address check or pin | medium | |
| 18 | 17 | |
| 19 | 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 | 61 | rights narrowed by its credential's scope, decided in one place, so a |
| 62 | 62 | bearer token is worth exactly its scope and no more, and a token or |
| 63 | 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 | |
| 65 | over the API. | |
| 64 | A browser session can create one, or grant access, only within 15 | |
| 65 | minutes of signing in (=control.ReauthWindow=, #297). Git transport | |
| 66 | never runs over the API. | |
| 66 | 67 | - *Anonymous surfaces* — HTTPS clone of public repos, =git://= where |
| 67 | 68 | enabled, the read-only web UI — carry no credentials and expose only |
| 68 | 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 | 701 | or =--all= ends them from the terminal, which is where a lost laptop is |
| 702 | 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 | 710 | =web theme set light= or =dark= fixes the web UI's colour scheme for |
| 705 | 711 | your account; =system=, the default, follows the browser's own |
| 706 | 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 | 23 | mirror or import whose host is written numerically (=127.1=, |
| 24 | 24 | =2130706433=, =0x7f.1=) rather than as a dotted address is refused |
| 25 | 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 | 36 | - The builds page's status badge section gives an org-mode snippet |
| 27 | 37 | beside the Markdown one, for a README.org (#299). |
| 28 | 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 | 31 | Examples: []string{"admin user show alice"}, |
| 32 | 32 | ReadOnly: true, Run: runAdminUserShow}) |
| 33 | 33 | register(Command{Path: []string{"admin", "user", "promote"}, |
| 34 | Summary: "make an account an instance admin", | |
| 35 | Usage: "admin user promote <username>", | |
| 36 | Examples: []string{"admin user promote alice"}, | |
| 37 | Run: runAdminUserPromote}) | |
| 34 | NeedsRecentSignIn: true, | |
| 35 | Summary: "make an account an instance admin", | |
| 36 | Usage: "admin user promote <username>", | |
| 37 | Examples: []string{"admin user promote alice"}, | |
| 38 | Run: runAdminUserPromote}) | |
| 38 | 39 | register(Command{Path: []string{"admin", "user", "demote"}, |
| 39 | 40 | Summary: "remove instance admin from an account (never the last one)", |
| 40 | 41 | Usage: "admin user demote <username>", |
internal/control/adminhost.go +10 −9
| @@ -32,17 +32,18 @@ func init() { | ||
| 32 | 32 | }, |
| 33 | 33 | Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"}, |
| 34 | 34 | ReadsStdin: true, |
| 35 | MintsCredential: true, Run: runAdminUserCreate}) | |
| 35 | MintsCredential: true, NeedsRecentSignIn: true, Run: runAdminUserCreate}) | |
| 36 | 36 | register(Command{Path: []string{"admin", "user", "disable"}, |
| 37 | 37 | Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled", |
| 38 | 38 | Usage: "admin user disable <username>", |
| 39 | 39 | Examples: []string{"admin user disable alice"}, |
| 40 | 40 | Run: runAdminUserDisable}) |
| 41 | 41 | register(Command{Path: []string{"admin", "user", "enable"}, |
| 42 | Summary: "restore a suspended account", | |
| 43 | Usage: "admin user enable <username>", | |
| 44 | Examples: []string{"admin user enable alice"}, | |
| 45 | Run: runAdminUserEnable}) | |
| 42 | NeedsRecentSignIn: true, | |
| 43 | Summary: "restore a suspended account", | |
| 44 | Usage: "admin user enable <username>", | |
| 45 | Examples: []string{"admin user enable alice"}, | |
| 46 | Run: runAdminUserEnable}) | |
| 46 | 47 | register(Command{Path: []string{"admin", "user", "delete"}, |
| 47 | 48 | Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)", |
| 48 | 49 | Usage: "admin user delete <username> --yes", |
| @@ -55,8 +56,8 @@ func init() { | ||
| 55 | 56 | Summary: "mark an address verified by admin assertion", |
| 56 | 57 | Usage: "admin email verify <username> <address>", |
| 57 | 58 | Examples: []string{"admin email verify alice alice@example.org"}, |
| 58 | MintsCredential: true, | |
| 59 | Run: runAdminEmailVerify}) | |
| 59 | MintsCredential: true, NeedsRecentSignIn: true, | |
| 60 | Run: runAdminEmailVerify}) | |
| 60 | 61 | register(Command{Path: []string{"admin", "invite"}, |
| 61 | 62 | Summary: "issue a registration invite and mail its code", |
| 62 | 63 | Usage: "admin invite --email <address>", |
| @@ -64,8 +65,8 @@ func init() { | ||
| 64 | 65 | {"--email", "<address>", "who the invite is for", ""}, |
| 65 | 66 | }, |
| 66 | 67 | Examples: []string{"admin invite --email alice@example.org"}, |
| 67 | MintsCredential: true, | |
| 68 | Run: runAdminInvite}) | |
| 68 | MintsCredential: true, NeedsRecentSignIn: true, | |
| 69 | Run: runAdminInvite}) | |
| 69 | 70 | register(Command{Path: []string{"admin", "stats"}, |
| 70 | 71 | Summary: "instance statistics: counts and per-repository disk usage", |
| 71 | 72 | Usage: "admin stats", |
internal/control/build.go +5 −4
| @@ -69,10 +69,11 @@ func init() { | ||
| 69 | 69 | // repo's builds as environment variables. Same discipline as mirror |
| 70 | 70 | // tokens — the value never appears in argv, logs, or output. |
| 71 | 71 | register(Command{Path: []string{"repo", "secret", "set"}, |
| 72 | Summary: "set a build secret", | |
| 73 | Usage: "repo secret set <owner/name> <NAME> (value on stdin)", | |
| 74 | Examples: []string{"repo secret set krz/gitbay DEPLOY_TOKEN"}, | |
| 75 | ReadsStdin: true, Run: runSecretSet}) | |
| 72 | NeedsRecentSignIn: true, | |
| 73 | Summary: "set a build secret", | |
| 74 | Usage: "repo secret set <owner/name> <NAME> (value on stdin)", | |
| 75 | Examples: []string{"repo secret set krz/gitbay DEPLOY_TOKEN"}, | |
| 76 | ReadsStdin: true, Run: runSecretSet}) | |
| 76 | 77 | register(Command{Path: []string{"repo", "secret", "remove"}, |
| 77 | 78 | Summary: "remove a build secret", |
| 78 | 79 | Usage: "repo secret remove <owner/name> <NAME>", |
internal/control/control.go +30 −1
| @@ -69,6 +69,27 @@ type Ctx struct { | ||
| 69 | 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 | 93 | // usage reports a bad invocation with the command's registered usage, |
| 73 | 94 | // the one source of it. |
| 74 | 95 | func (c *Ctx) usage() int { |
| @@ -104,7 +125,12 @@ type Command struct { | ||
| 104 | 125 | // to obtain one: tokens, keys, login links, invites, accounts, |
| 105 | 126 | // verified addresses. An expiring credential may not run it. |
| 106 | 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 | 136 | var registry []Command |
| @@ -212,6 +238,9 @@ func runChecked(c *Ctx, cmd Command, args []string) int { | ||
| 212 | 238 | if c.User.Disabled { |
| 213 | 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 | 244 | // The admin noun is gated here as well as in each handler, so a new |
| 216 | 245 | // admin command that forgets requireInstanceAdmin is still refused. |
| 217 | 246 | if cmd.Path[0] == "admin" && !c.User.IsAdmin { |
internal/control/deploykey.go +1 −1
| @@ -23,7 +23,7 @@ func init() { | ||
| 23 | 23 | }, |
| 24 | 24 | Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"}, |
| 25 | 25 | ReadsStdin: true, |
| 26 | MintsCredential: true, Run: runDeployKeyAdd}) | |
| 26 | MintsCredential: true, NeedsRecentSignIn: true, Run: runDeployKeyAdd}) | |
| 27 | 27 | register(Command{Path: []string{"repo", "deploy-key", "list"}, |
| 28 | 28 | Summary: "list deploy keys", |
| 29 | 29 | Usage: "repo deploy-key list <owner/name>", |
internal/control/identity.go +2 −2
| @@ -42,8 +42,8 @@ func init() { | ||
| 42 | 42 | }, |
| 43 | 43 | Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"}, |
| 44 | 44 | ReadsStdin: true, |
| 45 | MintsCredential: true, | |
| 46 | Run: runKeysAdd, | |
| 45 | MintsCredential: true, NeedsRecentSignIn: true, | |
| 46 | Run: runKeysAdd, | |
| 47 | 47 | }) |
| 48 | 48 | register(Command{ |
| 49 | 49 | Path: []string{"keys", "label"}, |
internal/control/mirrorcmd.go +3 −2
| @@ -16,8 +16,9 @@ import ( | ||
| 16 | 16 | |
| 17 | 17 | func init() { |
| 18 | 18 | register(Command{Path: []string{"repo", "mirror", "add"}, |
| 19 | Summary: "mirror to or from a remote", | |
| 20 | Usage: "repo mirror add <owner/name> <https-url> --direction push|pull [--username <u>] [--token-stdin]", | |
| 19 | NeedsRecentSignIn: true, | |
| 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 | 22 | Flags: []Flag{ |
| 22 | 23 | {"--direction", "push|pull", "which way the mirror syncs", ""}, |
| 23 | 24 | {"--username", "<u>", "the remote's username", ""}, |
internal/control/notifications.go +3 −2
| @@ -48,8 +48,9 @@ func init() { | ||
| 48 | 48 | Examples: []string{"notifications settings watch on"}, |
| 49 | 49 | Run: runNotificationsSettingsWatch}) |
| 50 | 50 | register(Command{Path: []string{"notifications", "device", "add"}, |
| 51 | Summary: "register an Apple device for push, token on stdin", | |
| 52 | Usage: "notifications device add [--label <name>] < token", | |
| 51 | NeedsRecentSignIn: true, | |
| 52 | Summary: "register an Apple device for push, token on stdin", | |
| 53 | Usage: "notifications device add [--label <name>] < token", | |
| 53 | 54 | Flags: []Flag{ |
| 54 | 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 | 38 | Examples: []string{"org delete krz --yes"}, Run: runOrgDelete}) |
| 39 | 39 | register(Command{Path: []string{"org", "members", "add"}, |
| 40 | Summary: "add or update a member", | |
| 41 | Usage: "org members add <org> <user> [--role member|admin]", | |
| 40 | NeedsRecentSignIn: true, | |
| 41 | Summary: "add or update a member", | |
| 42 | Usage: "org members add <org> <user> [--role member|admin]", | |
| 42 | 43 | Flags: []Flag{ |
| 43 | 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 | 38 | register(Command{Path: []string{"email", "verify"}, |
| 39 | 39 | Summary: "confirm a verification code", |
| 40 | 40 | Usage: "email verify <code>", |
| 41 | MintsCredential: true, | |
| 42 | Examples: []string{"email verify abc123"}, Run: runEmailVerify}) | |
| 41 | MintsCredential: true, NeedsRecentSignIn: true, | |
| 42 | Examples: []string{"email verify abc123"}, Run: runEmailVerify}) | |
| 43 | 43 | register(Command{Path: []string{"email", "list"}, |
| 44 | 44 | Summary: "list the addresses on your account", |
| 45 | 45 | Usage: "email list", |
internal/control/repo.go +10 −8
| @@ -50,10 +50,11 @@ func init() { | ||
| 50 | 50 | Examples: []string{"repo show krz/gitbay"}, |
| 51 | 51 | ReadOnly: true, Run: runRepoShow}) |
| 52 | 52 | register(Command{Path: []string{"repo", "transfer"}, |
| 53 | Summary: "move a repository to another owner", | |
| 54 | Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)", | |
| 55 | Examples: []string{"repo transfer krz/gitbay krazywarez"}, | |
| 56 | Run: runRepoTransfer}) | |
| 53 | NeedsRecentSignIn: true, | |
| 54 | Summary: "move a repository to another owner", | |
| 55 | Usage: "repo transfer <owner/name> <new-owner> (clone URLs change)", | |
| 56 | Examples: []string{"repo transfer krz/gitbay krazywarez"}, | |
| 57 | Run: runRepoTransfer}) | |
| 57 | 58 | register(Command{Path: []string{"repo", "rename"}, |
| 58 | 59 | Summary: "rename a repository", |
| 59 | 60 | Usage: "repo rename <owner/name> <new-name> (clone URLs change)", |
| @@ -68,10 +69,11 @@ func init() { | ||
| 68 | 69 | Examples: []string{"repo delete cmc/scratch --yes"}, |
| 69 | 70 | Run: runRepoDelete}) |
| 70 | 71 | register(Command{Path: []string{"repo", "access", "grant"}, |
| 71 | Summary: "grant access", | |
| 72 | Usage: "repo access grant <owner/name> <user> read|write|admin", | |
| 73 | Examples: []string{"repo access grant krz/gitbay cmc write"}, | |
| 74 | Run: runAccessGrant}) | |
| 72 | NeedsRecentSignIn: true, | |
| 73 | Summary: "grant access", | |
| 74 | Usage: "repo access grant <owner/name> <user> read|write|admin", | |
| 75 | Examples: []string{"repo access grant krz/gitbay cmc write"}, | |
| 76 | Run: runAccessGrant}) | |
| 75 | 77 | register(Command{Path: []string{"repo", "access", "revoke"}, |
| 76 | 78 | Summary: "revoke access", |
| 77 | 79 | Usage: "repo access revoke <owner/name> <user>", |
internal/control/runnerrepo.go +1 −1
| @@ -23,7 +23,7 @@ func init() { | ||
| 23 | 23 | Usage: "repo runner add <owner/name> < key.pub", |
| 24 | 24 | Examples: []string{"repo runner add krz/gitbay < key.pub"}, |
| 25 | 25 | ReadsStdin: true, |
| 26 | MintsCredential: true, Run: runRepoRunnerAdd}) | |
| 26 | MintsCredential: true, NeedsRecentSignIn: true, Run: runRepoRunnerAdd}) | |
| 27 | 27 | register(Command{Path: []string{"repo", "runner", "list"}, |
| 28 | 28 | Summary: "list the runners attached to a repository", |
| 29 | 29 | Usage: "repo runner list <owner/name>", |
internal/control/sig.go +5 −4
| @@ -18,10 +18,11 @@ import ( | ||
| 18 | 18 | |
| 19 | 19 | func init() { |
| 20 | 20 | register(Command{Path: []string{"pgp", "add"}, |
| 21 | Summary: "register an OpenPGP public key (armored)", | |
| 22 | Usage: "pgp add < key.asc", | |
| 23 | Examples: []string{"pgp add < key.asc"}, | |
| 24 | ReadsStdin: true, Run: runPGPAdd}) | |
| 21 | NeedsRecentSignIn: true, | |
| 22 | Summary: "register an OpenPGP public key (armored)", | |
| 23 | Usage: "pgp add < key.asc", | |
| 24 | Examples: []string{"pgp add < key.asc"}, | |
| 25 | ReadsStdin: true, Run: runPGPAdd}) | |
| 25 | 26 | register(Command{Path: []string{"pgp", "list"}, |
| 26 | 27 | Summary: "list registered OpenPGP keys", |
| 27 | 28 | Usage: "pgp list", |
internal/control/teams.go +12 −9
| @@ -30,25 +30,28 @@ func init() { | ||
| 30 | 30 | Usage: "org team show <org> <team>", |
| 31 | 31 | Examples: []string{"org team show krz maintainers"}, ReadOnly: true, Run: runTeamShow}) |
| 32 | 32 | register(Command{Path: []string{"org", "team", "add"}, |
| 33 | Summary: "add org members to a team", | |
| 34 | Usage: "org team add <org> <team> <user>...", | |
| 35 | Examples: []string{"org team add krz maintainers cmc"}, Run: runTeamAdd}) | |
| 33 | NeedsRecentSignIn: true, | |
| 34 | Summary: "add org members to a team", | |
| 35 | Usage: "org team add <org> <team> <user>...", | |
| 36 | Examples: []string{"org team add krz maintainers cmc"}, Run: runTeamAdd}) | |
| 36 | 37 | register(Command{Path: []string{"org", "team", "remove"}, |
| 37 | 38 | Summary: "remove members from a team", |
| 38 | 39 | Usage: "org team remove <org> <team> <user>...", |
| 39 | 40 | Examples: []string{"org team remove krz maintainers cmc"}, Run: runTeamRemove}) |
| 40 | 41 | register(Command{Path: []string{"org", "team", "grant"}, |
| 41 | Summary: "grant a team a role on an org repo", | |
| 42 | Usage: "org team grant <org> <team> <owner/name> read|write|admin", | |
| 43 | Examples: []string{"org team grant krz maintainers krz/gitbay write"}, Run: runTeamGrant}) | |
| 42 | NeedsRecentSignIn: true, | |
| 43 | Summary: "grant a team a role on an org repo", | |
| 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 | 46 | register(Command{Path: []string{"org", "team", "revoke"}, |
| 45 | 47 | Summary: "revoke a team's grant", |
| 46 | 48 | Usage: "org team revoke <org> <team> <owner/name>", |
| 47 | 49 | Examples: []string{"org team revoke krz maintainers krz/gitbay"}, Run: runTeamRevoke}) |
| 48 | 50 | register(Command{Path: []string{"org", "settings", "members-role"}, |
| 49 | Summary: "role plain membership implies on every org repo", | |
| 50 | Usage: "org settings members-role <org> write|read|none (default write)", | |
| 51 | Examples: []string{"org settings members-role krz read"}, Run: runOrgMembersRole}) | |
| 51 | NeedsRecentSignIn: true, | |
| 52 | Summary: "role plain membership implies on every org repo", | |
| 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 | 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 | 22 | {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"}, |
| 23 | 23 | }, |
| 24 | 24 | Examples: []string{"token create --name laptop --ttl 30d", "token create --name phone --scope full"}, |
| 25 | MintsCredential: true, | |
| 26 | Run: runTokenCreate}) | |
| 25 | MintsCredential: true, NeedsRecentSignIn: true, | |
| 26 | Run: runTokenCreate}) | |
| 27 | 27 | register(Command{Path: []string{"token", "list"}, |
| 28 | 28 | Summary: "list API tokens", |
| 29 | 29 | Usage: "token list", |
internal/control/web.go +2 −2
| @@ -16,8 +16,8 @@ func init() { | ||
| 16 | 16 | register(Command{Path: []string{"web", "login"}, |
| 17 | 17 | Summary: "mint a one-time browser login URL", |
| 18 | 18 | Usage: "web login", |
| 19 | MintsCredential: true, | |
| 20 | Examples: []string{"web login"}, Run: runWebLogin}) | |
| 19 | MintsCredential: true, NeedsRecentSignIn: true, | |
| 20 | Examples: []string{"web login"}, Run: runWebLogin}) | |
| 21 | 21 | register(Command{Path: []string{"web", "sessions", "list"}, |
| 22 | 22 | Summary: "list your browser sessions", |
| 23 | 23 | Usage: "web sessions list", |
internal/control/webhook.go +3 −2
| @@ -15,8 +15,9 @@ import ( | ||
| 15 | 15 | |
| 16 | 16 | func init() { |
| 17 | 17 | register(Command{Path: []string{"webhook", "add"}, |
| 18 | Summary: "add a webhook", | |
| 19 | Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]", | |
| 18 | NeedsRecentSignIn: true, | |
| 19 | Summary: "add a webhook", | |
| 20 | Usage: "webhook add <owner/name> <url> [--secret -] [--events push,issue.created|*]", | |
| 20 | 21 | Flags: []Flag{ |
| 21 | 22 | {"--secret", "-", "read the secret that signs deliveries from stdin", ""}, |
| 22 | 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 | 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 | 134 | s.render(w, "account.html", struct { |
| 132 | 135 | basePage |
| 133 | 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 | 151 | ThemeSetting string // system, light or dark: the form's selected option |
| 149 | 152 | Tokens []accountToken |
| 150 | 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 | 155 | }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links), |
| 152 | 156 | aboutRepo, aboutEdit, s.cfg.SiteHost(), |
| 153 | s.takeFlash(w, r), r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme, | |
| 154 | tokens, tokenShown}) | |
| 157 | notice, r.URL.Query().Get("m"), mailOn, watchOn, pushOn, devices, theme, | |
| 158 | tokens, tokenShown, reauth}) | |
| 155 | 159 | } |
| 156 | 160 | |
| 157 | 161 | // accountExport hands the browser the same bundle `account export` |
internal/httpd/account_test.go +2 −1
| @@ -7,6 +7,7 @@ import ( | ||
| 7 | 7 | "strconv" |
| 8 | 8 | "strings" |
| 9 | 9 | "testing" |
| 10 | "time" | |
| 10 | 11 | |
| 11 | 12 | "gitbay.org/gitbay/internal/config" |
| 12 | 13 | "gitbay.org/gitbay/internal/store" |
| @@ -300,7 +301,7 @@ func newTokenTestServer(t *testing.T) (*Server, *store.Store, store.User) { | ||
| 300 | 301 | if err != nil { |
| 301 | 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 | 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 | 63 | next = "?" + q.Encode() |
| 64 | 64 | } |
| 65 | notice := s.takeFlash(w, r) | |
| 65 | 66 | s.render(w, "adminusers.html", struct { |
| 66 | 67 | basePage |
| 67 | 68 | Tab string |
| @@ -69,7 +70,8 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, viewer store | ||
| 69 | 70 | Users []adminUserRow |
| 70 | 71 | Next string |
| 71 | 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 | 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 | 34 | var stdout, stderr bytes.Buffer |
| 35 | 35 | ctx := &control.Ctx{ |
| 36 | 36 | User: u, |
| 37 | Source: "web", | |
| 37 | Source: control.SourceWeb, | |
| 38 | 38 | Scope: "full", |
| 39 | 39 | Store: s.st, |
| 40 | 40 | Cfg: s.cfg, |
| @@ -58,7 +58,7 @@ func (s *Server) runControlStream(u store.User, argv []string, out io.Writer, do | ||
| 58 | 58 | var stderr bytes.Buffer |
| 59 | 59 | ctx := &control.Ctx{ |
| 60 | 60 | User: u, |
| 61 | Source: "web", | |
| 61 | Source: control.SourceWeb, | |
| 62 | 62 | Scope: "full", |
| 63 | 63 | Store: s.st, |
| 64 | 64 | Cfg: s.cfg, |
| @@ -104,7 +104,7 @@ func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string) | ||
| 104 | 104 | var stdout, stderr bytes.Buffer |
| 105 | 105 | ctx := &control.Ctx{ |
| 106 | 106 | User: u, |
| 107 | Source: "web", | |
| 107 | Source: control.SourceWeb, | |
| 108 | 108 | Scope: "full", |
| 109 | 109 | Store: s.st, |
| 110 | 110 | Cfg: s.cfg, |
| @@ -146,7 +146,7 @@ func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, ta | ||
| 146 | 146 | var stdout, stderr bytes.Buffer |
| 147 | 147 | ctx := &control.Ctx{ |
| 148 | 148 | User: u, |
| 149 | Source: "web", | |
| 149 | Source: control.SourceWeb, | |
| 150 | 150 | Scope: "full", |
| 151 | 151 | Store: s.st, |
| 152 | 152 | Cfg: s.cfg, |
| @@ -187,7 +187,7 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i | ||
| 187 | 187 | var stdout, stderr bytes.Buffer |
| 188 | 188 | ctx := &control.Ctx{ |
| 189 | 189 | User: u, |
| 190 | Source: "web", | |
| 190 | Source: control.SourceWeb, | |
| 191 | 191 | Scope: "full", |
| 192 | 192 | Store: s.st, |
| 193 | 193 | Cfg: s.cfg, |
internal/httpd/flash.go +23 −3
| @@ -4,6 +4,8 @@ import ( | ||
| 4 | 4 | "net/http" |
| 5 | 5 | "net/url" |
| 6 | 6 | "strings" |
| 7 | ||
| 8 | "gitbay.org/gitbay/internal/control" | |
| 7 | 9 | ) |
| 8 | 10 | |
| 9 | 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 | 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 | 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 | 69 | // setNext remembers the local path an anonymous visitor asked for, so |
| 50 | 70 | // the login that follows can return there. Only a GET path is stored: |
| 51 | 71 | // a POST must not be replayed. |
| 52 | 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 | 74 | return |
| 55 | 75 | } |
| 56 | 76 | http.SetCookie(w, &http.Cookie{ |
| @@ -69,7 +89,7 @@ func (s *Server) takeNext(w http.ResponseWriter, r *http.Request) string { | ||
| 69 | 89 | } |
| 70 | 90 | http.SetCookie(w, s.clearCookie(nextCookie, http.SameSiteLaxMode)) |
| 71 | 91 | p, err := url.QueryUnescape(c.Value) |
| 72 | if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") { | |
| 92 | if err != nil || !localPath(p) { | |
| 73 | 93 | return "" |
| 74 | 94 | } |
| 75 | 95 | return p |
| @@ -83,7 +103,7 @@ func (s *Server) peekNext(r *http.Request) string { | ||
| 83 | 103 | return "" |
| 84 | 104 | } |
| 85 | 105 | p, err := url.QueryUnescape(c.Value) |
| 86 | if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") { | |
| 106 | if err != nil || !localPath(p) { | |
| 87 | 107 | return "" |
| 88 | 108 | } |
| 89 | 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 | 26 | Runners []store.RepoRunner |
| 27 | 27 | Notice string |
| 28 | 28 | Saved bool |
| 29 | Reauth bool // Notice is the stale-session refusal: link to sign in | |
| 29 | 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 | 71 | Runners: runners, |
| 71 | 72 | Notice: notice, |
| 72 | 73 | Saved: strings.HasPrefix(notice, "Saved "), |
| 74 | Reauth: s.reauthNotice(w, notice, r.URL.Path), | |
| 73 | 75 | Submitted: subm, |
| 74 | 76 | }) |
| 75 | 77 | } |
internal/httpd/web.go +4 −1
| @@ -517,6 +517,7 @@ type ownerPage struct { | ||
| 517 | 517 | Self bool |
| 518 | 518 | Snippets int |
| 519 | 519 | Notice string |
| 520 | Reauth bool // Notice is the stale-session refusal: link to sign in | |
| 520 | 521 | Feed string |
| 521 | 522 | } |
| 522 | 523 | |
| @@ -572,6 +573,7 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) { | ||
| 572 | 573 | return |
| 573 | 574 | } |
| 574 | 575 | } |
| 576 | notice := s.takeFlash(w, r) | |
| 575 | 577 | s.render(w, "owner.html", ownerPage{ |
| 576 | 578 | basePage: s.baseFor(viewer), |
| 577 | 579 | Owner: name, |
| @@ -592,7 +594,8 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) { | ||
| 592 | 594 | CanAdmin: canAdmin, |
| 593 | 595 | Self: self, |
| 594 | 596 | Snippets: d.Snippets, |
| 595 | Notice: s.takeFlash(w, r), | |
| 597 | Notice: notice, | |
| 598 | Reauth: s.reauthNotice(w, notice, r.URL.Path), | |
| 596 | 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 | 80 | return err |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | // WebSessionUser resolves a session cookie hash to its user and renews | |
| 84 | // the session's idle expiry. A session is written at most once a | |
| 85 | // minute, so a burst of requests costs one UPDATE. | |
| 83 | // WebSessionUser resolves a session cookie hash to its user, with the | |
| 84 | // session's sign-in time, and renews the session's idle expiry. A | |
| 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 | 88 | func (s *Store) WebSessionUser(hash string) (User, error) { |
| 87 | 89 | now := time.Now() |
| 88 | 90 | var userID int64 |
| 91 | var created string | |
| 89 | 92 | err := s.DB.QueryRow( |
| 90 | "SELECT user_id FROM web_sessions WHERE token_hash = ? AND expires_at > ?", | |
| 91 | hash, fmtTime(now)).Scan(&userID) | |
| 93 | "SELECT user_id, created_at FROM web_sessions WHERE token_hash = ? AND expires_at > ?", | |
| 94 | hash, fmtTime(now)).Scan(&userID, &created) | |
| 92 | 95 | if errors.Is(err, sql.ErrNoRows) { |
| 93 | 96 | return User{}, ErrNotFound |
| 94 | 97 | } |
| @@ -98,7 +101,14 @@ func (s *Store) WebSessionUser(hash string) (User, error) { | ||
| 98 | 101 | s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?) |
| 99 | 102 | WHERE token_hash = ? AND last_used_at < ?`, |
| 100 | 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 | 114 | func (s *Store) DeleteWebSession(hash string) error { |
internal/store/sessions_test.go +40
| @@ -117,3 +117,43 @@ func TestWebSessionRenewsUpToTheCap(t *testing.T) { | ||
| 117 | 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 | 14 | IsAdmin bool |
| 15 | 15 | Pending bool // self-registered, email not yet verified |
| 16 | 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 | 22 | type SSHKey struct { |
internal/web/templates/account.html +1 −1
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | {{define "title"}}account settings{{end}} |
| 3 | 3 | {{define "content"}} |
| 4 | 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 | 6 | {{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}} |
| 7 | 7 | |
| 8 | 8 | <div class="withcol narrow"> |
internal/web/templates/adminusers.html +1 −1
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | {{define "content"}} |
| 8 | 8 | <h1>Accounts</h1> |
| 9 | 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 | 12 | <nav class="filters"> |
| 13 | 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 | 84 | {{if eq .Tab "people"}}{{$org := .Owner}} |
| 85 | 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 | 87 | <div class="tablewrap"><table class="keys"> |
| 88 | 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 | 89 | {{range .Members}}<tr> |
internal/web/templates/settings.html +1 −1
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | {{define "content"}} |
| 4 | 4 | {{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}} |
| 5 | 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 | 7 | <div class="withcol narrow"> |
| 8 | 8 | <nav class="sidecol" aria-label="Sections"> |
| 9 | 9 | <details class="sidedrop"> |