From af6aba3d37703387cb11ca50ac597f482eca6aa3 Mon Sep 17 00:00:00 2001 From: Fendy Date: Wed, 26 Aug 2026 23:16:08 +0800 Subject: [PATCH] feat: auth handlers and session management --- internal/server/auth.go | 94 +++++++++++++++++++++++++++++++++++ internal/server/auth_test.go | 50 +++++++++++++++++++ internal/server/middleware.go | 47 ++++++++++++++++++ internal/server/server.go | 38 ++++++++++++++ 4 files changed, 229 insertions(+) create mode 100644 internal/server/auth.go create mode 100644 internal/server/auth_test.go create mode 100644 internal/server/middleware.go create mode 100644 internal/server/server.go diff --git a/internal/server/auth.go b/internal/server/auth.go new file mode 100644 index 0000000..430e130 --- /dev/null +++ b/internal/server/auth.go @@ -0,0 +1,94 @@ +package server + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "time" +) + +type loginRequest struct { + Token string `json:"token"` +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + ip := r.RemoteAddr + + s.mu.Lock() + if lockTime, locked := s.loginLockout[ip]; locked && time.Now().Before(lockTime) { + s.mu.Unlock() + http.Error(w, "Too many attempts, locked for 60s", http.StatusForbidden) + return + } + s.mu.Unlock() + + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + if req.Token != s.accessToken { + s.mu.Lock() + s.loginAttempts[ip]++ + if s.loginAttempts[ip] >= 5 { + s.loginLockout[ip] = time.Now().Add(60 * time.Second) + s.loginAttempts[ip] = 0 + } + s.mu.Unlock() + http.Error(w, "Invalid token", http.StatusUnauthorized) + return + } + + s.mu.Lock() + delete(s.loginAttempts, ip) + delete(s.loginLockout, ip) + s.mu.Unlock() + + sessionID := generateSessionID() + s.mu.Lock() + s.sessions[sessionID] = time.Now().Unix() + s.mu.Unlock() + + http.SetCookie(w, &http.Cookie{ + Name: "ydropbox_session", + Value: sessionID, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: true, + MaxAge: 7 * 24 * 60 * 60, + }) + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{}) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("ydropbox_session") + if err != nil { + http.Error(w, "Not logged in", http.StatusUnauthorized) + return + } + + s.mu.Lock() + delete(s.sessions, cookie.Value) + s.mu.Unlock() + + http.SetCookie(w, &http.Cookie{ + Name: "ydropbox_session", + Value: "", + Path: "/", + MaxAge: -1, + }) + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{}) +} + +func generateSessionID() string { + b := make([]byte, 32) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/server/auth_test.go b/internal/server/auth_test.go new file mode 100644 index 0000000..18d152d --- /dev/null +++ b/internal/server/auth_test.go @@ -0,0 +1,50 @@ +package server + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestLoginSuccess(t *testing.T) { + s := &Server{ + accessToken: "secret123", + sessions: make(map[string]int64), + loginAttempts: make(map[string]int), + loginLockout: make(map[string]time.Time), + } + + req := httptest.NewRequest("POST", "/api/login", bytes.NewBufferString(`{"token":"secret123"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.handleLogin(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) + } + if len(w.Header().Values("Set-Cookie")) == 0 { + t.Error("Expected Set-Cookie header") + } +} + +func TestLoginFailure(t *testing.T) { + s := &Server{ + accessToken: "secret123", + sessions: make(map[string]int64), + loginAttempts: make(map[string]int), + loginLockout: make(map[string]time.Time), + } + + req := httptest.NewRequest("POST", "/api/login", bytes.NewBufferString(`{"token":"wrong"}`)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.handleLogin(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Status = %d, want %d", w.Code, http.StatusUnauthorized) + } +} diff --git a/internal/server/middleware.go b/internal/server/middleware.go new file mode 100644 index 0000000..8981d1f --- /dev/null +++ b/internal/server/middleware.go @@ -0,0 +1,47 @@ +package server + +import ( + "net/http" +) + +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("ydropbox_session") + if err != nil { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + s.mu.Lock() + _, exists := s.sessions[cookie.Value] + s.mu.Unlock() + + if !exists { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + next(w, r) + } +} + +func (s *Server) requireAuthPage(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("ydropbox_session") + if err != nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + s.mu.Lock() + _, exists := s.sessions[cookie.Value] + s.mu.Unlock() + + if !exists { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + + next(w, r) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..030e73a --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,38 @@ +package server + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" + "yoresee_dropbox/internal/store" +) + +type Server struct { + store *store.Store + accessToken string + sessions map[string]int64 + shareSessions map[string]string + loginAttempts map[string]int + loginLockout map[string]time.Time + mu sync.Mutex + workspace string +} + +func New(store *store.Store, accessToken string, workspace string) *Server { + return &Server{ + store: store, + accessToken: accessToken, + sessions: make(map[string]int64), + shareSessions: make(map[string]string), + loginAttempts: make(map[string]int), + loginLockout: make(map[string]time.Time), + workspace: workspace, + } +} + +func generateUUID() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +}