package hammerhead import ( "crypto/rand" _ "embed" "errors" "fmt" "math/big" "net/http" "net/url" "strings" "time" "git.veillax.com/Veillax/reef/authtoken" "git.veillax.com/Veillax/reef/model" "golang.org/x/crypto/bcrypt" ) //go:embed web/resource.html var resourceHTML []byte // The visitor-facing login flow for protected web ports. // // 1. blacktip sees an unauthenticated request and redirects to // /auth/r/?return= // 2. the visitor proves who they are here (account, password, PIN, emailed // code or share link) // 3. we redirect back to /.hammerhead/callback?t= // and blacktip turns that into a cookie. type otpEntry struct { hash string expires time.Time tries int } func (s *Server) resourcePage(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Referrer-Policy", "no-referrer") w.Write(resourceHTML) } // protectedMapping loads a mapping that has a login in front of it. Caller holds s.mu. func (s *Server) protectedMapping(id string) *model.Mapping { m := s.findMapping(id) if m == nil || !model.Web(m.Proto) || !m.Auth.Protected() || !m.Enabled { return nil } return m } // checkReturn makes sure the return URL points at this mapping, so the login can't be used as an open redirect. func checkReturn(m *model.Mapping, raw string) (*url.URL, error) { u, err := url.Parse(raw) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return nil, errors.New("invalid return address") } if (m.Proto == model.ProtoHTTPS) != (u.Scheme == "https") { return nil, errors.New("return address uses the wrong scheme") } host := u.Hostname() port := u.Port() if port == "" { port = "80" if u.Scheme == "https" { port = "443" } } if !model.HostMatches(m.Hostname, strings.ToLower(host)) || port != fmt.Sprint(m.Port) { return nil, errors.New("return address doesn't belong to this resource") } return u, nil } // canAccess applies the SSO policy. Caller holds s.mu. func (s *Server) canAccess(u *User, m *model.Mapping) bool { if u.ServerAdmin { return true } mem := s.member(u.ID, m.OrgID) if mem == nil { return false } if role := s.findRole(mem.RoleID); role != nil && role.Admin { return true } a := m.Auth if len(a.AllowedRoles) == 0 && len(a.AllowedUsers) == 0 { return true } for _, id := range a.AllowedRoles { if id == mem.RoleID { return true } } for _, id := range a.AllowedUsers { if id == u.ID { return true } } return false } func (s *Server) resourceInfo(w http.ResponseWriter, r *http.Request) { p := s.principalFrom(r) s.mu.Lock() defer s.mu.Unlock() m := s.protectedMapping(r.PathValue("id")) if m == nil { fail(w, 404, errors.New("this resource doesn't need a login, or doesn't exist")) return } if _, err := checkReturn(m, r.URL.Query().Get("return")); err != nil { fail(w, 400, err) return } info := map[string]any{ "name": m.Name, "host": m.Hostname, "sso": m.Auth.SSO, "password": m.Auth.Password, "pin": m.Auth.Pin, "email": m.Auth.Email && s.st.Settings.SMTP.Host != "", "providers": []map[string]string{}, } if m.Auth.SSO { info["providers"] = s.providerList() } if p != nil && p.User.ID != "" { u := s.findUser(p.User.ID) info["user"] = map[string]any{"email": u.Email, "name": u.Name, "allowed": s.canAccess(u, m)} } writeJSON(w, 200, info) } // grant mints the callback token and tells the browser where to go. func (s *Server) grant(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, sub string) { s.mu.Lock() e := s.findEdge(m.EdgeID) var key string if e != nil { key = e.AuthKey } s.mu.Unlock() if key == "" { fail(w, 500, errors.New("edge is not set up for logins")) return } tok := authtoken.Sign(key, authtoken.Token{Mapping: m.ID, Sub: sub, Kind: authtoken.KindCallback, Epoch: m.AuthEpoch, Exp: time.Now().Add(2 * time.Minute).Unix()}) dest := fmt.Sprintf("%s://%s/.hammerhead/callback?t=%s&r=%s", back.Scheme, back.Host, tok, url.QueryEscape(back.RequestURI())) s.audit(m.OrgID, sub, "resource.login", m.Name) writeJSON(w, 200, map[string]string{"redirect": dest}) } // resourceAuth wraps a method handler: it loads the mapping, checks the return URL and rate-limits. func (s *Server) resourceAuth(method string, fn func(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var body map[string]string if !decode(w, r, &body) { return } if !s.allow("res|"+method+"|"+clientIP(r)+"|"+r.PathValue("id"), 12, 5*time.Minute) { fail(w, 429, errors.New("too many attempts, wait a few minutes")) return } s.mu.Lock() pm := s.protectedMapping(r.PathValue("id")) var m model.Mapping if pm != nil { m = *pm } s.mu.Unlock() if pm == nil { fail(w, 404, errors.New("no such resource")) return } back, err := checkReturn(&m, body["return"]) if err != nil { fail(w, 400, err) return } fn(w, r, &m, back, body) } } func (s *Server) resourcePassword(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) { s.mu.Lock() sec := s.st.Secrets[m.ID] s.mu.Unlock() hash := string(dummyHash) if sec != nil && sec.PasswordHash != "" && m.Auth.Password { hash = sec.PasswordHash } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["password"])) != nil || hash == string(dummyHash) { time.Sleep(300 * time.Millisecond) fail(w, 401, errors.New("wrong password")) return } s.grant(w, r, m, back, "password") } func (s *Server) resourcePin(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) { s.mu.Lock() sec := s.st.Secrets[m.ID] s.mu.Unlock() hash := string(dummyHash) if sec != nil && sec.PinHash != "" && m.Auth.Pin { hash = sec.PinHash } if bcrypt.CompareHashAndPassword([]byte(hash), []byte(body["pin"])) != nil || hash == string(dummyHash) { time.Sleep(300 * time.Millisecond) fail(w, 401, errors.New("wrong PIN")) return } s.grant(w, r, m, back, "pin") } func (s *Server) resourceSSO(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) { p := s.principalFrom(r) if p == nil || p.User.ID == "" || !m.Auth.SSO { fail(w, 401, errors.New("sign in first")) return } s.mu.Lock() u := s.findUser(p.User.ID) allowed := u != nil && s.canAccess(u, m) s.mu.Unlock() if !allowed { fail(w, 403, errors.New("your account doesn't have access to this resource")) return } s.grant(w, r, m, back, p.User.Email) } func mailAllowed(list []string, email string) bool { email = strings.ToLower(strings.TrimSpace(email)) for _, a := range list { if a == email || (strings.HasPrefix(a, "*@") && strings.HasSuffix(email, a[1:])) { return true } } return false } func (s *Server) resourceEmailStart(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) { email := strings.ToLower(strings.TrimSpace(body["email"])) if !m.Auth.Email || !s.mailConfigured() { fail(w, 400, errors.New("email codes are not available for this resource")) return } // always answer the same way so addresses can't be probed reply := func() { writeJSON(w, 200, map[string]string{"status": "If that address has access, a code is on its way."}) } if !mailAllowed(m.Auth.AllowedMails, email) || !s.allow("otp|"+email, 3, 10*time.Minute) { reply() return } n, _ := rand.Int(rand.Reader, big.NewInt(1000000)) code := fmt.Sprintf("%06d", n.Int64()) s.mu.Lock() s.otps[m.ID+"|"+email] = otpEntry{hash: sha(code), expires: time.Now().Add(10 * time.Minute)} s.mu.Unlock() go s.Mailer(email, "Your sign-in code for "+m.Name, fmt.Sprintf("Your code is %s\n\nIt works for 10 minutes. If you didn't ask for it, ignore this email.", code)) reply() } func (s *Server) resourceEmailVerify(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) { email := strings.ToLower(strings.TrimSpace(body["email"])) key := m.ID + "|" + email s.mu.Lock() e, found := s.otps[key] good := false if found && time.Now().Before(e.expires) && e.tries < 5 { e.tries++ s.otps[key] = e good = e.hash == sha(strings.TrimSpace(body["code"])) if good { delete(s.otps, key) } } s.mu.Unlock() if !good || !mailAllowed(m.Auth.AllowedMails, email) { fail(w, 401, errors.New("wrong or expired code")) return } s.grant(w, r, m, back, email) } func (s *Server) resourceShare(w http.ResponseWriter, r *http.Request, m *model.Mapping, back *url.URL, body map[string]string) { h := sha(body["token"]) s.mu.Lock() var link *ShareLink for _, l := range s.st.Shares { if l.MappingID == m.ID && l.TokenHash == h { link = l } } valid := link != nil && (link.Expires.IsZero() || time.Now().Before(link.Expires)) && (link.MaxUses == 0 || link.Uses < link.MaxUses) name := "" if valid { link.Uses++ name = link.Name s.save() } s.mu.Unlock() if !valid { fail(w, 401, errors.New("this share link is invalid, expired or used up")) return } if name == "" { name = "link" } s.grant(w, r, m, back, "share:"+name) } // myResources lists the web ports the signed-in user may open. func (s *Server) myResources(w http.ResponseWriter, r *http.Request, p *principal) { s.mu.Lock() defer s.mu.Unlock() u := s.findUser(p.User.ID) out := []map[string]string{} for _, m := range s.st.Mappings { if !m.Enabled || !model.Web(m.Proto) { continue } if m.Auth.Protected() && !(m.Auth.SSO && s.canAccess(u, m)) { continue } if !m.Auth.Protected() && s.member(u.ID, m.OrgID) == nil && !u.ServerAdmin { continue } if link := publicURL(m); link != "" { org := "" if o := s.findOrg(m.OrgID); o != nil { org = o.Name } out = append(out, map[string]string{"name": m.Name, "url": link, "org": org}) } } writeJSON(w, 200, out) }