Files
Hammerhead/accounts.go
T
VeillaxandClaude Opus 5.5 c585ddbf63
CI / test (push) Successful in 3m20s
Release / release (push) Successful in 5m6s
Review pass on the Lightwell redesign: layout, copy and behavior fixes
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>
2026-10-26 18:38:56 -06:00

566 lines
15 KiB
Go

package hammerhead
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"encoding/binary"
"errors"
"fmt"
"net"
"net/http"
"sort"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
const sessionTTL = 14 * 24 * time.Hour
type principal struct {
User User // copy; zero ID when authenticated by API key
Key *APIKey // set for API-key callers
}
type ticket struct {
userID string
expires time.Time
}
func clientIP(r *http.Request) string {
h, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return h
}
func (s *Server) principalFrom(r *http.Request) *principal {
s.mu.Lock()
defer s.mu.Unlock()
if c, err := r.Cookie("hh_session"); err == nil {
if sess := s.st.Sessions[sha(c.Value)]; sess != nil && time.Now().Before(sess.Expires) {
if u := s.findUser(sess.UserID); u != nil {
return &principal{User: *u}
}
}
}
if tok := bearer(r); strings.HasPrefix(tok, "hhk_") {
parts := strings.SplitN(tok, "_", 3)
if len(parts) == 3 {
for _, k := range s.st.APIKeys {
if k.ID == parts[1] && hmac.Equal([]byte(k.Hash), []byte(sha(parts[2]))) {
k.LastUsed = time.Now()
kc := *k
return &principal{Key: &kc}
}
}
}
}
return nil
}
func (s *Server) withUser(fn func(http.ResponseWriter, *http.Request, *principal)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := s.principalFrom(r)
if p == nil || p.User.ID == "" {
fail(w, 401, errors.New("Sign in to continue."))
return
}
fn(w, r, p)
}
}
func (s *Server) withServerAdmin(fn func(http.ResponseWriter, *http.Request, *principal)) http.HandlerFunc {
return s.withUser(func(w http.ResponseWriter, r *http.Request, p *principal) {
if !p.User.ServerAdmin {
fail(w, 403, errors.New("server administrators only"))
return
}
fn(w, r, p)
})
}
// orgCtx is the caller's standing inside one org.
type orgCtx struct {
Org Org
Admin bool
User User
Key *APIKey
Actor string
}
func (s *Server) withOrg(needAdmin bool, fn func(http.ResponseWriter, *http.Request, *orgCtx)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := s.principalFrom(r)
if p == nil {
fail(w, 401, errors.New("Sign in to continue."))
return
}
id := r.PathValue("org")
s.mu.Lock()
org := s.findOrg(id)
c := &orgCtx{User: p.User, Key: p.Key}
allowed := false
if org != nil {
c.Org = *org
switch {
case p.Key != nil:
allowed = p.Key.OrgID == org.ID
c.Admin = allowed
c.Actor = "apikey:" + p.Key.Name
case p.User.ServerAdmin:
allowed, c.Admin = true, true
c.Actor = p.User.Email
default:
if m := s.member(p.User.ID, org.ID); m != nil {
allowed = true
if role := s.findRole(m.RoleID); role != nil {
c.Admin = role.Admin
}
}
c.Actor = p.User.Email
}
}
s.mu.Unlock()
if !allowed {
fail(w, 404, errors.New("no such organization"))
return
}
if needAdmin && !c.Admin {
fail(w, 403, errors.New("organization administrators only"))
return
}
fn(w, r, c)
}
}
// ---------- first run ----------
func hashPassword(pw string) (string, error) {
if len(pw) < 8 {
return "", errors.New("password must be at least 8 characters")
}
b, err := bcrypt.GenerateFromPassword([]byte(pw), 10)
return string(b), err
}
// newOrgLocked creates an org with its two built-in roles. Caller holds s.mu.
func (s *Server) newOrgLocked(name string, allowLocal bool) (*Org, *Role) {
o := &Org{ID: newID(), Name: name, AllowLocal: allowLocal}
admin := &Role{ID: newID(), OrgID: o.ID, Name: "Admin", Admin: true, Builtin: true}
member := &Role{ID: newID(), OrgID: o.ID, Name: "Member", Builtin: true}
s.st.Orgs = append(s.st.Orgs, o)
s.st.Roles = append(s.st.Roles, admin, member)
return o, admin
}
func (s *Server) createFirstAdmin(email, name, password, orgName string) error {
hash, err := hashPassword(password)
if err != nil {
return err
}
email = strings.ToLower(strings.TrimSpace(email))
if !strings.Contains(email, "@") {
return errors.New("enter a valid email address")
}
s.mu.Lock()
defer s.mu.Unlock()
if len(s.st.Users) != 0 {
return errors.New("Hammerhead is already set up. Sign in instead.")
}
u := &User{ID: newID(), Email: email, Name: name, PassHash: hash, ServerAdmin: true, Created: time.Now()}
s.st.Users = append(s.st.Users, u)
org, adminRole := s.newOrgLocked(orgName, true)
s.st.Members = append(s.st.Members, &Member{UserID: u.ID, OrgID: org.ID, RoleID: adminRole.ID})
return s.save()
}
func (s *Server) setup(w http.ResponseWriter, r *http.Request) {
var in struct{ Email, Name, Password, OrgName string }
if !decode(w, r, &in) {
return
}
if strings.TrimSpace(in.OrgName) == "" {
in.OrgName = "Default"
}
if strings.TrimSpace(in.Name) == "" {
in.Name = "Admin"
}
if err := s.createFirstAdmin(in.Email, in.Name, in.Password, in.OrgName); err != nil {
fail(w, 400, err)
return
}
ok(w)
}
// ---------- sessions ----------
func (s *Server) secureCookies(r *http.Request) bool {
return r.TLS != nil || strings.HasPrefix(s.st.Settings.DashboardURL, "https://")
}
func (s *Server) startSession(w http.ResponseWriter, r *http.Request, userID string) {
tok := randHex(24)
now := time.Now()
s.mu.Lock()
for k, v := range s.st.Sessions {
if now.After(v.Expires) {
delete(s.st.Sessions, k)
}
}
s.st.Sessions[sha(tok)] = &Session{UserID: userID, Expires: now.Add(sessionTTL), Created: now, IP: clientIP(r), UA: trim(r.UserAgent(), 200)}
secure := s.secureCookies(r)
s.save()
s.mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: "hh_session", Value: tok, Path: "/", HttpOnly: true,
Secure: secure, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionTTL.Seconds())})
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request, _ *principal) {
if c, err := r.Cookie("hh_session"); err == nil {
s.mu.Lock()
delete(s.st.Sessions, sha(c.Value))
s.save()
s.mu.Unlock()
}
http.SetCookie(w, &http.Cookie{Name: "hh_session", Value: "", Path: "/", MaxAge: -1})
ok(w)
}
// dummyHash lets a missing user cost the same as a wrong password.
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("not-a-real-password"), 10)
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var in struct{ Email, Password string }
if !decode(w, r, &in) {
return
}
email := strings.ToLower(strings.TrimSpace(in.Email))
if !s.allow("login|"+clientIP(r)+"|"+email, 8, 5*time.Minute) {
fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
return
}
s.mu.Lock()
u := s.userByEmail(email)
hash := string(dummyHash)
if u != nil && u.PassHash != "" {
hash = u.PassHash
}
s.mu.Unlock()
good := bcrypt.CompareHashAndPassword([]byte(hash), []byte(in.Password)) == nil
if u == nil || !good || u.PassHash == "" {
s.audit("", email, "login.failed", clientIP(r))
time.Sleep(300 * time.Millisecond)
fail(w, 401, errors.New("That email and password do not match. Check them and try again."))
return
}
if u.TOTPEnabled {
t := randHex(16)
s.mu.Lock()
s.tickets[t] = ticket{userID: u.ID, expires: time.Now().Add(5 * time.Minute)}
s.mu.Unlock()
writeJSON(w, 200, map[string]any{"needTotp": true, "ticket": t})
return
}
s.startSession(w, r, u.ID)
s.audit("", u.Email, "login", clientIP(r))
ok(w)
}
func (s *Server) loginTOTP(w http.ResponseWriter, r *http.Request) {
var in struct{ Ticket, Code string }
if !decode(w, r, &in) {
return
}
if !s.allow("totp|"+clientIP(r), 10, 5*time.Minute) {
fail(w, 429, errors.New("Too many attempts. Wait a few minutes and try again."))
return
}
s.mu.Lock()
t, found := s.tickets[in.Ticket]
var u *User
if found && time.Now().Before(t.expires) {
u = s.findUser(t.userID)
}
good := false
if u != nil {
code := strings.ReplaceAll(strings.TrimSpace(in.Code), " ", "")
if totpOK(u.TOTPSecret, code) {
good = true
} else {
h := sha(strings.ToLower(code))
for i, b := range u.BackupCodes {
if b == h {
u.BackupCodes = append(append([]string{}, u.BackupCodes[:i]...), u.BackupCodes[i+1:]...)
good = true
break
}
}
}
}
if good {
delete(s.tickets, in.Ticket)
s.save()
}
s.mu.Unlock()
if !good {
fail(w, 401, errors.New("That code is not right. Check your authenticator and try again."))
return
}
s.startSession(w, r, u.ID)
s.audit("", u.Email, "login", clientIP(r)+" (2fa)")
ok(w)
}
// ---------- TOTP ----------
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
func hotp(key []byte, counter uint64) string {
var b [8]byte
binary.BigEndian.PutUint64(b[:], counter)
m := hmac.New(sha1.New, key)
m.Write(b[:])
sum := m.Sum(nil)
off := sum[len(sum)-1] & 0xf
v := (binary.BigEndian.Uint32(sum[off:off+4]) & 0x7fffffff) % 1000000
return fmt.Sprintf("%06d", v)
}
func totpOK(secretB32, code string) bool {
key, err := b32.DecodeString(secretB32)
if err != nil || len(code) != 6 {
return false
}
now := uint64(time.Now().Unix() / 30)
for _, d := range []int{-1, 0, 1} {
if hmac.Equal([]byte(hotp(key, now+uint64(d))), []byte(code)) {
return true
}
}
return false
}
// ---------- /api/me ----------
type orgBrief struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Admin bool `json:"admin"`
}
func (s *Server) publicInfo(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
writeJSON(w, 200, map[string]any{"needsSetup": len(s.st.Users) == 0, "providers": s.providerList()})
}
func (s *Server) providerList() []map[string]string {
out := []map[string]string{}
for _, p := range s.st.IdPs {
out = append(out, map[string]string{"id": p.ID, "name": p.Name})
}
return out
}
func (s *Server) me(w http.ResponseWriter, r *http.Request, p *principal) {
s.mu.Lock()
defer s.mu.Unlock()
u := s.findUser(p.User.ID)
orgs := []orgBrief{}
for _, o := range s.st.Orgs {
if u.ServerAdmin {
orgs = append(orgs, orgBrief{ID: o.ID, Name: o.Name, Role: "Server admin", Admin: true})
continue
}
if m := s.member(u.ID, o.ID); m != nil {
role := s.findRole(m.RoleID)
b := orgBrief{ID: o.ID, Name: o.Name}
if role != nil {
b.Role, b.Admin = role.Name, role.Admin
}
orgs = append(orgs, b)
}
}
writeJSON(w, 200, map[string]any{
"id": u.ID, "email": u.Email, "name": u.Name, "serverAdmin": u.ServerAdmin,
"totpEnabled": u.TOTPEnabled, "hasPassword": u.PassHash != "", "orgs": orgs,
"backupCodes": len(u.BackupCodes), "dashboardUrl": s.st.Settings.DashboardURL,
})
}
func (s *Server) updateMe(w http.ResponseWriter, r *http.Request, p *principal) {
var in struct{ Name string }
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
if n := strings.TrimSpace(in.Name); n != "" {
s.findUser(p.User.ID).Name = n
}
s.save()
ok(w)
}
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request, p *principal) {
var in struct{ Current, New string }
if !decode(w, r, &in) {
return
}
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Current)) != nil {
fail(w, 400, errors.New("Your current password is not right. Check it and try again."))
return
}
hash, err := hashPassword(in.New)
if err != nil {
fail(w, 400, err)
return
}
s.mu.Lock()
s.findUser(p.User.ID).PassHash = hash
// sign out every other session
cur := ""
if c, err := r.Cookie("hh_session"); err == nil {
cur = sha(c.Value)
}
for k, sess := range s.st.Sessions {
if sess.UserID == p.User.ID && k != cur {
delete(s.st.Sessions, k)
}
}
s.save()
s.mu.Unlock()
s.audit("", p.User.Email, "password.change", "")
ok(w)
}
func (s *Server) totpSetup(w http.ResponseWriter, r *http.Request, p *principal) {
if p.User.TOTPEnabled {
fail(w, 400, errors.New("two-factor is already on"))
return
}
raw := make([]byte, 20)
rand.Read(raw)
secret := b32.EncodeToString(raw)
s.mu.Lock()
s.findUser(p.User.ID).TOTPSecret = secret
s.save()
s.mu.Unlock()
writeJSON(w, 200, map[string]string{"secret": secret,
"uri": fmt.Sprintf("otpauth://totp/Hammerhead:%s?secret=%s&issuer=Hammerhead", p.User.Email, secret)})
}
func (s *Server) totpEnable(w http.ResponseWriter, r *http.Request, p *principal) {
var in struct{ Code string }
if !decode(w, r, &in) {
return
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.findUser(p.User.ID)
if u.TOTPSecret == "" || !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
fail(w, 400, errors.New("That code does not match. Check that your authenticator's clock is right and try again."))
return
}
codes, hashes := make([]string, 8), make([]string, 8)
for i := range codes {
codes[i] = randHex(5)
hashes[i] = sha(codes[i])
}
u.TOTPEnabled, u.BackupCodes = true, hashes
s.save()
writeJSON(w, 200, map[string]any{"backupCodes": codes})
}
func (s *Server) totpDisable(w http.ResponseWriter, r *http.Request, p *principal) {
var in struct{ Password, Code string }
if !decode(w, r, &in) {
return
}
if p.User.PassHash != "" && bcrypt.CompareHashAndPassword([]byte(p.User.PassHash), []byte(in.Password)) != nil {
fail(w, 400, errors.New("That password is not right. Check it and try again."))
return
}
s.mu.Lock()
defer s.mu.Unlock()
u := s.findUser(p.User.ID)
if !totpOK(u.TOTPSecret, strings.TrimSpace(in.Code)) {
fail(w, 400, errors.New("That code is not right. Check your authenticator and try again."))
return
}
u.TOTPEnabled, u.TOTPSecret, u.BackupCodes = false, "", nil
s.save()
ok(w)
}
func trim(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// ---------- your signed-in devices ----------
func (s *Server) mySessions(w http.ResponseWriter, r *http.Request, p *principal) {
cur := ""
if c, err := r.Cookie("hh_session"); err == nil {
cur = sha(c.Value)
}
s.mu.Lock()
defer s.mu.Unlock()
type row struct {
ID string `json:"id"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
IP string `json:"ip"`
UA string `json:"ua"`
Current bool `json:"current"`
}
out := []row{}
for k, sess := range s.st.Sessions {
if sess.UserID == p.User.ID && time.Now().Before(sess.Expires) {
out = append(out, row{k[:16], sess.Created, sess.Expires, sess.IP, sess.UA, k == cur})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) })
writeJSON(w, 200, out)
}
func (s *Server) endSession(w http.ResponseWriter, r *http.Request, p *principal) {
id := r.PathValue("id")
s.mu.Lock()
defer s.mu.Unlock()
if len(id) >= 16 {
for k, sess := range s.st.Sessions {
if sess.UserID == p.User.ID && strings.HasPrefix(k, id) {
delete(s.st.Sessions, k)
}
}
}
s.save()
ok(w)
}
// endOtherSessions signs the user out everywhere except the browser they are using.
func (s *Server) endOtherSessions(w http.ResponseWriter, r *http.Request, p *principal) {
cur := ""
if c, err := r.Cookie("hh_session"); err == nil {
cur = sha(c.Value)
}
s.mu.Lock()
defer s.mu.Unlock()
for k, sess := range s.st.Sessions {
if sess.UserID == p.User.ID && k != cur {
delete(s.st.Sessions, k)
}
}
s.save()
ok(w)
}