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:
+62
-12
@@ -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 3–20 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, ""))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user