web: mints and grants need a sign-in from the last 15 minutes !513

merged merged by cmc on 2026-09-29 02:25 UTC · krz/gitbay:web-mint-reauth into main

40 files changed, +553 −85

Layout: unified · split

.gitbay/wiki/Architecture/05-Identity-and-Access.org +12 −1
@@ -18,7 +18,7 @@
1818| 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 |
1919| 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 |
2020| 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= |
2222| 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 |
2323| Email verification | 32 random bytes hex | SHA-256 hash, single use | verifies one address for one account | 24 h | consumed on use |
2424| 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=):
118118- Destructive web actions (key, email and PGP removal, release, snippet,
119119 team and label deletion, user disable and demote) require the target's
120120 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=).
121132
122133* Rate limits
123134
.gitbay/wiki/Architecture/09-Controls.org +1 −1
@@ -26,7 +26,7 @@ chapter names of OWASP ASVS 4.0 where one fits.
2626| Session lifetime | in place | 12 hours idle, 7 days absolute (=internal/store/sessions.go=) |
2727| Credential expiry | in place | optional =--ttl= on API tokens, SSH and deploy keys; checked at auth and per exec |
2828| 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=) |
3030
3131** Access control (V4)
3232
.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.
1313| #259 | Recovery | No restore has been exercised; the drill is written (Admin wiki) and not yet run | high |
1414| #260 | CI network | Builds share the runner's source address; no egress policy | medium |
1515| #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 |
1716| #301 | SSRF | =repo import-issues --api-base= fetches without an address check or pin | medium |
1817
1918* 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
6161 rights narrowed by its credential's scope, decided in one place, so a
6262 bearer token is worth exactly its scope and no more, and a token or
6363 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.
6667- *Anonymous surfaces* — HTTPS clone of public repos, =git://= where
6768 enabled, the read-only web UI — carry no credentials and expose only
6869 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>=
701701or =--all= ends them from the terminal, which is where a lost laptop is
702702handled.
703703
704Actions that create a credential or grant access (adding a key, token
705or email, org and repository roles, transfers) ask you to sign in again
706when your web sign-in is older than 15 minutes. The form shows a "Sign
707in again" link and the login returns to the page. Idle renewal does not
708extend this window.
709
704710=web theme set light= or =dark= fixes the web UI's colour scheme for
705711your account; =system=, the default, follows the browser's own
706712preference. =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
2323mirror or import whose host is written numerically (=127.1=,
2424=2130706433=, =0x7f.1=) rather than as a dotted address is refused
2525too; 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).
2636- The builds page's status badge section gives an org-mode snippet
2737 beside the Markdown one, for a README.org (#299).
2838- API tokens on the settings page: create with a scope and optional
internal/control/admin.go +5 −4
@@ -31,10 +31,11 @@ func init() {
3131 Examples: []string{"admin user show alice"},
3232 ReadOnly: true, Run: runAdminUserShow})
3333 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})
3839 register(Command{Path: []string{"admin", "user", "demote"},
3940 Summary: "remove instance admin from an account (never the last one)",
4041 Usage: "admin user demote <username>",
internal/control/adminhost.go +10 −9
@@ -32,17 +32,18 @@ func init() {
3232 },
3333 Examples: []string{"admin user create alice --email alice@example.org --key - < key.pub"},
3434 ReadsStdin: true,
35 MintsCredential: true, Run: runAdminUserCreate})
35 MintsCredential: true, NeedsRecentSignIn: true, Run: runAdminUserCreate})
3636 register(Command{Path: []string{"admin", "user", "disable"},
3737 Summary: "suspend an account: SSH, web sessions and API tokens refused until re-enabled",
3838 Usage: "admin user disable <username>",
3939 Examples: []string{"admin user disable alice"},
4040 Run: runAdminUserDisable})
4141 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})
4647 register(Command{Path: []string{"admin", "user", "delete"},
4748 Summary: "delete an account that anchors nothing (keys, emails and sessions go with it)",
4849 Usage: "admin user delete <username> --yes",
@@ -55,8 +56,8 @@ func init() {
5556 Summary: "mark an address verified by admin assertion",
5657 Usage: "admin email verify <username> <address>",
5758 Examples: []string{"admin email verify alice alice@example.org"},
58 MintsCredential: true,
59 Run: runAdminEmailVerify})
59 MintsCredential: true, NeedsRecentSignIn: true,
60 Run: runAdminEmailVerify})
6061 register(Command{Path: []string{"admin", "invite"},
6162 Summary: "issue a registration invite and mail its code",
6263 Usage: "admin invite --email <address>",
@@ -64,8 +65,8 @@ func init() {
6465 {"--email", "<address>", "who the invite is for", ""},
6566 },
6667 Examples: []string{"admin invite --email alice@example.org"},
67 MintsCredential: true,
68 Run: runAdminInvite})
68 MintsCredential: true, NeedsRecentSignIn: true,
69 Run: runAdminInvite})
6970 register(Command{Path: []string{"admin", "stats"},
7071 Summary: "instance statistics: counts and per-repository disk usage",
7172 Usage: "admin stats",
internal/control/build.go +5 −4
@@ -69,10 +69,11 @@ func init() {
6969 // repo's builds as environment variables. Same discipline as mirror
7070 // tokens — the value never appears in argv, logs, or output.
7171 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})
7677 register(Command{Path: []string{"repo", "secret", "remove"},
7778 Summary: "remove a build secret",
7879 Usage: "repo secret remove <owner/name> <NAME>",
internal/control/control.go +30 −1
@@ -69,6 +69,27 @@ type Ctx struct {
6969 Stopping <-chan struct{}
7070}
7171
72// SourceWeb is Ctx.Source for a request from a browser session. Its
73// User.SignedInAt is when that session signed in.
74const 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).
80const 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.
84var 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.
89func staleSignIn(at, now time.Time) bool {
90 return now.Sub(at) > ReauthWindow
91}
92
7293// usage reports a bad invocation with the command's registered usage,
7394// the one source of it.
7495func (c *Ctx) usage() int {
@@ -104,7 +125,12 @@ type Command struct {
104125 // to obtain one: tokens, keys, login links, invites, accounts,
105126 // verified addresses. An expiring credential may not run it.
106127 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
108134}
109135
110136var registry []Command
@@ -212,6 +238,9 @@ func runChecked(c *Ctx, cmd Command, args []string) int {
212238 if c.User.Disabled {
213239 return c.fail(protocol.ExitDenied, "this account is disabled; ask an instance admin to enable it")
214240 }
241 if cmd.NeedsRecentSignIn && c.Source == SourceWeb && staleSignIn(c.User.SignedInAt, time.Now()) {
242 return c.fail(protocol.ExitDenied, "%s", ReauthRefusal)
243 }
215244 // The admin noun is gated here as well as in each handler, so a new
216245 // admin command that forgets requireInstanceAdmin is still refused.
217246 if cmd.Path[0] == "admin" && !c.User.IsAdmin {
internal/control/deploykey.go +1 −1
@@ -23,7 +23,7 @@ func init() {
2323 },
2424 Examples: []string{"repo deploy-key add krz/gitbay < key.pub", "repo deploy-key add krz/gitbay --ttl 30d < key.pub"},
2525 ReadsStdin: true,
26 MintsCredential: true, Run: runDeployKeyAdd})
26 MintsCredential: true, NeedsRecentSignIn: true, Run: runDeployKeyAdd})
2727 register(Command{Path: []string{"repo", "deploy-key", "list"},
2828 Summary: "list deploy keys",
2929 Usage: "repo deploy-key list <owner/name>",
internal/control/identity.go +2 −2
@@ -42,8 +42,8 @@ func init() {
4242 },
4343 Examples: []string{"keys add --label laptop < key.pub", "keys add --scope git --ttl 90d < ci.pub"},
4444 ReadsStdin: true,
45 MintsCredential: true,
46 Run: runKeysAdd,
45 MintsCredential: true, NeedsRecentSignIn: true,
46 Run: runKeysAdd,
4747 })
4848 register(Command{
4949 Path: []string{"keys", "label"},
internal/control/mirrorcmd.go +3 −2
@@ -16,8 +16,9 @@ import (
1616
1717func init() {
1818 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]",
2122 Flags: []Flag{
2223 {"--direction", "push|pull", "which way the mirror syncs", ""},
2324 {"--username", "<u>", "the remote's username", ""},
internal/control/notifications.go +3 −2
@@ -48,8 +48,9 @@ func init() {
4848 Examples: []string{"notifications settings watch on"},
4949 Run: runNotificationsSettingsWatch})
5050 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",
5354 Flags: []Flag{
5455 {"--label", "<name>", "a name for the device", ""},
5556 },
internal/control/org.go +3 −2
@@ -37,8 +37,9 @@ func init() {
3737 },
3838 Examples: []string{"org delete krz --yes"}, Run: runOrgDelete})
3939 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]",
4243 Flags: []Flag{
4344 {"--role", "member|admin", "the member's role", "member"},
4445 },
internal/control/reauth_test.go added +136
@@ -0,0 +1,136 @@
1package control
2
3import (
4 "slices"
5 "strings"
6 "testing"
7 "time"
8
9 "gitbay.org/gitbay/internal/protocol"
10 "gitbay.org/gitbay/internal/store"
11)
12
13func 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).
29func 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.
98func 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() {
3838 register(Command{Path: []string{"email", "verify"},
3939 Summary: "confirm a verification code",
4040 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})
4343 register(Command{Path: []string{"email", "list"},
4444 Summary: "list the addresses on your account",
4545 Usage: "email list",
internal/control/repo.go +10 −8
@@ -50,10 +50,11 @@ func init() {
5050 Examples: []string{"repo show krz/gitbay"},
5151 ReadOnly: true, Run: runRepoShow})
5252 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})
5758 register(Command{Path: []string{"repo", "rename"},
5859 Summary: "rename a repository",
5960 Usage: "repo rename <owner/name> <new-name> (clone URLs change)",
@@ -68,10 +69,11 @@ func init() {
6869 Examples: []string{"repo delete cmc/scratch --yes"},
6970 Run: runRepoDelete})
7071 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})
7577 register(Command{Path: []string{"repo", "access", "revoke"},
7678 Summary: "revoke access",
7779 Usage: "repo access revoke <owner/name> <user>",
internal/control/runnerrepo.go +1 −1
@@ -23,7 +23,7 @@ func init() {
2323 Usage: "repo runner add <owner/name> < key.pub",
2424 Examples: []string{"repo runner add krz/gitbay < key.pub"},
2525 ReadsStdin: true,
26 MintsCredential: true, Run: runRepoRunnerAdd})
26 MintsCredential: true, NeedsRecentSignIn: true, Run: runRepoRunnerAdd})
2727 register(Command{Path: []string{"repo", "runner", "list"},
2828 Summary: "list the runners attached to a repository",
2929 Usage: "repo runner list <owner/name>",
internal/control/sig.go +5 −4
@@ -18,10 +18,11 @@ import (
1818
1919func init() {
2020 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})
2526 register(Command{Path: []string{"pgp", "list"},
2627 Summary: "list registered OpenPGP keys",
2728 Usage: "pgp list",
internal/control/teams.go +12 −9
@@ -30,25 +30,28 @@ func init() {
3030 Usage: "org team show <org> <team>",
3131 Examples: []string{"org team show krz maintainers"}, ReadOnly: true, Run: runTeamShow})
3232 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})
3637 register(Command{Path: []string{"org", "team", "remove"},
3738 Summary: "remove members from a team",
3839 Usage: "org team remove <org> <team> <user>...",
3940 Examples: []string{"org team remove krz maintainers cmc"}, Run: runTeamRemove})
4041 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})
4446 register(Command{Path: []string{"org", "team", "revoke"},
4547 Summary: "revoke a team's grant",
4648 Usage: "org team revoke <org> <team> <owner/name>",
4749 Examples: []string{"org team revoke krz maintainers krz/gitbay"}, Run: runTeamRevoke})
4850 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})
5255}
5356
5457// orgAdminRef resolves an org and requires the caller to admin it.
internal/control/token.go +2 −2
@@ -22,8 +22,8 @@ func init() {
2222 {"--ttl", "30d|720h", "how long the token is valid; an expiring token cannot mint credentials", "never expires"},
2323 },
2424 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})
2727 register(Command{Path: []string{"token", "list"},
2828 Summary: "list API tokens",
2929 Usage: "token list",
internal/control/web.go +2 −2
@@ -16,8 +16,8 @@ func init() {
1616 register(Command{Path: []string{"web", "login"},
1717 Summary: "mint a one-time browser login URL",
1818 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})
2121 register(Command{Path: []string{"web", "sessions", "list"},
2222 Summary: "list your browser sessions",
2323 Usage: "web sessions list",
internal/control/webhook.go +3 −2
@@ -15,8 +15,9 @@ import (
1515
1616func init() {
1717 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|*]",
2021 Flags: []Flag{
2122 {"--secret", "-", "read the secret that signs deliveries from stdin", ""},
2223 {"--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
128128 aboutEdit = "/" + aboutRepo + "/edit/main/" + profile.AboutPath
129129 }
130130
131 notice := s.takeFlash(w, r)
132 reauth := s.reauthNotice(w, notice, "/settings")
133
131134 s.render(w, "account.html", struct {
132135 basePage
133136 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
148151 ThemeSetting string // system, light or dark: the form's selected option
149152 Tokens []accountToken
150153 TokenShown string // a token minted by this request, shown once
154 Reauth bool // Notice is the stale-session refusal: link to sign in
151155 }{s.baseFor(u), "account", keys, pgp, emails, profile, profileLinksText(profile.Links),
152156 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})
155159}
156160
157161// accountExport hands the browser the same bundle `account export`
internal/httpd/account_test.go +2 −1
@@ -7,6 +7,7 @@ import (
77 "strconv"
88 "strings"
99 "testing"
10 "time"
1011
1112 "gitbay.org/gitbay/internal/config"
1213 "gitbay.org/gitbay/internal/store"
@@ -300,7 +301,7 @@ func newTokenTestServer(t *testing.T) (*Server, *store.Store, store.User) {
300301 if err != nil {
301302 t.Fatal(err)
302303 }
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()}
304305}
305306
306307// 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
6262 }
6363 next = "?" + q.Encode()
6464 }
65 notice := s.takeFlash(w, r)
6566 s.render(w, "adminusers.html", struct {
6667 basePage
6768 Tab string
@@ -69,7 +70,8 @@ func (s *Server) adminUsers(w http.ResponseWriter, r *http.Request, viewer store
6970 Users []adminUserRow
7071 Next string
7172 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)})
7375}
7476
7577// 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
3434 var stdout, stderr bytes.Buffer
3535 ctx := &control.Ctx{
3636 User: u,
37 Source: "web",
37 Source: control.SourceWeb,
3838 Scope: "full",
3939 Store: s.st,
4040 Cfg: s.cfg,
@@ -58,7 +58,7 @@ func (s *Server) runControlStream(u store.User, argv []string, out io.Writer, do
5858 var stderr bytes.Buffer
5959 ctx := &control.Ctx{
6060 User: u,
61 Source: "web",
61 Source: control.SourceWeb,
6262 Scope: "full",
6363 Store: s.st,
6464 Cfg: s.cfg,
@@ -104,7 +104,7 @@ func (s *Server) runControlStdinCode(u store.User, argv []string, stdin string)
104104 var stdout, stderr bytes.Buffer
105105 ctx := &control.Ctx{
106106 User: u,
107 Source: "web",
107 Source: control.SourceWeb,
108108 Scope: "full",
109109 Store: s.st,
110110 Cfg: s.cfg,
@@ -146,7 +146,7 @@ func (s *Server) dispatchIntoStdin(u store.User, argv []string, stdin string, ta
146146 var stdout, stderr bytes.Buffer
147147 ctx := &control.Ctx{
148148 User: u,
149 Source: "web",
149 Source: control.SourceWeb,
150150 Scope: "full",
151151 Store: s.st,
152152 Cfg: s.cfg,
@@ -187,7 +187,7 @@ func (s *Server) dispatchJSON(u store.User, argv []string, stdin string) (code i
187187 var stdout, stderr bytes.Buffer
188188 ctx := &control.Ctx{
189189 User: u,
190 Source: "web",
190 Source: control.SourceWeb,
191191 Scope: "full",
192192 Store: s.st,
193193 Cfg: s.cfg,
internal/httpd/flash.go +23 −3
@@ -4,6 +4,8 @@ import (
44 "net/http"
55 "net/url"
66 "strings"
7
8 "gitbay.org/gitbay/internal/control"
79)
810
911// 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 {
4446 return msg
4547}
4648
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).
53func (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
4761const nextCookie = "gitbay_next"
4862
63// localPath reports whether p is a path on this host. Browsers read a
64// leading `/\` like "//", so it is refused too.
65func localPath(p string) bool {
66 return strings.HasPrefix(p, "/") && !strings.HasPrefix(p, "//") && !strings.HasPrefix(p, "/\\")
67}
68
4969// setNext remembers the local path an anonymous visitor asked for, so
5070// the login that follows can return there. Only a GET path is stored:
5171// a POST must not be replayed.
5272func (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 {
5474 return
5575 }
5676 http.SetCookie(w, &http.Cookie{
@@ -69,7 +89,7 @@ func (s *Server) takeNext(w http.ResponseWriter, r *http.Request) string {
6989 }
7090 http.SetCookie(w, s.clearCookie(nextCookie, http.SameSiteLaxMode))
7191 p, err := url.QueryUnescape(c.Value)
72 if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
92 if err != nil || !localPath(p) {
7393 return ""
7494 }
7595 return p
@@ -83,7 +103,7 @@ func (s *Server) peekNext(r *http.Request) string {
83103 return ""
84104 }
85105 p, err := url.QueryUnescape(c.Value)
86 if err != nil || !strings.HasPrefix(p, "/") || strings.HasPrefix(p, "//") {
106 if err != nil || !localPath(p) {
87107 return ""
88108 }
89109 return p
internal/httpd/flash_test.go added +19
@@ -0,0 +1,19 @@
1package httpd
2
3import "testing"
4
5func 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 @@
1package httpd
2
3import (
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).
19func 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.
57func 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.
73func 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.
87func 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 {
2626 Runners []store.RepoRunner
2727 Notice string
2828 Saved bool
29 Reauth bool // Notice is the stale-session refusal: link to sign in
2930 Submitted map[string]string
3031}
3132
@@ -70,6 +71,7 @@ func (s *Server) settingsFormWith(w http.ResponseWriter, r *http.Request, u stor
7071 Runners: runners,
7172 Notice: notice,
7273 Saved: strings.HasPrefix(notice, "Saved "),
74 Reauth: s.reauthNotice(w, notice, r.URL.Path),
7375 Submitted: subm,
7476 })
7577}
internal/httpd/web.go +4 −1
@@ -517,6 +517,7 @@ type ownerPage struct {
517517 Self bool
518518 Snippets int
519519 Notice string
520 Reauth bool // Notice is the stale-session refusal: link to sign in
520521 Feed string
521522}
522523
@@ -572,6 +573,7 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) {
572573 return
573574 }
574575 }
576 notice := s.takeFlash(w, r)
575577 s.render(w, "owner.html", ownerPage{
576578 basePage: s.baseFor(viewer),
577579 Owner: name,
@@ -592,7 +594,8 @@ func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) {
592594 CanAdmin: canAdmin,
593595 Self: self,
594596 Snippets: d.Snippets,
595 Notice: s.takeFlash(w, r),
597 Notice: notice,
598 Reauth: s.reauthNotice(w, notice, r.URL.Path),
596599 Feed: "/" + name + "/activity.atom",
597600 })
598601}
internal/store/sessions.go +16 −6
@@ -80,15 +80,18 @@ func (s *Store) CreateWebSession(hash string, userID int64, ttl time.Duration) e
8080 return err
8181}
8282
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.
8688func (s *Store) WebSessionUser(hash string) (User, error) {
8789 now := time.Now()
8890 var userID int64
91 var created string
8992 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)
9295 if errors.Is(err, sql.ErrNoRows) {
9396 return User{}, ErrNotFound
9497 }
@@ -98,7 +101,14 @@ func (s *Store) WebSessionUser(hash string) (User, error) {
98101 s.DB.Exec(`UPDATE web_sessions SET last_used_at = ?, expires_at = min(absolute_expires_at, ?)
99102 WHERE token_hash = ? AND last_used_at < ?`,
100103 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
102112}
103113
104114func (s *Store) DeleteWebSession(hash string) error {
internal/store/sessions_test.go +40
@@ -117,3 +117,43 @@ func TestWebSessionRenewsUpToTheCap(t *testing.T) {
117117 t.Fatalf("list: %+v %v", list, err)
118118 }
119119}
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).
123func 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 {
1414 IsAdmin bool
1515 Pending bool // self-registered, email not yet verified
1616 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
1720}
1821
1922type SSHKey struct {
internal/web/templates/account.html +1 −1
@@ -2,7 +2,7 @@
22{{define "title"}}account settings{{end}}
33{{define "content"}}
44<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}}
66{{if .Message}}<p class="notice" role="status">{{.Message}}</p>{{end}}
77
88<div class="withcol narrow">
internal/web/templates/adminusers.html +1 −1
@@ -7,7 +7,7 @@
77{{define "content"}}
88<h1>Accounts</h1>
99<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}}
1111
1212<nav class="filters">
1313 <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 @@
8383
8484{{if eq .Tab "people"}}{{$org := .Owner}}
8585<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}}
8787<div class="tablewrap"><table class="keys">
8888<tr class="cols"><th scope="col">member</th><th scope="col">role</th><th scope="col"><span class="vh">actions</span></th></tr>
8989{{range .Members}}<tr>
internal/web/templates/settings.html +1 −1
@@ -3,7 +3,7 @@
33{{define "content"}}
44{{$base := printf "/%s/%s/settings" .Repo.OwnerName .Repo.Name}}
55<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}}
77<div class="withcol narrow">
88<nav class="sidecol" aria-label="Sections">
99<details class="sidedrop">