Files
Hammerhead/resauth.go
T
VeillaxandClaude Sonnet 5.5 f521fa8668 Add the Hammerhead control plane
Dashboard and API, accounts (organizations, roles, invitations, 2FA, OIDC
single sign-on, API keys, revocable sessions), an encrypted SQLite database,
resource logins, alerts and /metrics, mail egress management with DKIM keys,
and the .env loader. Split out of the original single repository; depends on
Reef for the shared types. The end-to-end tests that run Hammerhead, Blacktip
and Bamboo together live in e2e/.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 02:50:03 -06:00

341 lines
9.9 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 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)
}