Replaced one-pager with multiple pages and fixed security bugs
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user