2026-09-19 21:36:00 +03:00
|
|
|
package server
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
|
|
|
|
|
"glchat/internal/auth"
|
2026-09-19 21:50:06 +03:00
|
|
|
"glchat/internal/gateway"
|
2026-09-19 21:36:00 +03:00
|
|
|
"glchat/internal/httpx"
|
|
|
|
|
"glchat/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// sessionCookieName — имя cookie сессии (AGENT.md 8.1: префикс __Host-).
|
2026-09-19 21:50:06 +03:00
|
|
|
// Значение общее с Gateway: браузерный клиент аутентифицируется этой cookie.
|
|
|
|
|
const sessionCookieName = gateway.SessionCookieName
|
2026-09-19 21:36:00 +03:00
|
|
|
|
|
|
|
|
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) {
|
2026-09-19 21:51:47 +03:00
|
|
|
// Вход и регистрация дополнительно ограничены по 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)
|
2026-09-19 21:36:00 +03:00
|
|
|
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"`
|
2026-09-19 21:50:06 +03:00
|
|
|
// OnboardingCompleted — пройдена ли первичная настройка (AGENT.md 7.2).
|
|
|
|
|
OnboardingCompleted bool `json:"onboarding_completed"`
|
2026-09-19 21:36:00 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func userPayload(user *store.User) currentUserPayload {
|
|
|
|
|
payload := currentUserPayload{
|
2026-09-19 21:50:06 +03:00
|
|
|
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,
|
2026-09-19 21:36:00 +03:00
|
|
|
}
|
|
|
|
|
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:])
|
|
|
|
|
}
|