Files
glchat/internal/server/auth.go
T
grendervill 72537e9ff9 fix(invites): картинки оформления отдаются без сессии для страницы приглашения
- GET /files/{id} без сессии отдаёт только оформление сервера и картинку
  приглашения (guild_icon, guild_banner, guild_splash, invite_background):
  их показывают до входа (AGENT.md 7.9), остальное по-прежнему требует сессии
- optionalUser резолвит сессию без ответа об ошибке; путь файла проверяется на
  принадлежность каталогу данных (AGENT.md 9.2)
- аватар пригласившего показывается только вошедшим
- тесты: Go (splash анонимно, вложение закрыто) и правки живого скрипта
2026-09-21 19:30:25 +03:00

391 lines
12 KiB
Go

package server
import (
"bytes"
"context"
"encoding/base64"
"image/png"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/pquerna/otp"
"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"`
// TOTPEnabled — включена ли 2FA у пользователя (AGENT.md 7.1).
TOTPEnabled bool `json:"totp_enabled"`
}
// withTOTPState дополняет профиль фактом включённой 2FA.
func (s *Server) withTOTPState(ctx context.Context, user *store.User) map[string]any {
payload := userPayload(user)
if s.auth != nil {
if enabled, err := s.auth.TOTPEnabled(ctx, user.ID); err == nil {
payload.TOTPEnabled = enabled
}
}
return map[string]any{"user": payload}
}
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, s.withTOTPState(r.Context(), 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, s.withTOTPState(r.Context(), user))
}
// authenticate читает сессию из cookie или Bearer-токена (desktop, AGENT.md 8.1).
// optionalUser резолвит сессию, не отвечая ошибкой: `nil` — анонимный запрос.
// Нужен ручкам, которые часть данных отдают и без входа (страница приглашения).
func (s *Server) optionalUser(ctx context.Context, r *http.Request) (*store.User, error) {
if s.auth == nil {
return nil, nil
}
token := sessionToken(r)
if token == "" {
return nil, nil
}
user, _, err := s.auth.ResolveSession(ctx, token)
if err != nil {
return nil, err
}
return user, nil
}
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 := sessionToken(r)
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})
}
// totpQRDataURI рисует QR-код otpauth-ссылки локально и отдаёт data-URI.
func totpQRDataURI(otpauthURL string) (string, error) {
key, err := otp.NewKeyFromURL(otpauthURL)
if err != nil {
return "", err
}
image, err := key.Image(256, 256)
if err != nil {
return "", err
}
var buf bytes.Buffer
if err := png.Encode(&buf, image); err != nil {
return "", err
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()), nil
}
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
}
payload := map[string]any{
"secret": setup.Secret,
"otpauth_url": setup.URL,
"issuer": setup.IssuerName,
}
// QR-код рисуем сами: внешние сервисы генерации QR запрещены (AGENT.md 7.1 —
// никаких внешних зависимостей для секретов).
if qr, err := totpQRDataURI(setup.URL); err == nil {
payload["qr_png"] = qr
} else {
s.logger.WarnContext(r.Context(), "не удалось построить QR-код для 2FA", slog.Any("error", err))
}
writeJSON(w, payload)
}
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:])
}