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
+265 -69
View File
@@ -1,81 +1,277 @@
package server
// openAPIDocument is the hand-maintained OpenAPI 3.1 contract for the endpoints
// implemented so far. Phase 1 replaces it with a schema generated from typed
// handler definitions (AGENT.md 8.1).
var openAPIDocument = []byte(`{
"openapi": "3.1.0",
"info": {
"title": "glchat API",
"version": "0.1.0",
"description": "Self-hosted communication platform. Phase 0 exposes health and metadata endpoints only.",
"license": { "name": "AGPL-3.0-or-later", "identifier": "AGPL-3.0-or-later" }
},
"servers": [{ "url": "/api/v1" }],
"paths": {
"/meta": {
"get": {
"operationId": "getMeta",
"summary": "Instance metadata, API version and feature flags",
"tags": ["Meta"],
"responses": {
"200": {
"description": "Instance metadata",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/Meta" }
}
}
}
}
import (
"encoding/json"
"log/slog"
"net/http"
)
// handleOpenAPI отдаёт объединённый документ OpenAPI 3.1: пути, описанные
// huma (meta и служебные ручки), плюс контракт auth-ручек, которые живут
// на chi и описаны в authPathsJSON (AGENT.md 8.1: единый источник типов).
func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
document := map[string]any{}
if body, err := json.Marshal(s.api.OpenAPI()); err == nil {
if err := json.Unmarshal(body, &document); err != nil {
s.logger.ErrorContext(r.Context(), "decode generated openapi", slog.Any("error", err))
}
}
if document == nil {
document = map[string]any{}
}
paths, _ := document["paths"].(map[string]any)
if paths == nil {
paths = map[string]any{}
}
var extra map[string]any
if err := json.Unmarshal([]byte(authPathsJSON), &extra); err != nil {
s.logger.ErrorContext(r.Context(), "decode auth openapi paths", slog.Any("error", err))
}
for path, item := range extra {
paths[path] = item
}
document["paths"] = paths
if components, ok := document["components"].(map[string]any); ok {
if schemas, ok := components["schemas"].(map[string]any); ok {
var extraSchemas map[string]any
if err := json.Unmarshal([]byte(authSchemasJSON), &extraSchemas); err == nil {
for name, schema := range extraSchemas {
schemas[name] = schema
}
}
}
}
body, err := json.Marshal(document)
if err != nil {
httpxWriteInternalError(w)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(body); err != nil {
s.logger.ErrorContext(r.Context(), "write openapi document", slog.Any("error", err))
}
}
func httpxWriteInternalError(w http.ResponseWriter) {
http.Error(w, "internal error", http.StatusInternalServerError)
}
// authPathsJSON — контракт ручек аутентификации (chi-обработчики).
const authPathsJSON = `{
"/auth/register": {
"post": {
"operationId": "register",
"summary": "Регистрация по email и паролю",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/RegisterRequest" } } }
},
"responses": {
"200": { "description": "Аккаунт создан, сессия выдана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } },
"403": { "description": "Регистрация выключена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"409": { "description": "Email или username заняты", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } },
"422": { "description": "Пароль или username не проходят политику", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"components": {
"schemas": {
"Meta": {
"type": "object",
"required": ["name", "version", "api_version", "base_url", "features"],
"properties": {
"name": { "type": "string" },
"version": { "type": "string" },
"commit": { "type": "string" },
"build_date": { "type": "string" },
"api_version": { "type": "string", "enum": ["v1"] },
"base_url": { "type": "string" },
"files_url": { "type": "string" },
"gateway_url": { "type": "string" },
"rtc_path": { "type": "string" },
"max_upload_size": { "type": "integer", "format": "int64" },
"features": {
"type": "object",
"properties": {
"registration_enabled": { "type": "boolean" },
"anti_bot_enabled": { "type": "boolean" },
"voice_enabled": { "type": "boolean" },
"web_push_enabled": { "type": "boolean" },
"oauth_enabled": { "type": "boolean" },
"passkeys_enabled": { "type": "boolean" }
}
}
}
"/auth/login": {
"post": {
"operationId": "login",
"summary": "Вход по email и паролю (с TOTP при включённой 2FA)",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/LoginRequest" } } }
},
"Error": {
"responses": {
"200": { "description": "Вход выполнен, сессия выдана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AuthResponse" } } } },
"401": { "description": "Неверные данные или требуется код 2FA (auth.2fa_required)", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } }
}
}
},
"/auth/logout": {
"post": {
"operationId": "logout",
"summary": "Выход: отзывает текущую сессию",
"tags": ["Auth"],
"responses": { "200": { "description": "Сессия отозвана", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
}
},
"/auth/logout-all": {
"post": {
"operationId": "logoutAll",
"summary": "Выйти везде: отзывает все сессии пользователя",
"tags": ["Auth"],
"responses": { "200": { "description": "Все сессии отозваны", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
}
},
"/auth/sessions": {
"get": {
"operationId": "listSessions",
"summary": "Активные сессии пользователя",
"tags": ["Auth"],
"responses": { "200": { "description": "Список сессий", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SessionsResponse" } } } } }
}
},
"/auth/2fa/setup": {
"post": {
"operationId": "setupTOTP",
"summary": "Создать секрет TOTP (до подтверждения кодом)",
"tags": ["Auth"],
"responses": { "200": { "description": "Секрет и otpauth-URL", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/TOTPSetup" } } } } }
}
},
"/auth/2fa/enable": {
"post": {
"operationId": "enableTOTP",
"summary": "Включить 2FA, подтвердив код; возвращает резервные коды",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/TOTPEnableRequest" } } }
},
"responses": { "200": { "description": "2FA включена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RecoveryCodes" } } } } }
}
},
"/auth/step-up": {
"post": {
"operationId": "stepUp",
"summary": "Подтвердить пароль (и 2FA) для чувствительных действий",
"tags": ["Auth"],
"requestBody": {
"required": true,
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/StepUpRequest" } } }
},
"responses": { "200": { "description": "Аутентификация подтверждена", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } } }
}
},
"/users/@me": {
"get": {
"operationId": "getMe",
"summary": "Текущий пользователь",
"tags": ["Users"],
"responses": { "200": { "description": "Профиль пользователя", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/UserResponse" } } } } }
}
}
}`
// authSchemasJSON — схемы запросов и ответов auth-ручек.
const authSchemasJSON = `{
"RegisterRequest": {
"type": "object",
"required": ["username", "email", "password"],
"properties": {
"username": { "type": "string", "minLength": 2, "maxLength": 32 },
"display_name": { "type": "string", "maxLength": 64 },
"email": { "type": "string", "format": "email" },
"password": { "type": "string", "minLength": 10 },
"locale": { "type": "string", "enum": ["ru", "en"] }
}
},
"LoginRequest": {
"type": "object",
"required": ["email", "password"],
"properties": {
"email": { "type": "string", "format": "email" },
"password": { "type": "string" },
"totp_code": { "type": "string", "description": "Код TOTP или резервный код" }
}
},
"TOTPEnableRequest": {
"type": "object",
"required": ["code"],
"properties": { "code": { "type": "string", "minLength": 6 } }
},
"StepUpRequest": {
"type": "object",
"required": ["password"],
"properties": {
"password": { "type": "string" },
"totp_code": { "type": "string" }
}
},
"User": {
"type": "object",
"required": ["id", "username", "display_name", "status", "is_instance_admin", "badges", "locale"],
"properties": {
"id": { "type": "string", "description": "Snowflake строкой" },
"username": { "type": "string" },
"display_name": { "type": "string" },
"bio": { "type": "string" },
"status": { "type": "string", "enum": ["online", "idle", "dnd", "invisible"] },
"custom_status": { "type": "string" },
"avatar_file_id": { "type": "string" },
"banner_file_id": { "type": "string" },
"is_instance_admin": { "type": "boolean" },
"badges": { "type": "array", "items": { "type": "string" } },
"locale": { "type": "string", "enum": ["ru", "en"] }
}
},
"AuthResponse": {
"type": "object",
"required": ["user"],
"properties": { "user": { "$ref": "#/components/schemas/User" } }
},
"UserResponse": {
"type": "object",
"required": ["user"],
"properties": { "user": { "$ref": "#/components/schemas/User" } }
},
"Session": {
"type": "object",
"required": ["id", "created_at", "last_seen", "expires_at", "current", "stepped_up"],
"properties": {
"id": { "type": "string" },
"user_agent": { "type": "string" },
"ip": { "type": "string" },
"created_at": { "type": "string", "format": "date-time" },
"last_seen": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time" },
"current": { "type": "boolean" },
"stepped_up": { "type": "boolean" }
}
},
"SessionsResponse": {
"type": "object",
"required": ["sessions"],
"properties": { "sessions": { "type": "array", "items": { "$ref": "#/components/schemas/Session" } } }
},
"TOTPSetup": {
"type": "object",
"required": ["secret", "url"],
"properties": {
"secret": { "type": "string" },
"url": { "type": "string" },
"issuer": { "type": "string" }
}
},
"RecoveryCodes": {
"type": "object",
"required": ["recovery_codes"],
"properties": { "recovery_codes": { "type": "array", "items": { "type": "string" } } }
},
"OkResponse": {
"type": "object",
"required": ["ok"],
"properties": { "ok": { "type": "boolean" } }
},
"Error": {
"type": "object",
"required": ["error"],
"properties": {
"error": {
"type": "object",
"required": ["error"],
"required": ["code", "message"],
"properties": {
"error": {
"type": "object",
"required": ["code", "message"],
"properties": {
"code": { "type": "string" },
"message": { "type": "string" },
"details": { "type": "object", "additionalProperties": true }
}
}
"code": { "type": "string" },
"message": { "type": "string" },
"details": { "type": "object", "additionalProperties": true }
}
}
}
}
}
`)
}`