feat(web): sign the owner in with a one-time link, session cookie and CSRF gate
SessionService (CreateLoginLink, SignOutEverywhere), ermite web login and logout --all, GET/POST /-/login/<nonce>, POST /-/logout, the __Host-ermite cookie, the Origin and session gate on every browser POST, and private cache headers on pages rendered for the owner.
- commit
- 59b81ca47033d0274534b320e2534bca071f57dc
- author
- brice · · 2026-10-10 17:05 UTC
- committer
- brice · · 2026-10-10 17:06 UTC
- parent
- c1f3cc9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
33 files · +1347 -41
M cmd/ermite/api_helpers_test.go +4 -0
| @@ -31,6 +31,8 @@ type clients struct { | |||
| 31 | 31 | event ermiteconnect.EventServiceClient | |
| 32 | 32 | server ermiteconnect.ServerServiceClient | |
| 33 | 33 | mr ermiteconnect.MergeRequestServiceClient | |
| 34 | + | ||
| 35 | + | session ermiteconnect.SessionServiceClient | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | // bearerTransport adds the Authorization header of a secret, if any. | |
| @@ -77,6 +79,8 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti | |||
| 77 | 79 | event: ermiteconnect.NewEventServiceClient(hc, base, opts...), | |
| 78 | 80 | server: ermiteconnect.NewServerServiceClient(hc, base, opts...), | |
| 79 | 81 | mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...), | |
| 82 | + | ||
| 83 | + | session: ermiteconnect.NewSessionServiceClient(hc, base, opts...), | |
| 80 | 84 | } | |
| 81 | 85 | } | |
| 82 | 86 | ||
| @@ -31,6 +31,8 @@ type clients struct { | |||||
| 31 | event ermiteconnect.EventServiceClient | 31 | event ermiteconnect.EventServiceClient | ||
| 32 | server ermiteconnect.ServerServiceClient | 32 | server ermiteconnect.ServerServiceClient | ||
| 33 | mr ermiteconnect.MergeRequestServiceClient | 33 | mr ermiteconnect.MergeRequestServiceClient | ||
| 34 | + | ||||
| 35 | + | session ermiteconnect.SessionServiceClient | |||
| 34 | } | 36 | } | ||
| 35 | 37 | ||||
| 36 | // bearerTransport adds the Authorization header of a secret, if any. | 38 | // bearerTransport adds the Authorization header of a secret, if any. | ||
| @@ -77,6 +79,8 @@ func newClientsOn(hc connect.HTTPClient, base string, opts ...connect.ClientOpti | |||||
| 77 | event: ermiteconnect.NewEventServiceClient(hc, base, opts...), | 79 | event: ermiteconnect.NewEventServiceClient(hc, base, opts...), | ||
| 78 | server: ermiteconnect.NewServerServiceClient(hc, base, opts...), | 80 | server: ermiteconnect.NewServerServiceClient(hc, base, opts...), | ||
| 79 | mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...), | 81 | mr: ermiteconnect.NewMergeRequestServiceClient(hc, base, opts...), | ||
| 82 | + | ||||
| 83 | + | session: ermiteconnect.NewSessionServiceClient(hc, base, opts...), | |||
| 80 | } | 84 | } | ||
| 81 | } | 85 | } | ||
| 82 | 86 | ||||
M cmd/ermite/api_test.go +1 -1
| @@ -158,7 +158,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||
| 158 | 158 | for _, who := range allPrincipals { | |
| 159 | 159 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | |
| 160 | 160 | require.NoError(t, err, who) | |
| 161 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, resp.Msg.GetFeatures(), who) | |
| 161 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, resp.Msg.GetFeatures(), who) | |
| 162 | 162 | assert.NotEmpty(t, resp.Msg.GetVersion()) | |
| 163 | 163 | } | |
| 164 | 164 | }) | |
| @@ -158,7 +158,7 @@ func TestAC2ReadAccess(t *testing.T) { | |||||
| 158 | for _, who := range allPrincipals { | 158 | for _, who := range allPrincipals { | ||
| 159 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | 159 | resp, err := r.who[who].server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{})) | ||
| 160 | require.NoError(t, err, who) | 160 | require.NoError(t, err, who) | ||
| 161 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, resp.Msg.GetFeatures(), who) | 161 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, resp.Msg.GetFeatures(), who) |
| 162 | assert.NotEmpty(t, resp.Msg.GetVersion()) | 162 | assert.NotEmpty(t, resp.Msg.GetVersion()) | ||
| 163 | } | 163 | } | ||
| 164 | }) | 164 | }) | ||
M cmd/ermite/cli_e2e_test.go +1 -1
| @@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||
| 324 | 324 | ver := decode[versionOut](t, res.stdout) | |
| 325 | 325 | assert.Equal(t, c.url, ver.Server.URL) | |
| 326 | 326 | assert.NotEmpty(t, ver.Server.Version) | |
| 327 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, ver.Server.Features) | |
| 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, ver.Server.Features) | |
| 328 | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | |
| 329 | 329 | stdout, stderr, code = c.bin(t, secret, "version") | |
| 330 | 330 | require.Equal(t, 0, code, stderr) | |
| @@ -324,7 +324,7 @@ func TestAC6CLIEndToEnd(t *testing.T) { | |||||
| 324 | ver := decode[versionOut](t, res.stdout) | 324 | ver := decode[versionOut](t, res.stdout) | ||
| 325 | assert.Equal(t, c.url, ver.Server.URL) | 325 | assert.Equal(t, c.url, ver.Server.URL) | ||
| 326 | assert.NotEmpty(t, ver.Server.Version) | 326 | assert.NotEmpty(t, ver.Server.Version) | ||
| 327 | - | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key"}, ver.Server.Features) | 327 | + | assert.ElementsMatch(t, []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"}, ver.Server.Features) |
| 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | 328 | assert.Equal(t, []string{}, ver.Server.MissingFeatures) | ||
| 329 | stdout, stderr, code = c.bin(t, secret, "version") | 329 | stdout, stderr, code = c.bin(t, secret, "version") | ||
| 330 | require.Equal(t, 0, code, stderr) | 330 | require.Equal(t, 0, code, stderr) | ||
M cmd/ermite/cli_version.go +1 -0
| @@ -104,4 +104,5 @@ var featureUse = map[string]string{ | |||
| 104 | 104 | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | |
| 105 | 105 | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | |
| 106 | 106 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | |
| 107 | + | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |
| 107 | 108 | } | |
| @@ -104,4 +104,5 @@ var featureUse = map[string]string{ | |||||
| 104 | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | 104 | client.FeatureEventsResume: "`ermite watch` uses to resume after a reconnection: events during an outage may be missed", | ||
| 105 | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | 105 | client.FeatureMergeRequests: "`ermite mr` needs: the commands will fail on this server", | ||
| 106 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | 106 | client.FeatureSigningKey: "`ermite signing-key show` needs: the command will fail on this server", | ||
| 107 | + | client.FeatureSession: "`ermite web login` needs: the command will fail on this server", | |||
| 107 | } | 108 | } | ||
M cmd/ermite/cli_version_test.go +5 -5
| @@ -14,7 +14,7 @@ import ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | // allFeatures are the features this client uses. | |
| 17 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key"} | |
| 17 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"} | |
| 18 | 18 | ||
| 19 | 19 | func versionFake(features ...string) *fakeAPI { | |
| 20 | 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | |
| @@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) { | |||
| 35 | 35 | f := versionFake(allFeatures...) | |
| 36 | 36 | url := f.serve(t) | |
| 37 | 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 38 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key\n", res.stdout) | |
| 38 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key, session\n", res.stdout) | |
| 39 | 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | |
| 40 | 40 | ||
| 41 | 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 42 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key"],"missing_features":[]}}`, res.stdout) | |
| 42 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key","session"],"missing_features":[]}}`, res.stdout) | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |
| 46 | - | f := versionFake("repo.update", "merge_requests", "signing_key") | |
| 46 | + | f := versionFake("repo.update", "merge_requests", "signing_key", "session") | |
| 47 | 47 | url := f.serve(t) | |
| 48 | 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | |
| 49 | 49 | assert.Contains(t, res.stdout, "v0.4.0") | |
| @@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||
| 51 | 51 | assert.NotContains(t, res.stderr, "repo.update") | |
| 52 | 52 | ||
| 53 | 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | |
| 54 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 54 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout) | |
| 55 | 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | |
| 56 | 56 | ||
| 57 | 57 | f = versionFake() | |
| @@ -14,7 +14,7 @@ import ( | |||||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | // allFeatures are the features this client uses. | 16 | // allFeatures are the features this client uses. | ||
| 17 | - | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key"} | 17 | + | var allFeatures = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"} |
| 18 | 18 | ||||
| 19 | func versionFake(features ...string) *fakeAPI { | 19 | func versionFake(features ...string) *fakeAPI { | ||
| 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | 20 | return &fakeAPI{getVersion: func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error) { | ||
| @@ -35,15 +35,15 @@ func TestVersionWithServer(t *testing.T) { | |||||
| 35 | f := versionFake(allFeatures...) | 35 | f := versionFake(allFeatures...) | ||
| 36 | url := f.serve(t) | 36 | url := f.serve(t) | ||
| 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | 37 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 38 | - | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key\n", res.stdout) | 38 | + | assert.Equal(t, "client ermite v0.0.1\nserver v0.4.0 at "+url+"\nfeatures repo.update, events.resume, merge_requests, signing_key, session\n", res.stdout) |
| 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | 39 | assert.Empty(t, res.stderr, "no warning when every feature is there") | ||
| 40 | 40 | ||||
| 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 41 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 42 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key"],"missing_features":[]}}`, res.stdout) | 42 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","events.resume","merge_requests","signing_key","session"],"missing_features":[]}}`, res.stdout) |
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | 45 | func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | ||
| 46 | - | f := versionFake("repo.update", "merge_requests", "signing_key") | 46 | + | f := versionFake("repo.update", "merge_requests", "signing_key", "session") |
| 47 | url := f.serve(t) | 47 | url := f.serve(t) | ||
| 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | 48 | res := runCLI(t, envFor(url), "", "version").ok(t) | ||
| 49 | assert.Contains(t, res.stdout, "v0.4.0") | 49 | assert.Contains(t, res.stdout, "v0.4.0") | ||
| @@ -51,7 +51,7 @@ func TestVersionWarnsAboutMissingFeatures(t *testing.T) { | |||||
| 51 | assert.NotContains(t, res.stderr, "repo.update") | 51 | assert.NotContains(t, res.stderr, "repo.update") | ||
| 52 | 52 | ||||
| 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | 53 | res = runCLI(t, envFor(url), "", "version", "--json").ok(t) | ||
| 54 | - | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key"],"missing_features":["events.resume"]}}`, res.stdout) | 54 | + | assert.JSONEq(t, `{"client":{"version":"v0.0.1"},"server":{"url":"`+url+`","version":"v0.4.0","features":["repo.update","merge_requests","signing_key","session"],"missing_features":["events.resume"]}}`, res.stdout) |
| 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | 55 | assert.Contains(t, res.stderr, "events.resume", "the warning also goes to stderr with --json") | ||
| 56 | 56 | ||||
| 57 | f = versionFake() | 57 | f = versionFake() | ||
A cmd/ermite/cli_web.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | ||
| 9 | + | "connectrpc.com/connect" | |
| 10 | + | "github.com/spf13/cobra" | |
| 11 | + | ||
| 12 | + | "bnema.dev/ermite-api/gen/ermite" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // jsonLoginLink is the stable JSON shape of `ermite web login --json`. | |
| 16 | + | type jsonLoginLink struct { | |
| 17 | + | URL string `json:"url"` | |
| 18 | + | ExpiresAt *string `json:"expires_at"` | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func newWebCmd(env *cliEnv) *cobra.Command { | |
| 22 | + | web := &cobra.Command{ | |
| 23 | + | Use: "web", | |
| 24 | + | Short: "Sign the owner in to the web interface of a remote server", | |
| 25 | + | Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" + | |
| 26 | + | "token that holds every scope and is not limited to repositories.", | |
| 27 | + | } | |
| 28 | + | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env)) | |
| 29 | + | return web | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func newWebLoginCmd(env *cliEnv) *cobra.Command { | |
| 33 | + | cmd := &cobra.Command{ | |
| 34 | + | Use: "login", | |
| 35 | + | Short: "Print a one-time link that signs your browser in", | |
| 36 | + | Long: "Ask the server for a login link and print it on standard output. Open it in your browser and confirm:\n" + | |
| 37 | + | "the browser is then signed in as the owner for 12 hours (private repositories, merge requests).\n\n" + | |
| 38 | + | "The link works once, for about a minute, and only until the server restarts. It is a secret: it is\n" + | |
| 39 | + | "printed and nowhere else (not stored, not logged); do not paste it into a chat or a ticket. Opening\n" + | |
| 40 | + | "it only shows a confirmation page; confirming it signs the browser in.\n\n" + | |
| 41 | + | "JSON fields (--json): url, expires_at (RFC 3339 UTC).", | |
| 42 | + | Args: cobra.NoArgs, | |
| 43 | + | } | |
| 44 | + | asJSON := addJSONFlag(cmd) | |
| 45 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 46 | + | c, err := env.client(cmd, true) | |
| 47 | + | if err != nil { | |
| 48 | + | return err | |
| 49 | + | } | |
| 50 | + | res, err := c.Sessions().CreateLoginLink(env.ctx(cmd), connect.NewRequest(&ermite.CreateLoginLinkRequest{})) | |
| 51 | + | if err != nil { | |
| 52 | + | return c.Describe(err) | |
| 53 | + | } | |
| 54 | + | if *asJSON { | |
| 55 | + | return writeJSON(cmd.OutOrStdout(), jsonLoginLink{URL: res.Msg.GetUrl(), ExpiresAt: jsonTime(res.Msg.GetExpiresAt())}) | |
| 56 | + | } | |
| 57 | + | noticef(cmd, "open this link in your browser and confirm; it works once, until %s UTC", shortTime(res.Msg.GetExpiresAt(), "shortly")) | |
| 58 | + | _, err = fmt.Fprintln(cmd.OutOrStdout(), res.Msg.GetUrl()) | |
| 59 | + | return err | |
| 60 | + | } | |
| 61 | + | return cmd | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func newWebLogoutCmd(env *cliEnv) *cobra.Command { | |
| 65 | + | var all bool | |
| 66 | + | cmd := &cobra.Command{ | |
| 67 | + | Use: "logout --all", | |
| 68 | + | Short: "Sign every browser out", | |
| 69 | + | Long: "End every web session at once, on every device. Pending login links and API tokens are not affected\n" + | |
| 70 | + | "(revoke tokens with `ermite token revoke`). To sign out one browser, use the sign out button on the page.", | |
| 71 | + | Args: cobra.NoArgs, | |
| 72 | + | } | |
| 73 | + | cmd.Flags().BoolVar(&all, "all", false, "sign out every browser (required)") | |
| 74 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |
| 75 | + | if !all { | |
| 76 | + | return errors.New("--all is required: this signs every browser out (one browser signs out from its page)") | |
| 77 | + | } | |
| 78 | + | c, err := env.client(cmd, true) | |
| 79 | + | if err != nil { | |
| 80 | + | return err | |
| 81 | + | } | |
| 82 | + | if _, err := c.Sessions().SignOutEverywhere(env.ctx(cmd), connect.NewRequest(&ermite.SignOutEverywhereRequest{})); err != nil { | |
| 83 | + | return c.Describe(err) | |
| 84 | + | } | |
| 85 | + | noticef(cmd, "every web session is signed out") | |
| 86 | + | return nil | |
| 87 | + | } | |
| 88 | + | return cmd | |
| 89 | + | } | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | ||||
| 9 | + | "connectrpc.com/connect" | |||
| 10 | + | "github.com/spf13/cobra" | |||
| 11 | + | ||||
| 12 | + | "bnema.dev/ermite-api/gen/ermite" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // jsonLoginLink is the stable JSON shape of `ermite web login --json`. | |||
| 16 | + | type jsonLoginLink struct { | |||
| 17 | + | URL string `json:"url"` | |||
| 18 | + | ExpiresAt *string `json:"expires_at"` | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func newWebCmd(env *cliEnv) *cobra.Command { | |||
| 22 | + | web := &cobra.Command{ | |||
| 23 | + | Use: "web", | |||
| 24 | + | Short: "Sign the owner in to the web interface of a remote server", | |||
| 25 | + | Long: "Manage the owner's browser sessions on a remote server. They need the server's admin token, or a\n" + | |||
| 26 | + | "token that holds every scope and is not limited to repositories.", | |||
| 27 | + | } | |||
| 28 | + | web.AddCommand(newWebLoginCmd(env), newWebLogoutCmd(env)) | |||
| 29 | + | return web | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func newWebLoginCmd(env *cliEnv) *cobra.Command { | |||
| 33 | + | cmd := &cobra.Command{ | |||
| 34 | + | Use: "login", | |||
| 35 | + | Short: "Print a one-time link that signs your browser in", | |||
| 36 | + | Long: "Ask the server for a login link and print it on standard output. Open it in your browser and confirm:\n" + | |||
| 37 | + | "the browser is then signed in as the owner for 12 hours (private repositories, merge requests).\n\n" + | |||
| 38 | + | "The link works once, for about a minute, and only until the server restarts. It is a secret: it is\n" + | |||
| 39 | + | "printed and nowhere else (not stored, not logged); do not paste it into a chat or a ticket. Opening\n" + | |||
| 40 | + | "it only shows a confirmation page; confirming it signs the browser in.\n\n" + | |||
| 41 | + | "JSON fields (--json): url, expires_at (RFC 3339 UTC).", | |||
| 42 | + | Args: cobra.NoArgs, | |||
| 43 | + | } | |||
| 44 | + | asJSON := addJSONFlag(cmd) | |||
| 45 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |||
| 46 | + | c, err := env.client(cmd, true) | |||
| 47 | + | if err != nil { | |||
| 48 | + | return err | |||
| 49 | + | } | |||
| 50 | + | res, err := c.Sessions().CreateLoginLink(env.ctx(cmd), connect.NewRequest(&ermite.CreateLoginLinkRequest{})) | |||
| 51 | + | if err != nil { | |||
| 52 | + | return c.Describe(err) | |||
| 53 | + | } | |||
| 54 | + | if *asJSON { | |||
| 55 | + | return writeJSON(cmd.OutOrStdout(), jsonLoginLink{URL: res.Msg.GetUrl(), ExpiresAt: jsonTime(res.Msg.GetExpiresAt())}) | |||
| 56 | + | } | |||
| 57 | + | noticef(cmd, "open this link in your browser and confirm; it works once, until %s UTC", shortTime(res.Msg.GetExpiresAt(), "shortly")) | |||
| 58 | + | _, err = fmt.Fprintln(cmd.OutOrStdout(), res.Msg.GetUrl()) | |||
| 59 | + | return err | |||
| 60 | + | } | |||
| 61 | + | return cmd | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func newWebLogoutCmd(env *cliEnv) *cobra.Command { | |||
| 65 | + | var all bool | |||
| 66 | + | cmd := &cobra.Command{ | |||
| 67 | + | Use: "logout --all", | |||
| 68 | + | Short: "Sign every browser out", | |||
| 69 | + | Long: "End every web session at once, on every device. Pending login links and API tokens are not affected\n" + | |||
| 70 | + | "(revoke tokens with `ermite token revoke`). To sign out one browser, use the sign out button on the page.", | |||
| 71 | + | Args: cobra.NoArgs, | |||
| 72 | + | } | |||
| 73 | + | cmd.Flags().BoolVar(&all, "all", false, "sign out every browser (required)") | |||
| 74 | + | cmd.RunE = func(cmd *cobra.Command, _ []string) error { | |||
| 75 | + | if !all { | |||
| 76 | + | return errors.New("--all is required: this signs every browser out (one browser signs out from its page)") | |||
| 77 | + | } | |||
| 78 | + | c, err := env.client(cmd, true) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return err | |||
| 81 | + | } | |||
| 82 | + | if _, err := c.Sessions().SignOutEverywhere(env.ctx(cmd), connect.NewRequest(&ermite.SignOutEverywhereRequest{})); err != nil { | |||
| 83 | + | return c.Describe(err) | |||
| 84 | + | } | |||
| 85 | + | noticef(cmd, "every web session is signed out") | |||
| 86 | + | return nil | |||
| 87 | + | } | |||
| 88 | + | return cmd | |||
| 89 | + | } | |||
M cmd/ermite/e2e_test.go +6 -2
| @@ -110,13 +110,17 @@ func TestRoutes(t *testing.T) { | |||
| 110 | 110 | } { | |
| 111 | 111 | assert.Equal(t, 404, h.status("GET", path, nil), path) | |
| 112 | 112 | } | |
| 113 | - | assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil)) | |
| 113 | + | // A POST no Git route owns falls to the web, whose gate refuses it (a | |
| 114 | + | // browser POST needs an Origin and a session, ADR 0018). | |
| 115 | + | assert.Equal(t, 403, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil)) | |
| 114 | 116 | // The web owns GET on every other path: a GET of a POST-only Git route | |
| 115 | 117 | // is its 404 page, not a 405. | |
| 116 | 118 | assert.Equal(t, 404, h.status("GET", "/pub.git/git-upload-pack", nil)) | |
| 117 | 119 | assert.Equal(t, 200, h.status("GET", "/", nil), "the home page") | |
| 118 | 120 | assert.Equal(t, 200, h.status("GET", "/pub", nil), "the summary page") | |
| 119 | - | assert.Equal(t, 405, h.status("POST", "/", nil)) | |
| 121 | + | // A browser POST needs an Origin and a session (ADR 0018): refused | |
| 122 | + | // before the router sees it. | |
| 123 | + | assert.Equal(t, 403, h.status("POST", "/", nil)) | |
| 120 | 124 | assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact") | |
| 121 | 125 | assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil)) | |
| 122 | 126 | // Anonymous receive-pack is a challenge even for a public repository. | |
| @@ -110,13 +110,17 @@ func TestRoutes(t *testing.T) { | |||||
| 110 | } { | 110 | } { | ||
| 111 | assert.Equal(t, 404, h.status("GET", path, nil), path) | 111 | assert.Equal(t, 404, h.status("GET", path, nil), path) | ||
| 112 | } | 112 | } | ||
| 113 | - | assert.Equal(t, 405, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil)) | 113 | + | // A POST no Git route owns falls to the web, whose gate refuses it (a |
| 114 | + | // browser POST needs an Origin and a session, ADR 0018). | |||
| 115 | + | assert.Equal(t, 403, h.status("POST", "/pub.git/info/refs?service=git-upload-pack", nil)) | |||
| 114 | // The web owns GET on every other path: a GET of a POST-only Git route | 116 | // The web owns GET on every other path: a GET of a POST-only Git route | ||
| 115 | // is its 404 page, not a 405. | 117 | // is its 404 page, not a 405. | ||
| 116 | assert.Equal(t, 404, h.status("GET", "/pub.git/git-upload-pack", nil)) | 118 | assert.Equal(t, 404, h.status("GET", "/pub.git/git-upload-pack", nil)) | ||
| 117 | assert.Equal(t, 200, h.status("GET", "/", nil), "the home page") | 119 | assert.Equal(t, 200, h.status("GET", "/", nil), "the home page") | ||
| 118 | assert.Equal(t, 200, h.status("GET", "/pub", nil), "the summary page") | 120 | assert.Equal(t, 200, h.status("GET", "/pub", nil), "the summary page") | ||
| 119 | - | assert.Equal(t, 405, h.status("POST", "/", nil)) | 121 | + | // A browser POST needs an Origin and a session (ADR 0018): refused |
| 122 | + | // before the router sees it. | |||
| 123 | + | assert.Equal(t, 403, h.status("POST", "/", nil)) | |||
| 120 | assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact") | 124 | assert.Equal(t, 200, h.status("GET", "/-/health", nil), "internal routes are intact") | ||
| 121 | assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil)) | 125 | assert.Equal(t, 200, h.status("GET", "/pub.git/info/refs?service=git-upload-pack", nil)) | ||
| 122 | // Anonymous receive-pack is a challenge even for a public repository. | 126 | // Anonymous receive-pack is a challenge even for a public repository. | ||
M cmd/ermite/forge.go +10 -2
| @@ -69,6 +69,7 @@ type forge struct { | |||
| 69 | 69 | // ServerService.GetSigningKey. | |
| 70 | 70 | MergeRequests *app.MergeRequests | |
| 71 | 71 | SigningKey *app.SigningKey | |
| 72 | + | Sessions *app.Sessions | |
| 72 | 73 | EventLog *app.Events | |
| 73 | 74 | API *rpc.API | |
| 74 | 75 | Bus ports.EventBus | |
| @@ -187,7 +188,13 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 187 | 188 | }) | |
| 188 | 189 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | |
| 189 | 190 | ||
| 190 | - | // One budget of failed authentications per client, for Git and the API. | |
| 191 | + | f.Sessions, err = app.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL) | |
| 192 | + | if err != nil { | |
| 193 | + | return nil, err | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // One budget of failed authentications per client, for Git, the API and | |
| 197 | + | // the web login. | |
| 191 | 198 | limiter := clientlimit.NewLimiter(nil) | |
| 192 | 199 | f.API = rpc.New(rpc.Config{ | |
| 193 | 200 | Auth: f.Auth, | |
| @@ -197,6 +204,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 197 | 204 | Events: f.EventLog, | |
| 198 | 205 | MergeRequests: f.MergeRequests, | |
| 199 | 206 | SigningKey: f.SigningKey, | |
| 207 | + | Sessions: f.Sessions, | |
| 200 | 208 | Version: serverVersion(), | |
| 201 | 209 | TrustedProxies: cfg.TrustedProxies, | |
| 202 | 210 | Limiter: limiter, | |
| @@ -216,7 +224,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||
| 216 | 224 | for _, p := range githttp.Patterns { | |
| 217 | 225 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | |
| 218 | 226 | } | |
| 219 | - | site, err := newWeb(cfg, f.API) | |
| 227 | + | site, err := newWeb(cfg, f.API, f.Sessions, limiter) | |
| 220 | 228 | if err != nil { | |
| 221 | 229 | return nil, fmt.Errorf("web: %w", err) | |
| 222 | 230 | } | |
| @@ -69,6 +69,7 @@ type forge struct { | |||||
| 69 | // ServerService.GetSigningKey. | 69 | // ServerService.GetSigningKey. | ||
| 70 | MergeRequests *app.MergeRequests | 70 | MergeRequests *app.MergeRequests | ||
| 71 | SigningKey *app.SigningKey | 71 | SigningKey *app.SigningKey | ||
| 72 | + | Sessions *app.Sessions | |||
| 72 | EventLog *app.Events | 73 | EventLog *app.Events | ||
| 73 | API *rpc.API | 74 | API *rpc.API | ||
| 74 | Bus ports.EventBus | 75 | Bus ports.EventBus | ||
| @@ -187,7 +188,13 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 187 | }) | 188 | }) | ||
| 188 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | 189 | log.Info().Str("fingerprint", signer.PublicKey().Fingerprint).Msg("commit signing key ready") | ||
| 189 | 190 | ||||
| 190 | - | // One budget of failed authentications per client, for Git and the API. | 191 | + | f.Sessions, err = app.NewSessions(ctx, store.Sessions(), clock, cfg.PublicURL) |
| 192 | + | if err != nil { | |||
| 193 | + | return nil, err | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | // One budget of failed authentications per client, for Git, the API and | |||
| 197 | + | // the web login. | |||
| 191 | limiter := clientlimit.NewLimiter(nil) | 198 | limiter := clientlimit.NewLimiter(nil) | ||
| 192 | f.API = rpc.New(rpc.Config{ | 199 | f.API = rpc.New(rpc.Config{ | ||
| 193 | Auth: f.Auth, | 200 | Auth: f.Auth, | ||
| @@ -197,6 +204,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 197 | Events: f.EventLog, | 204 | Events: f.EventLog, | ||
| 198 | MergeRequests: f.MergeRequests, | 205 | MergeRequests: f.MergeRequests, | ||
| 199 | SigningKey: f.SigningKey, | 206 | SigningKey: f.SigningKey, | ||
| 207 | + | Sessions: f.Sessions, | |||
| 200 | Version: serverVersion(), | 208 | Version: serverVersion(), | ||
| 201 | TrustedProxies: cfg.TrustedProxies, | 209 | TrustedProxies: cfg.TrustedProxies, | ||
| 202 | Limiter: limiter, | 210 | Limiter: limiter, | ||
| @@ -216,7 +224,7 @@ func newForge(ctx context.Context, cfg config.Config, opts options) (_ *forge, e | |||||
| 216 | for _, p := range githttp.Patterns { | 224 | for _, p := range githttp.Patterns { | ||
| 217 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | 225 | routes = append(routes, httpserver.Route{Pattern: p, Handler: gh}) | ||
| 218 | } | 226 | } | ||
| 219 | - | site, err := newWeb(cfg, f.API) | 227 | + | site, err := newWeb(cfg, f.API, f.Sessions, limiter) |
| 220 | if err != nil { | 228 | if err != nil { | ||
| 221 | return nil, fmt.Errorf("web: %w", err) | 229 | return nil, fmt.Errorf("web: %w", err) | ||
| 222 | } | 230 | } | ||
M cmd/ermite/root.go +1 -1
| @@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||
| 26 | 26 | root.AddCommand( | |
| 27 | 27 | newServeCmd(), newHookCmd(), | |
| 28 | 28 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | |
| 29 | - | newMRCmd(env), newSigningKeyCmd(env), | |
| 29 | + | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), | |
| 30 | 30 | ) | |
| 31 | 31 | return root | |
| 32 | 32 | } | |
| @@ -26,7 +26,7 @@ func newRootCmdWith(env *cliEnv) *cobra.Command { | |||||
| 26 | root.AddCommand( | 26 | root.AddCommand( | ||
| 27 | newServeCmd(), newHookCmd(), | 27 | newServeCmd(), newHookCmd(), | ||
| 28 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | 28 | newVersionCmd(env), newRepoCmd(env), newTokenCmd(env), newWatchCmd(env), | ||
| 29 | - | newMRCmd(env), newSigningKeyCmd(env), | 29 | + | newMRCmd(env), newSigningKeyCmd(env), newWebCmd(env), |
| 30 | ) | 30 | ) | ||
| 31 | return root | 31 | return root | ||
| 32 | } | 32 | } | ||
M cmd/ermite/web.go +38 -2
| @@ -3,13 +3,17 @@ | |||
| 3 | 3 | package main | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 6 | 8 | "net/http" | |
| 7 | 9 | "net/netip" | |
| 10 | + | "time" | |
| 8 | 11 | ||
| 9 | 12 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 10 | 13 | "bnema.dev/ermite/internal/adapters/httpserver" | |
| 11 | 14 | "bnema.dev/ermite/internal/adapters/rpc" | |
| 12 | 15 | "bnema.dev/ermite/internal/adapters/web" | |
| 16 | + | "bnema.dev/ermite/internal/app" | |
| 13 | 17 | "bnema.dev/ermite/internal/clientlimit" | |
| 14 | 18 | "bnema.dev/ermite/internal/config" | |
| 15 | 19 | "bnema.dev/ermite/internal/domain" | |
| @@ -17,7 +21,10 @@ import ( | |||
| 17 | 21 | ||
| 18 | 22 | // newWeb builds the web pages on the API of this process: the generated | |
| 19 | 23 | // Connect clients are bound to the handlers of api, with no socket (ADR 0020). | |
| 20 | - | func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) { | |
| 24 | + | // | |
| 25 | + | // The owner's session cookie turns a request into the owner's (web.Server | |
| 26 | + | // checks it with sessions); every other request is anonymous. | |
| 27 | + | func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) { | |
| 21 | 28 | hc := api.InProcessClient() | |
| 22 | 29 | srv, err := web.New(web.Config{ | |
| 23 | 30 | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | |
| @@ -26,6 +33,10 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) { | |||
| 26 | 33 | PublicURL: cfg.PublicURL, | |
| 27 | 34 | SourceURL: cfg.SourceURL, | |
| 28 | 35 | Version: serverVersion(), | |
| 36 | + | ||
| 37 | + | Sessions: webSessions{sessions}, | |
| 38 | + | Limiter: limiter, | |
| 39 | + | TrustedProxies: cfg.TrustedProxies, | |
| 29 | 40 | }) | |
| 30 | 41 | if err != nil { | |
| 31 | 42 | return nil, err | |
| @@ -33,10 +44,35 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) { | |||
| 33 | 44 | return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil | |
| 34 | 45 | } | |
| 35 | 46 | ||
| 47 | + | // webSessions adapts the session use cases to web.SessionAuth: a valid token | |
| 48 | + | // gives the context of the owner, whose principal the API calls of the page | |
| 49 | + | // carry (rpc.API.InProcessClient). | |
| 50 | + | type webSessions struct{ *app.Sessions } | |
| 51 | + | ||
| 52 | + | func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Duration, error) { | |
| 53 | + | token, _, err := s.Sessions.Login(ctx, nonce) | |
| 54 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 55 | + | return "", 0, web.ErrLinkRejected | |
| 56 | + | } | |
| 57 | + | return token, app.SessionTTL, err | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func (s webSessions) Verify(ctx context.Context, token string) (context.Context, bool, error) { | |
| 61 | + | p, err := s.Sessions.Verify(ctx, token) | |
| 62 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |
| 63 | + | return ctx, false, nil | |
| 64 | + | } | |
| 65 | + | if err != nil { | |
| 66 | + | return ctx, false, err | |
| 67 | + | } | |
| 68 | + | return domain.ContextWithPrincipal(ctx, p), true, nil | |
| 69 | + | } | |
| 70 | + | ||
| 36 | 71 | // asPrincipal runs next with p as the caller of every request, attributed to | |
| 37 | 72 | // the browser's address: the API calls the web makes carry both | |
| 38 | 73 | // (rpc.API.InProcessClient), so the per-client limits of the API count each | |
| 39 | - | // visitor. Browsers are anonymous until the web login of P5b brings a session. | |
| 74 | + | // visitor. Browsers are anonymous; web.Server replaces the principal with the | |
| 75 | + | // owner's when the request carries a valid session. | |
| 40 | 76 | func asPrincipal(p domain.Principal, trusted []netip.Prefix, next http.Handler) http.Handler { | |
| 41 | 77 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 42 | 78 | ctx := domain.ContextWithPrincipal(r.Context(), p) | |
| @@ -3,13 +3,17 @@ | |||||
| 3 | package main | 3 | package main | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 6 | "net/http" | 8 | "net/http" | ||
| 7 | "net/netip" | 9 | "net/netip" | ||
| 10 | + | "time" | |||
| 8 | 11 | ||||
| 9 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | 12 | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | ||
| 10 | "bnema.dev/ermite/internal/adapters/httpserver" | 13 | "bnema.dev/ermite/internal/adapters/httpserver" | ||
| 11 | "bnema.dev/ermite/internal/adapters/rpc" | 14 | "bnema.dev/ermite/internal/adapters/rpc" | ||
| 12 | "bnema.dev/ermite/internal/adapters/web" | 15 | "bnema.dev/ermite/internal/adapters/web" | ||
| 16 | + | "bnema.dev/ermite/internal/app" | |||
| 13 | "bnema.dev/ermite/internal/clientlimit" | 17 | "bnema.dev/ermite/internal/clientlimit" | ||
| 14 | "bnema.dev/ermite/internal/config" | 18 | "bnema.dev/ermite/internal/config" | ||
| 15 | "bnema.dev/ermite/internal/domain" | 19 | "bnema.dev/ermite/internal/domain" | ||
| @@ -17,7 +21,10 @@ import ( | |||||
| 17 | 21 | ||||
| 18 | // newWeb builds the web pages on the API of this process: the generated | 22 | // newWeb builds the web pages on the API of this process: the generated | ||
| 19 | // Connect clients are bound to the handlers of api, with no socket (ADR 0020). | 23 | // Connect clients are bound to the handlers of api, with no socket (ADR 0020). | ||
| 20 | - | func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) { | 24 | + | // |
| 25 | + | // The owner's session cookie turns a request into the owner's (web.Server | |||
| 26 | + | // checks it with sessions); every other request is anonymous. | |||
| 27 | + | func newWeb(cfg config.Config, api *rpc.API, sessions *app.Sessions, limiter *clientlimit.Limiter) (http.Handler, error) { | |||
| 21 | hc := api.InProcessClient() | 28 | hc := api.InProcessClient() | ||
| 22 | srv, err := web.New(web.Config{ | 29 | srv, err := web.New(web.Config{ | ||
| 23 | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | 30 | Repos: ermiteconnect.NewRepoServiceClient(hc, rpc.InProcessURL), | ||
| @@ -26,6 +33,10 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) { | |||||
| 26 | PublicURL: cfg.PublicURL, | 33 | PublicURL: cfg.PublicURL, | ||
| 27 | SourceURL: cfg.SourceURL, | 34 | SourceURL: cfg.SourceURL, | ||
| 28 | Version: serverVersion(), | 35 | Version: serverVersion(), | ||
| 36 | + | ||||
| 37 | + | Sessions: webSessions{sessions}, | |||
| 38 | + | Limiter: limiter, | |||
| 39 | + | TrustedProxies: cfg.TrustedProxies, | |||
| 29 | }) | 40 | }) | ||
| 30 | if err != nil { | 41 | if err != nil { | ||
| 31 | return nil, err | 42 | return nil, err | ||
| @@ -33,10 +44,35 @@ func newWeb(cfg config.Config, api *rpc.API) (http.Handler, error) { | |||||
| 33 | return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil | 44 | return asPrincipal(domain.Anonymous(), cfg.TrustedProxies, srv), nil | ||
| 34 | } | 45 | } | ||
| 35 | 46 | ||||
| 47 | + | // webSessions adapts the session use cases to web.SessionAuth: a valid token | |||
| 48 | + | // gives the context of the owner, whose principal the API calls of the page | |||
| 49 | + | // carry (rpc.API.InProcessClient). | |||
| 50 | + | type webSessions struct{ *app.Sessions } | |||
| 51 | + | ||||
| 52 | + | func (s webSessions) Login(ctx context.Context, nonce string) (string, time.Duration, error) { | |||
| 53 | + | token, _, err := s.Sessions.Login(ctx, nonce) | |||
| 54 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |||
| 55 | + | return "", 0, web.ErrLinkRejected | |||
| 56 | + | } | |||
| 57 | + | return token, app.SessionTTL, err | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func (s webSessions) Verify(ctx context.Context, token string) (context.Context, bool, error) { | |||
| 61 | + | p, err := s.Sessions.Verify(ctx, token) | |||
| 62 | + | if errors.Is(err, domain.ErrUnauthenticated) { | |||
| 63 | + | return ctx, false, nil | |||
| 64 | + | } | |||
| 65 | + | if err != nil { | |||
| 66 | + | return ctx, false, err | |||
| 67 | + | } | |||
| 68 | + | return domain.ContextWithPrincipal(ctx, p), true, nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 36 | // asPrincipal runs next with p as the caller of every request, attributed to | 71 | // asPrincipal runs next with p as the caller of every request, attributed to | ||
| 37 | // the browser's address: the API calls the web makes carry both | 72 | // the browser's address: the API calls the web makes carry both | ||
| 38 | // (rpc.API.InProcessClient), so the per-client limits of the API count each | 73 | // (rpc.API.InProcessClient), so the per-client limits of the API count each | ||
| 39 | - | // visitor. Browsers are anonymous until the web login of P5b brings a session. | 74 | + | // visitor. Browsers are anonymous; web.Server replaces the principal with the |
| 75 | + | // owner's when the request carries a valid session. | |||
| 40 | func asPrincipal(p domain.Principal, trusted []netip.Prefix, next http.Handler) http.Handler { | 76 | func asPrincipal(p domain.Principal, trusted []netip.Prefix, next http.Handler) http.Handler { | ||
| 41 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 77 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 42 | ctx := domain.ContextWithPrincipal(r.Context(), p) | 78 | ctx := domain.ContextWithPrincipal(r.Context(), p) | ||
M cmd/ermite/web_events_test.go +2 -2
| @@ -333,8 +333,8 @@ func TestWebEventsTopics(t *testing.T) { | |||
| 333 | 333 | } | |
| 334 | 334 | assert.Equal(t, want, h.status("GET", path, nil), path) | |
| 335 | 335 | } | |
| 336 | - | // POST is not allowed. | |
| 337 | - | assert.Equal(t, http.StatusMethodNotAllowed, h.status("POST", "/-/events?topic=home", nil)) | |
| 336 | + | // POST is not allowed: a browser POST without Origin and session is a 403. | |
| 337 | + | assert.Equal(t, http.StatusForbidden, h.status("POST", "/-/events?topic=home", nil)) | |
| 338 | 338 | } | |
| 339 | 339 | ||
| 340 | 340 | // The pages connect to the stream of their topic. | |
| @@ -333,8 +333,8 @@ func TestWebEventsTopics(t *testing.T) { | |||||
| 333 | } | 333 | } | ||
| 334 | assert.Equal(t, want, h.status("GET", path, nil), path) | 334 | assert.Equal(t, want, h.status("GET", path, nil), path) | ||
| 335 | } | 335 | } | ||
| 336 | - | // POST is not allowed. | 336 | + | // POST is not allowed: a browser POST without Origin and session is a 403. |
| 337 | - | assert.Equal(t, http.StatusMethodNotAllowed, h.status("POST", "/-/events?topic=home", nil)) | 337 | + | assert.Equal(t, http.StatusForbidden, h.status("POST", "/-/events?topic=home", nil)) |
| 338 | } | 338 | } | ||
| 339 | 339 | ||||
| 340 | // The pages connect to the stream of their topic. | 340 | // The pages connect to the stream of their topic. | ||
A cmd/ermite/web_session_test.go +439 -0
| @@ -0,0 +1,439 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "net/url" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "connectrpc.com/connect" | |
| 15 | + | "github.com/golang-jwt/jwt/v5" | |
| 16 | + | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/require" | |
| 18 | + | ||
| 19 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 20 | + | "bnema.dev/ermite/internal/domain" | |
| 21 | + | ) | |
| 22 | + | ||
| 23 | + | const ( | |
| 24 | + | publicOrigin = "http://example.test" // ERMITE_PUBLIC_URL of the harness | |
| 25 | + | sessionCookie = "__Host-ermite" | |
| 26 | + | ) | |
| 27 | + | ||
| 28 | + | // page is the answer of a request to the web. | |
| 29 | + | type page struct { | |
| 30 | + | status int | |
| 31 | + | hdr http.Header | |
| 32 | + | body string | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (p page) cookie(name string) *http.Cookie { | |
| 36 | + | for _, c := range (&http.Response{Header: p.hdr}).Cookies() { | |
| 37 | + | if c.Name == name { | |
| 38 | + | return c | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | return nil | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // browser is a bare HTTP client: it follows no redirect and keeps no cookie, | |
| 45 | + | // so that the tests say exactly what the browser sends. | |
| 46 | + | type browser struct { | |
| 47 | + | h *harness | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (b browser) do(method, path string, hdr map[string]string, body string) page { | |
| 51 | + | b.h.t.Helper() | |
| 52 | + | req, err := http.NewRequest(method, b.h.url(path), strings.NewReader(body)) | |
| 53 | + | require.NoError(b.h.t, err) | |
| 54 | + | for k, v := range hdr { | |
| 55 | + | req.Header.Set(k, v) | |
| 56 | + | } | |
| 57 | + | if body != "" { | |
| 58 | + | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |
| 59 | + | } | |
| 60 | + | resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req) | |
| 61 | + | require.NoError(b.h.t, err) | |
| 62 | + | defer func() { _ = resp.Body.Close() }() | |
| 63 | + | out, err := io.ReadAll(resp.Body) | |
| 64 | + | require.NoError(b.h.t, err) | |
| 65 | + | return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)} | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (b browser) get(path string, hdr map[string]string) page { return b.do("GET", path, hdr, "") } | |
| 69 | + | ||
| 70 | + | // as is the header map of a browser holding the session token. | |
| 71 | + | func as(token string) map[string]string { | |
| 72 | + | return map[string]string{"Cookie": sessionCookie + "=" + token} | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func post(token string) map[string]string { | |
| 76 | + | h := as(token) | |
| 77 | + | h["Origin"] = publicOrigin | |
| 78 | + | return h | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // loginPath turns the link the CLI printed into a path on the test server. | |
| 82 | + | func loginPath(t *testing.T, link string) string { | |
| 83 | + | t.Helper() | |
| 84 | + | u, err := url.Parse(strings.TrimSpace(link)) | |
| 85 | + | require.NoError(t, err) | |
| 86 | + | assert.Equal(t, publicOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL") | |
| 87 | + | assert.True(t, strings.HasPrefix(u.Path, "/-/login/"), u.Path) | |
| 88 | + | return u.Path | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // signIn runs `ermite web login` and confirms the link: it returns the token | |
| 92 | + | // of the cookie the browser would hold. | |
| 93 | + | func (c *cliHarness) signIn(t *testing.T) string { | |
| 94 | + | t.Helper() | |
| 95 | + | res := c.cli(t, adminToken, "web", "login").ok(t) | |
| 96 | + | p := browser{c.harness}.do("POST", loginPath(t, res.stdout), map[string]string{"Origin": publicOrigin}, "") | |
| 97 | + | require.Equal(t, http.StatusSeeOther, p.status, p.body) | |
| 98 | + | ck := p.cookie(sessionCookie) | |
| 99 | + | require.NotNil(t, ck) | |
| 100 | + | return ck.Value | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // AC1, AC2, AC7 (the session part): the whole path of the owner, from the CLI | |
| 104 | + | // link to the private page and back out. | |
| 105 | + | func TestWebLoginEndToEnd(t *testing.T) { | |
| 106 | + | c := newCLIHarness(t) | |
| 107 | + | c.seed("secret", domain.VisibilityPrivate, "notes.txt") | |
| 108 | + | c.seed("pub", domain.VisibilityPublic, "a.txt") | |
| 109 | + | b := browser{c.harness} | |
| 110 | + | ||
| 111 | + | // Anonymous: the private repository is invisible; public caches as before. | |
| 112 | + | home := b.get("/", nil) | |
| 113 | + | assert.Contains(t, home.body, "pub") | |
| 114 | + | assert.NotContains(t, home.body, "secret") | |
| 115 | + | assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) | |
| 116 | + | assert.Empty(t, home.hdr.Values("Vary")) | |
| 117 | + | assert.NotContains(t, home.body, "sign out") | |
| 118 | + | assert.Equal(t, 404, b.get("/secret", nil).status) | |
| 119 | + | ||
| 120 | + | // The CLI asks for a link and prints it; stderr says when it ends. | |
| 121 | + | res := c.cli(t, adminToken, "web", "login").ok(t) | |
| 122 | + | assert.Contains(t, res.stderr, "works once") | |
| 123 | + | path := loginPath(t, res.stdout) | |
| 124 | + | ||
| 125 | + | // GET shows the confirmation and never consumes the link. | |
| 126 | + | for range 3 { | |
| 127 | + | p := b.get(path, nil) | |
| 128 | + | assert.Equal(t, 200, p.status) | |
| 129 | + | assert.Equal(t, "no-store", p.hdr.Get("Cache-Control")) | |
| 130 | + | assert.Equal(t, "no-referrer", p.hdr.Get("Referrer-Policy")) | |
| 131 | + | assert.Contains(t, p.body, `method="post"`) | |
| 132 | + | assert.Contains(t, p.body, `action="`+path+`"`) | |
| 133 | + | assert.Nil(t, p.cookie(sessionCookie), "a GET sets no session") | |
| 134 | + | assertNoInline(t, p.body) | |
| 135 | + | } | |
| 136 | + | // A POST that is not from this site is refused and does not consume it. | |
| 137 | + | assert.Equal(t, 403, b.do("POST", path, nil, "").status, "no Origin") | |
| 138 | + | assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "https://evil.example"}, "").status, "foreign Origin") | |
| 139 | + | assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "null"}, "").status, "opaque Origin") | |
| 140 | + | assert.Equal(t, 200, b.get(path, nil).status, "still pending") | |
| 141 | + | ||
| 142 | + | // The POST consumes it and sets the cookie. | |
| 143 | + | login := b.do("POST", path, map[string]string{"Origin": publicOrigin}, "") | |
| 144 | + | require.Equal(t, http.StatusSeeOther, login.status, login.body) | |
| 145 | + | assert.Equal(t, "/", login.hdr.Get("Location")) | |
| 146 | + | assert.Equal(t, "no-store", login.hdr.Get("Cache-Control")) | |
| 147 | + | ck := login.cookie(sessionCookie) | |
| 148 | + | require.NotNil(t, ck) | |
| 149 | + | assert.True(t, ck.Secure && ck.HttpOnly) | |
| 150 | + | assert.Equal(t, http.SameSiteStrictMode, ck.SameSite) | |
| 151 | + | assert.Equal(t, "/", ck.Path) | |
| 152 | + | assert.Empty(t, ck.Domain, "no Domain: host only") | |
| 153 | + | assert.Equal(t, 12*60*60, ck.MaxAge) | |
| 154 | + | token := ck.Value | |
| 155 | + | claims := jwt.MapClaims{} | |
| 156 | + | _, _, err := jwt.NewParser().ParseUnverified(token, claims) | |
| 157 | + | require.NoError(t, err) | |
| 158 | + | assert.Equal(t, "owner", claims["sub"]) | |
| 159 | + | assert.Equal(t, float64(0), claims["epoch"]) | |
| 160 | + | exp, err := claims.GetExpirationTime() | |
| 161 | + | require.NoError(t, err) | |
| 162 | + | assert.WithinDuration(t, time.Now().Add(12*time.Hour), exp.Time, time.Minute) | |
| 163 | + | assert.NotNil(t, claims["iat"]) | |
| 164 | + | ||
| 165 | + | // Replayed and unknown links fail with the same page. | |
| 166 | + | for _, p := range []string{path, "/-/login/" + strings.Repeat("a", 43), "/-/login/short"} { | |
| 167 | + | g := b.get(p, nil) | |
| 168 | + | assert.Equal(t, 404, g.status, p) | |
| 169 | + | assert.Equal(t, "no-store", g.hdr.Get("Cache-Control")) | |
| 170 | + | assert.Contains(t, g.body, "login link not valid") | |
| 171 | + | r := b.do("POST", p, map[string]string{"Origin": publicOrigin}, "") | |
| 172 | + | assert.Equal(t, 404, r.status, p) | |
| 173 | + | assert.Equal(t, g.body, r.body, "no detail tells why") | |
| 174 | + | assert.Nil(t, r.cookie(sessionCookie)) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // Signed in: private repositories show on every page, headers say private. | |
| 178 | + | for _, p := range []string{"/", "/secret", "/secret/tree", "/secret/blob/main/notes.txt", "/secret/commits/main", "/secret/refs", "/nope"} { | |
| 179 | + | got := b.get(p, as(token)) | |
| 180 | + | if p == "/nope" { | |
| 181 | + | assert.Equal(t, 404, got.status) | |
| 182 | + | } else { | |
| 183 | + | assert.Equal(t, 200, got.status, p) | |
| 184 | + | } | |
| 185 | + | assert.Equal(t, "private, no-store", got.hdr.Get("Cache-Control"), p) | |
| 186 | + | assert.Contains(t, got.hdr.Values("Vary"), "Cookie", p) | |
| 187 | + | assertNoInline(t, got.body) | |
| 188 | + | } | |
| 189 | + | home = b.get("/", as(token)) | |
| 190 | + | assert.Contains(t, home.body, "secret") | |
| 191 | + | assert.Contains(t, home.body, `<form class="st-owner" method="post" action="/-/logout">`) | |
| 192 | + | assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt") | |
| 193 | + | raw := b.get("/secret/raw/main/notes.txt", as(token)) | |
| 194 | + | assert.Equal(t, 200, raw.status) | |
| 195 | + | assert.Equal(t, "private, no-store", raw.hdr.Get("Cache-Control")) | |
| 196 | + | assert.Equal(t, 404, b.get("/secret/raw/main/notes.txt", nil).status) | |
| 197 | + | // Static assets stay public. | |
| 198 | + | assert.Contains(t, b.get("/", as(token)).body, "/-/static/") | |
| 199 | + | ||
| 200 | + | // The live stream of a private repository is the owner's. | |
| 201 | + | assert.Equal(t, 404, b.get("/-/events?topic=repo:secret", nil).status) | |
| 202 | + | sctx, stop := context.WithCancel(c.ctx) | |
| 203 | + | defer stop() | |
| 204 | + | sreq, err := http.NewRequestWithContext(sctx, http.MethodGet, c.harness.url("/-/events?topic=repo:secret"), nil) | |
| 205 | + | require.NoError(t, err) | |
| 206 | + | sreq.Header.Set("Cookie", sessionCookie+"="+token) | |
| 207 | + | sres, err := http.DefaultClient.Do(sreq) | |
| 208 | + | require.NoError(t, err) | |
| 209 | + | assert.Equal(t, 200, sres.StatusCode) | |
| 210 | + | assert.Equal(t, "private, no-store, no-transform", sres.Header.Get("Cache-Control")) | |
| 211 | + | assert.Contains(t, sres.Header.Values("Vary"), "Cookie") | |
| 212 | + | stop() | |
| 213 | + | _ = sres.Body.Close() | |
| 214 | + | ||
| 215 | + | // The browser routes ignore Authorization: the admin token shows nothing. | |
| 216 | + | bearer := b.get("/", map[string]string{"Authorization": "Bearer " + adminToken}) | |
| 217 | + | assert.NotContains(t, bearer.body, "secret") | |
| 218 | + | assert.Equal(t, "no-cache", bearer.hdr.Get("Cache-Control")) | |
| 219 | + | assert.Equal(t, 404, b.get("/secret", map[string]string{"Authorization": "Bearer " + adminToken}).status) | |
| 220 | + | ||
| 221 | + | // The API ignores the cookie: no bearer is anonymous, so nothing private | |
| 222 | + | // and nothing that needs a caller. | |
| 223 | + | api := c.api("", connect.WithInterceptors(cookieInterceptor(token))) | |
| 224 | + | list, err := api.repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{})) | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | require.Len(t, list.Msg.GetRepos(), 1) | |
| 227 | + | assert.Equal(t, "pub", list.Msg.GetRepos()[0].GetName()) | |
| 228 | + | _, err = api.repo.GetRepo(context.Background(), connect.NewRequest(&pb.GetRepoRequest{Repo: "secret"})) | |
| 229 | + | assert.Equal(t, "not_found", codeName(err)) | |
| 230 | + | _, err = api.token.ListTokens(context.Background(), connect.NewRequest(&pb.ListTokensRequest{})) | |
| 231 | + | assert.Equal(t, "unauthenticated", codeName(err)) | |
| 232 | + | _, err = api.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |
| 233 | + | assert.Equal(t, "unauthenticated", codeName(err)) | |
| 234 | + | _, err = api.session.SignOutEverywhere(context.Background(), connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |
| 235 | + | assert.Equal(t, "unauthenticated", codeName(err)) | |
| 236 | + | ||
| 237 | + | // Logout: a POST from this site with the session; the cookie is deleted. | |
| 238 | + | assert.Equal(t, 403, b.do("POST", "/-/logout", as(token), "").status, "no Origin") | |
| 239 | + | assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": "https://evil.example", "Cookie": sessionCookie + "=" + token}, "").status) | |
| 240 | + | assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": publicOrigin}, "").status, "no session") | |
| 241 | + | out := b.do("POST", "/-/logout", post(token), "") | |
| 242 | + | assert.Equal(t, http.StatusSeeOther, out.status) | |
| 243 | + | assert.Equal(t, "/", out.hdr.Get("Location")) | |
| 244 | + | gone := out.cookie(sessionCookie) | |
| 245 | + | require.NotNil(t, gone) | |
| 246 | + | assert.Negative(t, gone.MaxAge) | |
| 247 | + | assert.Empty(t, gone.Value) | |
| 248 | + | assert.Equal(t, 404, b.get("/secret", nil).status) | |
| 249 | + | // A GET never signs out. | |
| 250 | + | g := b.get("/-/logout", as(token)) | |
| 251 | + | assert.Equal(t, 404, g.status, "no page") | |
| 252 | + | assert.Nil(t, g.cookie(sessionCookie), "the session is untouched") | |
| 253 | + | assert.Equal(t, 200, b.get("/secret", as(token)).status, "the token itself lives until logout --all or expiry") | |
| 254 | + | ||
| 255 | + | // logout --all: the cookie is rejected and cleared; anonymous again. | |
| 256 | + | c.cli(t, adminToken, "web", "logout", "--all").ok(t) | |
| 257 | + | after := b.get("/secret", as(token)) | |
| 258 | + | assert.Equal(t, 404, after.status) | |
| 259 | + | assert.Equal(t, "no-cache", after.hdr.Get("Cache-Control")) | |
| 260 | + | cleared := after.cookie(sessionCookie) | |
| 261 | + | require.NotNil(t, cleared, "a rejected cookie is cleared") | |
| 262 | + | assert.Negative(t, cleared.MaxAge) | |
| 263 | + | assert.NotContains(t, b.get("/", as(token)).body, "secret") | |
| 264 | + | assert.Equal(t, 403, b.do("POST", "/-/logout", post(token), "").status, "POST needs a valid session") | |
| 265 | + | ||
| 266 | + | // A new link gives a session of the new epoch. | |
| 267 | + | fresh := c.signIn(t) | |
| 268 | + | assert.Equal(t, 200, b.get("/secret", as(fresh)).status) | |
| 269 | + | assert.Equal(t, 404, b.get("/secret", as(token)).status) | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // cookieInterceptor makes a Connect client send the session cookie, as a | |
| 273 | + | // browser-made call would, to show the API pays no attention to it. | |
| 274 | + | func cookieInterceptor(token string) connect.Interceptor { | |
| 275 | + | return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { | |
| 276 | + | return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { | |
| 277 | + | req.Header().Set("Cookie", sessionCookie+"="+token) | |
| 278 | + | return next(ctx, req) | |
| 279 | + | } | |
| 280 | + | }) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // The cookie must be an unforgeable HS256 token for the key of the server. | |
| 284 | + | func TestWebRejectsForgedSessions(t *testing.T) { | |
| 285 | + | c := newCLIHarness(t) | |
| 286 | + | c.seed("secret", domain.VisibilityPrivate, "notes.txt") | |
| 287 | + | b := browser{c.harness} | |
| 288 | + | good := c.signIn(t) | |
| 289 | + | require.Equal(t, 200, b.get("/secret", as(good)).status) | |
| 290 | + | ||
| 291 | + | now := time.Now() | |
| 292 | + | claims := jwt.MapClaims{"sub": "owner", "epoch": 0, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()} | |
| 293 | + | none, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).SignedString(jwt.UnsafeAllowNoneSignatureType) | |
| 294 | + | require.NoError(t, err) | |
| 295 | + | wrongKey, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("not the key of the server........")) | |
| 296 | + | require.NoError(t, err) | |
| 297 | + | wrongAlg, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString([]byte("not the key of the server........")) | |
| 298 | + | require.NoError(t, err) | |
| 299 | + | for name, tok := range map[string]string{ | |
| 300 | + | "alg none": none, "wrong key": wrongKey, "wrong alg": wrongAlg, "garbage": "x.y.z", "truncated": good[:len(good)-2], | |
| 301 | + | } { | |
| 302 | + | p := b.get("/secret", as(tok)) | |
| 303 | + | assert.Equal(t, 404, p.status, name) | |
| 304 | + | assert.Equal(t, "no-cache", p.hdr.Get("Cache-Control"), name) | |
| 305 | + | ck := p.cookie(sessionCookie) | |
| 306 | + | require.NotNil(t, ck, name) | |
| 307 | + | assert.Negative(t, ck.MaxAge, name) | |
| 308 | + | assert.Equal(t, 403, b.do("POST", "/-/logout", post(tok), "").status, name) | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | // Failed logins count in the limiter of failed authentications, like a bad | |
| 313 | + | // token on the API: the eleventh attempt of one client is refused, and a valid | |
| 314 | + | // link of that client is too. | |
| 315 | + | func TestWebLoginFailuresAreRateLimited(t *testing.T) { | |
| 316 | + | h := newHarness(t, harnessOpts{trustedProxies: "127.0.0.0/8"}) | |
| 317 | + | c := &cliHarness{harness: h, url: h.srv.URL} | |
| 318 | + | b := browser{c.harness} | |
| 319 | + | from := func(ip string) map[string]string { | |
| 320 | + | return map[string]string{"Origin": publicOrigin, "X-Forwarded-For": ip} | |
| 321 | + | } | |
| 322 | + | bad := "/-/login/" + strings.Repeat("b", 43) | |
| 323 | + | for range 10 { | |
| 324 | + | assert.Equal(t, 404, b.do("POST", bad, from("203.0.113.9"), "").status) | |
| 325 | + | } | |
| 326 | + | blocked := b.do("POST", bad, from("203.0.113.9"), "") | |
| 327 | + | assert.Equal(t, http.StatusTooManyRequests, blocked.status) | |
| 328 | + | assert.NotEmpty(t, blocked.hdr.Get("Retry-After")) | |
| 329 | + | ||
| 330 | + | link := c.cli(t, adminToken, "web", "login").ok(t).stdout | |
| 331 | + | assert.Equal(t, http.StatusTooManyRequests, b.do("POST", loginPath(t, link), from("203.0.113.9"), "").status, "the budget is spent") | |
| 332 | + | assert.Equal(t, http.StatusSeeOther, b.do("POST", loginPath(t, link), from("203.0.113.10"), "").status, "another client is not affected") | |
| 333 | + | // The same budget as the API's: a bad token from this client is refused. | |
| 334 | + | _, err := c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).server.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |
| 335 | + | require.NoError(t, err, "GetVersion needs no authentication") | |
| 336 | + | _, err = c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{})) | |
| 337 | + | assert.Equal(t, "resource_exhausted", codeName(err)) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func forwardedFor(ip string) connect.Interceptor { | |
| 341 | + | return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { | |
| 342 | + | return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { | |
| 343 | + | req.Header().Set("X-Forwarded-For", ip) | |
| 344 | + | return next(ctx, req) | |
| 345 | + | } | |
| 346 | + | }) | |
| 347 | + | } | |
| 348 | + | ||
| 349 | + | // Only the owner creates links: the admin token, or a token with every scope | |
| 350 | + | // and no repository list. | |
| 351 | + | func TestSessionServiceIsOwnerOnly(t *testing.T) { | |
| 352 | + | c := newCLIHarness(t) | |
| 353 | + | ctx := context.Background() | |
| 354 | + | c.seed("a", domain.VisibilityPublic) | |
| 355 | + | partial := c.token(domain.AllScopes()[:8]) | |
| 356 | + | limited := c.token(domain.AllScopes(), "a") | |
| 357 | + | full := c.token(domain.AllScopes()) | |
| 358 | + | ||
| 359 | + | for name, tc := range map[string]struct{ secret, code string }{ | |
| 360 | + | "anonymous": {"", "unauthenticated"}, | |
| 361 | + | "bad token": {"nope", "unauthenticated"}, | |
| 362 | + | "missing scope": {partial, "permission_denied"}, | |
| 363 | + | "repo list": {limited, "permission_denied"}, | |
| 364 | + | "every scope": {full, "ok"}, | |
| 365 | + | "admin token": {adminToken, "ok"}, | |
| 366 | + | } { | |
| 367 | + | a := c.api(tc.secret) | |
| 368 | + | _, err := a.session.CreateLoginLink(ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |
| 369 | + | assert.Equal(t, tc.code, codeName(err), name) | |
| 370 | + | _, err = a.session.SignOutEverywhere(ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |
| 371 | + | if tc.code == "unauthenticated" { | |
| 372 | + | assert.Equal(t, tc.code, codeName(err), name) | |
| 373 | + | } else { | |
| 374 | + | assert.Equal(t, tc.code, codeName(err), name) | |
| 375 | + | } | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | res := c.cli(t, partial, "web", "login") | |
| 379 | + | require.Error(t, res.err) | |
| 380 | + | assert.Contains(t, res.err.Error(), "permission denied") | |
| 381 | + | assert.Empty(t, res.stdout) | |
| 382 | + | res = c.cli(t, adminToken, "web", "logout") | |
| 383 | + | require.Error(t, res.err, "--all is required") | |
| 384 | + | res = c.cli(t, adminToken, "web", "login", "--json").ok(t) | |
| 385 | + | assert.ElementsMatch(t, []string{"expires_at", "url"}, keys(t, res.stdout)) | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | // Login links are bounded in number. | |
| 389 | + | func TestLoginLinksAreBoundedOverTheAPI(t *testing.T) { | |
| 390 | + | c := newCLIHarness(t) | |
| 391 | + | a := c.api(adminToken) | |
| 392 | + | var code string | |
| 393 | + | for range 20 { | |
| 394 | + | _, err := a.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |
| 395 | + | if code = codeName(err); code != "ok" { | |
| 396 | + | break | |
| 397 | + | } | |
| 398 | + | } | |
| 399 | + | assert.Equal(t, "resource_exhausted", code) | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // A link, a session token and the signing key are secrets: no log line holds | |
| 403 | + | // them, at the most verbose level. | |
| 404 | + | func TestWebLoginSecretsStayOutOfTheLog(t *testing.T) { | |
| 405 | + | c := newCLIHarness(t) | |
| 406 | + | c.seed("secret", domain.VisibilityPrivate, "notes.txt") | |
| 407 | + | b := browser{c.harness} | |
| 408 | + | ||
| 409 | + | link := strings.TrimSpace(c.cli(t, adminToken, "web", "login").ok(t).stdout) | |
| 410 | + | nonce := link[strings.LastIndex(link, "/")+1:] | |
| 411 | + | require.Len(t, nonce, 43) | |
| 412 | + | get := b.get(loginPath(t, link), nil) | |
| 413 | + | require.Equal(t, 200, get.status) | |
| 414 | + | p := b.do("POST", loginPath(t, link), map[string]string{"Origin": publicOrigin}, "") | |
| 415 | + | token := p.cookie(sessionCookie).Value | |
| 416 | + | b.get("/secret", as(token)) | |
| 417 | + | b.get("/-/login/"+strings.Repeat("z", 43), nil) | |
| 418 | + | b.do("POST", "/-/login/"+strings.Repeat("z", 43), map[string]string{"Origin": publicOrigin}, "") | |
| 419 | + | ||
| 420 | + | logs := c.logs.String() | |
| 421 | + | assert.NotContains(t, logs, nonce) | |
| 422 | + | assert.NotContains(t, logs, token) | |
| 423 | + | assert.NotContains(t, logs, strings.Repeat("z", 43)) | |
| 424 | + | assert.Contains(t, logs, "login link created") | |
| 425 | + | assert.Contains(t, logs, "owner signed in on the web") | |
| 426 | + | key, err := c.forge.store.Sessions().Key(context.Background()) | |
| 427 | + | require.NoError(t, err) | |
| 428 | + | assert.NotContains(t, logs, string(key)) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // The owner principal is what the API sees from a signed-in page, and is | |
| 432 | + | // labelled "owner". | |
| 433 | + | func TestWebOwnerIsLabelledInLogs(t *testing.T) { | |
| 434 | + | c := newCLIHarness(t) | |
| 435 | + | c.seed("secret", domain.VisibilityPrivate, "notes.txt") | |
| 436 | + | token := c.signIn(t) | |
| 437 | + | browser{c.harness}.get("/secret", as(token)) | |
| 438 | + | assert.Contains(t, c.logs.String(), `"principal":"owner"`) | |
| 439 | + | } | |
| @@ -0,0 +1,439 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "io" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/url" | |||
| 10 | + | "strings" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "connectrpc.com/connect" | |||
| 15 | + | "github.com/golang-jwt/jwt/v5" | |||
| 16 | + | "github.com/stretchr/testify/assert" | |||
| 17 | + | "github.com/stretchr/testify/require" | |||
| 18 | + | ||||
| 19 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 20 | + | "bnema.dev/ermite/internal/domain" | |||
| 21 | + | ) | |||
| 22 | + | ||||
| 23 | + | const ( | |||
| 24 | + | publicOrigin = "http://example.test" // ERMITE_PUBLIC_URL of the harness | |||
| 25 | + | sessionCookie = "__Host-ermite" | |||
| 26 | + | ) | |||
| 27 | + | ||||
| 28 | + | // page is the answer of a request to the web. | |||
| 29 | + | type page struct { | |||
| 30 | + | status int | |||
| 31 | + | hdr http.Header | |||
| 32 | + | body string | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (p page) cookie(name string) *http.Cookie { | |||
| 36 | + | for _, c := range (&http.Response{Header: p.hdr}).Cookies() { | |||
| 37 | + | if c.Name == name { | |||
| 38 | + | return c | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | return nil | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // browser is a bare HTTP client: it follows no redirect and keeps no cookie, | |||
| 45 | + | // so that the tests say exactly what the browser sends. | |||
| 46 | + | type browser struct { | |||
| 47 | + | h *harness | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (b browser) do(method, path string, hdr map[string]string, body string) page { | |||
| 51 | + | b.h.t.Helper() | |||
| 52 | + | req, err := http.NewRequest(method, b.h.url(path), strings.NewReader(body)) | |||
| 53 | + | require.NoError(b.h.t, err) | |||
| 54 | + | for k, v := range hdr { | |||
| 55 | + | req.Header.Set(k, v) | |||
| 56 | + | } | |||
| 57 | + | if body != "" { | |||
| 58 | + | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | |||
| 59 | + | } | |||
| 60 | + | resp, err := (&http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}).Do(req) | |||
| 61 | + | require.NoError(b.h.t, err) | |||
| 62 | + | defer func() { _ = resp.Body.Close() }() | |||
| 63 | + | out, err := io.ReadAll(resp.Body) | |||
| 64 | + | require.NoError(b.h.t, err) | |||
| 65 | + | return page{status: resp.StatusCode, hdr: resp.Header, body: string(out)} | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (b browser) get(path string, hdr map[string]string) page { return b.do("GET", path, hdr, "") } | |||
| 69 | + | ||||
| 70 | + | // as is the header map of a browser holding the session token. | |||
| 71 | + | func as(token string) map[string]string { | |||
| 72 | + | return map[string]string{"Cookie": sessionCookie + "=" + token} | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func post(token string) map[string]string { | |||
| 76 | + | h := as(token) | |||
| 77 | + | h["Origin"] = publicOrigin | |||
| 78 | + | return h | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | // loginPath turns the link the CLI printed into a path on the test server. | |||
| 82 | + | func loginPath(t *testing.T, link string) string { | |||
| 83 | + | t.Helper() | |||
| 84 | + | u, err := url.Parse(strings.TrimSpace(link)) | |||
| 85 | + | require.NoError(t, err) | |||
| 86 | + | assert.Equal(t, publicOrigin, u.Scheme+"://"+u.Host, "the link is on the public URL") | |||
| 87 | + | assert.True(t, strings.HasPrefix(u.Path, "/-/login/"), u.Path) | |||
| 88 | + | return u.Path | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // signIn runs `ermite web login` and confirms the link: it returns the token | |||
| 92 | + | // of the cookie the browser would hold. | |||
| 93 | + | func (c *cliHarness) signIn(t *testing.T) string { | |||
| 94 | + | t.Helper() | |||
| 95 | + | res := c.cli(t, adminToken, "web", "login").ok(t) | |||
| 96 | + | p := browser{c.harness}.do("POST", loginPath(t, res.stdout), map[string]string{"Origin": publicOrigin}, "") | |||
| 97 | + | require.Equal(t, http.StatusSeeOther, p.status, p.body) | |||
| 98 | + | ck := p.cookie(sessionCookie) | |||
| 99 | + | require.NotNil(t, ck) | |||
| 100 | + | return ck.Value | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // AC1, AC2, AC7 (the session part): the whole path of the owner, from the CLI | |||
| 104 | + | // link to the private page and back out. | |||
| 105 | + | func TestWebLoginEndToEnd(t *testing.T) { | |||
| 106 | + | c := newCLIHarness(t) | |||
| 107 | + | c.seed("secret", domain.VisibilityPrivate, "notes.txt") | |||
| 108 | + | c.seed("pub", domain.VisibilityPublic, "a.txt") | |||
| 109 | + | b := browser{c.harness} | |||
| 110 | + | ||||
| 111 | + | // Anonymous: the private repository is invisible; public caches as before. | |||
| 112 | + | home := b.get("/", nil) | |||
| 113 | + | assert.Contains(t, home.body, "pub") | |||
| 114 | + | assert.NotContains(t, home.body, "secret") | |||
| 115 | + | assert.Equal(t, "no-cache", home.hdr.Get("Cache-Control")) | |||
| 116 | + | assert.Empty(t, home.hdr.Values("Vary")) | |||
| 117 | + | assert.NotContains(t, home.body, "sign out") | |||
| 118 | + | assert.Equal(t, 404, b.get("/secret", nil).status) | |||
| 119 | + | ||||
| 120 | + | // The CLI asks for a link and prints it; stderr says when it ends. | |||
| 121 | + | res := c.cli(t, adminToken, "web", "login").ok(t) | |||
| 122 | + | assert.Contains(t, res.stderr, "works once") | |||
| 123 | + | path := loginPath(t, res.stdout) | |||
| 124 | + | ||||
| 125 | + | // GET shows the confirmation and never consumes the link. | |||
| 126 | + | for range 3 { | |||
| 127 | + | p := b.get(path, nil) | |||
| 128 | + | assert.Equal(t, 200, p.status) | |||
| 129 | + | assert.Equal(t, "no-store", p.hdr.Get("Cache-Control")) | |||
| 130 | + | assert.Equal(t, "no-referrer", p.hdr.Get("Referrer-Policy")) | |||
| 131 | + | assert.Contains(t, p.body, `method="post"`) | |||
| 132 | + | assert.Contains(t, p.body, `action="`+path+`"`) | |||
| 133 | + | assert.Nil(t, p.cookie(sessionCookie), "a GET sets no session") | |||
| 134 | + | assertNoInline(t, p.body) | |||
| 135 | + | } | |||
| 136 | + | // A POST that is not from this site is refused and does not consume it. | |||
| 137 | + | assert.Equal(t, 403, b.do("POST", path, nil, "").status, "no Origin") | |||
| 138 | + | assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "https://evil.example"}, "").status, "foreign Origin") | |||
| 139 | + | assert.Equal(t, 403, b.do("POST", path, map[string]string{"Origin": "null"}, "").status, "opaque Origin") | |||
| 140 | + | assert.Equal(t, 200, b.get(path, nil).status, "still pending") | |||
| 141 | + | ||||
| 142 | + | // The POST consumes it and sets the cookie. | |||
| 143 | + | login := b.do("POST", path, map[string]string{"Origin": publicOrigin}, "") | |||
| 144 | + | require.Equal(t, http.StatusSeeOther, login.status, login.body) | |||
| 145 | + | assert.Equal(t, "/", login.hdr.Get("Location")) | |||
| 146 | + | assert.Equal(t, "no-store", login.hdr.Get("Cache-Control")) | |||
| 147 | + | ck := login.cookie(sessionCookie) | |||
| 148 | + | require.NotNil(t, ck) | |||
| 149 | + | assert.True(t, ck.Secure && ck.HttpOnly) | |||
| 150 | + | assert.Equal(t, http.SameSiteStrictMode, ck.SameSite) | |||
| 151 | + | assert.Equal(t, "/", ck.Path) | |||
| 152 | + | assert.Empty(t, ck.Domain, "no Domain: host only") | |||
| 153 | + | assert.Equal(t, 12*60*60, ck.MaxAge) | |||
| 154 | + | token := ck.Value | |||
| 155 | + | claims := jwt.MapClaims{} | |||
| 156 | + | _, _, err := jwt.NewParser().ParseUnverified(token, claims) | |||
| 157 | + | require.NoError(t, err) | |||
| 158 | + | assert.Equal(t, "owner", claims["sub"]) | |||
| 159 | + | assert.Equal(t, float64(0), claims["epoch"]) | |||
| 160 | + | exp, err := claims.GetExpirationTime() | |||
| 161 | + | require.NoError(t, err) | |||
| 162 | + | assert.WithinDuration(t, time.Now().Add(12*time.Hour), exp.Time, time.Minute) | |||
| 163 | + | assert.NotNil(t, claims["iat"]) | |||
| 164 | + | ||||
| 165 | + | // Replayed and unknown links fail with the same page. | |||
| 166 | + | for _, p := range []string{path, "/-/login/" + strings.Repeat("a", 43), "/-/login/short"} { | |||
| 167 | + | g := b.get(p, nil) | |||
| 168 | + | assert.Equal(t, 404, g.status, p) | |||
| 169 | + | assert.Equal(t, "no-store", g.hdr.Get("Cache-Control")) | |||
| 170 | + | assert.Contains(t, g.body, "login link not valid") | |||
| 171 | + | r := b.do("POST", p, map[string]string{"Origin": publicOrigin}, "") | |||
| 172 | + | assert.Equal(t, 404, r.status, p) | |||
| 173 | + | assert.Equal(t, g.body, r.body, "no detail tells why") | |||
| 174 | + | assert.Nil(t, r.cookie(sessionCookie)) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // Signed in: private repositories show on every page, headers say private. | |||
| 178 | + | for _, p := range []string{"/", "/secret", "/secret/tree", "/secret/blob/main/notes.txt", "/secret/commits/main", "/secret/refs", "/nope"} { | |||
| 179 | + | got := b.get(p, as(token)) | |||
| 180 | + | if p == "/nope" { | |||
| 181 | + | assert.Equal(t, 404, got.status) | |||
| 182 | + | } else { | |||
| 183 | + | assert.Equal(t, 200, got.status, p) | |||
| 184 | + | } | |||
| 185 | + | assert.Equal(t, "private, no-store", got.hdr.Get("Cache-Control"), p) | |||
| 186 | + | assert.Contains(t, got.hdr.Values("Vary"), "Cookie", p) | |||
| 187 | + | assertNoInline(t, got.body) | |||
| 188 | + | } | |||
| 189 | + | home = b.get("/", as(token)) | |||
| 190 | + | assert.Contains(t, home.body, "secret") | |||
| 191 | + | assert.Contains(t, home.body, `<form class="st-owner" method="post" action="/-/logout">`) | |||
| 192 | + | assert.Contains(t, b.get("/secret/blob/main/notes.txt", as(token)).body, "notes.txt") | |||
| 193 | + | raw := b.get("/secret/raw/main/notes.txt", as(token)) | |||
| 194 | + | assert.Equal(t, 200, raw.status) | |||
| 195 | + | assert.Equal(t, "private, no-store", raw.hdr.Get("Cache-Control")) | |||
| 196 | + | assert.Equal(t, 404, b.get("/secret/raw/main/notes.txt", nil).status) | |||
| 197 | + | // Static assets stay public. | |||
| 198 | + | assert.Contains(t, b.get("/", as(token)).body, "/-/static/") | |||
| 199 | + | ||||
| 200 | + | // The live stream of a private repository is the owner's. | |||
| 201 | + | assert.Equal(t, 404, b.get("/-/events?topic=repo:secret", nil).status) | |||
| 202 | + | sctx, stop := context.WithCancel(c.ctx) | |||
| 203 | + | defer stop() | |||
| 204 | + | sreq, err := http.NewRequestWithContext(sctx, http.MethodGet, c.harness.url("/-/events?topic=repo:secret"), nil) | |||
| 205 | + | require.NoError(t, err) | |||
| 206 | + | sreq.Header.Set("Cookie", sessionCookie+"="+token) | |||
| 207 | + | sres, err := http.DefaultClient.Do(sreq) | |||
| 208 | + | require.NoError(t, err) | |||
| 209 | + | assert.Equal(t, 200, sres.StatusCode) | |||
| 210 | + | assert.Equal(t, "private, no-store, no-transform", sres.Header.Get("Cache-Control")) | |||
| 211 | + | assert.Contains(t, sres.Header.Values("Vary"), "Cookie") | |||
| 212 | + | stop() | |||
| 213 | + | _ = sres.Body.Close() | |||
| 214 | + | ||||
| 215 | + | // The browser routes ignore Authorization: the admin token shows nothing. | |||
| 216 | + | bearer := b.get("/", map[string]string{"Authorization": "Bearer " + adminToken}) | |||
| 217 | + | assert.NotContains(t, bearer.body, "secret") | |||
| 218 | + | assert.Equal(t, "no-cache", bearer.hdr.Get("Cache-Control")) | |||
| 219 | + | assert.Equal(t, 404, b.get("/secret", map[string]string{"Authorization": "Bearer " + adminToken}).status) | |||
| 220 | + | ||||
| 221 | + | // The API ignores the cookie: no bearer is anonymous, so nothing private | |||
| 222 | + | // and nothing that needs a caller. | |||
| 223 | + | api := c.api("", connect.WithInterceptors(cookieInterceptor(token))) | |||
| 224 | + | list, err := api.repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{})) | |||
| 225 | + | require.NoError(t, err) | |||
| 226 | + | require.Len(t, list.Msg.GetRepos(), 1) | |||
| 227 | + | assert.Equal(t, "pub", list.Msg.GetRepos()[0].GetName()) | |||
| 228 | + | _, err = api.repo.GetRepo(context.Background(), connect.NewRequest(&pb.GetRepoRequest{Repo: "secret"})) | |||
| 229 | + | assert.Equal(t, "not_found", codeName(err)) | |||
| 230 | + | _, err = api.token.ListTokens(context.Background(), connect.NewRequest(&pb.ListTokensRequest{})) | |||
| 231 | + | assert.Equal(t, "unauthenticated", codeName(err)) | |||
| 232 | + | _, err = api.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |||
| 233 | + | assert.Equal(t, "unauthenticated", codeName(err)) | |||
| 234 | + | _, err = api.session.SignOutEverywhere(context.Background(), connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |||
| 235 | + | assert.Equal(t, "unauthenticated", codeName(err)) | |||
| 236 | + | ||||
| 237 | + | // Logout: a POST from this site with the session; the cookie is deleted. | |||
| 238 | + | assert.Equal(t, 403, b.do("POST", "/-/logout", as(token), "").status, "no Origin") | |||
| 239 | + | assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": "https://evil.example", "Cookie": sessionCookie + "=" + token}, "").status) | |||
| 240 | + | assert.Equal(t, 403, b.do("POST", "/-/logout", map[string]string{"Origin": publicOrigin}, "").status, "no session") | |||
| 241 | + | out := b.do("POST", "/-/logout", post(token), "") | |||
| 242 | + | assert.Equal(t, http.StatusSeeOther, out.status) | |||
| 243 | + | assert.Equal(t, "/", out.hdr.Get("Location")) | |||
| 244 | + | gone := out.cookie(sessionCookie) | |||
| 245 | + | require.NotNil(t, gone) | |||
| 246 | + | assert.Negative(t, gone.MaxAge) | |||
| 247 | + | assert.Empty(t, gone.Value) | |||
| 248 | + | assert.Equal(t, 404, b.get("/secret", nil).status) | |||
| 249 | + | // A GET never signs out. | |||
| 250 | + | g := b.get("/-/logout", as(token)) | |||
| 251 | + | assert.Equal(t, 404, g.status, "no page") | |||
| 252 | + | assert.Nil(t, g.cookie(sessionCookie), "the session is untouched") | |||
| 253 | + | assert.Equal(t, 200, b.get("/secret", as(token)).status, "the token itself lives until logout --all or expiry") | |||
| 254 | + | ||||
| 255 | + | // logout --all: the cookie is rejected and cleared; anonymous again. | |||
| 256 | + | c.cli(t, adminToken, "web", "logout", "--all").ok(t) | |||
| 257 | + | after := b.get("/secret", as(token)) | |||
| 258 | + | assert.Equal(t, 404, after.status) | |||
| 259 | + | assert.Equal(t, "no-cache", after.hdr.Get("Cache-Control")) | |||
| 260 | + | cleared := after.cookie(sessionCookie) | |||
| 261 | + | require.NotNil(t, cleared, "a rejected cookie is cleared") | |||
| 262 | + | assert.Negative(t, cleared.MaxAge) | |||
| 263 | + | assert.NotContains(t, b.get("/", as(token)).body, "secret") | |||
| 264 | + | assert.Equal(t, 403, b.do("POST", "/-/logout", post(token), "").status, "POST needs a valid session") | |||
| 265 | + | ||||
| 266 | + | // A new link gives a session of the new epoch. | |||
| 267 | + | fresh := c.signIn(t) | |||
| 268 | + | assert.Equal(t, 200, b.get("/secret", as(fresh)).status) | |||
| 269 | + | assert.Equal(t, 404, b.get("/secret", as(token)).status) | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // cookieInterceptor makes a Connect client send the session cookie, as a | |||
| 273 | + | // browser-made call would, to show the API pays no attention to it. | |||
| 274 | + | func cookieInterceptor(token string) connect.Interceptor { | |||
| 275 | + | return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { | |||
| 276 | + | return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { | |||
| 277 | + | req.Header().Set("Cookie", sessionCookie+"="+token) | |||
| 278 | + | return next(ctx, req) | |||
| 279 | + | } | |||
| 280 | + | }) | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // The cookie must be an unforgeable HS256 token for the key of the server. | |||
| 284 | + | func TestWebRejectsForgedSessions(t *testing.T) { | |||
| 285 | + | c := newCLIHarness(t) | |||
| 286 | + | c.seed("secret", domain.VisibilityPrivate, "notes.txt") | |||
| 287 | + | b := browser{c.harness} | |||
| 288 | + | good := c.signIn(t) | |||
| 289 | + | require.Equal(t, 200, b.get("/secret", as(good)).status) | |||
| 290 | + | ||||
| 291 | + | now := time.Now() | |||
| 292 | + | claims := jwt.MapClaims{"sub": "owner", "epoch": 0, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix()} | |||
| 293 | + | none, err := jwt.NewWithClaims(jwt.SigningMethodNone, claims).SignedString(jwt.UnsafeAllowNoneSignatureType) | |||
| 294 | + | require.NoError(t, err) | |||
| 295 | + | wrongKey, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("not the key of the server........")) | |||
| 296 | + | require.NoError(t, err) | |||
| 297 | + | wrongAlg, err := jwt.NewWithClaims(jwt.SigningMethodHS512, claims).SignedString([]byte("not the key of the server........")) | |||
| 298 | + | require.NoError(t, err) | |||
| 299 | + | for name, tok := range map[string]string{ | |||
| 300 | + | "alg none": none, "wrong key": wrongKey, "wrong alg": wrongAlg, "garbage": "x.y.z", "truncated": good[:len(good)-2], | |||
| 301 | + | } { | |||
| 302 | + | p := b.get("/secret", as(tok)) | |||
| 303 | + | assert.Equal(t, 404, p.status, name) | |||
| 304 | + | assert.Equal(t, "no-cache", p.hdr.Get("Cache-Control"), name) | |||
| 305 | + | ck := p.cookie(sessionCookie) | |||
| 306 | + | require.NotNil(t, ck, name) | |||
| 307 | + | assert.Negative(t, ck.MaxAge, name) | |||
| 308 | + | assert.Equal(t, 403, b.do("POST", "/-/logout", post(tok), "").status, name) | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 311 | + | ||||
| 312 | + | // Failed logins count in the limiter of failed authentications, like a bad | |||
| 313 | + | // token on the API: the eleventh attempt of one client is refused, and a valid | |||
| 314 | + | // link of that client is too. | |||
| 315 | + | func TestWebLoginFailuresAreRateLimited(t *testing.T) { | |||
| 316 | + | h := newHarness(t, harnessOpts{trustedProxies: "127.0.0.0/8"}) | |||
| 317 | + | c := &cliHarness{harness: h, url: h.srv.URL} | |||
| 318 | + | b := browser{c.harness} | |||
| 319 | + | from := func(ip string) map[string]string { | |||
| 320 | + | return map[string]string{"Origin": publicOrigin, "X-Forwarded-For": ip} | |||
| 321 | + | } | |||
| 322 | + | bad := "/-/login/" + strings.Repeat("b", 43) | |||
| 323 | + | for range 10 { | |||
| 324 | + | assert.Equal(t, 404, b.do("POST", bad, from("203.0.113.9"), "").status) | |||
| 325 | + | } | |||
| 326 | + | blocked := b.do("POST", bad, from("203.0.113.9"), "") | |||
| 327 | + | assert.Equal(t, http.StatusTooManyRequests, blocked.status) | |||
| 328 | + | assert.NotEmpty(t, blocked.hdr.Get("Retry-After")) | |||
| 329 | + | ||||
| 330 | + | link := c.cli(t, adminToken, "web", "login").ok(t).stdout | |||
| 331 | + | assert.Equal(t, http.StatusTooManyRequests, b.do("POST", loginPath(t, link), from("203.0.113.9"), "").status, "the budget is spent") | |||
| 332 | + | assert.Equal(t, http.StatusSeeOther, b.do("POST", loginPath(t, link), from("203.0.113.10"), "").status, "another client is not affected") | |||
| 333 | + | // The same budget as the API's: a bad token from this client is refused. | |||
| 334 | + | _, err := c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).server.GetVersion(context.Background(), connect.NewRequest(&pb.GetVersionRequest{})) | |||
| 335 | + | require.NoError(t, err, "GetVersion needs no authentication") | |||
| 336 | + | _, err = c.api("bad-token", connect.WithInterceptors(forwardedFor("203.0.113.9"))).repo.ListRepos(context.Background(), connect.NewRequest(&pb.ListReposRequest{})) | |||
| 337 | + | assert.Equal(t, "resource_exhausted", codeName(err)) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func forwardedFor(ip string) connect.Interceptor { | |||
| 341 | + | return connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc { | |||
| 342 | + | return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { | |||
| 343 | + | req.Header().Set("X-Forwarded-For", ip) | |||
| 344 | + | return next(ctx, req) | |||
| 345 | + | } | |||
| 346 | + | }) | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | // Only the owner creates links: the admin token, or a token with every scope | |||
| 350 | + | // and no repository list. | |||
| 351 | + | func TestSessionServiceIsOwnerOnly(t *testing.T) { | |||
| 352 | + | c := newCLIHarness(t) | |||
| 353 | + | ctx := context.Background() | |||
| 354 | + | c.seed("a", domain.VisibilityPublic) | |||
| 355 | + | partial := c.token(domain.AllScopes()[:8]) | |||
| 356 | + | limited := c.token(domain.AllScopes(), "a") | |||
| 357 | + | full := c.token(domain.AllScopes()) | |||
| 358 | + | ||||
| 359 | + | for name, tc := range map[string]struct{ secret, code string }{ | |||
| 360 | + | "anonymous": {"", "unauthenticated"}, | |||
| 361 | + | "bad token": {"nope", "unauthenticated"}, | |||
| 362 | + | "missing scope": {partial, "permission_denied"}, | |||
| 363 | + | "repo list": {limited, "permission_denied"}, | |||
| 364 | + | "every scope": {full, "ok"}, | |||
| 365 | + | "admin token": {adminToken, "ok"}, | |||
| 366 | + | } { | |||
| 367 | + | a := c.api(tc.secret) | |||
| 368 | + | _, err := a.session.CreateLoginLink(ctx, connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |||
| 369 | + | assert.Equal(t, tc.code, codeName(err), name) | |||
| 370 | + | _, err = a.session.SignOutEverywhere(ctx, connect.NewRequest(&pb.SignOutEverywhereRequest{})) | |||
| 371 | + | if tc.code == "unauthenticated" { | |||
| 372 | + | assert.Equal(t, tc.code, codeName(err), name) | |||
| 373 | + | } else { | |||
| 374 | + | assert.Equal(t, tc.code, codeName(err), name) | |||
| 375 | + | } | |||
| 376 | + | } | |||
| 377 | + | ||||
| 378 | + | res := c.cli(t, partial, "web", "login") | |||
| 379 | + | require.Error(t, res.err) | |||
| 380 | + | assert.Contains(t, res.err.Error(), "permission denied") | |||
| 381 | + | assert.Empty(t, res.stdout) | |||
| 382 | + | res = c.cli(t, adminToken, "web", "logout") | |||
| 383 | + | require.Error(t, res.err, "--all is required") | |||
| 384 | + | res = c.cli(t, adminToken, "web", "login", "--json").ok(t) | |||
| 385 | + | assert.ElementsMatch(t, []string{"expires_at", "url"}, keys(t, res.stdout)) | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | // Login links are bounded in number. | |||
| 389 | + | func TestLoginLinksAreBoundedOverTheAPI(t *testing.T) { | |||
| 390 | + | c := newCLIHarness(t) | |||
| 391 | + | a := c.api(adminToken) | |||
| 392 | + | var code string | |||
| 393 | + | for range 20 { | |||
| 394 | + | _, err := a.session.CreateLoginLink(context.Background(), connect.NewRequest(&pb.CreateLoginLinkRequest{})) | |||
| 395 | + | if code = codeName(err); code != "ok" { | |||
| 396 | + | break | |||
| 397 | + | } | |||
| 398 | + | } | |||
| 399 | + | assert.Equal(t, "resource_exhausted", code) | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // A link, a session token and the signing key are secrets: no log line holds | |||
| 403 | + | // them, at the most verbose level. | |||
| 404 | + | func TestWebLoginSecretsStayOutOfTheLog(t *testing.T) { | |||
| 405 | + | c := newCLIHarness(t) | |||
| 406 | + | c.seed("secret", domain.VisibilityPrivate, "notes.txt") | |||
| 407 | + | b := browser{c.harness} | |||
| 408 | + | ||||
| 409 | + | link := strings.TrimSpace(c.cli(t, adminToken, "web", "login").ok(t).stdout) | |||
| 410 | + | nonce := link[strings.LastIndex(link, "/")+1:] | |||
| 411 | + | require.Len(t, nonce, 43) | |||
| 412 | + | get := b.get(loginPath(t, link), nil) | |||
| 413 | + | require.Equal(t, 200, get.status) | |||
| 414 | + | p := b.do("POST", loginPath(t, link), map[string]string{"Origin": publicOrigin}, "") | |||
| 415 | + | token := p.cookie(sessionCookie).Value | |||
| 416 | + | b.get("/secret", as(token)) | |||
| 417 | + | b.get("/-/login/"+strings.Repeat("z", 43), nil) | |||
| 418 | + | b.do("POST", "/-/login/"+strings.Repeat("z", 43), map[string]string{"Origin": publicOrigin}, "") | |||
| 419 | + | ||||
| 420 | + | logs := c.logs.String() | |||
| 421 | + | assert.NotContains(t, logs, nonce) | |||
| 422 | + | assert.NotContains(t, logs, token) | |||
| 423 | + | assert.NotContains(t, logs, strings.Repeat("z", 43)) | |||
| 424 | + | assert.Contains(t, logs, "login link created") | |||
| 425 | + | assert.Contains(t, logs, "owner signed in on the web") | |||
| 426 | + | key, err := c.forge.store.Sessions().Key(context.Background()) | |||
| 427 | + | require.NoError(t, err) | |||
| 428 | + | assert.NotContains(t, logs, string(key)) | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | // The owner principal is what the API sees from a signed-in page, and is | |||
| 432 | + | // labelled "owner". | |||
| 433 | + | func TestWebOwnerIsLabelledInLogs(t *testing.T) { | |||
| 434 | + | c := newCLIHarness(t) | |||
| 435 | + | c.seed("secret", domain.VisibilityPrivate, "notes.txt") | |||
| 436 | + | token := c.signIn(t) | |||
| 437 | + | browser{c.harness}.get("/secret", as(token)) | |||
| 438 | + | assert.Contains(t, c.logs.String(), `"principal":"owner"`) | |||
| 439 | + | } | |||
M internal/adapters/client/client.go +5 -0
| @@ -70,6 +70,11 @@ func (c *Client) Tokens() ermiteconnect.TokenServiceClient { | |||
| 70 | 70 | return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...) | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 73 | + | // Sessions returns the SessionService client. | |
| 74 | + | func (c *Client) Sessions() ermiteconnect.SessionServiceClient { | |
| 75 | + | return ermiteconnect.NewSessionServiceClient(c.http, c.base, c.opts...) | |
| 76 | + | } | |
| 77 | + | ||
| 73 | 78 | // Events returns the EventService client. | |
| 74 | 79 | func (c *Client) Events() ermiteconnect.EventServiceClient { | |
| 75 | 80 | return ermiteconnect.NewEventServiceClient(c.http, c.base, c.opts...) | |
| @@ -70,6 +70,11 @@ func (c *Client) Tokens() ermiteconnect.TokenServiceClient { | |||||
| 70 | return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...) | 70 | return ermiteconnect.NewTokenServiceClient(c.http, c.base, c.opts...) | ||
| 71 | } | 71 | } | ||
| 72 | 72 | ||||
| 73 | + | // Sessions returns the SessionService client. | |||
| 74 | + | func (c *Client) Sessions() ermiteconnect.SessionServiceClient { | |||
| 75 | + | return ermiteconnect.NewSessionServiceClient(c.http, c.base, c.opts...) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 73 | // Events returns the EventService client. | 78 | // Events returns the EventService client. | ||
| 74 | func (c *Client) Events() ermiteconnect.EventServiceClient { | 79 | func (c *Client) Events() ermiteconnect.EventServiceClient { | ||
| 75 | return ermiteconnect.NewEventServiceClient(c.http, c.base, c.opts...) | 80 | return ermiteconnect.NewEventServiceClient(c.http, c.base, c.opts...) | ||
M internal/adapters/client/client_test.go +2 -2
| @@ -140,9 +140,9 @@ func TestMissingFeatures(t *testing.T) { | |||
| 140 | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | |
| 141 | 141 | assert.Equal(t, | |
| 142 | 142 | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSigningKey}, | |
| 143 | - | client.MissingFeatures([]string{client.FeatureRepoUpdate, "other"})) | |
| 143 | + | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) | |
| 144 | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | |
| 145 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, | |
| 145 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSession, | |
| 146 | 146 | })) | |
| 147 | 147 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | |
| 148 | 148 | } | |
| @@ -140,9 +140,9 @@ func TestMissingFeatures(t *testing.T) { | |||||
| 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | 140 | assert.Empty(t, client.MissingFeatures(client.UsedFeatures())) | ||
| 141 | assert.Equal(t, | 141 | assert.Equal(t, | ||
| 142 | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSigningKey}, | 142 | []string{client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSigningKey}, | ||
| 143 | - | client.MissingFeatures([]string{client.FeatureRepoUpdate, "other"})) | 143 | + | client.MissingFeatures([]string{client.FeatureRepoUpdate, client.FeatureSession, "other"})) |
| 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | 144 | assert.Equal(t, []string{client.FeatureSigningKey}, client.MissingFeatures([]string{ | ||
| 145 | - | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, | 145 | + | client.FeatureRepoUpdate, client.FeatureEventsResume, client.FeatureMergeRequests, client.FeatureSession, |
| 146 | })) | 146 | })) | ||
| 147 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | 147 | assert.Equal(t, client.UsedFeatures(), client.MissingFeatures(nil)) | ||
| 148 | } | 148 | } | ||
M internal/adapters/client/features.go +3 -1
| @@ -17,11 +17,13 @@ const ( | |||
| 17 | 17 | FeatureMergeRequests = "merge_requests" | |
| 18 | 18 | // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`). | |
| 19 | 19 | FeatureSigningKey = "signing_key" | |
| 20 | + | // FeatureSession is SessionService (`ermite web login`, `web logout`). | |
| 21 | + | FeatureSession = "session" | |
| 20 | 22 | ) | |
| 21 | 23 | ||
| 22 | 24 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | |
| 23 | 25 | func UsedFeatures() []string { | |
| 24 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey} | |
| 26 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey, FeatureSession} | |
| 25 | 27 | } | |
| 26 | 28 | ||
| 27 | 29 | // MissingFeatures returns the features the CLI uses that have is lacking. | |
| @@ -17,11 +17,13 @@ const ( | |||||
| 17 | FeatureMergeRequests = "merge_requests" | 17 | FeatureMergeRequests = "merge_requests" | ||
| 18 | // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`). | 18 | // FeatureSigningKey is ServerService.GetSigningKey (`ermite signing-key`). | ||
| 19 | FeatureSigningKey = "signing_key" | 19 | FeatureSigningKey = "signing_key" | ||
| 20 | + | // FeatureSession is SessionService (`ermite web login`, `web logout`). | |||
| 21 | + | FeatureSession = "session" | |||
| 20 | ) | 22 | ) | ||
| 21 | 23 | ||||
| 22 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | 24 | // UsedFeatures lists the features the CLI uses, in the order they are reported. | ||
| 23 | func UsedFeatures() []string { | 25 | func UsedFeatures() []string { | ||
| 24 | - | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey} | 26 | + | return []string{FeatureRepoUpdate, FeatureEventsResume, FeatureMergeRequests, FeatureSigningKey, FeatureSession} |
| 25 | } | 27 | } | ||
| 26 | 28 | ||||
| 27 | // MissingFeatures returns the features the CLI uses that have is lacking. | 29 | // MissingFeatures returns the features the CLI uses that have is lacking. | ||
M internal/adapters/httpserver/middleware.go +16 -2
| @@ -4,6 +4,7 @@ package httpserver | |||
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | 6 | "net/http" | |
| 7 | + | "strings" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/zerowrap" | |
| @@ -60,8 +61,21 @@ func (s *statusRecorder) Flush() { | |||
| 60 | 61 | // Unwrap lets http.ResponseController reach Flush and friends. | |
| 61 | 62 | func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter } | |
| 62 | 63 | ||
| 64 | + | // loginPathPrefix is where the web serves one-time login links: the rest of | |
| 65 | + | // the path is a secret. | |
| 66 | + | const loginPathPrefix = "/-/login/" | |
| 67 | + | ||
| 68 | + | // loggedPath is the path of a request as the log may show it. | |
| 69 | + | func loggedPath(p string) string { | |
| 70 | + | if strings.HasPrefix(p, loginPathPrefix) { | |
| 71 | + | return loginPathPrefix + "…" | |
| 72 | + | } | |
| 73 | + | return p | |
| 74 | + | } | |
| 75 | + | ||
| 63 | 76 | // withRequestLog logs method, path, status and duration at debug level. It | |
| 64 | - | // never logs the query string or any header: they can carry credentials. | |
| 77 | + | // never logs the query string or any header: they can carry credentials. The | |
| 78 | + | // secret of a login link is cut from the path. | |
| 65 | 79 | func withRequestLog(next http.Handler) http.Handler { | |
| 66 | 80 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 67 | 81 | start := time.Now() | |
| @@ -74,7 +88,7 @@ func withRequestLog(next http.Handler) http.Handler { | |||
| 74 | 88 | log := zerowrap.FromCtx(r.Context()) | |
| 75 | 89 | log.Debug(). | |
| 76 | 90 | Str("method", r.Method). | |
| 77 | - | Str("path", r.URL.Path). | |
| 91 | + | Str("path", loggedPath(r.URL.Path)). | |
| 78 | 92 | Int("status", status). | |
| 79 | 93 | Dur("duration", time.Since(start)). | |
| 80 | 94 | Msg("request") | |
| @@ -4,6 +4,7 @@ package httpserver | |||||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | "net/http" | 6 | "net/http" | ||
| 7 | + | "strings" | |||
| 7 | "time" | 8 | "time" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/zerowrap" | 10 | "github.com/bnema/zerowrap" | ||
| @@ -60,8 +61,21 @@ func (s *statusRecorder) Flush() { | |||||
| 60 | // Unwrap lets http.ResponseController reach Flush and friends. | 61 | // Unwrap lets http.ResponseController reach Flush and friends. | ||
| 61 | func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter } | 62 | func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter } | ||
| 62 | 63 | ||||
| 64 | + | // loginPathPrefix is where the web serves one-time login links: the rest of | |||
| 65 | + | // the path is a secret. | |||
| 66 | + | const loginPathPrefix = "/-/login/" | |||
| 67 | + | ||||
| 68 | + | // loggedPath is the path of a request as the log may show it. | |||
| 69 | + | func loggedPath(p string) string { | |||
| 70 | + | if strings.HasPrefix(p, loginPathPrefix) { | |||
| 71 | + | return loginPathPrefix + "…" | |||
| 72 | + | } | |||
| 73 | + | return p | |||
| 74 | + | } | |||
| 75 | + | ||||
| 63 | // withRequestLog logs method, path, status and duration at debug level. It | 76 | // withRequestLog logs method, path, status and duration at debug level. It | ||
| 64 | - | // never logs the query string or any header: they can carry credentials. | 77 | + | // never logs the query string or any header: they can carry credentials. The |
| 78 | + | // secret of a login link is cut from the path. | |||
| 65 | func withRequestLog(next http.Handler) http.Handler { | 79 | func withRequestLog(next http.Handler) http.Handler { | ||
| 66 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 80 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 67 | start := time.Now() | 81 | start := time.Now() | ||
| @@ -74,7 +88,7 @@ func withRequestLog(next http.Handler) http.Handler { | |||||
| 74 | log := zerowrap.FromCtx(r.Context()) | 88 | log := zerowrap.FromCtx(r.Context()) | ||
| 75 | log.Debug(). | 89 | log.Debug(). | ||
| 76 | Str("method", r.Method). | 90 | Str("method", r.Method). | ||
| 77 | - | Str("path", r.URL.Path). | 91 | + | Str("path", loggedPath(r.URL.Path)). |
| 78 | Int("status", status). | 92 | Int("status", status). | ||
| 79 | Dur("duration", time.Since(start)). | 93 | Dur("duration", time.Since(start)). | ||
| 80 | Msg("request") | 94 | Msg("request") | ||
M internal/adapters/rpc/api.go +6 -1
| @@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20 | |||
| 27 | 27 | ||
| 28 | 28 | // Features are the optional capabilities of the contract that this server | |
| 29 | 29 | // implements, as GetVersion lists them. | |
| 30 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key"} | |
| 30 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"} | |
| 31 | 31 | ||
| 32 | 32 | // Authenticator turns a token secret into a principal, or | |
| 33 | 33 | // domain.ErrUnauthenticated. app.Auth fits. | |
| @@ -52,6 +52,8 @@ type Config struct { | |||
| 52 | 52 | // it with the other adapters that authenticate, so that a client has one | |
| 53 | 53 | // budget of failures. | |
| 54 | 54 | Limiter *clientlimit.Limiter | |
| 55 | + | // Sessions are the owner's web sessions; nil serves no SessionService. | |
| 56 | + | Sessions *app.Sessions | |
| 55 | 57 | } | |
| 56 | 58 | ||
| 57 | 59 | // API is the set of Connect handlers of the contract. | |
| @@ -80,6 +82,9 @@ func New(cfg Config) *API { | |||
| 80 | 82 | mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...)) | |
| 81 | 83 | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...)) | |
| 82 | 84 | mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...)) | |
| 85 | + | if cfg.Sessions != nil { | |
| 86 | + | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...)) | |
| 87 | + | } | |
| 83 | 88 | return &API{handler: mux} | |
| 84 | 89 | } | |
| 85 | 90 | ||
| @@ -27,7 +27,7 @@ const MaxRequestBytes = 1 << 20 | |||||
| 27 | 27 | ||||
| 28 | // Features are the optional capabilities of the contract that this server | 28 | // Features are the optional capabilities of the contract that this server | ||
| 29 | // implements, as GetVersion lists them. | 29 | // implements, as GetVersion lists them. | ||
| 30 | - | var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key"} | 30 | + | var Features = []string{"repo.update", "events.resume", "merge_requests", "signing_key", "session"} |
| 31 | 31 | ||||
| 32 | // Authenticator turns a token secret into a principal, or | 32 | // Authenticator turns a token secret into a principal, or | ||
| 33 | // domain.ErrUnauthenticated. app.Auth fits. | 33 | // domain.ErrUnauthenticated. app.Auth fits. | ||
| @@ -52,6 +52,8 @@ type Config struct { | |||||
| 52 | // it with the other adapters that authenticate, so that a client has one | 52 | // it with the other adapters that authenticate, so that a client has one | ||
| 53 | // budget of failures. | 53 | // budget of failures. | ||
| 54 | Limiter *clientlimit.Limiter | 54 | Limiter *clientlimit.Limiter | ||
| 55 | + | // Sessions are the owner's web sessions; nil serves no SessionService. | |||
| 56 | + | Sessions *app.Sessions | |||
| 55 | } | 57 | } | ||
| 56 | 58 | ||||
| 57 | // API is the set of Connect handlers of the contract. | 59 | // API is the set of Connect handlers of the contract. | ||
| @@ -80,6 +82,9 @@ func New(cfg Config) *API { | |||||
| 80 | mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...)) | 82 | mux.Handle(ermiteconnect.NewEventServiceHandler(&eventService{cfg.Events}, opts...)) | ||
| 81 | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...)) | 83 | mux.Handle(ermiteconnect.NewServerServiceHandler(&serverService{version: cfg.Version, key: cfg.SigningKey}, opts...)) | ||
| 82 | mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...)) | 84 | mux.Handle(ermiteconnect.NewMergeRequestServiceHandler(&mergeRequestService{cfg.MergeRequests}, opts...)) | ||
| 85 | + | if cfg.Sessions != nil { | |||
| 86 | + | mux.Handle(ermiteconnect.NewSessionServiceHandler(&sessionService{cfg.Sessions}, opts...)) | |||
| 87 | + | } | |||
| 83 | return &API{handler: mux} | 88 | return &API{handler: mux} | ||
| 84 | } | 89 | } | ||
| 85 | 90 | ||||
A internal/adapters/rpc/session.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package rpc | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | ||
| 8 | + | "connectrpc.com/connect" | |
| 9 | + | ||
| 10 | + | pb "bnema.dev/ermite-api/gen/ermite" | |
| 11 | + | "bnema.dev/ermite/internal/app" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | |
| 15 | + | // never calls it: sessions come from the link, and the API ignores cookies. | |
| 16 | + | type sessionService struct{ sessions *app.Sessions } | |
| 17 | + | ||
| 18 | + | func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) { | |
| 19 | + | link, err := s.sessions.CreateLoginLink(ctx, caller(ctx)) | |
| 20 | + | if err != nil { | |
| 21 | + | return nil, connectError(ctx, err) | |
| 22 | + | } | |
| 23 | + | return connect.NewResponse(&pb.CreateLoginLinkResponse{Url: link.URL, ExpiresAt: timestamp(link.ExpiresAt)}), nil | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Request[pb.SignOutEverywhereRequest]) (*connect.Response[pb.SignOutEverywhereResponse], error) { | |
| 27 | + | if err := s.sessions.SignOutEverywhere(ctx, caller(ctx)); err != nil { | |
| 28 | + | return nil, connectError(ctx, err) | |
| 29 | + | } | |
| 30 | + | return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package rpc | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | ||||
| 8 | + | "connectrpc.com/connect" | |||
| 9 | + | ||||
| 10 | + | pb "bnema.dev/ermite-api/gen/ermite" | |||
| 11 | + | "bnema.dev/ermite/internal/app" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // sessionService implements ermiteconnect.SessionServiceHandler. The browser | |||
| 15 | + | // never calls it: sessions come from the link, and the API ignores cookies. | |||
| 16 | + | type sessionService struct{ sessions *app.Sessions } | |||
| 17 | + | ||||
| 18 | + | func (s *sessionService) CreateLoginLink(ctx context.Context, _ *connect.Request[pb.CreateLoginLinkRequest]) (*connect.Response[pb.CreateLoginLinkResponse], error) { | |||
| 19 | + | link, err := s.sessions.CreateLoginLink(ctx, caller(ctx)) | |||
| 20 | + | if err != nil { | |||
| 21 | + | return nil, connectError(ctx, err) | |||
| 22 | + | } | |||
| 23 | + | return connect.NewResponse(&pb.CreateLoginLinkResponse{Url: link.URL, ExpiresAt: timestamp(link.ExpiresAt)}), nil | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (s *sessionService) SignOutEverywhere(ctx context.Context, _ *connect.Request[pb.SignOutEverywhereRequest]) (*connect.Response[pb.SignOutEverywhereResponse], error) { | |||
| 27 | + | if err := s.sessions.SignOutEverywhere(ctx, caller(ctx)); err != nil { | |||
| 28 | + | return nil, connectError(ctx, err) | |||
| 29 | + | } | |||
| 30 | + | return connect.NewResponse(&pb.SignOutEverywhereResponse{}), nil | |||
| 31 | + | } | |||
M internal/adapters/web/events.go +6 -1
| @@ -163,7 +163,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 163 | 163 | rc := http.NewResponseController(w) | |
| 164 | 164 | hdr := w.Header() | |
| 165 | 165 | hdr.Set("Content-Type", "text/event-stream") | |
| 166 | - | hdr.Set("Cache-Control", "no-cache, no-transform") | |
| 166 | + | cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform") | |
| 167 | 167 | hdr.Set("X-Accel-Buffering", "no") | |
| 168 | 168 | _ = rc.SetWriteDeadline(time.Now().Add(writeWindow)) | |
| 169 | 169 | w.WriteHeader(http.StatusOK) | |
| @@ -211,6 +211,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||
| 211 | 211 | return | |
| 212 | 212 | } | |
| 213 | 213 | case <-tick.C: | |
| 214 | + | // A stream opened by the owner ends when the session does | |
| 215 | + | // (sign out everywhere, expiry): the page reconnects as it can. | |
| 216 | + | if SignedIn(ctx) && !s.sessionStillValid(ctx, r) { | |
| 217 | + | return | |
| 218 | + | } | |
| 214 | 219 | if out.raw(": heartbeat\n\n") != nil { | |
| 215 | 220 | return | |
| 216 | 221 | } | |
| @@ -163,7 +163,7 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 163 | rc := http.NewResponseController(w) | 163 | rc := http.NewResponseController(w) | ||
| 164 | hdr := w.Header() | 164 | hdr := w.Header() | ||
| 165 | hdr.Set("Content-Type", "text/event-stream") | 165 | hdr.Set("Content-Type", "text/event-stream") | ||
| 166 | - | hdr.Set("Cache-Control", "no-cache, no-transform") | 166 | + | cacheHeaders(ctx, hdr, "no-cache, no-transform", sessionCache+", no-transform") |
| 167 | hdr.Set("X-Accel-Buffering", "no") | 167 | hdr.Set("X-Accel-Buffering", "no") | ||
| 168 | _ = rc.SetWriteDeadline(time.Now().Add(writeWindow)) | 168 | _ = rc.SetWriteDeadline(time.Now().Add(writeWindow)) | ||
| 169 | w.WriteHeader(http.StatusOK) | 169 | w.WriteHeader(http.StatusOK) | ||
| @@ -211,6 +211,11 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) { | |||||
| 211 | return | 211 | return | ||
| 212 | } | 212 | } | ||
| 213 | case <-tick.C: | 213 | case <-tick.C: | ||
| 214 | + | // A stream opened by the owner ends when the session does | |||
| 215 | + | // (sign out everywhere, expiry): the page reconnects as it can. | |||
| 216 | + | if SignedIn(ctx) && !s.sessionStillValid(ctx, r) { | |||
| 217 | + | return | |||
| 218 | + | } | |||
| 214 | if out.raw(": heartbeat\n\n") != nil { | 219 | if out.raw(": heartbeat\n\n") != nil { | ||
| 215 | return | 220 | return | ||
| 216 | } | 221 | } | ||
M internal/adapters/web/pages.templ +17 -0
| @@ -156,3 +156,20 @@ templ State(l Layout, p StatePage) { | |||
| 156 | 156 | </section> | |
| 157 | 157 | } | |
| 158 | 158 | } | |
| 159 | + | ||
| 160 | + | // Login asks the owner to confirm a login link. Opening the link consumes | |
| 161 | + | // nothing; only this form does. It is a state page with a button. | |
| 162 | + | templ Login(l Layout, p LoginPage) { | |
| 163 | + | @Shell(l, nil) { | |
| 164 | + | <section class="state" aria-labelledby="state-title"> | |
| 165 | + | <h1 id="state-title"><span class="code">login</span> { p.Title }</h1> | |
| 166 | + | for _, line := range p.Lines { | |
| 167 | + | <p class="dim">{ line }</p> | |
| 168 | + | } | |
| 169 | + | <form class="state-act" method="post" action={ templ.SafeURL(p.Action) }> | |
| 170 | + | <button type="submit">{ p.Button }</button> | |
| 171 | + | </form> | |
| 172 | + | <p class="state-back"><a href="/">home</a></p> | |
| 173 | + | </section> | |
| 174 | + | } | |
| 175 | + | } | |
| @@ -156,3 +156,20 @@ templ State(l Layout, p StatePage) { | |||||
| 156 | </section> | 156 | </section> | ||
| 157 | } | 157 | } | ||
| 158 | } | 158 | } | ||
| 159 | + | ||||
| 160 | + | // Login asks the owner to confirm a login link. Opening the link consumes | |||
| 161 | + | // nothing; only this form does. It is a state page with a button. | |||
| 162 | + | templ Login(l Layout, p LoginPage) { | |||
| 163 | + | @Shell(l, nil) { | |||
| 164 | + | <section class="state" aria-labelledby="state-title"> | |||
| 165 | + | <h1 id="state-title"><span class="code">login</span> { p.Title }</h1> | |||
| 166 | + | for _, line := range p.Lines { | |||
| 167 | + | <p class="dim">{ line }</p> | |||
| 168 | + | } | |||
| 169 | + | <form class="state-act" method="post" action={ templ.SafeURL(p.Action) }> | |||
| 170 | + | <button type="submit">{ p.Button }</button> | |||
| 171 | + | </form> | |||
| 172 | + | <p class="state-back"><a href="/">home</a></p> | |||
| 173 | + | </section> | |||
| 174 | + | } | |||
| 175 | + | } | |||
M internal/adapters/web/pages_templ.go +111 -0
| @@ -801,4 +801,115 @@ func State(l Layout, p StatePage) templ.Component { | |||
| 801 | 801 | }) | |
| 802 | 802 | } | |
| 803 | 803 | ||
| 804 | + | // Login asks the owner to confirm a login link. Opening the link consumes | |
| 805 | + | // nothing; only this form does. It is a state page with a button. | |
| 806 | + | func Login(l Layout, p LoginPage) templ.Component { | |
| 807 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 808 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 809 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |
| 810 | + | return templ_7745c5c3_CtxErr | |
| 811 | + | } | |
| 812 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 813 | + | if !templ_7745c5c3_IsBuffer { | |
| 814 | + | defer func() { | |
| 815 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 816 | + | if templ_7745c5c3_Err == nil { | |
| 817 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 818 | + | } | |
| 819 | + | }() | |
| 820 | + | } | |
| 821 | + | ctx = templ.InitializeContext(ctx) | |
| 822 | + | templ_7745c5c3_Var38 := templ.GetChildren(ctx) | |
| 823 | + | if templ_7745c5c3_Var38 == nil { | |
| 824 | + | templ_7745c5c3_Var38 = templ.NopComponent | |
| 825 | + | } | |
| 826 | + | ctx = templ.ClearChildren(ctx) | |
| 827 | + | templ_7745c5c3_Var39 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |
| 828 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |
| 829 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |
| 830 | + | if !templ_7745c5c3_IsBuffer { | |
| 831 | + | defer func() { | |
| 832 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |
| 833 | + | if templ_7745c5c3_Err == nil { | |
| 834 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |
| 835 | + | } | |
| 836 | + | }() | |
| 837 | + | } | |
| 838 | + | ctx = templ.InitializeContext(ctx) | |
| 839 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\"><span class=\"code\">login</span> ") | |
| 840 | + | if templ_7745c5c3_Err != nil { | |
| 841 | + | return templ_7745c5c3_Err | |
| 842 | + | } | |
| 843 | + | var templ_7745c5c3_Var40 string | |
| 844 | + | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |
| 845 | + | if templ_7745c5c3_Err != nil { | |
| 846 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 165, Col: 65} | |
| 847 | + | } | |
| 848 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |
| 849 | + | if templ_7745c5c3_Err != nil { | |
| 850 | + | return templ_7745c5c3_Err | |
| 851 | + | } | |
| 852 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</h1>") | |
| 853 | + | if templ_7745c5c3_Err != nil { | |
| 854 | + | return templ_7745c5c3_Err | |
| 855 | + | } | |
| 856 | + | for _, line := range p.Lines { | |
| 857 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<p class=\"dim\">") | |
| 858 | + | if templ_7745c5c3_Err != nil { | |
| 859 | + | return templ_7745c5c3_Err | |
| 860 | + | } | |
| 861 | + | var templ_7745c5c3_Var41 string | |
| 862 | + | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |
| 863 | + | if templ_7745c5c3_Err != nil { | |
| 864 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 167, Col: 25} | |
| 865 | + | } | |
| 866 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |
| 867 | + | if templ_7745c5c3_Err != nil { | |
| 868 | + | return templ_7745c5c3_Err | |
| 869 | + | } | |
| 870 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</p>") | |
| 871 | + | if templ_7745c5c3_Err != nil { | |
| 872 | + | return templ_7745c5c3_Err | |
| 873 | + | } | |
| 874 | + | } | |
| 875 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<form class=\"state-act\" method=\"post\" action=\"") | |
| 876 | + | if templ_7745c5c3_Err != nil { | |
| 877 | + | return templ_7745c5c3_Err | |
| 878 | + | } | |
| 879 | + | var templ_7745c5c3_Var42 templ.SafeURL | |
| 880 | + | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action)) | |
| 881 | + | if templ_7745c5c3_Err != nil { | |
| 882 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 169, Col: 73} | |
| 883 | + | } | |
| 884 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |
| 885 | + | if templ_7745c5c3_Err != nil { | |
| 886 | + | return templ_7745c5c3_Err | |
| 887 | + | } | |
| 888 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\"><button type=\"submit\">") | |
| 889 | + | if templ_7745c5c3_Err != nil { | |
| 890 | + | return templ_7745c5c3_Err | |
| 891 | + | } | |
| 892 | + | var templ_7745c5c3_Var43 string | |
| 893 | + | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button) | |
| 894 | + | if templ_7745c5c3_Err != nil { | |
| 895 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 170, Col: 36} | |
| 896 | + | } | |
| 897 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |
| 898 | + | if templ_7745c5c3_Err != nil { | |
| 899 | + | return templ_7745c5c3_Err | |
| 900 | + | } | |
| 901 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</button></form><p class=\"state-back\"><a href=\"/\">home</a></p></section>") | |
| 902 | + | if templ_7745c5c3_Err != nil { | |
| 903 | + | return templ_7745c5c3_Err | |
| 904 | + | } | |
| 905 | + | return nil | |
| 906 | + | }) | |
| 907 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var39), templ_7745c5c3_Buffer) | |
| 908 | + | if templ_7745c5c3_Err != nil { | |
| 909 | + | return templ_7745c5c3_Err | |
| 910 | + | } | |
| 911 | + | return nil | |
| 912 | + | }) | |
| 913 | + | } | |
| 914 | + | ||
| 804 | 915 | var _ = templruntime.GeneratedTemplate | |
| @@ -801,4 +801,115 @@ func State(l Layout, p StatePage) templ.Component { | |||||
| 801 | }) | 801 | }) | ||
| 802 | } | 802 | } | ||
| 803 | 803 | ||||
| 804 | + | // Login asks the owner to confirm a login link. Opening the link consumes | |||
| 805 | + | // nothing; only this form does. It is a state page with a button. | |||
| 806 | + | func Login(l Layout, p LoginPage) templ.Component { | |||
| 807 | + | return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 808 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 809 | + | if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { | |||
| 810 | + | return templ_7745c5c3_CtxErr | |||
| 811 | + | } | |||
| 812 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 813 | + | if !templ_7745c5c3_IsBuffer { | |||
| 814 | + | defer func() { | |||
| 815 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 816 | + | if templ_7745c5c3_Err == nil { | |||
| 817 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 818 | + | } | |||
| 819 | + | }() | |||
| 820 | + | } | |||
| 821 | + | ctx = templ.InitializeContext(ctx) | |||
| 822 | + | templ_7745c5c3_Var38 := templ.GetChildren(ctx) | |||
| 823 | + | if templ_7745c5c3_Var38 == nil { | |||
| 824 | + | templ_7745c5c3_Var38 = templ.NopComponent | |||
| 825 | + | } | |||
| 826 | + | ctx = templ.ClearChildren(ctx) | |||
| 827 | + | templ_7745c5c3_Var39 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { | |||
| 828 | + | templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context | |||
| 829 | + | templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) | |||
| 830 | + | if !templ_7745c5c3_IsBuffer { | |||
| 831 | + | defer func() { | |||
| 832 | + | templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) | |||
| 833 | + | if templ_7745c5c3_Err == nil { | |||
| 834 | + | templ_7745c5c3_Err = templ_7745c5c3_BufErr | |||
| 835 | + | } | |||
| 836 | + | }() | |||
| 837 | + | } | |||
| 838 | + | ctx = templ.InitializeContext(ctx) | |||
| 839 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<section class=\"state\" aria-labelledby=\"state-title\"><h1 id=\"state-title\"><span class=\"code\">login</span> ") | |||
| 840 | + | if templ_7745c5c3_Err != nil { | |||
| 841 | + | return templ_7745c5c3_Err | |||
| 842 | + | } | |||
| 843 | + | var templ_7745c5c3_Var40 string | |||
| 844 | + | templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(p.Title) | |||
| 845 | + | if templ_7745c5c3_Err != nil { | |||
| 846 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 165, Col: 65} | |||
| 847 | + | } | |||
| 848 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40)) | |||
| 849 | + | if templ_7745c5c3_Err != nil { | |||
| 850 | + | return templ_7745c5c3_Err | |||
| 851 | + | } | |||
| 852 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "</h1>") | |||
| 853 | + | if templ_7745c5c3_Err != nil { | |||
| 854 | + | return templ_7745c5c3_Err | |||
| 855 | + | } | |||
| 856 | + | for _, line := range p.Lines { | |||
| 857 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "<p class=\"dim\">") | |||
| 858 | + | if templ_7745c5c3_Err != nil { | |||
| 859 | + | return templ_7745c5c3_Err | |||
| 860 | + | } | |||
| 861 | + | var templ_7745c5c3_Var41 string | |||
| 862 | + | templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(line) | |||
| 863 | + | if templ_7745c5c3_Err != nil { | |||
| 864 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 167, Col: 25} | |||
| 865 | + | } | |||
| 866 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41)) | |||
| 867 | + | if templ_7745c5c3_Err != nil { | |||
| 868 | + | return templ_7745c5c3_Err | |||
| 869 | + | } | |||
| 870 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</p>") | |||
| 871 | + | if templ_7745c5c3_Err != nil { | |||
| 872 | + | return templ_7745c5c3_Err | |||
| 873 | + | } | |||
| 874 | + | } | |||
| 875 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "<form class=\"state-act\" method=\"post\" action=\"") | |||
| 876 | + | if templ_7745c5c3_Err != nil { | |||
| 877 | + | return templ_7745c5c3_Err | |||
| 878 | + | } | |||
| 879 | + | var templ_7745c5c3_Var42 templ.SafeURL | |||
| 880 | + | templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(p.Action)) | |||
| 881 | + | if templ_7745c5c3_Err != nil { | |||
| 882 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 169, Col: 73} | |||
| 883 | + | } | |||
| 884 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42)) | |||
| 885 | + | if templ_7745c5c3_Err != nil { | |||
| 886 | + | return templ_7745c5c3_Err | |||
| 887 | + | } | |||
| 888 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\"><button type=\"submit\">") | |||
| 889 | + | if templ_7745c5c3_Err != nil { | |||
| 890 | + | return templ_7745c5c3_Err | |||
| 891 | + | } | |||
| 892 | + | var templ_7745c5c3_Var43 string | |||
| 893 | + | templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(p.Button) | |||
| 894 | + | if templ_7745c5c3_Err != nil { | |||
| 895 | + | return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/adapters/web/pages.templ`, Line: 170, Col: 36} | |||
| 896 | + | } | |||
| 897 | + | _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43)) | |||
| 898 | + | if templ_7745c5c3_Err != nil { | |||
| 899 | + | return templ_7745c5c3_Err | |||
| 900 | + | } | |||
| 901 | + | templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "</button></form><p class=\"state-back\"><a href=\"/\">home</a></p></section>") | |||
| 902 | + | if templ_7745c5c3_Err != nil { | |||
| 903 | + | return templ_7745c5c3_Err | |||
| 904 | + | } | |||
| 905 | + | return nil | |||
| 906 | + | }) | |||
| 907 | + | templ_7745c5c3_Err = Shell(l, nil).Render(templ.WithChildren(ctx, templ_7745c5c3_Var39), templ_7745c5c3_Buffer) | |||
| 908 | + | if templ_7745c5c3_Err != nil { | |||
| 909 | + | return templ_7745c5c3_Err | |||
| 910 | + | } | |||
| 911 | + | return nil | |||
| 912 | + | }) | |||
| 913 | + | } | |||
| 914 | + | ||||
| 804 | var _ = templruntime.GeneratedTemplate | 915 | var _ = templruntime.GeneratedTemplate | ||
M internal/adapters/web/raw.go +1 -1
| @@ -59,7 +59,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||
| 59 | 59 | // Revalidated every time, even at a full commit id: a repository can turn | |
| 60 | 60 | // private, and a shared cache must not keep serving its files (AC2). The | |
| 61 | 61 | // ETag keeps the revalidation cheap. | |
| 62 | - | h.Set("Cache-Control", "no-cache") | |
| 62 | + | cacheHeaders(ctx, h, "no-cache", sessionCache) | |
| 63 | 63 | if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") { | |
| 64 | 64 | w.WriteHeader(http.StatusNotModified) | |
| 65 | 65 | return | |
| @@ -59,7 +59,7 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request) { | |||||
| 59 | // Revalidated every time, even at a full commit id: a repository can turn | 59 | // Revalidated every time, even at a full commit id: a repository can turn | ||
| 60 | // private, and a shared cache must not keep serving its files (AC2). The | 60 | // private, and a shared cache must not keep serving its files (AC2). The | ||
| 61 | // ETag keeps the revalidation cheap. | 61 | // ETag keeps the revalidation cheap. | ||
| 62 | - | h.Set("Cache-Control", "no-cache") | 62 | + | cacheHeaders(ctx, h, "no-cache", sessionCache) |
| 63 | if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") { | 63 | if match := r.Header.Get("If-None-Match"); match != "" && h.Get("ETag") != "" && match == h.Get("ETag") { | ||
| 64 | w.WriteHeader(http.StatusNotModified) | 64 | w.WriteHeader(http.StatusNotModified) | ||
| 65 | return | 65 | return | ||
A internal/adapters/web/session.go +279 -0
| @@ -0,0 +1,279 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "net/http" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "time" | |
| 12 | + | ||
| 13 | + | "github.com/a-h/templ" | |
| 14 | + | "github.com/bnema/zerowrap" | |
| 15 | + | ||
| 16 | + | "bnema.dev/ermite/internal/clientlimit" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // The owner's session on the web (ADR 0018). | |
| 20 | + | // | |
| 21 | + | // A browser signs in by opening the one-time link `ermite web login` printed: | |
| 22 | + | // GET /-/login/<nonce> shows a confirmation and consumes nothing, so link | |
| 23 | + | // previews and prefetchers cannot use it; POST /-/login/<nonce> consumes the | |
| 24 | + | // link and sets the cookie. The cookie holds a signed token; every request | |
| 25 | + | // that carries a valid one runs as the owner, every other as anonymous. | |
| 26 | + | // | |
| 27 | + | // State changes are POST only and pass one gate (ServeHTTP): an Origin equal to | |
| 28 | + | // the origin of the public URL, and a valid session, except the login POST, | |
| 29 | + | // which needs the link instead. The routes read the cookie and never the | |
| 30 | + | // Authorization header; the API does the opposite. | |
| 31 | + | ||
| 32 | + | // CookieName is the session cookie. The __Host- prefix makes browsers accept | |
| 33 | + | // it only with Secure, Path=/ and no Domain, and refuse it from a sibling | |
| 34 | + | // subdomain. | |
| 35 | + | const CookieName = "__Host-ermite" | |
| 36 | + | ||
| 37 | + | // loginPrefix is the path of login links, up to the nonce. | |
| 38 | + | const loginPrefix = "/-/login/" | |
| 39 | + | ||
| 40 | + | // ErrLinkRejected is what SessionAuth.Login returns for a link that is | |
| 41 | + | // unknown, expired or already used. | |
| 42 | + | var ErrLinkRejected = errors.New("login link rejected") | |
| 43 | + | ||
| 44 | + | // SessionAuth is the session side of the use cases, as the web needs it. | |
| 45 | + | type SessionAuth interface { | |
| 46 | + | // LoginLinkValid reports whether the link is pending, without using it. | |
| 47 | + | LoginLinkValid(nonce string) bool | |
| 48 | + | // Login uses the link and returns the session token and how long it | |
| 49 | + | // lasts, or ErrLinkRejected. | |
| 50 | + | Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error) | |
| 51 | + | // Verify checks a session token. When it is valid it returns the context | |
| 52 | + | // to serve the request with, which carries the owner as the caller of the | |
| 53 | + | // API; when it is not, ok is false and err nil. An error means the check | |
| 54 | + | // could not be made (the request then runs as anonymous). | |
| 55 | + | Verify(ctx context.Context, token string) (owner context.Context, ok bool, err error) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // FailureLimiter counts failed logins per client, with the failed | |
| 59 | + | // authentications of the other routes. *clientlimit.Limiter fits. | |
| 60 | + | type FailureLimiter interface { | |
| 61 | + | Blocked(ctx context.Context, addr string) (bool, time.Duration) | |
| 62 | + | Fail(ctx context.Context, addr string) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | type signedInKey struct{} | |
| 66 | + | ||
| 67 | + | // SignedIn reports whether the request carries a valid owner session. The | |
| 68 | + | // page is then private: it was rendered for the owner and must not be cached | |
| 69 | + | // by anyone else. Handlers of POST routes can rely on it: the gate refused | |
| 70 | + | // the request otherwise. | |
| 71 | + | func SignedIn(ctx context.Context) bool { | |
| 72 | + | _, ok := ctx.Value(signedInKey{}).(struct{}) | |
| 73 | + | return ok | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // sessionCache is the Cache-Control of a page rendered for the owner. | |
| 77 | + | const sessionCache = "private, no-store" | |
| 78 | + | ||
| 79 | + | // cacheHeaders sets Cache-Control: public for a visitor, and for the owner | |
| 80 | + | // the private variant with Vary: Cookie. | |
| 81 | + | func cacheHeaders(ctx context.Context, h http.Header, public, private string) { | |
| 82 | + | if SignedIn(ctx) { | |
| 83 | + | h.Set("Cache-Control", private) | |
| 84 | + | h.Add("Vary", "Cookie") | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | h.Set("Cache-Control", public) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // logPath is the path of a request as the log may show it: a login link is | |
| 91 | + | // a secret. | |
| 92 | + | func logPath(p string) string { | |
| 93 | + | if strings.HasPrefix(p, loginPrefix) { | |
| 94 | + | return loginPrefix + "…" | |
| 95 | + | } | |
| 96 | + | return p | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // withSession resolves the session of the request: the cookie, when valid, | |
| 100 | + | // makes the request the owner's. A cookie that is not valid is cleared. A | |
| 101 | + | // request without a cookie is untouched. | |
| 102 | + | func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Request { | |
| 103 | + | if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.Path, StaticPrefix) { | |
| 104 | + | return r | |
| 105 | + | } | |
| 106 | + | c, err := r.Cookie(CookieName) | |
| 107 | + | if err != nil { | |
| 108 | + | return r | |
| 109 | + | } | |
| 110 | + | ctx := r.Context() | |
| 111 | + | owner, ok, err := s.cfg.Sessions.Verify(ctx, c.Value) | |
| 112 | + | switch { | |
| 113 | + | case err != nil: | |
| 114 | + | // Fail closed, and keep the cookie: the failure may be passing. | |
| 115 | + | log := zerowrap.FromCtx(ctx) | |
| 116 | + | log.Error().Err(err).Msg("check web session") | |
| 117 | + | return r | |
| 118 | + | case !ok: | |
| 119 | + | http.SetCookie(w, s.cookie("", -1)) | |
| 120 | + | return r | |
| 121 | + | } | |
| 122 | + | return r.WithContext(context.WithValue(owner, signedInKey{}, struct{}{})) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // sessionStillValid checks again the cookie a long-lived request started | |
| 126 | + | // with. It fails closed: a check that cannot be made ends the session of the | |
| 127 | + | // stream. | |
| 128 | + | func (s *Server) sessionStillValid(ctx context.Context, r *http.Request) bool { | |
| 129 | + | c, err := r.Cookie(CookieName) | |
| 130 | + | if err != nil { | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | _, ok, err := s.cfg.Sessions.Verify(ctx, c.Value) | |
| 134 | + | return err == nil && ok | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // cookie builds the session cookie. maxAge < 0 deletes it. | |
| 138 | + | func (s *Server) cookie(token string, maxAge int) *http.Cookie { | |
| 139 | + | // SameSite=Strict, as ADR 0018 says: the cookie is not sent when another | |
| 140 | + | // site links to a page (that first page shows as signed out, the next | |
| 141 | + | // navigation on the site is signed in), and not on any cross-site request, | |
| 142 | + | // on top of the Origin check of the POST gate. | |
| 143 | + | return &http.Cookie{ | |
| 144 | + | Name: CookieName, Value: token, Path: "/", MaxAge: maxAge, | |
| 145 | + | Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // gate refuses a state change that does not come from a page of this site | |
| 150 | + | // with a session. It answers 403 and returns false. GET and HEAD pass: they | |
| 151 | + | // never change anything. | |
| 152 | + | func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool { | |
| 153 | + | if r.Method == http.MethodGet || r.Method == http.MethodHead { | |
| 154 | + | return true | |
| 155 | + | } | |
| 156 | + | // The Origin is compared with the configured public URL, never with the | |
| 157 | + | // Host header, which the client chooses. A missing header, "null" and any | |
| 158 | + | // other origin are refused. | |
| 159 | + | if r.Header.Get("Origin") != s.origin { | |
| 160 | + | denyForbidden(w) | |
| 161 | + | return false | |
| 162 | + | } | |
| 163 | + | if !SignedIn(r.Context()) && !strings.HasPrefix(r.URL.Path, loginPrefix) { | |
| 164 | + | denyForbidden(w) | |
| 165 | + | return false | |
| 166 | + | } | |
| 167 | + | return true | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func denyForbidden(w http.ResponseWriter) { | |
| 171 | + | w.Header().Set("Cache-Control", "no-store") | |
| 172 | + | http.Error(w, "forbidden", http.StatusForbidden) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // validNonce tells whether s has the shape of a login link secret, so that | |
| 176 | + | // anything else is refused before it reaches the store. | |
| 177 | + | func validNonce(s string) bool { | |
| 178 | + | if len(s) != nonceLen { | |
| 179 | + | return false | |
| 180 | + | } | |
| 181 | + | for _, c := range []byte(s) { | |
| 182 | + | switch { | |
| 183 | + | case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_': | |
| 184 | + | default: | |
| 185 | + | return false | |
| 186 | + | } | |
| 187 | + | } | |
| 188 | + | return true | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // nonceLen is the length of the secret of a login link: 32 bytes in unpadded | |
| 192 | + | // base64url. | |
| 193 | + | const nonceLen = 43 | |
| 194 | + | ||
| 195 | + | // secretPage writes a page of the login flow: never cached, and its address, | |
| 196 | + | // which holds the link, is not sent along when the visitor follows a link. | |
| 197 | + | func (s *Server) secretPage(w http.ResponseWriter, r *http.Request, status int, c templ.Component) { | |
| 198 | + | h := w.Header() | |
| 199 | + | h.Set("Cache-Control", "no-store") | |
| 200 | + | h.Set("Referrer-Policy", "no-referrer") | |
| 201 | + | s.write(r.Context(), w, r, status, c) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // loginConfirm is GET /-/login/<nonce>: it shows the confirmation and does not | |
| 205 | + | // use the link. | |
| 206 | + | func (s *Server) loginConfirm(w http.ResponseWriter, r *http.Request) { | |
| 207 | + | nonce := r.PathValue("nonce") | |
| 208 | + | if !validNonce(nonce) || !s.cfg.Sessions.LoginLinkValid(nonce) { | |
| 209 | + | s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage()) | |
| 210 | + | return | |
| 211 | + | } | |
| 212 | + | l := s.stateLayout("sign in", "sign in · confirm", []Crumb{{Label: "-"}, {Label: "sign in"}}) | |
| 213 | + | s.secretPage(w, r, http.StatusOK, Login(l, LoginPage{ | |
| 214 | + | Title: "sign in as the owner", | |
| 215 | + | Lines: []string{"this browser will be signed in for 12 hours.", "only confirm if you ran `ermite web login` just now."}, | |
| 216 | + | Action: loginPrefix + nonce, | |
| 217 | + | Button: "[sign in]", | |
| 218 | + | })) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // loginPost is POST /-/login/<nonce>: it uses the link, sets the cookie and | |
| 222 | + | // goes home. A link that does not work answers the same page whatever the | |
| 223 | + | // reason, and counts as a failed authentication of the client. | |
| 224 | + | func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) { | |
| 225 | + | ctx := r.Context() | |
| 226 | + | addr := clientlimit.ClientAddr(r, s.cfg.TrustedProxies) | |
| 227 | + | if blocked, wait := s.cfg.Limiter.Blocked(ctx, addr); blocked { | |
| 228 | + | w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) | |
| 229 | + | s.secretPage(w, r, http.StatusTooManyRequests, s.stateOnly("sign in", "429", "too many attempts", | |
| 230 | + | "too many failed sign-in attempts from this address.", "try again later.")) | |
| 231 | + | return | |
| 232 | + | } | |
| 233 | + | nonce := r.PathValue("nonce") | |
| 234 | + | var token string | |
| 235 | + | var lifetime time.Duration | |
| 236 | + | err := ErrLinkRejected | |
| 237 | + | if validNonce(nonce) { | |
| 238 | + | token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce) | |
| 239 | + | } | |
| 240 | + | switch { | |
| 241 | + | case errors.Is(err, ErrLinkRejected): | |
| 242 | + | s.cfg.Limiter.Fail(ctx, addr) | |
| 243 | + | s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage()) | |
| 244 | + | return | |
| 245 | + | case err != nil: | |
| 246 | + | id := requestID() | |
| 247 | + | log := zerowrap.FromCtx(ctx) | |
| 248 | + | log.Error().Err(err).Str("request_id", id).Msg("web login failed") | |
| 249 | + | s.secretPage(w, r, http.StatusInternalServerError, s.errorPage(id)) | |
| 250 | + | return | |
| 251 | + | } | |
| 252 | + | h := w.Header() | |
| 253 | + | h.Del("Set-Cookie") // a stale cookie cleared by withSession | |
| 254 | + | http.SetCookie(w, s.cookie(token, int(lifetime.Seconds()))) | |
| 255 | + | h.Set("Cache-Control", "no-store") | |
| 256 | + | http.Redirect(w, r, "/", http.StatusSeeOther) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // logout is POST /-/logout: it ends the session of this browser. The token | |
| 260 | + | // itself stays valid until it expires, as a stateless token must; `ermite web | |
| 261 | + | // logout --all` ends them all. | |
| 262 | + | func (s *Server) logout(w http.ResponseWriter, r *http.Request) { | |
| 263 | + | w.Header().Del("Set-Cookie") | |
| 264 | + | http.SetCookie(w, s.cookie("", -1)) | |
| 265 | + | w.Header().Set("Cache-Control", "no-store") | |
| 266 | + | http.Redirect(w, r, "/", http.StatusSeeOther) | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | // stateOnly is a state page with a title and two lines, for the login flow. | |
| 270 | + | func (s *Server) stateOnly(crumb, code, title string, lines ...string) templ.Component { | |
| 271 | + | l := s.stateLayout(title, code+" · "+title, []Crumb{{Label: "-"}, {Label: crumb}}) | |
| 272 | + | return State(l, StatePage{Code: code, Title: title, Lines: lines, Back: []Link{{Label: "home", Href: "/"}}}) | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // loginRejectedPage is the answer for a link that is unknown, expired or used. | |
| 276 | + | func (s *Server) loginRejectedPage() templ.Component { | |
| 277 | + | return s.stateOnly("sign in", "404", "login link not valid", | |
| 278 | + | "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.") | |
| 279 | + | } | |
| @@ -0,0 +1,279 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "net/http" | |||
| 9 | + | "strconv" | |||
| 10 | + | "strings" | |||
| 11 | + | "time" | |||
| 12 | + | ||||
| 13 | + | "github.com/a-h/templ" | |||
| 14 | + | "github.com/bnema/zerowrap" | |||
| 15 | + | ||||
| 16 | + | "bnema.dev/ermite/internal/clientlimit" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // The owner's session on the web (ADR 0018). | |||
| 20 | + | // | |||
| 21 | + | // A browser signs in by opening the one-time link `ermite web login` printed: | |||
| 22 | + | // GET /-/login/<nonce> shows a confirmation and consumes nothing, so link | |||
| 23 | + | // previews and prefetchers cannot use it; POST /-/login/<nonce> consumes the | |||
| 24 | + | // link and sets the cookie. The cookie holds a signed token; every request | |||
| 25 | + | // that carries a valid one runs as the owner, every other as anonymous. | |||
| 26 | + | // | |||
| 27 | + | // State changes are POST only and pass one gate (ServeHTTP): an Origin equal to | |||
| 28 | + | // the origin of the public URL, and a valid session, except the login POST, | |||
| 29 | + | // which needs the link instead. The routes read the cookie and never the | |||
| 30 | + | // Authorization header; the API does the opposite. | |||
| 31 | + | ||||
| 32 | + | // CookieName is the session cookie. The __Host- prefix makes browsers accept | |||
| 33 | + | // it only with Secure, Path=/ and no Domain, and refuse it from a sibling | |||
| 34 | + | // subdomain. | |||
| 35 | + | const CookieName = "__Host-ermite" | |||
| 36 | + | ||||
| 37 | + | // loginPrefix is the path of login links, up to the nonce. | |||
| 38 | + | const loginPrefix = "/-/login/" | |||
| 39 | + | ||||
| 40 | + | // ErrLinkRejected is what SessionAuth.Login returns for a link that is | |||
| 41 | + | // unknown, expired or already used. | |||
| 42 | + | var ErrLinkRejected = errors.New("login link rejected") | |||
| 43 | + | ||||
| 44 | + | // SessionAuth is the session side of the use cases, as the web needs it. | |||
| 45 | + | type SessionAuth interface { | |||
| 46 | + | // LoginLinkValid reports whether the link is pending, without using it. | |||
| 47 | + | LoginLinkValid(nonce string) bool | |||
| 48 | + | // Login uses the link and returns the session token and how long it | |||
| 49 | + | // lasts, or ErrLinkRejected. | |||
| 50 | + | Login(ctx context.Context, nonce string) (token string, lifetime time.Duration, err error) | |||
| 51 | + | // Verify checks a session token. When it is valid it returns the context | |||
| 52 | + | // to serve the request with, which carries the owner as the caller of the | |||
| 53 | + | // API; when it is not, ok is false and err nil. An error means the check | |||
| 54 | + | // could not be made (the request then runs as anonymous). | |||
| 55 | + | Verify(ctx context.Context, token string) (owner context.Context, ok bool, err error) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // FailureLimiter counts failed logins per client, with the failed | |||
| 59 | + | // authentications of the other routes. *clientlimit.Limiter fits. | |||
| 60 | + | type FailureLimiter interface { | |||
| 61 | + | Blocked(ctx context.Context, addr string) (bool, time.Duration) | |||
| 62 | + | Fail(ctx context.Context, addr string) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | type signedInKey struct{} | |||
| 66 | + | ||||
| 67 | + | // SignedIn reports whether the request carries a valid owner session. The | |||
| 68 | + | // page is then private: it was rendered for the owner and must not be cached | |||
| 69 | + | // by anyone else. Handlers of POST routes can rely on it: the gate refused | |||
| 70 | + | // the request otherwise. | |||
| 71 | + | func SignedIn(ctx context.Context) bool { | |||
| 72 | + | _, ok := ctx.Value(signedInKey{}).(struct{}) | |||
| 73 | + | return ok | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | // sessionCache is the Cache-Control of a page rendered for the owner. | |||
| 77 | + | const sessionCache = "private, no-store" | |||
| 78 | + | ||||
| 79 | + | // cacheHeaders sets Cache-Control: public for a visitor, and for the owner | |||
| 80 | + | // the private variant with Vary: Cookie. | |||
| 81 | + | func cacheHeaders(ctx context.Context, h http.Header, public, private string) { | |||
| 82 | + | if SignedIn(ctx) { | |||
| 83 | + | h.Set("Cache-Control", private) | |||
| 84 | + | h.Add("Vary", "Cookie") | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | h.Set("Cache-Control", public) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // logPath is the path of a request as the log may show it: a login link is | |||
| 91 | + | // a secret. | |||
| 92 | + | func logPath(p string) string { | |||
| 93 | + | if strings.HasPrefix(p, loginPrefix) { | |||
| 94 | + | return loginPrefix + "…" | |||
| 95 | + | } | |||
| 96 | + | return p | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // withSession resolves the session of the request: the cookie, when valid, | |||
| 100 | + | // makes the request the owner's. A cookie that is not valid is cleared. A | |||
| 101 | + | // request without a cookie is untouched. | |||
| 102 | + | func (s *Server) withSession(w http.ResponseWriter, r *http.Request) *http.Request { | |||
| 103 | + | if s.cfg.Sessions == nil || strings.HasPrefix(r.URL.Path, StaticPrefix) { | |||
| 104 | + | return r | |||
| 105 | + | } | |||
| 106 | + | c, err := r.Cookie(CookieName) | |||
| 107 | + | if err != nil { | |||
| 108 | + | return r | |||
| 109 | + | } | |||
| 110 | + | ctx := r.Context() | |||
| 111 | + | owner, ok, err := s.cfg.Sessions.Verify(ctx, c.Value) | |||
| 112 | + | switch { | |||
| 113 | + | case err != nil: | |||
| 114 | + | // Fail closed, and keep the cookie: the failure may be passing. | |||
| 115 | + | log := zerowrap.FromCtx(ctx) | |||
| 116 | + | log.Error().Err(err).Msg("check web session") | |||
| 117 | + | return r | |||
| 118 | + | case !ok: | |||
| 119 | + | http.SetCookie(w, s.cookie("", -1)) | |||
| 120 | + | return r | |||
| 121 | + | } | |||
| 122 | + | return r.WithContext(context.WithValue(owner, signedInKey{}, struct{}{})) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // sessionStillValid checks again the cookie a long-lived request started | |||
| 126 | + | // with. It fails closed: a check that cannot be made ends the session of the | |||
| 127 | + | // stream. | |||
| 128 | + | func (s *Server) sessionStillValid(ctx context.Context, r *http.Request) bool { | |||
| 129 | + | c, err := r.Cookie(CookieName) | |||
| 130 | + | if err != nil { | |||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | _, ok, err := s.cfg.Sessions.Verify(ctx, c.Value) | |||
| 134 | + | return err == nil && ok | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | // cookie builds the session cookie. maxAge < 0 deletes it. | |||
| 138 | + | func (s *Server) cookie(token string, maxAge int) *http.Cookie { | |||
| 139 | + | // SameSite=Strict, as ADR 0018 says: the cookie is not sent when another | |||
| 140 | + | // site links to a page (that first page shows as signed out, the next | |||
| 141 | + | // navigation on the site is signed in), and not on any cross-site request, | |||
| 142 | + | // on top of the Origin check of the POST gate. | |||
| 143 | + | return &http.Cookie{ | |||
| 144 | + | Name: CookieName, Value: token, Path: "/", MaxAge: maxAge, | |||
| 145 | + | Secure: true, HttpOnly: true, SameSite: http.SameSiteStrictMode, | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // gate refuses a state change that does not come from a page of this site | |||
| 150 | + | // with a session. It answers 403 and returns false. GET and HEAD pass: they | |||
| 151 | + | // never change anything. | |||
| 152 | + | func (s *Server) gate(w http.ResponseWriter, r *http.Request) bool { | |||
| 153 | + | if r.Method == http.MethodGet || r.Method == http.MethodHead { | |||
| 154 | + | return true | |||
| 155 | + | } | |||
| 156 | + | // The Origin is compared with the configured public URL, never with the | |||
| 157 | + | // Host header, which the client chooses. A missing header, "null" and any | |||
| 158 | + | // other origin are refused. | |||
| 159 | + | if r.Header.Get("Origin") != s.origin { | |||
| 160 | + | denyForbidden(w) | |||
| 161 | + | return false | |||
| 162 | + | } | |||
| 163 | + | if !SignedIn(r.Context()) && !strings.HasPrefix(r.URL.Path, loginPrefix) { | |||
| 164 | + | denyForbidden(w) | |||
| 165 | + | return false | |||
| 166 | + | } | |||
| 167 | + | return true | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func denyForbidden(w http.ResponseWriter) { | |||
| 171 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 172 | + | http.Error(w, "forbidden", http.StatusForbidden) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // validNonce tells whether s has the shape of a login link secret, so that | |||
| 176 | + | // anything else is refused before it reaches the store. | |||
| 177 | + | func validNonce(s string) bool { | |||
| 178 | + | if len(s) != nonceLen { | |||
| 179 | + | return false | |||
| 180 | + | } | |||
| 181 | + | for _, c := range []byte(s) { | |||
| 182 | + | switch { | |||
| 183 | + | case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_': | |||
| 184 | + | default: | |||
| 185 | + | return false | |||
| 186 | + | } | |||
| 187 | + | } | |||
| 188 | + | return true | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // nonceLen is the length of the secret of a login link: 32 bytes in unpadded | |||
| 192 | + | // base64url. | |||
| 193 | + | const nonceLen = 43 | |||
| 194 | + | ||||
| 195 | + | // secretPage writes a page of the login flow: never cached, and its address, | |||
| 196 | + | // which holds the link, is not sent along when the visitor follows a link. | |||
| 197 | + | func (s *Server) secretPage(w http.ResponseWriter, r *http.Request, status int, c templ.Component) { | |||
| 198 | + | h := w.Header() | |||
| 199 | + | h.Set("Cache-Control", "no-store") | |||
| 200 | + | h.Set("Referrer-Policy", "no-referrer") | |||
| 201 | + | s.write(r.Context(), w, r, status, c) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // loginConfirm is GET /-/login/<nonce>: it shows the confirmation and does not | |||
| 205 | + | // use the link. | |||
| 206 | + | func (s *Server) loginConfirm(w http.ResponseWriter, r *http.Request) { | |||
| 207 | + | nonce := r.PathValue("nonce") | |||
| 208 | + | if !validNonce(nonce) || !s.cfg.Sessions.LoginLinkValid(nonce) { | |||
| 209 | + | s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage()) | |||
| 210 | + | return | |||
| 211 | + | } | |||
| 212 | + | l := s.stateLayout("sign in", "sign in · confirm", []Crumb{{Label: "-"}, {Label: "sign in"}}) | |||
| 213 | + | s.secretPage(w, r, http.StatusOK, Login(l, LoginPage{ | |||
| 214 | + | Title: "sign in as the owner", | |||
| 215 | + | Lines: []string{"this browser will be signed in for 12 hours.", "only confirm if you ran `ermite web login` just now."}, | |||
| 216 | + | Action: loginPrefix + nonce, | |||
| 217 | + | Button: "[sign in]", | |||
| 218 | + | })) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // loginPost is POST /-/login/<nonce>: it uses the link, sets the cookie and | |||
| 222 | + | // goes home. A link that does not work answers the same page whatever the | |||
| 223 | + | // reason, and counts as a failed authentication of the client. | |||
| 224 | + | func (s *Server) loginPost(w http.ResponseWriter, r *http.Request) { | |||
| 225 | + | ctx := r.Context() | |||
| 226 | + | addr := clientlimit.ClientAddr(r, s.cfg.TrustedProxies) | |||
| 227 | + | if blocked, wait := s.cfg.Limiter.Blocked(ctx, addr); blocked { | |||
| 228 | + | w.Header().Set("Retry-After", strconv.Itoa(int(wait.Seconds())+1)) | |||
| 229 | + | s.secretPage(w, r, http.StatusTooManyRequests, s.stateOnly("sign in", "429", "too many attempts", | |||
| 230 | + | "too many failed sign-in attempts from this address.", "try again later.")) | |||
| 231 | + | return | |||
| 232 | + | } | |||
| 233 | + | nonce := r.PathValue("nonce") | |||
| 234 | + | var token string | |||
| 235 | + | var lifetime time.Duration | |||
| 236 | + | err := ErrLinkRejected | |||
| 237 | + | if validNonce(nonce) { | |||
| 238 | + | token, lifetime, err = s.cfg.Sessions.Login(ctx, nonce) | |||
| 239 | + | } | |||
| 240 | + | switch { | |||
| 241 | + | case errors.Is(err, ErrLinkRejected): | |||
| 242 | + | s.cfg.Limiter.Fail(ctx, addr) | |||
| 243 | + | s.secretPage(w, r, http.StatusNotFound, s.loginRejectedPage()) | |||
| 244 | + | return | |||
| 245 | + | case err != nil: | |||
| 246 | + | id := requestID() | |||
| 247 | + | log := zerowrap.FromCtx(ctx) | |||
| 248 | + | log.Error().Err(err).Str("request_id", id).Msg("web login failed") | |||
| 249 | + | s.secretPage(w, r, http.StatusInternalServerError, s.errorPage(id)) | |||
| 250 | + | return | |||
| 251 | + | } | |||
| 252 | + | h := w.Header() | |||
| 253 | + | h.Del("Set-Cookie") // a stale cookie cleared by withSession | |||
| 254 | + | http.SetCookie(w, s.cookie(token, int(lifetime.Seconds()))) | |||
| 255 | + | h.Set("Cache-Control", "no-store") | |||
| 256 | + | http.Redirect(w, r, "/", http.StatusSeeOther) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // logout is POST /-/logout: it ends the session of this browser. The token | |||
| 260 | + | // itself stays valid until it expires, as a stateless token must; `ermite web | |||
| 261 | + | // logout --all` ends them all. | |||
| 262 | + | func (s *Server) logout(w http.ResponseWriter, r *http.Request) { | |||
| 263 | + | w.Header().Del("Set-Cookie") | |||
| 264 | + | http.SetCookie(w, s.cookie("", -1)) | |||
| 265 | + | w.Header().Set("Cache-Control", "no-store") | |||
| 266 | + | http.Redirect(w, r, "/", http.StatusSeeOther) | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | // stateOnly is a state page with a title and two lines, for the login flow. | |||
| 270 | + | func (s *Server) stateOnly(crumb, code, title string, lines ...string) templ.Component { | |||
| 271 | + | l := s.stateLayout(title, code+" · "+title, []Crumb{{Label: "-"}, {Label: crumb}}) | |||
| 272 | + | return State(l, StatePage{Code: code, Title: title, Lines: lines, Back: []Link{{Label: "home", Href: "/"}}}) | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // loginRejectedPage is the answer for a link that is unknown, expired or used. | |||
| 276 | + | func (s *Server) loginRejectedPage() templ.Component { | |||
| 277 | + | return s.stateOnly("sign in", "404", "login link not valid", | |||
| 278 | + | "this link has expired, was already used, or never existed.", "run `ermite web login` for a new one.") | |||
| 279 | + | } | |||
A internal/adapters/web/session_test.go +168 -0
| @@ -0,0 +1,168 @@ | |||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |
| 2 | + | ||
| 3 | + | package web | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "errors" | |
| 8 | + | "net/http" | |
| 9 | + | "net/http/httptest" | |
| 10 | + | "net/url" | |
| 11 | + | "strings" | |
| 12 | + | "testing" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/stretchr/testify/assert" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // fakeSessions accepts one token. | |
| 22 | + | type fakeSessions struct { | |
| 23 | + | valid string | |
| 24 | + | err error | |
| 25 | + | used []string | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (f *fakeSessions) LoginLinkValid(n string) bool { return n == strings.Repeat("a", 43) } | |
| 29 | + | ||
| 30 | + | func (f *fakeSessions) Login(_ context.Context, n string) (string, time.Duration, error) { | |
| 31 | + | f.used = append(f.used, n) | |
| 32 | + | if n != strings.Repeat("a", 43) { | |
| 33 | + | return "", 0, ErrLinkRejected | |
| 34 | + | } | |
| 35 | + | return f.valid, time.Hour, nil | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (f *fakeSessions) Verify(ctx context.Context, tok string) (context.Context, bool, error) { | |
| 39 | + | return ctx, f.err == nil && tok == f.valid, f.err | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | type countingLimiter struct{ fails int } | |
| 43 | + | ||
| 44 | + | func (*countingLimiter) Blocked(context.Context, string) (bool, time.Duration) { return false, 0 } | |
| 45 | + | func (l *countingLimiter) Fail(context.Context, string) { l.fails++ } | |
| 46 | + | ||
| 47 | + | func newSessionServer(t *testing.T, sess SessionAuth, publicURL string) (*Server, *countingLimiter) { | |
| 48 | + | t.Helper() | |
| 49 | + | lim := &countingLimiter{} | |
| 50 | + | srv, err := New(Config{ | |
| 51 | + | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 52 | + | PublicURL: publicURL, SourceURL: "https://forge.example/ermite", Version: "v1", | |
| 53 | + | Sessions: sess, Limiter: lim, | |
| 54 | + | }) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | return srv, lim | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestOriginOf(t *testing.T) { | |
| 60 | + | for in, want := range map[string]string{ | |
| 61 | + | "https://forge.example": "https://forge.example", | |
| 62 | + | "https://Forge.Example/": "https://forge.example", | |
| 63 | + | "https://forge.example:443": "https://forge.example", | |
| 64 | + | "http://forge.example:80": "http://forge.example", | |
| 65 | + | "http://forge.example:8080": "http://forge.example:8080", | |
| 66 | + | "https://forge.example:8443": "https://forge.example:8443", | |
| 67 | + | "http://127.0.0.1:3000": "http://127.0.0.1:3000", | |
| 68 | + | "http://[::1]:3000": "http://[::1]:3000", | |
| 69 | + | "https://forge.example/sub/dir": "https://forge.example", | |
| 70 | + | } { | |
| 71 | + | u, err := url.Parse(in) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | assert.Equal(t, want, originOf(u), in) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestValidNonce(t *testing.T) { | |
| 78 | + | assert.True(t, validNonce(strings.Repeat("aZ09-_", 7)+"a")) | |
| 79 | + | for _, n := range []string{"", "short", strings.Repeat("a", 44), strings.Repeat("a", 42) + "=", strings.Repeat("a", 42) + "/", strings.Repeat("é", 22)} { | |
| 80 | + | assert.False(t, validNonce(n), n) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // The gate: a POST needs the Origin of the public URL, whatever Host says, and | |
| 85 | + | // a session unless it is the login. | |
| 86 | + | func TestGate(t *testing.T) { | |
| 87 | + | srv, lim := newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example") | |
| 88 | + | do := func(path, origin, cookie string) int { | |
| 89 | + | req := httptest.NewRequest(http.MethodPost, path, nil) | |
| 90 | + | req.Host = "forge.example" | |
| 91 | + | if origin != "" { | |
| 92 | + | req.Header.Set("Origin", origin) | |
| 93 | + | } | |
| 94 | + | if cookie != "" { | |
| 95 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: cookie}) | |
| 96 | + | } | |
| 97 | + | rec := httptest.NewRecorder() | |
| 98 | + | srv.ServeHTTP(rec, req) | |
| 99 | + | return rec.Code | |
| 100 | + | } | |
| 101 | + | link := "/-/login/" + strings.Repeat("a", 43) | |
| 102 | + | ||
| 103 | + | assert.Equal(t, 403, do("/-/logout", "", "tok"), "no Origin") | |
| 104 | + | assert.Equal(t, 403, do("/-/logout", "https://evil.example", "tok")) | |
| 105 | + | assert.Equal(t, 403, do("/-/logout", "https://forge.example.evil.example", "tok")) | |
| 106 | + | assert.Equal(t, 403, do("/-/logout", "http://forge.example", "tok"), "other scheme") | |
| 107 | + | assert.Equal(t, 403, do("/-/logout", "null", "tok")) | |
| 108 | + | assert.Equal(t, 403, do("/-/logout", "https://forge.example", ""), "no session") | |
| 109 | + | assert.Equal(t, 403, do("/-/logout", "https://forge.example", "stale"), "bad session") | |
| 110 | + | assert.Equal(t, 303, do("/-/logout", "https://forge.example", "tok")) | |
| 111 | + | assert.Equal(t, 403, do("/anything", "https://forge.example", ""), "every POST is gated") | |
| 112 | + | ||
| 113 | + | assert.Equal(t, 403, do(link, "", ""), "login without Origin") | |
| 114 | + | assert.Equal(t, 403, do(link, "https://evil.example", "")) | |
| 115 | + | assert.Equal(t, 303, do(link, "https://forge.example", ""), "login needs the link, not a session") | |
| 116 | + | assert.Equal(t, 404, do("/-/login/"+strings.Repeat("b", 43), "https://forge.example", "")) | |
| 117 | + | assert.Equal(t, 1, lim.fails, "one failed login counted") | |
| 118 | + | assert.Equal(t, 404, do("/-/login/x", "https://forge.example", "")) | |
| 119 | + | assert.Equal(t, 2, lim.fails, "a malformed link counts too") | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // A session check that cannot be made fails closed (anonymous) and keeps the | |
| 123 | + | // cookie; a check that says no clears it. | |
| 124 | + | func TestWithSessionFailsClosed(t *testing.T) { | |
| 125 | + | broken := &fakeSessions{valid: "tok", err: errors.New("kv down")} | |
| 126 | + | srv, _ := newSessionServer(t, broken, "https://forge.example") | |
| 127 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 128 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | |
| 129 | + | rec := httptest.NewRecorder() | |
| 130 | + | r := srv.withSession(rec, req) | |
| 131 | + | assert.False(t, SignedIn(r.Context())) | |
| 132 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |
| 133 | + | ||
| 134 | + | srv, _ = newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example") | |
| 135 | + | req = httptest.NewRequest(http.MethodGet, "/", nil) | |
| 136 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"}) | |
| 137 | + | rec = httptest.NewRecorder() | |
| 138 | + | r = srv.withSession(rec, req) | |
| 139 | + | assert.False(t, SignedIn(r.Context())) | |
| 140 | + | assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0") | |
| 141 | + | ||
| 142 | + | req = httptest.NewRequest(http.MethodGet, "/", nil) | |
| 143 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | |
| 144 | + | assert.True(t, SignedIn(srv.withSession(httptest.NewRecorder(), req).Context())) | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Without sessions configured there is no login route and no cookie is read. | |
| 148 | + | func TestNoSessionsNoRoutes(t *testing.T) { | |
| 149 | + | srv, err := New(Config{ | |
| 150 | + | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 151 | + | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |
| 152 | + | }) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | rec := httptest.NewRecorder() | |
| 155 | + | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil)) | |
| 156 | + | assert.Equal(t, 404, rec.Code) | |
| 157 | + | ||
| 158 | + | _, err = New(Config{ | |
| 159 | + | Repos: failing{}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |
| 160 | + | PublicURL: "https://forge.example", Sessions: &fakeSessions{}, | |
| 161 | + | }) | |
| 162 | + | assert.Error(t, err, "sessions need a limiter") | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func TestLogPathHidesLinks(t *testing.T) { | |
| 166 | + | assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43))) | |
| 167 | + | assert.Equal(t, "/demo/tree", logPath("/demo/tree")) | |
| 168 | + | } | |
| @@ -0,0 +1,168 @@ | |||||
| 1 | + | // SPDX-License-Identifier: AGPL-3.0-or-later | |||
| 2 | + | ||||
| 3 | + | package web | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "context" | |||
| 7 | + | "errors" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/http/httptest" | |||
| 10 | + | "net/url" | |||
| 11 | + | "strings" | |||
| 12 | + | "testing" | |||
| 13 | + | "time" | |||
| 14 | + | ||||
| 15 | + | "github.com/stretchr/testify/assert" | |||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 17 | + | ||||
| 18 | + | "bnema.dev/ermite-api/gen/ermite/ermiteconnect" | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | // fakeSessions accepts one token. | |||
| 22 | + | type fakeSessions struct { | |||
| 23 | + | valid string | |||
| 24 | + | err error | |||
| 25 | + | used []string | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (f *fakeSessions) LoginLinkValid(n string) bool { return n == strings.Repeat("a", 43) } | |||
| 29 | + | ||||
| 30 | + | func (f *fakeSessions) Login(_ context.Context, n string) (string, time.Duration, error) { | |||
| 31 | + | f.used = append(f.used, n) | |||
| 32 | + | if n != strings.Repeat("a", 43) { | |||
| 33 | + | return "", 0, ErrLinkRejected | |||
| 34 | + | } | |||
| 35 | + | return f.valid, time.Hour, nil | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (f *fakeSessions) Verify(ctx context.Context, tok string) (context.Context, bool, error) { | |||
| 39 | + | return ctx, f.err == nil && tok == f.valid, f.err | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | type countingLimiter struct{ fails int } | |||
| 43 | + | ||||
| 44 | + | func (*countingLimiter) Blocked(context.Context, string) (bool, time.Duration) { return false, 0 } | |||
| 45 | + | func (l *countingLimiter) Fail(context.Context, string) { l.fails++ } | |||
| 46 | + | ||||
| 47 | + | func newSessionServer(t *testing.T, sess SessionAuth, publicURL string) (*Server, *countingLimiter) { | |||
| 48 | + | t.Helper() | |||
| 49 | + | lim := &countingLimiter{} | |||
| 50 | + | srv, err := New(Config{ | |||
| 51 | + | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |||
| 52 | + | PublicURL: publicURL, SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 53 | + | Sessions: sess, Limiter: lim, | |||
| 54 | + | }) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | return srv, lim | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestOriginOf(t *testing.T) { | |||
| 60 | + | for in, want := range map[string]string{ | |||
| 61 | + | "https://forge.example": "https://forge.example", | |||
| 62 | + | "https://Forge.Example/": "https://forge.example", | |||
| 63 | + | "https://forge.example:443": "https://forge.example", | |||
| 64 | + | "http://forge.example:80": "http://forge.example", | |||
| 65 | + | "http://forge.example:8080": "http://forge.example:8080", | |||
| 66 | + | "https://forge.example:8443": "https://forge.example:8443", | |||
| 67 | + | "http://127.0.0.1:3000": "http://127.0.0.1:3000", | |||
| 68 | + | "http://[::1]:3000": "http://[::1]:3000", | |||
| 69 | + | "https://forge.example/sub/dir": "https://forge.example", | |||
| 70 | + | } { | |||
| 71 | + | u, err := url.Parse(in) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | assert.Equal(t, want, originOf(u), in) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestValidNonce(t *testing.T) { | |||
| 78 | + | assert.True(t, validNonce(strings.Repeat("aZ09-_", 7)+"a")) | |||
| 79 | + | for _, n := range []string{"", "short", strings.Repeat("a", 44), strings.Repeat("a", 42) + "=", strings.Repeat("a", 42) + "/", strings.Repeat("é", 22)} { | |||
| 80 | + | assert.False(t, validNonce(n), n) | |||
| 81 | + | } | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // The gate: a POST needs the Origin of the public URL, whatever Host says, and | |||
| 85 | + | // a session unless it is the login. | |||
| 86 | + | func TestGate(t *testing.T) { | |||
| 87 | + | srv, lim := newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example") | |||
| 88 | + | do := func(path, origin, cookie string) int { | |||
| 89 | + | req := httptest.NewRequest(http.MethodPost, path, nil) | |||
| 90 | + | req.Host = "forge.example" | |||
| 91 | + | if origin != "" { | |||
| 92 | + | req.Header.Set("Origin", origin) | |||
| 93 | + | } | |||
| 94 | + | if cookie != "" { | |||
| 95 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: cookie}) | |||
| 96 | + | } | |||
| 97 | + | rec := httptest.NewRecorder() | |||
| 98 | + | srv.ServeHTTP(rec, req) | |||
| 99 | + | return rec.Code | |||
| 100 | + | } | |||
| 101 | + | link := "/-/login/" + strings.Repeat("a", 43) | |||
| 102 | + | ||||
| 103 | + | assert.Equal(t, 403, do("/-/logout", "", "tok"), "no Origin") | |||
| 104 | + | assert.Equal(t, 403, do("/-/logout", "https://evil.example", "tok")) | |||
| 105 | + | assert.Equal(t, 403, do("/-/logout", "https://forge.example.evil.example", "tok")) | |||
| 106 | + | assert.Equal(t, 403, do("/-/logout", "http://forge.example", "tok"), "other scheme") | |||
| 107 | + | assert.Equal(t, 403, do("/-/logout", "null", "tok")) | |||
| 108 | + | assert.Equal(t, 403, do("/-/logout", "https://forge.example", ""), "no session") | |||
| 109 | + | assert.Equal(t, 403, do("/-/logout", "https://forge.example", "stale"), "bad session") | |||
| 110 | + | assert.Equal(t, 303, do("/-/logout", "https://forge.example", "tok")) | |||
| 111 | + | assert.Equal(t, 403, do("/anything", "https://forge.example", ""), "every POST is gated") | |||
| 112 | + | ||||
| 113 | + | assert.Equal(t, 403, do(link, "", ""), "login without Origin") | |||
| 114 | + | assert.Equal(t, 403, do(link, "https://evil.example", "")) | |||
| 115 | + | assert.Equal(t, 303, do(link, "https://forge.example", ""), "login needs the link, not a session") | |||
| 116 | + | assert.Equal(t, 404, do("/-/login/"+strings.Repeat("b", 43), "https://forge.example", "")) | |||
| 117 | + | assert.Equal(t, 1, lim.fails, "one failed login counted") | |||
| 118 | + | assert.Equal(t, 404, do("/-/login/x", "https://forge.example", "")) | |||
| 119 | + | assert.Equal(t, 2, lim.fails, "a malformed link counts too") | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | // A session check that cannot be made fails closed (anonymous) and keeps the | |||
| 123 | + | // cookie; a check that says no clears it. | |||
| 124 | + | func TestWithSessionFailsClosed(t *testing.T) { | |||
| 125 | + | broken := &fakeSessions{valid: "tok", err: errors.New("kv down")} | |||
| 126 | + | srv, _ := newSessionServer(t, broken, "https://forge.example") | |||
| 127 | + | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 128 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | |||
| 129 | + | rec := httptest.NewRecorder() | |||
| 130 | + | r := srv.withSession(rec, req) | |||
| 131 | + | assert.False(t, SignedIn(r.Context())) | |||
| 132 | + | assert.Empty(t, rec.Header().Values("Set-Cookie")) | |||
| 133 | + | ||||
| 134 | + | srv, _ = newSessionServer(t, &fakeSessions{valid: "tok"}, "https://forge.example") | |||
| 135 | + | req = httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 136 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "bad"}) | |||
| 137 | + | rec = httptest.NewRecorder() | |||
| 138 | + | r = srv.withSession(rec, req) | |||
| 139 | + | assert.False(t, SignedIn(r.Context())) | |||
| 140 | + | assert.Contains(t, rec.Header().Get("Set-Cookie"), "Max-Age=0") | |||
| 141 | + | ||||
| 142 | + | req = httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 143 | + | req.AddCookie(&http.Cookie{Name: CookieName, Value: "tok"}) | |||
| 144 | + | assert.True(t, SignedIn(srv.withSession(httptest.NewRecorder(), req).Context())) | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // Without sessions configured there is no login route and no cookie is read. | |||
| 148 | + | func TestNoSessionsNoRoutes(t *testing.T) { | |||
| 149 | + | srv, err := New(Config{ | |||
| 150 | + | Repos: failing{err: errors.New("x")}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |||
| 151 | + | PublicURL: "https://forge.example", SourceURL: "https://forge.example/ermite", Version: "v1", | |||
| 152 | + | }) | |||
| 153 | + | require.NoError(t, err) | |||
| 154 | + | rec := httptest.NewRecorder() | |||
| 155 | + | srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/-/login/"+strings.Repeat("a", 43), nil)) | |||
| 156 | + | assert.Equal(t, 404, rec.Code) | |||
| 157 | + | ||||
| 158 | + | _, err = New(Config{ | |||
| 159 | + | Repos: failing{}, Git: ermiteconnect.NewGitServiceClient(http.DefaultClient, "http://unused.invalid"), | |||
| 160 | + | PublicURL: "https://forge.example", Sessions: &fakeSessions{}, | |||
| 161 | + | }) | |||
| 162 | + | assert.Error(t, err, "sessions need a limiter") | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestLogPathHidesLinks(t *testing.T) { | |||
| 166 | + | assert.Equal(t, "/-/login/…", logPath("/-/login/"+strings.Repeat("a", 43))) | |||
| 167 | + | assert.Equal(t, "/demo/tree", logPath("/demo/tree")) | |||
| 168 | + | } | |||
M internal/adapters/web/shell.templ +6 -0
| @@ -119,6 +119,12 @@ templ Statusbar(l Layout) { | |||
| 119 | 119 | } | |
| 120 | 120 | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span> | |
| 121 | 121 | <span class="st-hints">{ l.Status.Hints }</span> | |
| 122 | + | if SignedIn(ctx) { | |
| 123 | + | <form class="st-owner" method="post" action="/-/logout"> | |
| 124 | + | <span>owner</span> | |
| 125 | + | <button type="submit" title="sign out of this browser">[sign out]</button> | |
| 126 | + | </form> | |
| 127 | + | } | |
| 122 | 128 | @Footer(l) | |
| 123 | 129 | @Live(l) | |
| 124 | 130 | if l.Status.Cursor { | |
| @@ -119,6 +119,12 @@ templ Statusbar(l Layout) { | |||||
| 119 | } | 119 | } | ||
| 120 | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span> | 120 | <span class="st-flash" aria-live="polite" :text="document.body.dataset.flash || ''"></span> | ||
| 121 | <span class="st-hints">{ l.Status.Hints }</span> | 121 | <span class="st-hints">{ l.Status.Hints }</span> | ||
| 122 | + | if SignedIn(ctx) { | |||
| 123 | + | <form class="st-owner" method="post" action="/-/logout"> | |||
| 124 | + | <span>owner</span> | |||
| 125 | + | <button type="submit" title="sign out of this browser">[sign out]</button> | |||
| 126 | + | </form> | |||
| 127 | + | } | |||
| 122 | @Footer(l) | 128 | @Footer(l) | ||
| 123 | @Live(l) | 129 | @Live(l) | ||
| 124 | if l.Status.Cursor { | 130 | if l.Status.Cursor { | ||
M internal/adapters/web/shell_templ.go +16 -10 generated file: collapsed
M internal/adapters/web/static/css/20-shell.css +4 -0
| @@ -55,6 +55,10 @@ | |||
| 55 | 55 | & .st-live::before { content: "○ "; } | |
| 56 | 56 | & .st-live[data-state="live"] { color: var(--bar-ink); } | |
| 57 | 57 | & .st-live[data-state="live"]::before { content: "● "; } | |
| 58 | + | & .st-owner { flex: none; display: flex; gap: 1ch; } | |
| 59 | + | & .st-owner span { color: var(--bar-ink); } | |
| 60 | + | & .st-owner button { color: var(--bar-dim); } | |
| 61 | + | & .st-owner button:hover { color: #fff; text-decoration: underline; } | |
| 58 | 62 | & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); } | |
| 59 | 63 | } | |
| 60 | 64 | ||
| @@ -55,6 +55,10 @@ | |||||
| 55 | & .st-live::before { content: "○ "; } | 55 | & .st-live::before { content: "○ "; } | ||
| 56 | & .st-live[data-state="live"] { color: var(--bar-ink); } | 56 | & .st-live[data-state="live"] { color: var(--bar-ink); } | ||
| 57 | & .st-live[data-state="live"]::before { content: "● "; } | 57 | & .st-live[data-state="live"]::before { content: "● "; } | ||
| 58 | + | & .st-owner { flex: none; display: flex; gap: 1ch; } | |||
| 59 | + | & .st-owner span { color: var(--bar-ink); } | |||
| 60 | + | & .st-owner button { color: var(--bar-dim); } | |||
| 61 | + | & .st-owner button:hover { color: #fff; text-decoration: underline; } | |||
| 58 | & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); } | 62 | & .st-pos { min-width: 7ch; text-align: right; color: var(--bar-ink); } | ||
| 59 | } | 63 | } | ||
| 60 | 64 | ||||
M internal/adapters/web/static/css/30-list.css +3 -0
| @@ -62,6 +62,9 @@ | |||
| 62 | 62 | & h1 { margin-bottom: var(--lh); } | |
| 63 | 63 | & .code { display: inline-block; min-width: 6ch; } | |
| 64 | 64 | & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; } | |
| 65 | + | & .state-act { margin-top: var(--lh); } | |
| 66 | + | & .state-act button { color: var(--ink-hi); font-weight: bold; } | |
| 67 | + | & .state-act button:hover { text-decoration: underline; } | |
| 65 | 68 | } | |
| 66 | 69 | .clone { | |
| 67 | 70 | margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch; | |
| @@ -62,6 +62,9 @@ | |||||
| 62 | & h1 { margin-bottom: var(--lh); } | 62 | & h1 { margin-bottom: var(--lh); } | ||
| 63 | & .code { display: inline-block; min-width: 6ch; } | 63 | & .code { display: inline-block; min-width: 6ch; } | ||
| 64 | & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; } | 64 | & .state-back { margin-top: var(--lh); display: flex; gap: 2ch; } | ||
| 65 | + | & .state-act { margin-top: var(--lh); } | |||
| 66 | + | & .state-act button { color: var(--ink-hi); font-weight: bold; } | |||
| 67 | + | & .state-act button:hover { text-decoration: underline; } | |||
| 65 | } | 68 | } | ||
| 66 | .clone { | 69 | .clone { | ||
| 67 | margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch; | 70 | margin: var(--lh) 0 0; border: 1px solid var(--rule); padding: 0 1ch; | ||
M internal/adapters/web/view.go +8 -0
| @@ -179,3 +179,11 @@ type StatePage struct { | |||
| 179 | 179 | Clone *Clone | |
| 180 | 180 | Back []Link | |
| 181 | 181 | } | |
| 182 | + | ||
| 183 | + | // LoginPage is the confirmation of a login link: a form with one button. | |
| 184 | + | type LoginPage struct { | |
| 185 | + | Title string | |
| 186 | + | Lines []string | |
| 187 | + | Action string // where the form posts | |
| 188 | + | Button string | |
| 189 | + | } | |
| @@ -179,3 +179,11 @@ type StatePage struct { | |||||
| 179 | Clone *Clone | 179 | Clone *Clone | ||
| 180 | Back []Link | 180 | Back []Link | ||
| 181 | } | 181 | } | ||
| 182 | + | ||||
| 183 | + | // LoginPage is the confirmation of a login link: a form with one button. | |||
| 184 | + | type LoginPage struct { | |||
| 185 | + | Title string | |||
| 186 | + | Lines []string | |||
| 187 | + | Action string // where the form posts | |||
| 188 | + | Button string | |||
| 189 | + | } | |||
M internal/adapters/web/web.go +51 -5
| @@ -23,7 +23,9 @@ import ( | |||
| 23 | 23 | "errors" | |
| 24 | 24 | "fmt" | |
| 25 | 25 | "net/http" | |
| 26 | + | "net/netip" | |
| 26 | 27 | "net/url" | |
| 28 | + | "strings" | |
| 27 | 29 | "time" | |
| 28 | 30 | ||
| 29 | 31 | "connectrpc.com/connect" | |
| @@ -62,12 +64,22 @@ type Config struct { | |||
| 62 | 64 | Version string | |
| 63 | 65 | // Now is the clock of relative dates; time.Now when nil. | |
| 64 | 66 | Now func() time.Time | |
| 67 | + | // Sessions signs the owner in (ADR 0018). Without it there are no login | |
| 68 | + | // routes and every visitor is anonymous. | |
| 69 | + | Sessions SessionAuth | |
| 70 | + | // Limiter counts the failed logins; required with Sessions. Share it with | |
| 71 | + | // the API and Git, so that a client has one budget of failures. | |
| 72 | + | Limiter FailureLimiter | |
| 73 | + | // TrustedProxies are the networks allowed to set X-Forwarded-For, to find | |
| 74 | + | // the address of the client that fails a login. | |
| 75 | + | TrustedProxies []netip.Prefix | |
| 65 | 76 | } | |
| 66 | 77 | ||
| 67 | 78 | // Server serves the pages. It is an http.Handler. | |
| 68 | 79 | type Server struct { | |
| 69 | 80 | cfg Config | |
| 70 | 81 | instance string | |
| 82 | + | origin string // scheme://host of the public URL, what a POST must come from | |
| 71 | 83 | assets *assetSet | |
| 72 | 84 | md *markdown | |
| 73 | 85 | mux *http.ServeMux | |
| @@ -89,7 +101,13 @@ func New(cfg Config) (*Server, error) { | |||
| 89 | 101 | if err != nil { | |
| 90 | 102 | return nil, fmt.Errorf("web: load assets: %w", err) | |
| 91 | 103 | } | |
| 92 | - | s := &Server{cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown()} | |
| 104 | + | if cfg.Sessions != nil && cfg.Limiter == nil { | |
| 105 | + | return nil, errors.New("web: sessions need a failure limiter") | |
| 106 | + | } | |
| 107 | + | s := &Server{ | |
| 108 | + | cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(), | |
| 109 | + | origin: originOf(u), | |
| 110 | + | } | |
| 93 | 111 | s.mux = s.routes() | |
| 94 | 112 | return s, nil | |
| 95 | 113 | } | |
| @@ -100,6 +118,11 @@ func (s *Server) routes() *http.ServeMux { | |||
| 100 | 118 | mux := http.NewServeMux() | |
| 101 | 119 | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | |
| 102 | 120 | mux.HandleFunc("GET /-/events", s.events) | |
| 121 | + | if s.cfg.Sessions != nil { | |
| 122 | + | mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm) | |
| 123 | + | mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost) | |
| 124 | + | mux.HandleFunc("POST /-/logout", s.logout) | |
| 125 | + | } | |
| 103 | 126 | mux.HandleFunc("GET /{$}", s.page(s.home)) | |
| 104 | 127 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | |
| 105 | 128 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | |
| @@ -117,6 +140,10 @@ func (s *Server) routes() *http.ServeMux { | |||
| 117 | 140 | ||
| 118 | 141 | // ServeHTTP implements http.Handler. | |
| 119 | 142 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 143 | + | r = s.withSession(w, r) | |
| 144 | + | if !s.gate(w, r) { | |
| 145 | + | return | |
| 146 | + | } | |
| 120 | 147 | s.mux.ServeHTTP(w, r) | |
| 121 | 148 | } | |
| 122 | 149 | ||
| @@ -146,14 +173,18 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque | |||
| 146 | 173 | if err := c.Render(ctx, &buf); err != nil { | |
| 147 | 174 | // The error page itself failed: a bare answer is all that is left. | |
| 148 | 175 | log := zerowrap.FromCtx(ctx) | |
| 149 | - | log.Error().Err(err).Str("path", r.URL.Path).Msg("render page") | |
| 176 | + | log.Error().Err(err).Str("path", logPath(r.URL.Path)).Msg("render page") | |
| 150 | 177 | http.Error(w, "internal error", http.StatusInternalServerError) | |
| 151 | 178 | return | |
| 152 | 179 | } | |
| 153 | 180 | h := w.Header() | |
| 154 | 181 | h.Set("Content-Type", "text/html; charset=utf-8") | |
| 155 | - | // Public pages: the browser and any proxy revalidate each time. | |
| 156 | - | h.Set("Cache-Control", "no-cache") | |
| 182 | + | // Public pages: the browser and any proxy revalidate each time. A page | |
| 183 | + | // rendered for the owner is private and never stored; a handler that set | |
| 184 | + | // its own Cache-Control keeps it. | |
| 185 | + | if h.Get("Cache-Control") == "" { | |
| 186 | + | cacheHeaders(ctx, h, "no-cache", sessionCache) | |
| 187 | + | } | |
| 157 | 188 | w.WriteHeader(status) | |
| 158 | 189 | if r.Method != http.MethodHead { | |
| 159 | 190 | _, _ = w.Write(buf.Bytes()) | |
| @@ -173,7 +204,7 @@ func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Reques | |||
| 173 | 204 | default: | |
| 174 | 205 | id := requestID() | |
| 175 | 206 | log := zerowrap.FromCtx(ctx) | |
| 176 | - | log.Error().Err(err).Str("request_id", id).Str("path", r.URL.Path).Msg("web request failed") | |
| 207 | + | log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web request failed") | |
| 177 | 208 | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | |
| 178 | 209 | } | |
| 179 | 210 | } | |
| @@ -236,3 +267,18 @@ func (s *Server) errorPage(id string) templ.Component { | |||
| 236 | 267 | Back: []Link{{Label: "home", Href: "/"}}, | |
| 237 | 268 | }) | |
| 238 | 269 | } | |
| 270 | + | ||
| 271 | + | // originOf is the origin of a URL as a browser writes it in the Origin | |
| 272 | + | // header: scheme and host in lower case, the port only when it is not the | |
| 273 | + | // default of the scheme. | |
| 274 | + | func originOf(u *url.URL) string { | |
| 275 | + | scheme, host, port := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname()), u.Port() | |
| 276 | + | if strings.Contains(host, ":") { | |
| 277 | + | host = "[" + host + "]" | |
| 278 | + | } | |
| 279 | + | defaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443") | |
| 280 | + | if port != "" && !defaultPort { | |
| 281 | + | host += ":" + port | |
| 282 | + | } | |
| 283 | + | return scheme + "://" + host | |
| 284 | + | } | |
| @@ -23,7 +23,9 @@ import ( | |||||
| 23 | "errors" | 23 | "errors" | ||
| 24 | "fmt" | 24 | "fmt" | ||
| 25 | "net/http" | 25 | "net/http" | ||
| 26 | + | "net/netip" | |||
| 26 | "net/url" | 27 | "net/url" | ||
| 28 | + | "strings" | |||
| 27 | "time" | 29 | "time" | ||
| 28 | 30 | ||||
| 29 | "connectrpc.com/connect" | 31 | "connectrpc.com/connect" | ||
| @@ -62,12 +64,22 @@ type Config struct { | |||||
| 62 | Version string | 64 | Version string | ||
| 63 | // Now is the clock of relative dates; time.Now when nil. | 65 | // Now is the clock of relative dates; time.Now when nil. | ||
| 64 | Now func() time.Time | 66 | Now func() time.Time | ||
| 67 | + | // Sessions signs the owner in (ADR 0018). Without it there are no login | |||
| 68 | + | // routes and every visitor is anonymous. | |||
| 69 | + | Sessions SessionAuth | |||
| 70 | + | // Limiter counts the failed logins; required with Sessions. Share it with | |||
| 71 | + | // the API and Git, so that a client has one budget of failures. | |||
| 72 | + | Limiter FailureLimiter | |||
| 73 | + | // TrustedProxies are the networks allowed to set X-Forwarded-For, to find | |||
| 74 | + | // the address of the client that fails a login. | |||
| 75 | + | TrustedProxies []netip.Prefix | |||
| 65 | } | 76 | } | ||
| 66 | 77 | ||||
| 67 | // Server serves the pages. It is an http.Handler. | 78 | // Server serves the pages. It is an http.Handler. | ||
| 68 | type Server struct { | 79 | type Server struct { | ||
| 69 | cfg Config | 80 | cfg Config | ||
| 70 | instance string | 81 | instance string | ||
| 82 | + | origin string // scheme://host of the public URL, what a POST must come from | |||
| 71 | assets *assetSet | 83 | assets *assetSet | ||
| 72 | md *markdown | 84 | md *markdown | ||
| 73 | mux *http.ServeMux | 85 | mux *http.ServeMux | ||
| @@ -89,7 +101,13 @@ func New(cfg Config) (*Server, error) { | |||||
| 89 | if err != nil { | 101 | if err != nil { | ||
| 90 | return nil, fmt.Errorf("web: load assets: %w", err) | 102 | return nil, fmt.Errorf("web: load assets: %w", err) | ||
| 91 | } | 103 | } | ||
| 92 | - | s := &Server{cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown()} | 104 | + | if cfg.Sessions != nil && cfg.Limiter == nil { |
| 105 | + | return nil, errors.New("web: sessions need a failure limiter") | |||
| 106 | + | } | |||
| 107 | + | s := &Server{ | |||
| 108 | + | cfg: cfg, instance: u.Host, assets: assets, md: newMarkdown(), | |||
| 109 | + | origin: originOf(u), | |||
| 110 | + | } | |||
| 93 | s.mux = s.routes() | 111 | s.mux = s.routes() | ||
| 94 | return s, nil | 112 | return s, nil | ||
| 95 | } | 113 | } | ||
| @@ -100,6 +118,11 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 100 | mux := http.NewServeMux() | 118 | mux := http.NewServeMux() | ||
| 101 | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | 119 | mux.HandleFunc("GET "+StaticPrefix, s.assets.serve) | ||
| 102 | mux.HandleFunc("GET /-/events", s.events) | 120 | mux.HandleFunc("GET /-/events", s.events) | ||
| 121 | + | if s.cfg.Sessions != nil { | |||
| 122 | + | mux.HandleFunc("GET "+loginPrefix+"{nonce}", s.loginConfirm) | |||
| 123 | + | mux.HandleFunc("POST "+loginPrefix+"{nonce}", s.loginPost) | |||
| 124 | + | mux.HandleFunc("POST /-/logout", s.logout) | |||
| 125 | + | } | |||
| 103 | mux.HandleFunc("GET /{$}", s.page(s.home)) | 126 | mux.HandleFunc("GET /{$}", s.page(s.home)) | ||
| 104 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | 127 | mux.HandleFunc("GET /{repo}", s.page(s.summary)) | ||
| 105 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | 128 | mux.HandleFunc("GET /{repo}/{$}", s.page(s.summary)) | ||
| @@ -117,6 +140,10 @@ func (s *Server) routes() *http.ServeMux { | |||||
| 117 | 140 | ||||
| 118 | // ServeHTTP implements http.Handler. | 141 | // ServeHTTP implements http.Handler. | ||
| 119 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | 142 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 143 | + | r = s.withSession(w, r) | |||
| 144 | + | if !s.gate(w, r) { | |||
| 145 | + | return | |||
| 146 | + | } | |||
| 120 | s.mux.ServeHTTP(w, r) | 147 | s.mux.ServeHTTP(w, r) | ||
| 121 | } | 148 | } | ||
| 122 | 149 | ||||
| @@ -146,14 +173,18 @@ func (s *Server) write(ctx context.Context, w http.ResponseWriter, r *http.Reque | |||||
| 146 | if err := c.Render(ctx, &buf); err != nil { | 173 | if err := c.Render(ctx, &buf); err != nil { | ||
| 147 | // The error page itself failed: a bare answer is all that is left. | 174 | // The error page itself failed: a bare answer is all that is left. | ||
| 148 | log := zerowrap.FromCtx(ctx) | 175 | log := zerowrap.FromCtx(ctx) | ||
| 149 | - | log.Error().Err(err).Str("path", r.URL.Path).Msg("render page") | 176 | + | log.Error().Err(err).Str("path", logPath(r.URL.Path)).Msg("render page") |
| 150 | http.Error(w, "internal error", http.StatusInternalServerError) | 177 | http.Error(w, "internal error", http.StatusInternalServerError) | ||
| 151 | return | 178 | return | ||
| 152 | } | 179 | } | ||
| 153 | h := w.Header() | 180 | h := w.Header() | ||
| 154 | h.Set("Content-Type", "text/html; charset=utf-8") | 181 | h.Set("Content-Type", "text/html; charset=utf-8") | ||
| 155 | - | // Public pages: the browser and any proxy revalidate each time. | 182 | + | // Public pages: the browser and any proxy revalidate each time. A page |
| 156 | - | h.Set("Cache-Control", "no-cache") | 183 | + | // rendered for the owner is private and never stored; a handler that set |
| 184 | + | // its own Cache-Control keeps it. | |||
| 185 | + | if h.Get("Cache-Control") == "" { | |||
| 186 | + | cacheHeaders(ctx, h, "no-cache", sessionCache) | |||
| 187 | + | } | |||
| 157 | w.WriteHeader(status) | 188 | w.WriteHeader(status) | ||
| 158 | if r.Method != http.MethodHead { | 189 | if r.Method != http.MethodHead { | ||
| 159 | _, _ = w.Write(buf.Bytes()) | 190 | _, _ = w.Write(buf.Bytes()) | ||
| @@ -173,7 +204,7 @@ func (s *Server) fail(ctx context.Context, w http.ResponseWriter, r *http.Reques | |||||
| 173 | default: | 204 | default: | ||
| 174 | id := requestID() | 205 | id := requestID() | ||
| 175 | log := zerowrap.FromCtx(ctx) | 206 | log := zerowrap.FromCtx(ctx) | ||
| 176 | - | log.Error().Err(err).Str("request_id", id).Str("path", r.URL.Path).Msg("web request failed") | 207 | + | log.Error().Err(err).Str("request_id", id).Str("path", logPath(r.URL.Path)).Msg("web request failed") |
| 177 | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | 208 | s.write(ctx, w, r, http.StatusInternalServerError, s.errorPage(id)) | ||
| 178 | } | 209 | } | ||
| 179 | } | 210 | } | ||
| @@ -236,3 +267,18 @@ func (s *Server) errorPage(id string) templ.Component { | |||||
| 236 | Back: []Link{{Label: "home", Href: "/"}}, | 267 | Back: []Link{{Label: "home", Href: "/"}}, | ||
| 237 | }) | 268 | }) | ||
| 238 | } | 269 | } | ||
| 270 | + | ||||
| 271 | + | // originOf is the origin of a URL as a browser writes it in the Origin | |||
| 272 | + | // header: scheme and host in lower case, the port only when it is not the | |||
| 273 | + | // default of the scheme. | |||
| 274 | + | func originOf(u *url.URL) string { | |||
| 275 | + | scheme, host, port := strings.ToLower(u.Scheme), strings.ToLower(u.Hostname()), u.Port() | |||
| 276 | + | if strings.Contains(host, ":") { | |||
| 277 | + | host = "[" + host + "]" | |||
| 278 | + | } | |||
| 279 | + | defaultPort := (scheme == "http" && port == "80") || (scheme == "https" && port == "443") | |||
| 280 | + | if port != "" && !defaultPort { | |||
| 281 | + | host += ":" + port | |||
| 282 | + | } | |||
| 283 | + | return scheme + "://" + host | |||
| 284 | + | } | |||
M internal/app/access.go +4 -2
| @@ -75,10 +75,12 @@ func scopeAccess(p domain.Principal, repo domain.Repo, scope domain.Scope) error | |||
| 75 | 75 | } | |
| 76 | 76 | ||
| 77 | 77 | // authorOf names who acts, for the author of a merge request or a comment: the | |
| 78 | - | // label of the token, "root" for the root token, "owner" for any other caller | |
| 79 | - | // (a web session). | |
| 78 | + | // label of the token, "root" for the root token, "owner" for the owner signed | |
| 79 | + | // in on the web and for any other caller without a label. | |
| 80 | 80 | func authorOf(p domain.Principal) string { | |
| 81 | 81 | switch { | |
| 82 | + | case p.IsOwner(): | |
| 83 | + | return "owner" | |
| 82 | 84 | case p.IsRoot(): | |
| 83 | 85 | return "root" | |
| 84 | 86 | case p.TokenLabel() != "": | |
| @@ -75,10 +75,12 @@ func scopeAccess(p domain.Principal, repo domain.Repo, scope domain.Scope) error | |||||
| 75 | } | 75 | } | ||
| 76 | 76 | ||||
| 77 | // authorOf names who acts, for the author of a merge request or a comment: the | 77 | // authorOf names who acts, for the author of a merge request or a comment: the | ||
| 78 | - | // label of the token, "root" for the root token, "owner" for any other caller | 78 | + | // label of the token, "root" for the root token, "owner" for the owner signed |
| 79 | - | // (a web session). | 79 | + | // in on the web and for any other caller without a label. |
| 80 | func authorOf(p domain.Principal) string { | 80 | func authorOf(p domain.Principal) string { | ||
| 81 | switch { | 81 | switch { | ||
| 82 | + | case p.IsOwner(): | |||
| 83 | + | return "owner" | |||
| 82 | case p.IsRoot(): | 84 | case p.IsRoot(): | ||
| 83 | return "root" | 85 | return "root" | ||
| 84 | case p.TokenLabel() != "": | 86 | case p.TokenLabel() != "": | ||
M internal/app/export_test.go +5 -0
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | 3 | package app | |
| 4 | 4 | ||
| 5 | + | import "bnema.dev/ermite/internal/domain" | |
| 6 | + | ||
| 5 | 7 | // Idle reports whether nothing is pending or running: tests wait for it | |
| 6 | 8 | // instead of sleeping. | |
| 7 | 9 | func (m *Maintainer) Idle() bool { | |
| @@ -9,3 +11,6 @@ func (m *Maintainer) Idle() bool { | |||
| 9 | 11 | defer m.mu.Unlock() | |
| 10 | 12 | return len(m.entries) == 0 | |
| 11 | 13 | } | |
| 14 | + | ||
| 15 | + | // AuthorOf exposes authorOf to the tests. | |
| 16 | + | func AuthorOf(p domain.Principal) string { return authorOf(p) } | |
| @@ -2,6 +2,8 @@ | |||||
| 2 | 2 | ||||
| 3 | package app | 3 | package app | ||
| 4 | 4 | ||||
| 5 | + | import "bnema.dev/ermite/internal/domain" | |||
| 6 | + | ||||
| 5 | // Idle reports whether nothing is pending or running: tests wait for it | 7 | // Idle reports whether nothing is pending or running: tests wait for it | ||
| 6 | // instead of sleeping. | 8 | // instead of sleeping. | ||
| 7 | func (m *Maintainer) Idle() bool { | 9 | func (m *Maintainer) Idle() bool { | ||
| @@ -9,3 +11,6 @@ func (m *Maintainer) Idle() bool { | |||||
| 9 | defer m.mu.Unlock() | 11 | defer m.mu.Unlock() | ||
| 10 | return len(m.entries) == 0 | 12 | return len(m.entries) == 0 | ||
| 11 | } | 13 | } | ||
| 14 | + | ||||
| 15 | + | // AuthorOf exposes authorOf to the tests. | |||
| 16 | + | func AuthorOf(p domain.Principal) string { return authorOf(p) } | |||
M internal/app/sessions_test.go +8 -0
| @@ -291,3 +291,11 @@ func TestSignOutEverywhereEndsSessions(t *testing.T) { | |||
| 291 | 291 | _, err = f.sessions.Verify(ctx, c) | |
| 292 | 292 | assert.NoError(t, err, "a new session works") | |
| 293 | 293 | } | |
| 294 | + | ||
| 295 | + | // The owner signed in on the web signs what it writes as "owner", where root | |
| 296 | + | // signs as "root" although both hold every right. | |
| 297 | + | func TestAuthorOfOwnerSession(t *testing.T) { | |
| 298 | + | assert.Equal(t, "owner", app.AuthorOf(domain.Owner())) | |
| 299 | + | assert.Equal(t, "root", app.AuthorOf(domain.Root())) | |
| 300 | + | assert.Equal(t, "ci", app.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()}))) | |
| 301 | + | } | |
| @@ -291,3 +291,11 @@ func TestSignOutEverywhereEndsSessions(t *testing.T) { | |||||
| 291 | _, err = f.sessions.Verify(ctx, c) | 291 | _, err = f.sessions.Verify(ctx, c) | ||
| 292 | assert.NoError(t, err, "a new session works") | 292 | assert.NoError(t, err, "a new session works") | ||
| 293 | } | 293 | } | ||
| 294 | + | ||||
| 295 | + | // The owner signed in on the web signs what it writes as "owner", where root | |||
| 296 | + | // signs as "root" although both hold every right. | |||
| 297 | + | func TestAuthorOfOwnerSession(t *testing.T) { | |||
| 298 | + | assert.Equal(t, "owner", app.AuthorOf(domain.Owner())) | |||
| 299 | + | assert.Equal(t, "root", app.AuthorOf(domain.Root())) | |||
| 300 | + | assert.Equal(t, "ci", app.AuthorOf(domain.TokenPrincipal(domain.Token{Label: "ci", Scopes: domain.AllScopes()}))) | |||
| 301 | + | } | |||