package server import ( "net/http" "time" "github.com/go-chi/chi/v5" "glchat/internal/auth" "glchat/internal/gateway" "glchat/internal/httpx" "glchat/internal/store" ) // sessionCookieName — имя cookie сессии (AGENT.md 8.1: префикс __Host-). // Значение общее с Gateway: браузерный клиент аутентифицируется этой cookie. const sessionCookieName = gateway.SessionCookieName const sessionCookiePath = "/" // sessionCookie собирает cookie сессии: HttpOnly, SameSite=Lax и Secure при TLS. // Secure выключается только для установок без TLS (--skip-tls, стенд за туннелем): // в этом режиме браузер не принимает Secure-cookie по http. func (s *Server) sessionCookie(token string, expires time.Time) *http.Cookie { return &http.Cookie{ //nolint:gosec // Secure зависит от TLS_ENABLED, HttpOnly и SameSite заданы Name: sessionCookieName, Value: token, Path: sessionCookiePath, HttpOnly: true, Secure: s.cfg.TLSEnabled, SameSite: http.SameSiteLaxMode, Expires: expires.UTC(), } } func (s *Server) clearSessionCookie() *http.Cookie { return &http.Cookie{ //nolint:gosec // Secure зависит от TLS_ENABLED, HttpOnly и SameSite заданы Name: sessionCookieName, Value: "", Path: sessionCookiePath, HttpOnly: true, Secure: s.cfg.TLSEnabled, SameSite: http.SameSiteLaxMode, MaxAge: -1, } } // registerAuthRoutes вешает ручки аутентификации на chi: cookie и заголовки // выставляются напрямую, а контракт описан в OpenAPI (docs.go). func (s *Server) registerAuthRoutes(router chi.Router) { // Вход и регистрация дополнительно ограничены по IP (AGENT.md 8.6). authLimit := s.authLimiter.Middleware(func(r *http.Request) string { return httpx.ClientIP(r, nil) }) router.With(authLimit).Post("/auth/register", s.handleRegister) router.With(authLimit).Post("/auth/login", s.handleLogin) router.Post("/auth/logout", s.handleLogout) router.Post("/auth/logout-all", s.handleLogoutAll) router.Get("/auth/sessions", s.handleListSessions) router.Post("/auth/step-up", s.handleStepUp) router.Post("/auth/2fa/setup", s.handleSetupTOTP) router.Post("/auth/2fa/enable", s.handleEnableTOTP) } type registerRequest struct { Username string `json:"username"` DisplayName string `json:"display_name"` Email string `json:"email"` Password string `json:"password"` Locale string `json:"locale"` } type currentUserPayload struct { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"display_name"` Bio string `json:"bio"` Status string `json:"status"` CustomStatus string `json:"custom_status"` AvatarFileID string `json:"avatar_file_id,omitempty"` BannerFileID string `json:"banner_file_id,omitempty"` IsInstanceAdmin bool `json:"is_instance_admin"` Badges []string `json:"badges"` Locale string `json:"locale"` // OnboardingCompleted — пройдена ли первичная настройка (AGENT.md 7.2). OnboardingCompleted bool `json:"onboarding_completed"` } func userPayload(user *store.User) currentUserPayload { payload := currentUserPayload{ ID: formatSnowflake(user.ID), Username: user.Username, DisplayName: user.DisplayName, Bio: user.Bio, Status: user.Status, CustomStatus: user.CustomStatus, IsInstanceAdmin: user.IsInstanceAdmin, Badges: user.Badges, Locale: user.Locale, OnboardingCompleted: user.OnboardingCompletedAt != nil, } if payload.Badges == nil { payload.Badges = []string{} } if user.AvatarFileID != nil { payload.AvatarFileID = formatSnowflake(*user.AvatarFileID) } if user.BannerFileID != nil { payload.BannerFileID = formatSnowflake(*user.BannerFileID) } return payload } func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { if s.auth == nil { writeAPIError(w, auth.ErrSessionExpired) return } var request registerRequest if !decodeBody(w, r, &request) { return } user, token, session, err := s.auth.Register(r.Context(), auth.RegisterInput{ Username: request.Username, DisplayName: request.DisplayName, Email: request.Email, Password: request.Password, Locale: request.Locale, IP: httpx.ClientIPFromContext(r.Context()), UserAgent: httpx.UserAgentFromContext(r.Context()), }) if err != nil { writeAPIError(w, err) return } http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt)) writeJSON(w, map[string]any{"user": userPayload(user)}) } type loginRequest struct { Email string `json:"email"` Password string `json:"password"` TOTPCode string `json:"totp_code"` } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if s.auth == nil { writeAPIError(w, auth.ErrSessionExpired) return } var request loginRequest if !decodeBody(w, r, &request) { return } user, token, session, err := s.auth.Login(r.Context(), auth.LoginInput{ Email: request.Email, Password: request.Password, TOTPCode: request.TOTPCode, IP: httpx.ClientIPFromContext(r.Context()), UserAgent: httpx.UserAgentFromContext(r.Context()), }) if err != nil { writeAPIError(w, err) return } http.SetCookie(w, s.sessionCookie(token, session.ExpiresAt)) writeJSON(w, map[string]any{"user": userPayload(user)}) } // authenticate читает сессию из cookie или Bearer-токена (desktop, AGENT.md 8.1). func (s *Server) authenticate(w http.ResponseWriter, r *http.Request) (*store.User, *store.Session, bool) { if s.auth == nil { writeAPIError(w, auth.ErrSessionExpired) return nil, nil, false } token := "" if cookie, err := r.Cookie(sessionCookieName); err == nil { token = cookie.Value } if token == "" { token = normalizeBearer(r.Header.Get("Authorization")) } if token == "" { writeAPIError(w, auth.ErrSessionExpired) return nil, nil, false } user, session, err := s.auth.ResolveSession(r.Context(), token) if err != nil { writeAPIError(w, err) return nil, nil, false } return user, session, true } func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { _, session, ok := s.authenticate(w, r) if !ok { // Выход без валидной сессии не ошибка: cookie всё равно очищаем. http.SetCookie(w, s.clearSessionCookie()) writeJSON(w, map[string]any{"ok": true}) return } if err := s.auth.Logout(r.Context(), session.ID); err != nil { writeAPIError(w, err) return } http.SetCookie(w, s.clearSessionCookie()) writeJSON(w, map[string]any{"ok": true}) } func (s *Server) handleLogoutAll(w http.ResponseWriter, r *http.Request) { user, _, ok := s.authenticate(w, r) if !ok { return } if err := s.auth.LogoutAll(r.Context(), user.ID); err != nil { writeAPIError(w, err) return } http.SetCookie(w, s.clearSessionCookie()) writeJSON(w, map[string]any{"ok": true}) } type sessionPayload struct { ID string `json:"id"` UserAgent string `json:"user_agent"` IP string `json:"ip"` CreatedAt string `json:"created_at"` LastSeen string `json:"last_seen"` ExpiresAt string `json:"expires_at"` Current bool `json:"current"` SteppedUp bool `json:"stepped_up"` } func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { user, current, ok := s.authenticate(w, r) if !ok { return } sessions, err := s.store.ListSessions(r.Context(), user.ID) if err != nil { writeAPIError(w, err) return } now := time.Now().UTC() payload := make([]sessionPayload, 0, len(sessions)) for _, session := range sessions { payload = append(payload, sessionPayload{ ID: formatSnowflake(session.ID), UserAgent: session.UserAgent, IP: session.IP, CreatedAt: session.CreatedAt.Format(time.RFC3339), LastSeen: session.LastSeen.Format(time.RFC3339), ExpiresAt: session.ExpiresAt.Format(time.RFC3339), Current: session.ID == current.ID, SteppedUp: session.SteppedUp(now), }) } writeJSON(w, map[string]any{"sessions": payload}) } type totpEnableRequest struct { Code string `json:"code"` } func (s *Server) handleSetupTOTP(w http.ResponseWriter, r *http.Request) { user, _, ok := s.authenticate(w, r) if !ok { return } setup, err := s.auth.SetupTOTP(r.Context(), user.ID) if err != nil { writeAPIError(w, err) return } writeJSON(w, map[string]any{ "secret": setup.Secret, "url": setup.URL, "issuer": setup.IssuerName, }) } func (s *Server) handleEnableTOTP(w http.ResponseWriter, r *http.Request) { user, _, ok := s.authenticate(w, r) if !ok { return } var request totpEnableRequest if !decodeBody(w, r, &request) { return } codes, err := s.auth.EnableTOTP(r.Context(), user.ID, request.Code) if err != nil { writeAPIError(w, err) return } writeJSON(w, map[string]any{"recovery_codes": codes}) } type stepUpRequest struct { Password string `json:"password"` TOTPCode string `json:"totp_code"` } func (s *Server) handleStepUp(w http.ResponseWriter, r *http.Request) { user, session, ok := s.authenticate(w, r) if !ok { return } var request stepUpRequest if !decodeBody(w, r, &request) { return } if err := s.auth.RequireStepUp(r.Context(), user, session, request.Password, request.TOTPCode); err != nil { writeAPIError(w, err) return } writeJSON(w, map[string]any{"ok": true}) } // formatSnowflake сериализует идентификатор строкой: JS не хранит uint64 // без потери точности (AGENT.md 8.1). func formatSnowflake(id uint64) string { if id == 0 { return "" } var buf [20]byte pos := len(buf) for id > 0 { pos-- buf[pos] = byte('0' + id%10) id /= 10 } return string(buf[pos:]) }