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>
566 lines
15 KiB
Go
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)
|
|
}
|