Skip to content

Commit 9104d08

Browse files
authored
Harden dashboard authentication sessions (#171)
1 parent bd1361f commit 9104d08

63 files changed

Lines changed: 14363 additions & 80095 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -267,12 +267,14 @@ Three methods are supported, checked in order:
267267
| Method | How |
268268
|---|---|
269269
| **Authentik ForwardAuth** | Set `TRUST_FORWARD_AUTH=true` (legacy alias: `TRUST_FWD_AUTH=true`). Traefik passes `X-authentik-username` / `X-authentik-email` headers. By default those headers are trusted only from loopback/private proxy addresses; set `TRUSTED_FORWARD_AUTH_NETWORK=<cidr>[,<cidr>...]` or `TRUSTED_FORWARD_AUTH_NETWORKS=<cidr>[,<cidr>...]` when the trusted proxy is outside those ranges. The proxy must strip any client-supplied `X-authentik-*` headers before forwarding. On first request the server also issues a 30-day local cookie, so sessions survive Authentik token expiry. |
270-
| **Username + password** | Login form at `/login`. Cookie valid for 30 days. |
270+
| **Username + password** | Login form at `/login`. Passwords are stored with bcrypt. Browser cookies contain opaque 30-day session tokens, not password hashes. Passwords must fit bcrypt's 72-byte input limit. |
271271
| **API key** | `X-API-Key` header (for iOS app sync and MCP). |
272272

273+
Existing installs that predate bcrypt keep working: the first successful password login upgrades that user's legacy SHA-256 password hash to bcrypt. Existing browser cookies from older releases are intentionally invalidated once; log in again after deployment.
274+
273275
### Legacy Single-User Mode
274276

275-
If no `health_registry` schema exists (fresh install without setup wizard), the server falls back to env-var credentials: `API_KEY` + `UI_PASSWORD`. Data lives in the `health` schema. Backward compatible with existing single-user deployments.
277+
If no `health_registry` schema exists (fresh install without setup wizard), the server can run as a single-user install using env-var credentials: `API_KEY` + `UI_PASSWORD`. Data lives in the `health` schema. This mode also uses opaque server-side sessions for browser auth.
276278

277279
---
278280

cmd/server/main.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,14 @@ func main() {
102102
legacyDB.EnsureEnergySnapshotsTable()
103103
legacyDB.EnsureReadinessRedesignTables()
104104
legacyDB.EnsureSubjectiveCheckinsTable()
105+
legacyDB.EnsureAuthSessionsTable()
105106

106107
passwordHash := ""
107108
if uiPassword != "" {
108-
passwordHash = registry.HashPassword(uiPassword)
109+
passwordHash, err = registry.HashPassword(uiPassword)
110+
if err != nil {
111+
log.Fatalf("hash UI_PASSWORD: %v", err)
112+
}
109113
}
110114
mgr.SetLegacyMode(legacyDB, apiKey, passwordHash)
111115

@@ -123,7 +127,10 @@ func main() {
123127
log.Println("Registry empty — seeding admin user from API_KEY / UI_PASSWORD env vars…")
124128
passwordHash := ""
125129
if uiPassword != "" {
126-
passwordHash = registry.HashPassword(uiPassword)
130+
passwordHash, err = registry.HashPassword(uiPassword)
131+
if err != nil {
132+
log.Fatalf("hash UI_PASSWORD: %v", err)
133+
}
127134
}
128135
const adminSchema = "health"
129136
if err := reg.MigrateFromEnv(ctx, apiKey, passwordHash, adminSchema, adminEmail); err != nil {
@@ -166,6 +173,7 @@ func main() {
166173
db.EnsureEnergySnapshotsTable()
167174
db.EnsureReadinessRedesignTables()
168175
db.EnsureSubjectiveCheckinsTable()
176+
db.EnsureAuthSessionsTable()
169177
startTenant(ctx, mgr, reg, db, u.SchemaName, envNotifyDefaults, envAIDefaults, baseURL)
170178
}
171179

@@ -220,6 +228,7 @@ func main() {
220228
db.EnsureEnergySnapshotsTable()
221229
db.EnsureReadinessRedesignTables()
222230
db.EnsureSubjectiveCheckinsTable()
231+
db.EnsureAuthSessionsTable()
223232
startTenant(ctx, mgr, reg, db, schema, envNotifyDefaults, envAIDefaults, baseURL)
224233
})
225234
uiHandler.Register(mux)

docs/ai-plans/2026-06-01-auth-bcrypt-opaque-sessions.html

Lines changed: 226 additions & 0 deletions
Large diffs are not rendered by default.

go.mod

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ module health-receiver
22

33
go 1.25.0
44

5-
require github.com/jackc/pgx/v5 v5.9.1
5+
require (
6+
github.com/jackc/pgx/v5 v5.9.1
7+
golang.org/x/crypto v0.52.0
8+
)
69

710
require (
811
github.com/jackc/pgpassfile v1.0.0 // indirect
912
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
1013
github.com/jackc/puddle/v2 v2.2.2 // indirect
11-
golang.org/x/sync v0.17.0 // indirect
12-
golang.org/x/text v0.29.0 // indirect
14+
golang.org/x/sync v0.20.0 // indirect
15+
golang.org/x/text v0.37.0 // indirect
1316
)
1417

1518
require (

go.sum

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/
4545
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
4646
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
4747
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
48-
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
49-
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
50-
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
51-
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
48+
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
49+
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
50+
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
51+
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
52+
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
53+
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
5254
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
5355
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
5456
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

internal/registry/password.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package registry
2+
3+
import (
4+
"crypto/sha256"
5+
"crypto/subtle"
6+
"encoding/hex"
7+
"errors"
8+
"strings"
9+
10+
"golang.org/x/crypto/bcrypt"
11+
)
12+
13+
const bcryptCost = 12
14+
15+
// HashPassword returns a bcrypt password hash for new credentials.
16+
func HashPassword(password string) (string, error) {
17+
return hashPassword(password)
18+
}
19+
20+
func HashPasswordForStorage(password string) (string, error) {
21+
return hashPassword(password)
22+
}
23+
24+
func IsPasswordTooLong(err error) bool {
25+
return errors.Is(err, bcrypt.ErrPasswordTooLong)
26+
}
27+
28+
func hashPassword(password string) (string, error) {
29+
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
30+
if err != nil {
31+
return "", err
32+
}
33+
return string(hash), nil
34+
}
35+
36+
// VerifyPassword accepts current bcrypt hashes and legacy unsalted SHA-256
37+
// hashes. The second return value is true when a successful legacy match should
38+
// be rehashed to bcrypt.
39+
func VerifyPassword(storedHash, password string) (ok bool, needsRehash bool) {
40+
if IsBcryptHash(storedHash) {
41+
return bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) == nil, false
42+
}
43+
if IsLegacySHA256Hash(storedHash) {
44+
legacy := LegacySHA256Hash(password)
45+
if subtle.ConstantTimeCompare([]byte(legacy), []byte(storedHash)) == 1 {
46+
return true, true
47+
}
48+
}
49+
return false, false
50+
}
51+
52+
func IsBcryptHash(hash string) bool {
53+
return strings.HasPrefix(hash, "$2a$") ||
54+
strings.HasPrefix(hash, "$2b$") ||
55+
strings.HasPrefix(hash, "$2y$")
56+
}
57+
58+
func IsLegacySHA256Hash(hash string) bool {
59+
if len(hash) != sha256.Size*2 {
60+
return false
61+
}
62+
_, err := hex.DecodeString(hash)
63+
return err == nil
64+
}
65+
66+
func LegacySHA256Hash(password string) string {
67+
sum := sha256.Sum256([]byte(password))
68+
return hex.EncodeToString(sum[:])
69+
}

internal/registry/password_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package registry
2+
3+
import "testing"
4+
5+
func TestPasswordHashAndVerifyBcrypt(t *testing.T) {
6+
hash, err := HashPasswordForStorage("correct horse")
7+
if err != nil {
8+
t.Fatalf("HashPasswordForStorage: %v", err)
9+
}
10+
if !IsBcryptHash(hash) {
11+
t.Fatalf("hash = %q, want bcrypt prefix", hash)
12+
}
13+
ok, needsRehash := VerifyPassword(hash, "correct horse")
14+
if !ok || needsRehash {
15+
t.Fatalf("VerifyPassword bcrypt ok=%v needsRehash=%v, want ok without rehash", ok, needsRehash)
16+
}
17+
ok, _ = VerifyPassword(hash, "wrong")
18+
if ok {
19+
t.Fatalf("VerifyPassword accepted wrong password")
20+
}
21+
}
22+
23+
func TestVerifyPasswordAcceptsLegacySHA256AndRequestsRehash(t *testing.T) {
24+
hash := LegacySHA256Hash("old password")
25+
if !IsLegacySHA256Hash(hash) {
26+
t.Fatalf("legacy hash not recognized")
27+
}
28+
ok, needsRehash := VerifyPassword(hash, "old password")
29+
if !ok || !needsRehash {
30+
t.Fatalf("VerifyPassword legacy ok=%v needsRehash=%v, want ok with rehash", ok, needsRehash)
31+
}
32+
ok, _ = VerifyPassword(hash, "wrong")
33+
if ok {
34+
t.Fatalf("VerifyPassword accepted wrong legacy password")
35+
}
36+
}
37+
38+
func TestHashPasswordReturnsErrorForOverlongPassword(t *testing.T) {
39+
_, err := HashPassword(string(make([]byte, 73)))
40+
if err == nil {
41+
t.Fatalf("HashPassword accepted overlong bcrypt password")
42+
}
43+
if !IsPasswordTooLong(err) {
44+
t.Fatalf("HashPassword error = %v, want password-too-long", err)
45+
}
46+
}
47+
48+
func TestGenerateSessionTokenUsesExpectedEntropyShape(t *testing.T) {
49+
a, err := generateSessionToken()
50+
if err != nil {
51+
t.Fatalf("generateSessionToken: %v", err)
52+
}
53+
b, err := generateSessionToken()
54+
if err != nil {
55+
t.Fatalf("generateSessionToken second: %v", err)
56+
}
57+
if len(a) != sessionTokenBytes*2 {
58+
t.Fatalf("token length = %d, want %d", len(a), sessionTokenBytes*2)
59+
}
60+
if a == b {
61+
t.Fatalf("two generated session tokens matched")
62+
}
63+
if sessionDigest(a) == sessionDigest(b) {
64+
t.Fatalf("two generated session token digests matched")
65+
}
66+
}

internal/registry/registry.go

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package registry
33
import (
44
"context"
55
"crypto/rand"
6-
"crypto/sha256"
76
"encoding/hex"
87
"errors"
98
"fmt"
@@ -136,6 +135,20 @@ func (r *Registry) EnsureSchema(ctx context.Context) error {
136135
if err != nil {
137136
return fmt.Errorf("create global_settings table: %w", err)
138137
}
138+
_, err = r.pool.Exec(ctx, `
139+
CREATE TABLE IF NOT EXISTS health_registry.sessions (
140+
id_hash TEXT PRIMARY KEY,
141+
username TEXT NOT NULL REFERENCES health_registry.users(username) ON DELETE CASCADE,
142+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
143+
expires_at TIMESTAMPTZ NOT NULL,
144+
last_seen_at TIMESTAMPTZ
145+
)
146+
`)
147+
if err != nil {
148+
return fmt.Errorf("create sessions table: %w", err)
149+
}
150+
_, _ = r.pool.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_registry_sessions_user ON health_registry.sessions (username)`)
151+
_, _ = r.pool.Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_registry_sessions_expires ON health_registry.sessions (expires_at)`)
139152
return nil
140153
}
141154

@@ -338,7 +351,10 @@ func (r *Registry) CreateUser(ctx context.Context, req CreateUserReq) (*User, er
338351
if err != nil {
339352
return nil, fmt.Errorf("generate api key: %w", err)
340353
}
341-
hash := hashPassword(req.Password)
354+
hash, err := hashPassword(req.Password)
355+
if err != nil {
356+
return nil, fmt.Errorf("hash password: %w", err)
357+
}
342358

343359
var emailPtr *string
344360
if req.Email != "" {
@@ -393,21 +409,20 @@ func (r *Registry) DeleteUser(ctx context.Context, username string) error {
393409
return err
394410
}
395411

412+
// UpdatePasswordHash replaces a user's stored password hash after a verified
413+
// legacy login or an explicit password reset.
414+
func (r *Registry) UpdatePasswordHash(ctx context.Context, username, passwordHash string) error {
415+
_, err := r.pool.Exec(ctx, `
416+
UPDATE health_registry.users SET password_hash = $2 WHERE username = $1
417+
`, username, passwordHash)
418+
return err
419+
}
420+
396421
// Close releases the connection pool.
397422
func (r *Registry) Close() {
398423
r.pool.Close()
399424
}
400425

401-
// HashPassword returns hex(sha256(password)), matching the cookie auth format.
402-
func HashPassword(password string) string {
403-
return hashPassword(password)
404-
}
405-
406-
func hashPassword(password string) string {
407-
sum := sha256.Sum256([]byte(password))
408-
return hex.EncodeToString(sum[:])
409-
}
410-
411426
func generateAPIKey() (string, error) {
412427
b := make([]byte, 32)
413428
if _, err := rand.Read(b); err != nil {

internal/registry/sessions.go

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package registry
2+
3+
import (
4+
"context"
5+
"crypto/rand"
6+
"crypto/sha256"
7+
"encoding/hex"
8+
"errors"
9+
"fmt"
10+
"time"
11+
12+
"github.com/jackc/pgx/v5"
13+
)
14+
15+
const sessionTokenBytes = 32
16+
17+
var ErrSessionNotFound = errors.New("session not found")
18+
19+
func (r *Registry) CreateSession(ctx context.Context, username string, ttl time.Duration) (string, error) {
20+
token, err := generateSessionToken()
21+
if err != nil {
22+
return "", err
23+
}
24+
_, _ = r.pool.Exec(ctx, `DELETE FROM health_registry.sessions WHERE expires_at <= NOW()`)
25+
_, err = r.pool.Exec(ctx, `
26+
INSERT INTO health_registry.sessions (id_hash, username, expires_at)
27+
VALUES ($1, $2, $3)
28+
`, sessionDigest(token), username, time.Now().Add(ttl))
29+
if err != nil {
30+
return "", fmt.Errorf("create session: %w", err)
31+
}
32+
return token, nil
33+
}
34+
35+
func (r *Registry) GetSessionUser(ctx context.Context, token string) (*User, error) {
36+
var u User
37+
var email *string
38+
err := r.pool.QueryRow(ctx, `
39+
SELECT u.username, u.schema_name, u.api_key, u.password_hash, u.email, u.is_admin, u.created_at
40+
FROM health_registry.sessions s
41+
JOIN health_registry.users u ON u.username = s.username
42+
WHERE s.id_hash = $1 AND s.expires_at > NOW()
43+
`, sessionDigest(token)).Scan(
44+
&u.Username, &u.SchemaName, &u.APIKey, &u.PasswordHash, &email, &u.IsAdmin, &u.CreatedAt,
45+
)
46+
if err != nil {
47+
if errors.Is(err, pgx.ErrNoRows) {
48+
return nil, ErrSessionNotFound
49+
}
50+
return nil, err
51+
}
52+
if email != nil {
53+
u.Email = *email
54+
}
55+
_, _ = r.pool.Exec(ctx, `UPDATE health_registry.sessions SET last_seen_at = NOW() WHERE id_hash = $1`, sessionDigest(token))
56+
return &u, nil
57+
}
58+
59+
func (r *Registry) DeleteSession(ctx context.Context, token string) error {
60+
_, err := r.pool.Exec(ctx, `DELETE FROM health_registry.sessions WHERE id_hash = $1`, sessionDigest(token))
61+
return err
62+
}
63+
64+
func generateSessionToken() (string, error) {
65+
b := make([]byte, sessionTokenBytes)
66+
if _, err := rand.Read(b); err != nil {
67+
return "", fmt.Errorf("crypto/rand: %w", err)
68+
}
69+
return hex.EncodeToString(b), nil
70+
}
71+
72+
func sessionDigest(token string) string {
73+
sum := sha256.Sum256([]byte(token))
74+
return hex.EncodeToString(sum[:])
75+
}

0 commit comments

Comments
 (0)