c5b17dbc8d
Self-contained Go application, no database server. Pages, users and configuration live as JSON/HTML files under wiki_data/, which ships with placeholder start and imprint pages only — everything institution-specific is configured at runtime via /settings. Features: bilingual pages (DE/EN) up to four levels deep with sidebar navigation and full-text search, a WYSIWYG editor with per-page uploads, per-page access control (external/internal) and drafts, version history with restore, local or LDAP login with locally managed roles, bcrypt password hashing, CSRF protection and login rate limiting, and TLS with hot certificate reload. The editor (Quill) and the webfonts are vendored under static/vendor/, so the application makes no third-party requests at runtime.
361 lines
9.9 KiB
Go
361 lines
9.9 KiB
Go
package main
|
|
|
|
import (
|
|
crand "crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/flosch/pongo2/v6"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
type SessionInfo struct {
|
|
Expiry time.Time
|
|
Username string
|
|
IsAdmin bool
|
|
CSRFToken string
|
|
}
|
|
|
|
type User struct {
|
|
Username string `json:"username"`
|
|
PasswordHash string `json:"password_hash"`
|
|
Salt string `json:"salt"`
|
|
}
|
|
|
|
type UsersConfig struct {
|
|
Users []User `json:"users"`
|
|
}
|
|
|
|
var (
|
|
usersFile string
|
|
usersMutex sync.RWMutex
|
|
|
|
// In-memory session store (sessionId -> SessionInfo)
|
|
sessionsMutex sync.Mutex
|
|
sessions = make(map[string]SessionInfo)
|
|
)
|
|
|
|
func initUsers() {
|
|
usersFile = filepath.Join(dataDir, "users.json")
|
|
if _, err := os.Stat(usersFile); os.IsNotExist(err) {
|
|
// Create default admin user using the config password
|
|
config := loadConfig()
|
|
hash, err := newPasswordHash(config.Password)
|
|
if err != nil {
|
|
log.Fatalf("Cannot create the initial admin account: %v", err)
|
|
}
|
|
saveUsers(UsersConfig{Users: []User{{Username: "admin", PasswordHash: hash}}})
|
|
|
|
// The plaintext has served its only purpose. Leaving it in config.json
|
|
// would keep an admin password readable on disk forever, so drop it —
|
|
// the account now lives in users.json as a bcrypt hash.
|
|
if config.Password != "" {
|
|
config.Password = ""
|
|
if err := saveConfig(config); err != nil {
|
|
log.Printf("Could not clear the plaintext password from config.json: %v", err)
|
|
} else {
|
|
log.Print("Created the initial admin account and removed the plaintext password from config.json")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func loadUsers() UsersConfig {
|
|
usersMutex.RLock()
|
|
defer usersMutex.RUnlock()
|
|
data, err := os.ReadFile(usersFile)
|
|
if err != nil {
|
|
return UsersConfig{}
|
|
}
|
|
var uc UsersConfig
|
|
_ = json.Unmarshal(data, &uc)
|
|
return uc
|
|
}
|
|
|
|
func saveUsers(uc UsersConfig) {
|
|
usersMutex.Lock()
|
|
defer usersMutex.Unlock()
|
|
data, _ := json.MarshalIndent(uc, "", " ")
|
|
_ = os.WriteFile(usersFile, data, 0644)
|
|
}
|
|
|
|
// hashPassword produces the legacy salted SHA-256 hash. It is only kept to
|
|
// verify accounts created before the switch to bcrypt; never use it to store a
|
|
// new password — use newPasswordHash instead.
|
|
func hashPassword(password string, salt string) string {
|
|
hasher := sha256.New()
|
|
hasher.Write([]byte(password + salt))
|
|
return hex.EncodeToString(hasher.Sum(nil))
|
|
}
|
|
|
|
// newPasswordHash hashes a password for storage. bcrypt is deliberately slow
|
|
// and carries its own salt, so the User.Salt field stays empty for new hashes.
|
|
func newPasswordHash(password string) (string, error) {
|
|
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
|
if err != nil {
|
|
// Notably ErrPasswordTooLong for anything beyond 72 bytes — bcrypt
|
|
// would silently truncate, so x/crypto refuses instead.
|
|
return "", err
|
|
}
|
|
return string(h), nil
|
|
}
|
|
|
|
// verifyPassword checks a password against a stored hash, transparently
|
|
// handling both formats. The second return value reports whether the stored
|
|
// hash is a legacy one and should be replaced on a successful login.
|
|
func verifyPassword(u User, password string) (ok bool, needsRehash bool) {
|
|
// bcrypt hashes are self-describing and always start with the algorithm
|
|
// marker, so no extra field is needed to tell the two apart.
|
|
if strings.HasPrefix(u.PasswordHash, "$2") {
|
|
return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil, false
|
|
}
|
|
if u.PasswordHash == "" {
|
|
return false, false
|
|
}
|
|
legacy := hashPassword(password, u.Salt)
|
|
if subtle.ConstantTimeCompare([]byte(legacy), []byte(u.PasswordHash)) == 1 {
|
|
return true, true
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
// setUserPassword stores a freshly hashed password for the named user and drops
|
|
// any leftover legacy salt. Returns false if the user does not exist.
|
|
func setUserPassword(uc *UsersConfig, username, password string) (bool, error) {
|
|
hash, err := newPasswordHash(password)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
for i, u := range uc.Users {
|
|
if strings.EqualFold(u.Username, username) {
|
|
uc.Users[i].PasswordHash = hash
|
|
uc.Users[i].Salt = ""
|
|
return true, nil
|
|
}
|
|
}
|
|
return false, nil
|
|
}
|
|
|
|
// Session generation helper
|
|
func generateSessionID() string {
|
|
b := make([]byte, 16)
|
|
_, _ = crand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func generateCSRFToken() string {
|
|
b := make([]byte, 32)
|
|
_, _ = crand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
// isSafeRedirectTarget reports whether next is safe to redirect to after
|
|
// login: a same-site path only. A bare "starts with /" check isn't enough -
|
|
// "//evil.com" and "/\evil.com" both start with a single slash but browsers
|
|
// resolve them as protocol-relative URLs to a different host.
|
|
func isSafeRedirectTarget(next string) bool {
|
|
if next == "" || next[0] != '/' {
|
|
return false
|
|
}
|
|
return !strings.HasPrefix(next, "//") && !strings.HasPrefix(next, "/\\")
|
|
}
|
|
|
|
// IP validation
|
|
func getClientIP(r *http.Request) string {
|
|
// The server terminates TLS and listens directly on the configured ports
|
|
// (no trusted reverse proxy in front), so X-Forwarded-For is attacker-
|
|
// controlled and must not be used to determine the client IP for access
|
|
// control decisions.
|
|
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
if err != nil {
|
|
return r.RemoteAddr
|
|
}
|
|
return ip
|
|
}
|
|
|
|
func isIPAllowed(clientIP string) bool {
|
|
config := loadConfig()
|
|
clientAddr := net.ParseIP(clientIP)
|
|
if clientAddr == nil {
|
|
return false
|
|
}
|
|
|
|
for _, rule := range config.AllowedIPs {
|
|
rule = strings.TrimSpace(rule)
|
|
if rule == "" {
|
|
continue
|
|
}
|
|
if strings.Contains(rule, "/") {
|
|
_, ipNet, err := net.ParseCIDR(rule)
|
|
if err == nil && ipNet.Contains(clientAddr) {
|
|
return true
|
|
}
|
|
} else {
|
|
ip := net.ParseIP(rule)
|
|
if ip != nil && ip.Equal(clientAddr) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// cleanupExpiredSessions periodically removes expired entries from the
|
|
// in-memory session store (and stale login rate-limit entries) so they
|
|
// don't grow unbounded over long uptimes.
|
|
func cleanupExpiredSessions() {
|
|
ticker := time.NewTicker(1 * time.Hour)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
now := time.Now()
|
|
sessionsMutex.Lock()
|
|
for id, info := range sessions {
|
|
if now.After(info.Expiry) {
|
|
delete(sessions, id)
|
|
}
|
|
}
|
|
sessionsMutex.Unlock()
|
|
|
|
loginAttemptsMutex.Lock()
|
|
for ip, info := range loginAttempts {
|
|
if now.After(info.lockedUntil) && now.Sub(info.lastAttempt) > time.Hour {
|
|
delete(loginAttempts, ip)
|
|
}
|
|
}
|
|
loginAttemptsMutex.Unlock()
|
|
}
|
|
}
|
|
|
|
// Login rate limiting: after maxLoginAttempts failures from the same IP,
|
|
// lock out further attempts from that IP for lockoutDuration.
|
|
const (
|
|
maxLoginAttempts = 5
|
|
lockoutDuration = 30 * time.Second
|
|
)
|
|
|
|
type loginAttemptInfo struct {
|
|
count int
|
|
lastAttempt time.Time
|
|
lockedUntil time.Time
|
|
}
|
|
|
|
var (
|
|
loginAttemptsMutex sync.Mutex
|
|
loginAttempts = make(map[string]*loginAttemptInfo)
|
|
)
|
|
|
|
// checkLoginRateLimit reports whether a login attempt from clientIP is
|
|
// currently allowed, and if not, how long until it is.
|
|
func checkLoginRateLimit(clientIP string) (allowed bool, retryAfter time.Duration) {
|
|
loginAttemptsMutex.Lock()
|
|
defer loginAttemptsMutex.Unlock()
|
|
info, ok := loginAttempts[clientIP]
|
|
if !ok {
|
|
return true, 0
|
|
}
|
|
if time.Now().Before(info.lockedUntil) {
|
|
return false, time.Until(info.lockedUntil)
|
|
}
|
|
return true, 0
|
|
}
|
|
|
|
func recordLoginFailure(clientIP string) {
|
|
loginAttemptsMutex.Lock()
|
|
defer loginAttemptsMutex.Unlock()
|
|
info, ok := loginAttempts[clientIP]
|
|
if !ok {
|
|
info = &loginAttemptInfo{}
|
|
loginAttempts[clientIP] = info
|
|
}
|
|
info.count++
|
|
info.lastAttempt = time.Now()
|
|
if info.count >= maxLoginAttempts {
|
|
info.lockedUntil = time.Now().Add(lockoutDuration)
|
|
info.count = 0
|
|
}
|
|
}
|
|
|
|
func recordLoginSuccess(clientIP string) {
|
|
loginAttemptsMutex.Lock()
|
|
defer loginAttemptsMutex.Unlock()
|
|
delete(loginAttempts, clientIP)
|
|
}
|
|
|
|
// isLoggedIn reports whether a request carries a valid, unexpired session from
|
|
// an allowed IP. getCommonContext determines the same thing, but also builds
|
|
// the entire sidebar tree — this is the cheap variant for paths that only need
|
|
// the yes/no answer (media serving, search, draft visibility).
|
|
func isLoggedIn(r *http.Request) bool {
|
|
cookie, err := r.Cookie("session_id")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
sessionsMutex.Lock()
|
|
sess, ok := sessions[cookie.Value]
|
|
sessionsMutex.Unlock()
|
|
return ok && time.Now().Before(sess.Expiry) && isIPAllowed(getClientIP(r))
|
|
}
|
|
|
|
// validCSRFRequest checks a state-changing request's CSRF token (sent via
|
|
// the X-CSRF-Token header for fetch/AJAX calls, or a csrf_token form field
|
|
// for classic form submissions) against the value stored on the caller's
|
|
// session.
|
|
func validCSRFRequest(r *http.Request) bool {
|
|
cookie, err := r.Cookie("session_id")
|
|
if err != nil {
|
|
return false
|
|
}
|
|
sessionsMutex.Lock()
|
|
sess, ok := sessions[cookie.Value]
|
|
sessionsMutex.Unlock()
|
|
if !ok || sess.CSRFToken == "" {
|
|
return false
|
|
}
|
|
|
|
token := r.Header.Get("X-CSRF-Token")
|
|
if token == "" {
|
|
token = r.FormValue("csrf_token")
|
|
}
|
|
return token != "" && subtle.ConstantTimeCompare([]byte(token), []byte(sess.CSRFToken)) == 1
|
|
}
|
|
|
|
// Redirect middleware helper
|
|
func requireLogin(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
clientIP := getClientIP(r)
|
|
if !isIPAllowed(clientIP) {
|
|
renderTemplate(w, r, "denied.html", pongo2.Context{"slug": "home", "page_title_de": "Zugriff verweigert", "page_title_en": "Access Denied"})
|
|
return
|
|
}
|
|
|
|
ctx := getCommonContext(r)
|
|
if loggedIn, ok := ctx["logged_in"].(bool); !ok || !loggedIn {
|
|
http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
|
|
return
|
|
}
|
|
|
|
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
|
// Cap request bodies before parsing them for the CSRF check, so
|
|
// this doesn't bypass the per-endpoint upload size limits set
|
|
// further down the handler chain.
|
|
r.Body = http.MaxBytesReader(w, r.Body, 55<<20)
|
|
if !validCSRFRequest(r) {
|
|
http.Error(w, "Invalid or missing CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
}
|
|
|
|
next(w, r)
|
|
}
|
|
}
|