Files
richter c5b17dbc8d Initial commit: content-free bilingual wiki engine
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.
2026-07-28 19:40:13 +02:00

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)
}
}