package server import ( "crypto/rand" "crypto/sha256" "encoding/hex" "net/http" "strings" "time" "golang.org/x/crypto/bcrypt" "tracker/internal/store" ) const ( sessionCookie = "tracker_session" sessionLifetime = 30 * 24 * time.Hour keyPrefix = "trk_" ) func hashToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func randomHex(n int) string { b := make([]byte, n) if _, err := rand.Read(b); err != nil { panic("crypto/rand unavailable: " + err.Error()) } return hex.EncodeToString(b) } func (s *Server) checkPassword(username, password string) bool { u, err := s.store.UserByName(username) if err != nil { // burn comparable time so missing users aren't distinguishable bcrypt.CompareHashAndPassword([]byte("$2a$10$7EqJtq98hPqEX7fNZaFWoOhi5B0X0PYmVlSkC0pR0uW9QlY0S3zKe"), []byte(password)) return false } return bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil } // --- session helpers --- func (s *Server) sessionUser(r *http.Request) (string, bool) { c, err := r.Cookie(sessionCookie) if err != nil || c.Value == "" { return "", false } username, err := s.store.SessionByTokenHash(hashToken(c.Value)) if err != nil { return "", false } return username, true } func (s *Server) startSession(w http.ResponseWriter, username string) error { token := randomHex(32) expires := time.Now().Add(sessionLifetime).Format("2006-01-02 15:04:05") if err := s.store.CreateSession(hashToken(token), username, expires); err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int(sessionLifetime.Seconds()), }) return nil } // --- login / logout --- func (s *Server) handleLoginPage(w http.ResponseWriter, r *http.Request) { if _, ok := s.sessionUser(r); ok { http.Redirect(w, r, "/", http.StatusSeeOther) return } s.render(w, http.StatusOK, "login.html", map[string]any{ "Title": "login", }) } func (s *Server) handleLoginPost(w http.ResponseWriter, r *http.Request) { username := strings.TrimSpace(r.FormValue("username")) password := r.FormValue("password") if !s.checkPassword(username, password) { time.Sleep(400 * time.Millisecond) s.render(w, http.StatusUnauthorized, "login.html", map[string]any{ "Title": "login", "Error": "access denied: unknown handle or bad passphrase", }) return } s.store.DeleteExpiredSessions() if err := s.startSession(w, username); err != nil { s.error(w, http.StatusInternalServerError, err) return } http.Redirect(w, r, "/", http.StatusSeeOther) } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" { s.store.DeleteSession(hashToken(c.Value)) } http.SetCookie(w, &http.Cookie{ Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, MaxAge: -1, }) http.Redirect(w, r, "/login", http.StatusSeeOther) } // --- api keys page --- func (s *Server) handleKeys(w http.ResponseWriter, r *http.Request) { s.renderKeys(w, r, http.StatusOK, nil, "") } func (s *Server) renderKeys(w http.ResponseWriter, r *http.Request, status int, keys []store.APIKey, newKey string) { if keys == nil { var err error keys, err = s.store.APIKeys() if err != nil { s.error(w, http.StatusInternalServerError, err) return } } s.render(w, status, "keys.html", map[string]any{ "Title": "api keys", "Keys": keys, "NewKey": newKey, }) } func (s *Server) handleKeyCreate(w http.ResponseWriter, r *http.Request) { name := strings.TrimSpace(r.FormValue("name")) if name == "" { s.renderKeys(w, r, http.StatusBadRequest, nil, "") return } plain := keyPrefix + randomHex(24) if _, err := s.store.CreateAPIKey(name, hashToken(plain)); err != nil { s.error(w, http.StatusInternalServerError, err) return } s.renderKeys(w, r, http.StatusOK, nil, plain) } func (s *Server) handleKeyDelete(w http.ResponseWriter, r *http.Request) { id, ok := s.pathID(w, r) if !ok { return } if err := s.store.DeleteAPIKey(id); err != nil { s.error(w, http.StatusInternalServerError, err) return } http.Redirect(w, r, "/keys", http.StatusSeeOther) } // --- credential checks --- func apiKeyFromRequest(r *http.Request) string { if key := r.Header.Get("X-API-Key"); key != "" { return key } if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") { return strings.TrimPrefix(auth, "Bearer ") } return "" } func (s *Server) apiAuthorized(r *http.Request) bool { if key := apiKeyFromRequest(r); key != "" { k, err := s.store.KeyByHash(hashToken(key)) if err != nil { return false } s.store.TouchKey(k.ID) return true } if user, pass, ok := r.BasicAuth(); ok { return s.checkPassword(user, pass) } if _, ok := s.sessionUser(r); ok { return true } return false } // --- middleware --- func (s *Server) auth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := r.URL.Path switch { case strings.HasPrefix(p, "/static/"): next.ServeHTTP(w, r) return case p == "/favicon.ico": http.NotFound(w, r) return case strings.HasPrefix(p, "/api/"): if s.apiAuthorized(r) { next.ServeHTTP(w, r) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) w.Write([]byte(`{"error":"unauthorized"}`)) return case p == "/login": next.ServeHTTP(w, r) return } if _, ok := s.sessionUser(r); ok { next.ServeHTTP(w, r) return } http.Redirect(w, r, "/login", http.StatusSeeOther) }) }