package httpd import ( "context" "errors" "fmt" "net" "net/http" "os" "path/filepath" "strconv" "strings" "time" "github.com/barerepo/server/internal/auth" "github.com/barerepo/server/internal/gitread" "github.com/barerepo/server/internal/store" "github.com/barerepo/server/internal/token" ) // cookieName holds an opaque token in a server table, not a JWT, because revocation matters. 10. const cookieName = "barerepo_session" // viewer is who is asking, or "" for nobody, and every private page goes through here. func (s *Server) viewer(r *http.Request) string { c, err := r.Cookie(cookieName) if err != nil || c.Value == "" { return "" } account, err := s.DB.SessionAccount(r.Context(), c.Value) if err != nil { return "" } return account } func (s *Server) setSession(w http.ResponseWriter, r *http.Request, value string) { http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: value, Path: "/", HttpOnly: true, // Secure over https, because a plain-http install on localhost still has to sign in. Secure: strings.HasPrefix(s.Cfg.Server.ExternalURL, "https://"), SameSite: http.SameSiteLaxMode, MaxAge: int(store.SessionLife.Seconds()), }) } func (s *Server) clearSession(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: cookieName, Value: "", Path: "/", HttpOnly: true, MaxAge: -1, }) } // signupPage is the form and its errors, with Nonce set once a signature is what is missing. type signupPage struct { chrome Name string PubKey string Nonce string Error string } func (s *Server) serveSignup(w http.ResponseWriter, r *http.Request) { page := signupPage{chrome: newChrome("barerepo · signup", "Signup page where an ssh public key is the only credential collected.")} if isGet(r) { s.render(w, r, "signup", page) return } if err := r.ParseForm(); err != nil { http.Error(w, "malformed form", http.StatusBadRequest) return } page.Name = strings.TrimSpace(r.FormValue("name")) page.PubKey = strings.TrimSpace(r.FormValue("pubkey")) fail := func(msg string) { // A bad key is not echoed back, so a private key pasted by mistake leaves the screen. if strings.Contains(msg, "private key") { page.PubKey = "" } page.Error = msg w.WriteHeader(http.StatusBadRequest) s.render(w, r, "signup", page) } // Second step: the signature over the nonce the first step handed out. if nonce := strings.TrimSpace(r.FormValue("nonce")); nonce != "" { s.finishSignup(w, r, nonce, strings.TrimSpace(r.FormValue("signature"))) return } if !s.signupAllowed(r) { page.Error = "too many signups from this address in the last hour. try later." w.WriteHeader(http.StatusTooManyRequests) s.render(w, r, "signup", page) return } if err := s.DB.CheckNewAccount(r.Context(), page.Name, page.PubKey); err != nil { fail(err.Error()) return } // Proof of possession, or anyone can claim an account with a public key they found. nonce, err := auth.NewNonce() if err != nil { s.oops(w, r, err) return } if err := s.DB.NewSignupChallenge(r.Context(), nonce, page.Name, page.PubKey); err != nil { s.oops(w, r, err) return } page.Nonce = nonce s.render(w, r, "signup", page) } // signinPage carries either the name form or the nonce form. type signinPage struct { chrome Name string Nonce string Error string // Unknown means no such account, so the page points at signup instead of leaving a guess. Unknown bool } func (s *Server) serveSignin(w http.ResponseWriter, r *http.Request) { s.render(w, r, "signin", signinPage{chrome: signinChrome()}) } func signinChrome() chrome { return newChrome("barerepo · sign in", "Sign in page where the server challenges an ssh key instead of asking for a password.") } // serveChallenge is step 2 of chapter 10: generate a nonce and show it. func (s *Server) serveChallenge(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(r.FormValue("name")) page := signinPage{chrome: signinChrome(), Name: name} // A name is a public URL namespace, so hiding it here only wastes the user's signature. if _, err := s.DB.Account(r.Context(), name); err != nil { page.Error = "there is no account named " + name + "." page.Unknown = true w.WriteHeader(http.StatusNotFound) s.render(w, r, "signin", page) return } nonce, err := auth.NewNonce() if err != nil { s.oops(w, r, err) return } if err := s.DB.NewChallenge(r.Context(), name, nonce); err != nil { s.oops(w, r, err) return } if wantsText(r) { plainText(w, nonce) return } page.Nonce = nonce s.render(w, r, "signin", page) } // wantsText marks the caller as a terminal, which reads a nonce and never a page. func wantsText(r *http.Request) bool { return strings.Contains(r.Header.Get("Accept"), "text/plain") } func plainText(w http.ResponseWriter, lines ...string) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") for _, line := range lines { fmt.Fprintln(w, line) } } // serveVerify is steps 4 to 6: check the signature, issue a session. func (s *Server) serveVerify(w http.ResponseWriter, r *http.Request) { nonce := strings.TrimSpace(r.FormValue("nonce")) signature := strings.TrimSpace(r.FormValue("signature")) // The name rides along, because a spent nonce is not the user's mistake to retype for. name := strings.TrimSpace(r.FormValue("name")) fail := func(msg string) { w.WriteHeader(http.StatusUnauthorized) if wantsText(r) { plainText(w, msg) return } s.render(w, r, "signin", signinPage{chrome: signinChrome(), Name: name, Error: msg}) } // Spent whether or not the signature is good, so no answer is retried on one challenge. c, err := s.DB.TakeChallenge(r.Context(), nonce) if err != nil { fail("that challenge is used up. press send challenge for a new one.") return } keys, err := s.DB.Keys(r.Context(), c.Account) if err != nil { s.oops(w, r, err) return } used, err := auth.Verify(r.Context(), c.Account, nonce, signature, keys) if err != nil { fail(err.Error()) return } if err := s.DB.TouchKey(r.Context(), used.ID); err != nil { s.log(r, err) } if wantsText(r) { code, err := s.DB.NewClaim(r.Context(), c.Account) if err != nil { s.oops(w, r, err) return } plainText(w, c.Account, s.Cfg.Server.ExternalURL+"/auth/claim?c="+code) return } cookie, err := s.DB.NewSession(r.Context(), c.Account) if err != nil { s.oops(w, r, err) return } s.setSession(w, r, cookie) http.Redirect(w, r, "/"+c.Account, http.StatusFound) } // serveClaim is the other end of br auth: one link, spent once, and the browser is signed in. func (s *Server) serveClaim(w http.ResponseWriter, r *http.Request) { account, err := s.DB.TakeClaim(r.Context(), r.URL.Query().Get("c")) if err != nil { w.WriteHeader(http.StatusUnauthorized) s.render(w, r, "signin", signinPage{chrome: signinChrome(), Error: "that sign in link is used up or expired. run br auth again."}) return } cookie, err := s.DB.NewSession(r.Context(), account) if err != nil { s.oops(w, r, err) return } s.setSession(w, r, cookie) http.Redirect(w, r, "/"+account, http.StatusFound) } func (s *Server) serveSignout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(cookieName); err == nil { if err := s.DB.EndSession(r.Context(), c.Value); err != nil && !errors.Is(err, store.ErrNotFound) { s.log(r, err) } } s.clearSession(w) http.Redirect(w, r, "/signin", http.StatusFound) } // keysPage is the only settings page, because keys and tokens cannot live in a repository. 24. type keysPage struct { chrome Account string Keys []keyView GPGKeys []gpgKeyView Tokens []tokenView Error string NewToken string NewTokenLabel string } // gpgKeyView is one signing key, named the way its owner named it when they made it. type gpgKeyView struct { ID int64 Fingerprint string UID string Added string } type keyView struct { ID int64 Algo string Fingerprint string Comment string Added string LastUsed string } type tokenView struct { ID int64 Name string Detail string Created string } // requireViewer sends anyone who is not signed in to the sign-in page. func (s *Server) requireViewer(w http.ResponseWriter, r *http.Request) (string, bool) { who := s.viewer(r) if who == "" { http.Redirect(w, r, "/signin", http.StatusFound) return "", false } return who, true } func (s *Server) serveKeys(w http.ResponseWriter, r *http.Request) { who, ok := s.requireViewer(w, r) if !ok { return } s.renderKeys(w, r, who, "", "", "") } func (s *Server) renderKeys(w http.ResponseWriter, r *http.Request, who, errMsg, newToken, newTokenLabel string) { ctx := r.Context() page := keysPage{ chrome: newChrome("barerepo · keys", "Page listing ssh keys, signing keys, runner tokens and feed tokens, each with a revoke control."), Account: who, Error: errMsg, NewToken: newToken, NewTokenLabel: newTokenLabel, } keys, err := s.DB.Keys(ctx, who) if err != nil { s.oops(w, r, err) return } for _, k := range keys { page.Keys = append(page.Keys, keyView{ ID: k.ID, Algo: strings.TrimPrefix(k.Algo, "ssh-"), Fingerprint: k.Fingerprint, Comment: k.Comment, Added: month(k.Created), LastUsed: lastUsed(k.LastUsed), }) } gpg, err := s.DB.GPGKeys(ctx, who) if err != nil { s.oops(w, r, err) return } for _, k := range gpg { page.GPGKeys = append(page.GPGKeys, gpgKeyView{ ID: k.ID, Fingerprint: k.Fingerprint, UID: k.UID, Added: month(k.Created)}) } tokens, err := s.DB.TokensOf(ctx, who) if err != nil { s.oops(w, r, err) return } // Chapter 24 asks a runner token for its labels and the machine that attached with it. attached, err := s.DB.RunnersByToken(ctx, who) if err != nil { s.log(r, err) } for _, t := range tokens { page.Tokens = append(page.Tokens, tokenView{ ID: t.ID, Name: tokenName(t), Detail: tokenDetail(t, attached[t.ID]), Created: ago(t.Created), }) } s.render(w, r, "keys", page) } // tokenName names the row and never holds the token, which was shown once inside its command. func tokenName(t store.Token) string { if t.Label != "" { return t.Label } switch t.Kind { case token.Runner: return "an unattached runner" case token.Feed: return "inbox" default: return "git over https" } } func tokenDetail(t store.Token, runners []store.Runner) string { parts := []string{} switch t.Kind { case token.Runner: parts = append(parts, "runner") case token.Feed: parts = append(parts, "feed", "read only") default: parts = append(parts, "clone and push over https") } if t.Scope != "" { parts = append(parts, t.Scope) } for _, r := range runners { parts = append(parts, r.Hostname) if len(r.Labels) > 0 { parts = append(parts, "labels "+strings.Join(r.Labels, ", ")) } } parts = append(parts, lastTouched(t.Kind, t.LastUsed)) return strings.Join(parts, " · ") } // lastTouched is the keys mockup's wording for each row: a machine is seen, a feed is read, and everything else is used. func lastTouched(k token.Kind, t time.Time) string { verb := "used" switch k { case token.Runner: verb = "seen" case token.Feed: verb = "read" } if t.IsZero() { return "never " + verb } return "last " + verb + " " + ago(t) } func lastUsed(t time.Time) string { if t.IsZero() { return "never used" } return "last used " + ago(t) } func (s *Server) serveAddKey(w http.ResponseWriter, r *http.Request) { who, ok := s.requireViewer(w, r) if !ok { return } if _, err := s.DB.AddKey(r.Context(), who, strings.TrimSpace(r.FormValue("pubkey"))); err != nil { w.WriteHeader(http.StatusBadRequest) s.renderKeys(w, r, who, err.Error(), "", "") return } http.Redirect(w, r, "/keys", http.StatusFound) } func (s *Server) serveDeleteKey(w http.ResponseWriter, r *http.Request) { who, ok := s.requireViewer(w, r) if !ok { return } id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) if err := s.DB.DeleteKey(r.Context(), who, id); err != nil { w.WriteHeader(http.StatusBadRequest) s.renderKeys(w, r, who, err.Error(), "", "") return } http.Redirect(w, r, "/keys", http.StatusFound) } func (s *Server) serveNewToken(w http.ResponseWriter, r *http.Request) { who, ok := s.requireViewer(w, r) if !ok { return } // Runner tokens belong to one repository, so chapter 15 issues them from its runner page. kind, label := token.Git, "git over https" if r.FormValue("kind") == "feed" { kind, label = token.Feed, "inbox" } tok, _, err := s.DB.CreateToken(r.Context(), kind, who, "", label) if err != nil { s.oops(w, r, err) return } // Shown once, as the URL chapter 39.3 asks for, because a redirect would lose it. shown := tok if kind == token.Feed { shown = s.Cfg.Server.ExternalURL + "/inbox.atom?token=" + tok } s.renderKeys(w, r, who, "", shown, "your new "+label+" token") } func (s *Server) serveDeleteToken(w http.ResponseWriter, r *http.Request) { who, ok := s.requireViewer(w, r) if !ok { return } id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) if err := s.DB.DeleteToken(r.Context(), who, id); err != nil { w.WriteHeader(http.StatusBadRequest) s.renderKeys(w, r, who, err.Error(), "", "") return } http.Redirect(w, r, "/keys", http.StatusFound) } // finishSignup creates the account once the signature checks out. func (s *Server) finishSignup(w http.ResponseWriter, r *http.Request, nonce, signature string) { page := signupPage{chrome: newChrome("barerepo · signup", "Signup page where an ssh public key is the only credential collected.")} c, err := s.DB.TakeSignupChallenge(r.Context(), nonce) if err != nil { page.Error = "that challenge is used up. start again." w.WriteHeader(http.StatusBadRequest) s.render(w, r, "signup", page) return } page.Name, page.PubKey = c.Name, c.PubKey if err := auth.VerifySignup(r.Context(), c.Name, c.Nonce, signature, c.PubKey); err != nil { page.Nonce = "" page.Error = err.Error() + " the nonce is spent, so press create for a new one." w.WriteHeader(http.StatusUnauthorized) s.render(w, r, "signup", page) return } account, err := s.DB.CreateAccount(r.Context(), c.Name, c.PubKey, false) if err != nil { page.Nonce = "" page.Error = err.Error() w.WriteHeader(http.StatusBadRequest) s.render(w, r, "signup", page) return } cookie, err := s.DB.NewSession(r.Context(), account.Name) if err != nil { s.oops(w, r, err) return } s.setSession(w, r, cookie) http.Redirect(w, r, "/"+account.Name, http.StatusFound) } // signupAllowed is the barrier that costs a squatter something, since a key is cheap. 27. func (s *Server) signupAllowed(r *http.Request) bool { limit := s.Cfg.Limits.SignupPerHourPerIP if limit <= 0 { return true } addr := clientAddr(r) cutoff := time.Now().Add(-time.Hour) s.signupsMu.Lock() defer s.signupsMu.Unlock() if s.signups == nil { s.signups = map[string][]time.Time{} } kept := s.signups[addr][:0] for _, t := range s.signups[addr] { if t.After(cutoff) { kept = append(kept, t) } } if len(kept) >= limit { s.signups[addr] = kept return false } s.signups[addr] = append(kept, time.Now()) return true } // clientAddr names the bucket chapter 27 counts a signup in, which the counted party must not pick. func clientAddr(r *http.Request) string { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } // Chapter 41.4 puts the reverse proxy on this machine, so only from here is X-Real-IP the proxy's and not the caller's own writing. if ip := r.Header.Get("X-Real-IP"); ip != "" && loopback(host) { return ip } return host } // loopback reports the address a proxy on this machine dials from, which is the one 41.4 describes. func loopback(host string) bool { ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } // serveAddGPGKey takes a pasted public key, so a signature on a commit can be checked against it. func (s *Server) serveAddGPGKey(w http.ResponseWriter, r *http.Request) { who, ok := s.requireViewer(w, r) if !ok { return } armor := strings.TrimSpace(r.FormValue("armor")) key, err := auth.ReadGPGKey(r.Context(), armor) if err != nil { s.renderKeys(w, r, who, err.Error(), "", "") return } if err := s.DB.AddGPGKey(r.Context(), who, key.Fingerprint, key.UID, armor); err != nil { s.renderKeys(w, r, who, "that key is already here, on this account or another one", "", "") return } s.rebuildKeyring(r.Context()) http.Redirect(w, r, "/keys", http.StatusFound) } // serveDeleteGPGKey drops a key, which stops barerepo naming its owner on a commit it signed. func (s *Server) serveDeleteGPGKey(w http.ResponseWriter, r *http.Request) { who, ok := s.requireViewer(w, r) if !ok { return } id, _ := strconv.ParseInt(r.FormValue("id"), 10, 64) if err := s.DB.DeleteGPGKey(r.Context(), who, id); err != nil { s.renderKeys(w, r, who, err.Error(), "", "") return } s.rebuildKeyring(r.Context()) http.Redirect(w, r, "/keys", http.StatusFound) } // rebuildKeyring writes every published key into one ring, which is the only one git is given. func (s *Server) rebuildKeyring(ctx context.Context) { keys, err := s.DB.AllGPGKeys(ctx) if err != nil { s.Log.Error("keyring", "err", err) return } armors := make([]string, 0, len(keys)) for _, k := range keys { armors = append(armors, k.Armor) } dir := filepath.Join(s.Cfg.Paths.Cache, "keyring") // A fresh ring every time, since a revoked key must stop naming anybody at once. os.RemoveAll(dir) if len(armors) == 0 { gitread.Keyring = "" return } ring, err := auth.Keyring(ctx, dir, armors) if err != nil { s.Log.Error("keyring", "err", err) return } gitread.Keyring = ring } // serveUserKeys hands out the public halves, so a signature is checked against a key and not a badge. func (s *Server) serveUserKeys(w http.ResponseWriter, r *http.Request, who string) { keys, err := s.DB.GPGKeys(r.Context(), who) if err != nil || len(keys) == 0 { s.notFound(w, r) return } // Plain text, so a browser shows the keys rather than downloading a file nobody asked for. w.Header().Set("Content-Type", "text/plain; charset=utf-8") for _, k := range keys { fmt.Fprintln(w, strings.TrimSpace(k.Armor)) } } // serveUserSSHKeys is the same idea for the keys that push, which is what an authorized_keys wants. func (s *Server) serveUserSSHKeys(w http.ResponseWriter, r *http.Request, who string) { keys, err := s.DB.Keys(r.Context(), who) if err != nil || len(keys) == 0 { s.notFound(w, r) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") for _, k := range keys { fmt.Fprintf(w, "%s %s\n", k.Algo, k.Blob) } }