Files
Hammerhead/resauth.go
T
VeillaxandClaude Sonnet 5.5 d74e6c1e72
CI / test (push) Successful in 2m27s
Redesign the dashboard and sign-in page with the Lightwell design language
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>
2026-10-26 18:18:55 -06:00

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)
}