Replaced one-pager with multiple pages and fixed security bugs

This commit is contained in:
Jan Meinl
2026-05-16 21:10:55 +02:00
parent 802906f9d4
commit 68034dea7d
25 changed files with 2311 additions and 1217 deletions
+125 -27
View File
@@ -6,8 +6,10 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"net"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
@@ -20,8 +22,13 @@ const userCtxKey ctxKey = "user"
const sessionCookie = "pt_session"
const sessionDuration = 24 * time.Hour * 14
const maxUsernameLen = 64
const maxDisplayNameLen = 128
const maxPasswordLen = 256
const maxFieldLen = 2000
func registerAuthRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/login", handleLogin)
mux.HandleFunc("POST /api/login", loginRateLimit(handleLogin))
mux.HandleFunc("POST /api/logout", handleLogout)
mux.HandleFunc("GET /api/me", requireAuth(handleMe))
mux.HandleFunc("PATCH /api/me", requireAuth(handleUpdateMe))
@@ -40,7 +47,7 @@ func ensureDefaultAdmin() error {
return err
}
_, err = db.Exec(
"INSERT INTO users(username,password_hash,display_name,language,is_system_admin) VALUES(?,?,?,?,1)",
"INSERT INTO users(username,password_hash,display_name,language,is_system_admin,must_change_password) VALUES(?,?,?,?,1,1)",
"admin", string(hash), "System Admin", "en",
)
return err
@@ -54,6 +61,79 @@ func newToken() (string, error) {
return hex.EncodeToString(b), nil
}
// ---- login rate limiter ---------------------------------------------------
type loginAttempts struct {
times []time.Time
}
var (
loginMu sync.Mutex
loginAttempts_ = map[string]*loginAttempts{}
)
const loginMaxAttempts = 8
const loginWindow = 5 * time.Minute
func clientIP(r *http.Request) string {
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
parts := strings.Split(xf, ",")
return strings.TrimSpace(parts[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
func loginAllowed(ip string) bool {
loginMu.Lock()
defer loginMu.Unlock()
a, ok := loginAttempts_[ip]
if !ok {
a = &loginAttempts{}
loginAttempts_[ip] = a
}
cutoff := time.Now().Add(-loginWindow)
kept := a.times[:0]
for _, t := range a.times {
if t.After(cutoff) {
kept = append(kept, t)
}
}
a.times = kept
return len(a.times) < loginMaxAttempts
}
func loginRecord(ip string) {
loginMu.Lock()
defer loginMu.Unlock()
a, ok := loginAttempts_[ip]
if !ok {
a = &loginAttempts{}
loginAttempts_[ip] = a
}
a.times = append(a.times, time.Now())
}
func loginRateLimit(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
if !loginAllowed(ip) {
writeError(w, http.StatusTooManyRequests, "too_many_attempts")
return
}
h.ServeHTTP(w, r)
}
}
// ---- handlers --------------------------------------------------------------
func normalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
@@ -63,19 +143,25 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
req.Username = strings.TrimSpace(req.Username)
req.Username = normalizeUsername(req.Username)
if req.Username == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "missing_credentials")
return
}
if len(req.Username) > maxUsernameLen || len(req.Password) > maxPasswordLen {
writeError(w, http.StatusBadRequest, "too_long")
return
}
var id int64
var hash string
err := db.QueryRow("SELECT id,password_hash FROM users WHERE username=?", req.Username).Scan(&id, &hash)
if err != nil {
loginRecord(clientIP(r))
writeError(w, http.StatusUnauthorized, "invalid_credentials")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(req.Password)); err != nil {
loginRecord(clientIP(r))
writeError(w, http.StatusUnauthorized, "invalid_credentials")
return
}
@@ -96,6 +182,7 @@ func handleLogin(w http.ResponseWriter, r *http.Request) {
Expires: expires,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
}
if crossSiteCookies {
cookie.SameSite = http.SameSiteNoneMode
@@ -118,6 +205,7 @@ func handleLogout(w http.ResponseWriter, r *http.Request) {
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"),
}
if crossSiteCookies {
clear.SameSite = http.SameSiteNoneMode
@@ -132,48 +220,41 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, u)
}
// handleUpdateMe lets the logged-in user change ONLY their language and password.
// Username and display name are administrative attributes and can only be set
// through the admin user endpoints.
func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
u := userFromCtx(r)
var req struct {
Username *string `json:"username"`
Language *string `json:"language"`
DisplayName *string `json:"display_name"`
Password *string `json:"password"`
Language *string `json:"language"`
Password *string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_body")
return
}
if req.Username != nil {
newName := strings.TrimSpace(*req.Username)
if newName == "" {
writeError(w, http.StatusBadRequest, "missing_username")
return
}
if _, err := db.Exec("UPDATE users SET username=? WHERE id=?", newName, u.ID); err != nil {
writeError(w, http.StatusConflict, "username_taken")
return
}
}
if req.Language != nil {
if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", *req.Language, u.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
lang := strings.TrimSpace(*req.Language)
if len(lang) > 8 {
writeError(w, http.StatusBadRequest, "invalid_language")
return
}
}
if req.DisplayName != nil {
if _, err := db.Exec("UPDATE users SET display_name=? WHERE id=?", *req.DisplayName, u.ID); err != nil {
if _, err := db.Exec("UPDATE users SET language=? WHERE id=?", lang, u.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
}
if req.Password != nil && *req.Password != "" {
if len(*req.Password) > maxPasswordLen {
writeError(w, http.StatusBadRequest, "too_long")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(*req.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, "hash_error")
return
}
if _, err := db.Exec("UPDATE users SET password_hash=? WHERE id=?", string(hash), u.ID); err != nil {
if _, err := db.Exec("UPDATE users SET password_hash=?,must_change_password=0 WHERE id=?", string(hash), u.ID); err != nil {
writeError(w, http.StatusInternalServerError, "db_error")
return
}
@@ -184,13 +265,14 @@ func handleUpdateMe(w http.ResponseWriter, r *http.Request) {
func loadUser(id int64) (*User, error) {
u := &User{}
var admin int
err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin FROM users WHERE id=?", id).
Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin)
var admin, mustChange int
err := db.QueryRow("SELECT id,username,display_name,language,is_system_admin,must_change_password FROM users WHERE id=?", id).
Scan(&u.ID, &u.Username, &u.DisplayName, &u.Language, &admin, &mustChange)
if err != nil {
return nil, err
}
u.IsSystemAdmin = admin == 1
u.MustChangePassword = mustChange == 1
return u, nil
}
@@ -212,6 +294,18 @@ func authUser(r *http.Request) (*User, error) {
return loadUser(userID)
}
// passwordChangeExempt returns true for endpoints that a user must remain able
// to access even while in the "must change password" lock state.
func passwordChangeExempt(r *http.Request) bool {
if r.URL.Path == "/api/me" {
return true
}
if r.URL.Path == "/api/logout" {
return true
}
return false
}
func requireAuth(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
u, err := authUser(r)
@@ -219,6 +313,10 @@ func requireAuth(h http.HandlerFunc) http.HandlerFunc {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
if u.MustChangePassword && !passwordChangeExempt(r) {
writeError(w, http.StatusForbidden, "password_change_required")
return
}
ctx := context.WithValue(r.Context(), userCtxKey, u)
h.ServeHTTP(w, r.WithContext(ctx))
}