Dialog buttons are Cancel then the primary action at the bottom right, and each page has one primary action. List pages no longer repeat their title. Dates have the month written out. Fields in a row line up, hints sit under fields instead of inside labels, and long certificate errors are clamped. Organizations show whether local access is on and name the action; setting a user's password uses a dialog instead of a browser prompt. Server errors for sign-in and ranges are reworded with a next step, and the stray scrollbar inside tables is gone. Co-Authored-By: Claude Opus 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("This sign-in link is incomplete. Open the page you wanted again and you will be brought back here.")
|
|
}
|
|
if (m.Proto == model.ProtoHTTPS) != (u.Scheme == "https") {
|
|
return nil, errors.New("This sign-in link is not valid. Open the page you wanted again and you will be brought back here.")
|
|
}
|
|
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("This sign-in link points somewhere else. Open the page you wanted again and you will be brought back here.")
|
|
}
|
|
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)
|
|
}
|