CI / test (push) Successful in 2m27s
Warm neutrals, teal accent, 15px type, and Lightwell's component shapes. Status is a badge with text instead of a colored dot. Every destructive action asks first, naming the object and what is lost. Dialogs trap focus, close on Escape and return focus. The sidebar is a drawer on phones, there is a theme switch, loading shows skeleton rows, and empty states explain what belongs there. Emoji and dashes are gone, and server errors are capitalized and tell visitors what to do next. Adds scripts/lint-design.mjs and docs/design.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
341 lines
10 KiB
Go
341 lines
10 KiB
Go
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
|
|
// <dashboard>/auth/r/<mapping>?return=<original url>
|
|
// 2. the visitor proves who they are here (account, password, PIN, emailed
|
|
// code or share link)
|
|
// 3. we redirect back to <original host>/.hammerhead/callback?t=<signed token>
|
|
// 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 page does not need a sign-in, or it no longer exists. Check the address, or ask the person who shared it."))
|
|
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("This server is not set up for sign-ins yet. Ask the administrator to finish setting it up."))
|
|
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 and try again."))
|
|
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("That page does not exist. Check the address."))
|
|
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("That password is not right. Check it and try again."))
|
|
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("That PIN is not right. Check it and try again."))
|
|
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, then try again."))
|
|
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 does not have access to this page. Ask the person who shared it to add you."))
|
|
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 turned on for this page. Use another sign-in method."))
|
|
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("That code is wrong or has expired. Request a new one and try again."))
|
|
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 already used up. Ask for a new one."))
|
|
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)
|
|
}
|