feat(api): REST на chi + huma с auth-ручками и OpenAPI 3.1

- переход на chi + huma (решение D-006): huma отдаёт типизированные ручки и
  генерирует документ, auth-ручки живут на chi (cookie и заголовки напрямую)
- единый формат ошибок: код (auth.invalid_credentials, auth.2fa_required,
  perm.denied и т.д.) + человекочитаемое сообщение (AGENT.md 8.5)
- cookie сессии __Host-session: HttpOnly, SameSite=Lax, Secure при TLS;
  альтернатива — Bearer-токен для desktop/CLI (AGENT.md 8.1)
- ручки: register, login, logout, logout-all, sessions, step-up, 2fa/setup,
  2fa/enable, users/@me; IP и User-Agent прокидываются из запроса в контекст
- /api/v1/openapi.json: объединённый документ (схемы huma + контракт auth)
- тесты: регистрация через API с cookie, ошибки входа, обязательная сессия,
  валидация, наличие всех путей в OpenAPI
This commit is contained in:
2026-09-19 21:36:00 +03:00
parent 16218ee045
commit f61751ed26
12 changed files with 984 additions and 148 deletions
+335
View File
@@ -0,0 +1,335 @@
package server
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"glchat/internal/auth"
"glchat/internal/httpx"
"glchat/internal/store"
)
// sessionCookieName — имя cookie сессии (AGENT.md 8.1: префикс __Host-).
const sessionCookieName = "__Host-session"
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) {
router.Post("/auth/register", s.handleRegister)
router.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)
router.Get("/users/@me", s.handleGetMe)
}
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"`
}
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,
}
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})
}
func (s *Server) handleGetMe(w http.ResponseWriter, r *http.Request) {
user, _, ok := s.authenticate(w, r)
if !ok {
return
}
writeJSON(w, map[string]any{"user": userPayload(user)})
}
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:])
}