Harden auth: setup secret, throttling, session destroy, secure cookies.

Replace username-based admin bootstrap with a one-time setup secret, rate-limit login/register, equalize login bcrypt timing, cap passwords at 72 bytes, destroy sessions on logout, and require Secure cookies when PORT is set.
This commit is contained in:
2026-08-22 11:47:42 -07:00
parent 96b0ce795a
commit 59513ab75e
9 changed files with 287 additions and 50 deletions
+62 -12
View File
@@ -1,6 +1,7 @@
package web
import (
"crypto/subtle"
"net/http"
"net/url"
"regexp"
@@ -14,6 +15,23 @@ import (
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`)
const (
minPasswordRunes = 8
maxPasswordBytes = 72 // bcrypt truncation limit
)
// loginDummyHash is compared when the username is unknown so login timing
// does not reveal whether an account exists (same bcrypt cost as real hashes).
var loginDummyHash = mustBcrypt("timing-dummy-not-a-real-password")
func mustBcrypt(s string) []byte {
h, err := bcrypt.GenerateFromPassword([]byte(s), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
return h
}
func safeNext(raw string) string {
if raw == "" {
return "/"
@@ -25,6 +43,16 @@ func safeNext(raw string) string {
return u.RequestURI()
}
func passwordValid(password string) (ok bool, msg string) {
if utf8.RuneCountInString(password) < minPasswordRunes {
return false, "Password must be at least 8 characters."
}
if len(password) > maxPasswordBytes {
return false, "Password must be at most 72 bytes."
}
return true, ""
}
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
if currentUser(r) != nil {
http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
@@ -43,8 +71,16 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
next := safeNext(r.PostFormValue("next"))
if !s.allowLoginAttempt(w, r, store.NormalizeUsername(username)) {
return
}
u, err := s.store.UserByUsername(r.Context(), username)
if err != nil || bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
hash := loginDummyHash
if err == nil {
hash = []byte(u.PasswordHash)
}
if err != nil || bcrypt.CompareHashAndPassword(hash, []byte(password)) != nil {
w.WriteHeader(http.StatusUnauthorized)
s.exec(w, "login", authPage{
page: s.basePage(r, "Sign in"),
@@ -74,16 +110,20 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
if !s.requireCSRF(w, r) {
return
}
if !s.allowRegisterAttempt(w, r) {
return
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
setupSecret := r.PostFormValue("setup_secret")
p := authPage{page: s.basePage(r, "Create account"), Username: username}
if !usernameRe.MatchString(username) {
p.Error = "Username must be 320 letters, numbers, or underscores."
s.exec(w, "register", p)
return
}
if utf8.RuneCountInString(password) < 8 {
p.Error = "Password must be at least 8 characters."
if ok, msg := passwordValid(password); !ok {
p.Error = msg
s.exec(w, "register", p)
return
}
@@ -93,15 +133,8 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
return
}
role := store.RoleUser
if s.cfg.AdminUsername != "" && store.NormalizeUsername(username) == store.NormalizeUsername(s.cfg.AdminUsername) {
n, err := s.store.CountAdmins(r.Context())
if err != nil {
http.Error(w, "could not create account", http.StatusInternalServerError)
return
}
if n == 0 {
role = store.RoleAdmin
}
if s.consumeAdminSetup(r, setupSecret) {
role = store.RoleAdmin
}
u := &store.User{
Username: username,
@@ -121,6 +154,23 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// consumeAdminSetup grants first-admin when a strong one-time setup secret matches
// and no admin exists yet. Username alone is never enough.
func (s *Server) consumeAdminSetup(r *http.Request, provided string) bool {
want := s.cfg.AdminSetupSecret
if want == "" || provided == "" {
return false
}
if subtle.ConstantTimeCompare([]byte(provided), []byte(want)) != 1 {
return false
}
n, err := s.store.CountAdmins(r.Context())
if err != nil || n > 0 {
return false
}
return true
}
func (s *Server) handleAuthPrompt(w http.ResponseWriter, r *http.Request) {
s.exec(w, "signin-prompt", s.basePage(r, ""))
}