diff --git a/.env.example b/.env.example index 2b5047e..0bbd541 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,4 @@ ADMIN_PASSWORD=change-me # MCP_TOKEN=legacy-token PORT=8080 SQLITE_PATH=flashcard.db +HMAC_KEY_FILE=.secret diff --git a/.gitignore b/.gitignore index b847883..34cbb6f 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ result # Environment .env +.secret # IDE .idea/ diff --git a/auth.go b/auth.go index 792be8b..28639d1 100644 --- a/auth.go +++ b/auth.go @@ -2,14 +2,17 @@ package main import ( "context" + "crypto/hmac" "crypto/rand" "crypto/sha256" "database/sql" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" "net/http" "os" + "strconv" "strings" "time" @@ -74,6 +77,29 @@ func hashToken(token string) string { return hex.EncodeToString(h[:]) } +func loadHMACKey(path string, logger *zap.Logger) ([]byte, error) { + if data, err := os.ReadFile(path); err == nil { + key, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(data))) + if err == nil && len(key) >= 32 { + return key, nil + } + logger.Warn("invalid HMAC key file, regenerating", zap.Error(err)) + } + + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate HMAC key: %w", err) + } + + encoded := base64.StdEncoding.EncodeToString(key) + if err := os.WriteFile(path, []byte(encoded+"\n"), 0600); err != nil { + return nil, fmt.Errorf("write HMAC key: %w", err) + } + + logger.Info("generated new HMAC key", zap.String("path", path)) + return key, nil +} + func (s *sqliteStore) countUsers(ctx context.Context) (int, error) { var n int err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n) @@ -276,44 +302,86 @@ func getUserFromContext(r *http.Request) *userRow { return nil } -func authMiddleware(store *sqliteStore, logger *zap.Logger, next http.Handler) http.HandlerFunc { +func signCookie(key []byte, userID int64, username string) string { + expiry := time.Now().Add(30 * 24 * time.Hour).Unix() + payload := fmt.Sprintf("%d|%s|%d", userID, username, expiry) + mac := hmac.New(sha256.New, key) + mac.Write([]byte(payload)) + sig := hex.EncodeToString(mac.Sum(nil)) + return base64.RawURLEncoding.EncodeToString([]byte(payload + "|" + sig)) +} + +func verifyCookie(key []byte, encoded string) (int64, string, bool) { + raw, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return 0, "", false + } + parts := strings.SplitN(string(raw), "|", 4) + if len(parts) != 4 { + return 0, "", false + } + + userID, username, expiryStr, sig := parts[0], parts[1], parts[2], parts[3] + payload := userID + "|" + username + "|" + expiryStr + + mac := hmac.New(sha256.New, key) + mac.Write([]byte(payload)) + expected := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(sig), []byte(expected)) { + return 0, "", false + } + + expiryUnix, err := strconv.ParseInt(expiryStr, 10, 64) + if err != nil || time.Now().Unix() > expiryUnix { + return 0, "", false + } + + id, err := strconv.ParseInt(userID, 10, 64) + if err != nil { + return 0, "", false + } + + return id, username, true +} + +func authMiddleware(store *sqliteStore, hmacKey []byte, logger *zap.Logger, next http.Handler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodOptions { next.ServeHTTP(w, r) return } - token := "" + var user *userRow cookie, err := r.Cookie("st_pierre_session") if err == nil && cookie.Value != "" { - token = cookie.Value - } - - if token == "" { - authHeader := r.Header.Get("Authorization") - if strings.HasPrefix(authHeader, "Bearer ") { - token = strings.TrimPrefix(authHeader, "Bearer ") + if id, username, ok := verifyCookie(hmacKey, cookie.Value); ok { + u, err := store.getUserByID(r.Context(), id) + if err == nil && u != nil && u.Active && u.Username == username { + user = u + } } } - if token == "" { - writeUnauthorized(w) - return + if user == nil { + authHeader := r.Header.Get("Authorization") + if strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimPrefix(authHeader, "Bearer ") + session, u, err := store.getSessionByToken(r.Context(), token) + if err != nil { + logger.Error("token lookup failed", zap.Error(err)) + } + if session != nil && session.Active && u != nil && u.Active { + store.updateSessionLastUsed(r.Context(), session.ID) + user = u + } + } } - session, user, err := store.getSessionByToken(r.Context(), token) - if err != nil { - logger.Error("auth lookup failed", zap.Error(err)) + if user == nil { writeUnauthorized(w) return } - if session == nil || !session.Active || !user.Active { - writeUnauthorized(w) - return - } - - store.updateSessionLastUsed(r.Context(), session.ID) ctx := context.WithValue(r.Context(), userContextKey, user) next.ServeHTTP(w, r.WithContext(ctx)) @@ -376,7 +444,7 @@ func getenvDefault(key, fallback string) string { return fallback } -func loginHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { +func loginHandler(store *sqliteStore, hmacKey []byte, logger *zap.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) @@ -405,31 +473,18 @@ func loginHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { return } - token, err := generateToken() - if err != nil { - logger.Error("generate token failed", zap.Error(err)) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) - return - } - - session, err := store.createSession(r.Context(), user.ID, token, "UI login") - if err != nil { - logger.Error("create session failed", zap.Error(err)) - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) - return - } + signed := signCookie(hmacKey, user.ID, user.Username) http.SetCookie(w, &http.Cookie{ Name: "st_pierre_session", - Value: token, + Value: signed, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(365 * 24 * time.Hour), + Expires: time.Now().Add(30 * 24 * time.Hour), }) writeJSON(w, http.StatusOK, map[string]any{ - "session": session, "user": map[string]any{ "id": user.ID, "username": user.Username, @@ -438,21 +493,13 @@ func loginHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { } } -func logoutHandler(store *sqliteStore) http.HandlerFunc { +func logoutHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } - cookie, err := r.Cookie("st_pierre_session") - if err == nil && cookie.Value != "" { - session, _, _ := store.getSessionByToken(r.Context(), cookie.Value) - if session != nil { - store.deactivateSession(r.Context(), session.ID, session.UserID) - } - } - http.SetCookie(w, &http.Cookie{ Name: "st_pierre_session", Value: "", @@ -731,4 +778,3 @@ func getUserHandler(store *sqliteStore, logger *zap.Logger) http.HandlerFunc { writeJSON(w, http.StatusOK, user) } } - diff --git a/flake.nix b/flake.nix index 4b88755..9bb4285 100644 --- a/flake.nix +++ b/flake.nix @@ -124,6 +124,12 @@ default = "/var/lib/st-pierre/st-pierre.sqlite"; description = "Path to the SQLite database file."; }; + + hmacKeyFile = lib.mkOption { + type = lib.types.path; + default = "/var/lib/st-pierre/secret"; + description = "Path to the HMAC key file for cookie signing. Will be auto-generated if missing."; + }; }; config = lib.mkIf cfg.enable { @@ -170,6 +176,7 @@ EMBEDDING_BASE_URL = cfg.embeddingBaseUrl; EMBEDDING_MODEL = cfg.embeddingModel; SQLITE_PATH = builtins.toString cfg.sqlitePath; + HMAC_KEY_FILE = builtins.toString cfg.hmacKeyFile; GIT_COMMIT = gitHash; BUILD_DATE = lastUpdated; } diff --git a/main.go b/main.go index ff28c86..f48217c 100644 --- a/main.go +++ b/main.go @@ -41,6 +41,12 @@ func main() { store.bootstrapAdmin(context.Background(), logger) + hmacKeyFile := envOrDefault("HMAC_KEY_FILE", ".secret") + hmacKey, err := loadHMACKey(hmacKeyFile, logger) + if err != nil { + logger.Fatal("failed to load HMAC key", zap.Error(err)) + } + searchEngine := createSearchEngine(logger, store, openAIKey) mcpServer := server.NewMCPServer( @@ -59,7 +65,7 @@ func main() { streamableServer := server.NewStreamableHTTPServer(mcpServer) withAuth := func(next http.Handler) http.Handler { - return authMiddleware(store, logger, next) + return authMiddleware(store, hmacKey, logger, next) } mux := http.NewServeMux() @@ -78,8 +84,8 @@ func main() { mux.Handle("/mcp", withAuth(streamableServer)) - mux.HandleFunc("/api/v1/auth/login", loginHandler(store, logger)) - mux.HandleFunc("/api/v1/auth/logout", logoutHandler(store)) + mux.HandleFunc("/api/v1/auth/login", loginHandler(store, hmacKey, logger)) + mux.HandleFunc("/api/v1/auth/logout", logoutHandler()) mux.HandleFunc("/api/v1/auth/me", withAuth(getUserHandler(store, logger)).ServeHTTP) mux.HandleFunc("/api/v1/auth/password", withAuth(changePasswordHandler(store, logger)).ServeHTTP)