Prototype commit
This commit is contained in:
commit
9884ccd9ed
29 changed files with 3438 additions and 0 deletions
97
server/auth.go
Normal file
97
server/auth.go
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
cookieName = "docent_session"
|
||||
cookieMaxAge = 30 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type session struct {
|
||||
Exp int64 `json:"exp"`
|
||||
}
|
||||
|
||||
func (s *server) signCookieValue() (string, error) {
|
||||
payload := session{Exp: time.Now().Add(cookieMaxAge).Unix()}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
enc := base64.RawURLEncoding.EncodeToString(data)
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
mac.Write([]byte(enc))
|
||||
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
return enc + "." + sig, nil
|
||||
}
|
||||
|
||||
func (s *server) verifyCookieValue(value string) bool {
|
||||
parts := strings.SplitN(value, ".", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
mac := hmac.New(sha256.New, s.secret)
|
||||
mac.Write([]byte(parts[0]))
|
||||
expected := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if !hmac.Equal([]byte(expected), []byte(parts[1])) {
|
||||
return false
|
||||
}
|
||||
data, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var sess session
|
||||
if err := json.Unmarshal(data, &sess); err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Now().Unix() < sess.Exp
|
||||
}
|
||||
|
||||
func (s *server) setSessionCookie(w http.ResponseWriter) {
|
||||
value, err := s.signCookieValue()
|
||||
if err != nil {
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: value,
|
||||
Path: "/",
|
||||
MaxAge: int(cookieMaxAge.Seconds()),
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.secure,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *server) isAuthed(r *http.Request) bool {
|
||||
c, err := r.Cookie(cookieName)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return s.verifyCookieValue(c.Value)
|
||||
}
|
||||
|
||||
func constantTimeEqualString(a, b string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
|
||||
}
|
||||
3
server/go.mod
Normal file
3
server/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
module pentacle.games/docent
|
||||
|
||||
go 1.23
|
||||
213
server/main.go
Normal file
213
server/main.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type server struct {
|
||||
staticDir string
|
||||
password string
|
||||
qrKey string
|
||||
secret []byte
|
||||
secure bool
|
||||
loginLimiter *rateLimiter
|
||||
}
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", ":8181", "listen address")
|
||||
staticDir := flag.String("static", "../web/build", "static asset directory")
|
||||
insecure := flag.Bool("insecure", false, "drop Secure flag from cookie (dev only)")
|
||||
strict := flag.Bool("strict", false, "production mode: refuse to start without DOCENT_COOKIE_SECRET and DOCENT_QR_KEY")
|
||||
flag.Parse()
|
||||
|
||||
password := os.Getenv("DOCENT_PASSWORD")
|
||||
if password == "" {
|
||||
log.Fatal("DOCENT_PASSWORD must be set")
|
||||
}
|
||||
qrKey := os.Getenv("DOCENT_QR_KEY")
|
||||
if qrKey == "" {
|
||||
if *strict {
|
||||
log.Fatal("DOCENT_QR_KEY must be set in -strict mode")
|
||||
}
|
||||
log.Println("warning: DOCENT_QR_KEY not set; QR-code auto-login disabled")
|
||||
}
|
||||
|
||||
cookieSecret := os.Getenv("DOCENT_COOKIE_SECRET")
|
||||
if *strict && cookieSecret == "" {
|
||||
log.Fatal("DOCENT_COOKIE_SECRET must be set in -strict mode")
|
||||
}
|
||||
secret, err := loadOrGenSecret(cookieSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("DOCENT_COOKIE_SECRET: %v", err)
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(*staticDir)
|
||||
if err != nil {
|
||||
log.Fatalf("static dir: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(abs); err != nil {
|
||||
log.Printf("warning: static dir %s not found yet (build the SvelteKit app first)", abs)
|
||||
}
|
||||
|
||||
s := &server{
|
||||
staticDir: abs,
|
||||
password: password,
|
||||
qrKey: qrKey,
|
||||
secret: secret,
|
||||
secure: !*insecure,
|
||||
loginLimiter: newRateLimiter(8, time.Minute),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /api/login", s.handleLogin)
|
||||
mux.HandleFunc("POST /api/key", s.handleKey)
|
||||
mux.HandleFunc("POST /api/logout", s.handleLogout)
|
||||
mux.HandleFunc("GET /api/me", s.handleMe)
|
||||
mux.Handle("GET /audio/", s.requireAuth(http.HandlerFunc(s.serveStatic)))
|
||||
mux.Handle("GET /images/", s.requireAuth(http.HandlerFunc(s.serveStatic)))
|
||||
mux.HandleFunc("GET /", s.serveSPA)
|
||||
|
||||
log.Printf("docent listening on %s, serving %s", *addr, abs)
|
||||
if err := http.ListenAndServe(*addr, mux); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func loadOrGenSecret(env string) ([]byte, error) {
|
||||
if env != "" {
|
||||
b, err := base64.StdEncoding.DecodeString(env)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(b) < 32 {
|
||||
return nil, errShortSecret
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
log.Println("warning: DOCENT_COOKIE_SECRET not set, generating ephemeral secret (sessions reset on restart)")
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
var errShortSecret = &cookieError{"DOCENT_COOKIE_SECRET must decode to at least 32 bytes"}
|
||||
|
||||
type cookieError struct{ msg string }
|
||||
|
||||
func (e *cookieError) Error() string { return e.msg }
|
||||
|
||||
func (s *server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.loginLimiter.allow(clientIP(r)) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(&body)
|
||||
if err != nil || !constantTimeEqualString(body.Password, s.password) {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *server) handleKey(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.loginLimiter.allow(clientIP(r)) {
|
||||
w.Header().Set("Retry-After", "60")
|
||||
http.Error(w, "too many attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
err := json.NewDecoder(r.Body).Decode(&body)
|
||||
// An empty qrKey means the QR auto-login is disabled — never accept any value.
|
||||
if err != nil || s.qrKey == "" || !constantTimeEqualString(body.Key, s.qrKey) {
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
s.setSessionCookie(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
s.clearSessionCookie(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *server) handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.isAuthed(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"ok":true}`))
|
||||
}
|
||||
|
||||
func (s *server) requireAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.isAuthed(r) {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// serveStatic serves files relative to staticDir, refusing path traversal.
|
||||
func (s *server) serveStatic(w http.ResponseWriter, r *http.Request) {
|
||||
clean := filepath.Clean(r.URL.Path)
|
||||
if strings.Contains(clean, "..") {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
full := filepath.Join(s.staticDir, clean)
|
||||
if !strings.HasPrefix(full, s.staticDir) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil || info.IsDir() {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, full)
|
||||
}
|
||||
|
||||
// serveSPA serves files from staticDir, falling back to index.html for client routes.
|
||||
func (s *server) serveSPA(w http.ResponseWriter, r *http.Request) {
|
||||
clean := filepath.Clean(r.URL.Path)
|
||||
if clean == "/" {
|
||||
clean = "/index.html"
|
||||
}
|
||||
full := filepath.Join(s.staticDir, clean)
|
||||
if !strings.HasPrefix(full, s.staticDir) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if info, err := os.Stat(full); err == nil && !info.IsDir() {
|
||||
http.ServeFile(w, r, full)
|
||||
return
|
||||
}
|
||||
|
||||
// Unknown path → SPA fallback.
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, filepath.Join(s.staticDir, "index.html"))
|
||||
}
|
||||
80
server/ratelimit.go
Normal file
80
server/ratelimit.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
visitors map[string]*visitor
|
||||
rate int
|
||||
window time.Duration
|
||||
}
|
||||
|
||||
type visitor struct {
|
||||
count int
|
||||
reset time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter(rate int, window time.Duration) *rateLimiter {
|
||||
rl := &rateLimiter{
|
||||
visitors: make(map[string]*visitor),
|
||||
rate: rate,
|
||||
window: window,
|
||||
}
|
||||
go rl.cleanup()
|
||||
return rl
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) allow(key string) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
now := time.Now()
|
||||
v, ok := rl.visitors[key]
|
||||
if !ok || now.After(v.reset) {
|
||||
rl.visitors[key] = &visitor{count: 1, reset: now.Add(rl.window)}
|
||||
return true
|
||||
}
|
||||
if v.count >= rl.rate {
|
||||
return false
|
||||
}
|
||||
v.count++
|
||||
return true
|
||||
}
|
||||
|
||||
func (rl *rateLimiter) cleanup() {
|
||||
for {
|
||||
time.Sleep(5 * time.Minute)
|
||||
rl.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, v := range rl.visitors {
|
||||
if now.After(v.reset) {
|
||||
delete(rl.visitors, k)
|
||||
}
|
||||
}
|
||||
rl.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// clientIP extracts the client address from X-Forwarded-For (set by nginx),
|
||||
// falling back to X-Real-IP and finally RemoteAddr.
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if comma := strings.Index(xff, ","); comma > 0 {
|
||||
return strings.TrimSpace(xff[:comma])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
if rip := r.Header.Get("X-Real-IP"); rip != "" {
|
||||
return rip
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return r.RemoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue