Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ff355ad1d9 | |||
| 2da484d781 | |||
| 9da532e9dc | |||
| 9d2f69898a | |||
| 35ddfc938c | |||
| 57ce3dea5f | |||
| 56ab583223 | |||
| f1308b3be7 | |||
| a822d8c3b6 | |||
| 321cba3f9b | |||
| ea645860cf |
@@ -0,0 +1,5 @@
|
||||
SERVER_PORT=8080
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/aegisguard?sslmode=disable
|
||||
JWT_SECRET=change_me_to_a_random_secret_at_least_32_chars
|
||||
JWT_EXPIRATION=24h
|
||||
JWT_REFRESH_EXPIRATION=168h
|
||||
@@ -0,0 +1,29 @@
|
||||
name: ci
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
- name: Go setup
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26.1"
|
||||
cache: false
|
||||
- name: Install deps
|
||||
run: go mod tidy
|
||||
- name: Golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
args: --timeout=5m
|
||||
skip-cache: true
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
- name: Build
|
||||
run: go build ./cmd/backend
|
||||
@@ -1,4 +1,4 @@
|
||||
PHONY: docs
|
||||
|
||||
docs:
|
||||
swag init -g cmd/main.go --output docs/
|
||||
swag init -g cmd/backend/main.go --output docs/
|
||||
|
||||
@@ -1,2 +1,155 @@
|
||||
# Control-plane
|
||||
# Control-plane API
|
||||
|
||||
## Аутентификация
|
||||
|
||||
### POST /api/auth/register
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "john",
|
||||
"email": "john@example.com",
|
||||
"password": "Secret123"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Описание |
|
||||
|------|----------|
|
||||
| `username` | 3–30 символов |
|
||||
| `email` | Валидный email |
|
||||
| `password` | От 8 символов, заглавная + строчная + цифра |
|
||||
|
||||
`201` — `{ "token": "...", "refresh_token": "...", "user": { "id": "...", "username": "...", "email": "...", "created_at": "..." } }`
|
||||
`400` — ошибка валидации
|
||||
`409` — email уже занят
|
||||
|
||||
### POST /api/auth/login
|
||||
|
||||
```json
|
||||
{ "email": "john@example.com", "password": "Secret123" }
|
||||
```
|
||||
|
||||
`200` — `{ "token": "...", "refresh_token": "...", "user": { ... } }`
|
||||
`401` — неверный email или пароль
|
||||
`429` — превышен лимит (10 попыток/мин с IP)
|
||||
|
||||
### POST /api/auth/refresh
|
||||
|
||||
Обновить токены. Старый refresh_token удаляется, выдаётся новая пара.
|
||||
|
||||
```json
|
||||
{ "refresh_token": "..." }
|
||||
```
|
||||
|
||||
`200` — `{ "token": "...", "refresh_token": "...", "user": { ... } }`
|
||||
`401` — токен невалиден или умер
|
||||
|
||||
### POST /api/auth/logout
|
||||
|
||||
Удалить refresh_token из БД.
|
||||
|
||||
```json
|
||||
{ "refresh_token": "..." }
|
||||
```
|
||||
|
||||
`200` — `{ "message": "logged out successfully" }`
|
||||
|
||||
---
|
||||
|
||||
## Защищённые (требуют Bearer-токен)
|
||||
|
||||
Заголовок: `Authorization: Bearer <access_token>`
|
||||
|
||||
### GET /api/auth/me
|
||||
|
||||
Профиль текущего пользователя.
|
||||
|
||||
`200` — `{ "user": { "id": "...", "username": "...", "email": "...", "created_at": "..." } }`
|
||||
|
||||
### PUT /api/auth/me
|
||||
|
||||
Обновить username.
|
||||
|
||||
```json
|
||||
{ "username": "john_updated" }
|
||||
```
|
||||
|
||||
`200` — `{ "user": { "id": "...", "username": "john_updated", ... } }`
|
||||
|
||||
### PUT /api/auth/password
|
||||
|
||||
Сменить пароль. Старый пароль обязателен для подтверждения.
|
||||
|
||||
```json
|
||||
{ "old_password": "Secret123", "new_password": "NewSecret456!" }
|
||||
```
|
||||
|
||||
`200` — `{ "message": "password changed successfully" }`
|
||||
`400` — неверный старый пароль, слабый новый или совпадают
|
||||
|
||||
---
|
||||
|
||||
## Организации (все Bearer)
|
||||
|
||||
### POST /api/organizations
|
||||
|
||||
```json
|
||||
{ "name": "My Corp", "slug": "my-corp" }
|
||||
```
|
||||
|
||||
`201` — `{ "organization": { "id": "...", "name": "My Corp", "slug": "my-corp", "created_at": "...", "updated_at": "..." } }`
|
||||
`409` — slug уже занят
|
||||
|
||||
### GET /api/organizations
|
||||
|
||||
`200` — `{ "organizations": [...], "total": 1 }`
|
||||
|
||||
### GET /api/organizations/:id
|
||||
|
||||
`200` — `{ "organization": { ... } }`
|
||||
`404` — не найдена
|
||||
|
||||
### PUT /api/organizations/:id
|
||||
|
||||
```json
|
||||
{ "name": "Updated Corp" }
|
||||
```
|
||||
|
||||
`200` — `{ "organization": { ... } }`
|
||||
|
||||
### DELETE /api/organizations/:id
|
||||
|
||||
`200` — `{ "message": "organization deleted" }`
|
||||
|
||||
---
|
||||
|
||||
## Формат JWT
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": "uuid",
|
||||
"email": "john@example.com",
|
||||
"sub": "uuid",
|
||||
"exp": 1718000000,
|
||||
"iat": 1717913600
|
||||
}
|
||||
```
|
||||
|
||||
- `user_id` / `sub` — UUID пользователя
|
||||
- `exp` — timestamp истечения (24ч)
|
||||
- `iat` — timestamp выпуска
|
||||
|
||||
## Ошибки
|
||||
|
||||
```json
|
||||
{ "error": "описание" }
|
||||
```
|
||||
|
||||
| Статус | Описание |
|
||||
|--------|----------|
|
||||
| 400 | Ошибка валидации |
|
||||
| 401 | Неверные данные, токен протух или невалиден |
|
||||
| 404 | Пользователь или организация не найдены |
|
||||
| 409 | Email или slug уже заняты |
|
||||
| 429 | Превышен лимит попыток логина |
|
||||
| 500 | Внутренняя ошибка |
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
docs "gitea.d3m0k1d.ru/HellreigN/Control-plane/docs"
|
||||
"gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/auth"
|
||||
"gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/config"
|
||||
"gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/org"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pressly/goose/v3"
|
||||
swaggerFiles "github.com/swaggo/files"
|
||||
ginSwagger "github.com/swaggo/gin-swagger"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// @title AegisGuard API
|
||||
// @version 1.0
|
||||
// @description API системы управления AegisGuard. Позволяет управлять пользователями и организациями.
|
||||
// @description Все защищённые эндпоинты требуют заголовок `Authorization: Bearer <token>`.
|
||||
// @description Токен получается при регистрации или входе.
|
||||
// @schemes http
|
||||
// @BasePath /api/v1
|
||||
//
|
||||
// @securityDefinitions.apikey Bearer
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Введите `Bearer <token>`, где token — access_token из ответа /auth/login или /auth/register
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to postgres: %v", err)
|
||||
}
|
||||
|
||||
sqlDB, err := gormDB.DB()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get underlying sql.DB: %v", err)
|
||||
}
|
||||
|
||||
if err := sqlDB.PingContext(ctx); err != nil {
|
||||
log.Fatalf("failed to ping postgres: %v", err)
|
||||
}
|
||||
log.Println("connected to postgres")
|
||||
|
||||
if err := goose.Up(sqlDB, "migrations"); err != nil {
|
||||
log.Fatalf("failed to run migrations: %v", err)
|
||||
}
|
||||
log.Println("migrations applied")
|
||||
|
||||
repo := auth.NewRepository(gormDB)
|
||||
orgRepo := org.NewRepository(gormDB)
|
||||
|
||||
svc := auth.NewService(repo, cfg.JWTSecret, cfg.JWTExpiration, cfg.JWTRefreshExpiration)
|
||||
handler := auth.NewHandler(svc)
|
||||
|
||||
orgSvc := org.NewService(orgRepo)
|
||||
orgHandler := org.NewHandler(orgSvc)
|
||||
|
||||
authMW := auth.AuthMiddleware([]byte(cfg.JWTSecret))
|
||||
|
||||
go func() {
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if err := repo.DeleteExpiredRefreshTokens(cleanupCtx); err != nil {
|
||||
log.Printf("failed to cleanup expired tokens: %v", err)
|
||||
}
|
||||
cleanupCancel()
|
||||
}
|
||||
}()
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.Use(gin.Logger(), gin.Recovery())
|
||||
|
||||
docs.SwaggerInfo.Title = "AegisGuard API"
|
||||
docs.SwaggerInfo.Version = "1.0"
|
||||
docs.SwaggerInfo.Description = "API системы управления AegisGuard. Позволяет управлять пользователями и организациями."
|
||||
docs.SwaggerInfo.Schemes = []string{"http"}
|
||||
docs.SwaggerInfo.BasePath = "/api/v1"
|
||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
api := r.Group("/api/v1/auth")
|
||||
{
|
||||
api.POST("/register", handler.Register)
|
||||
api.POST("/login", handler.Login)
|
||||
api.POST("/refresh", handler.Refresh)
|
||||
api.POST("/logout", handler.Logout)
|
||||
api.GET("/me", authMW, handler.Me)
|
||||
api.PUT("/me", authMW, handler.UpdateProfile)
|
||||
api.PUT("/password", authMW, handler.ChangePassword)
|
||||
}
|
||||
|
||||
orgs := r.Group("/api/v1/organizations", authMW)
|
||||
{
|
||||
orgs.POST("", orgHandler.Create)
|
||||
orgs.GET("", orgHandler.List)
|
||||
orgs.GET("/:id", orgHandler.GetByID)
|
||||
orgs.PUT("/:id", orgHandler.Update)
|
||||
orgs.DELETE("/:id", orgHandler.Delete)
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + cfg.ServerPort,
|
||||
Handler: r,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("server starting on :%s", cfg.ServerPort)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("failed to start server: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
log.Println("shutting down server...")
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatalf("server forced to shutdown: %v", err)
|
||||
}
|
||||
|
||||
_ = sqlDB.Close()
|
||||
|
||||
log.Println("server stopped")
|
||||
}
|
||||
-28
@@ -1,28 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
docs "gitea.d3m0k1d.ru/HellreigN/Control-plane/docs"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/swaggo/files"
|
||||
"github.com/swaggo/gin-swagger"
|
||||
)
|
||||
|
||||
// @securityDefinitions.apikey Bearer
|
||||
// @in header
|
||||
// @name Authorization
|
||||
// @description Type "Bearer" followed by a space and the JWT token.
|
||||
|
||||
func main() {
|
||||
r := gin.Default()
|
||||
docs.SwaggerInfo.Title = "AegisGuard API"
|
||||
docs.SwaggerInfo.Version = "1.0"
|
||||
docs.SwaggerInfo.Description = "API for AegisGuard"
|
||||
docs.SwaggerInfo.Schemes = []string{"http"}
|
||||
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
})
|
||||
})
|
||||
r.Run(":8080")
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM golang:1.26.1 as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
ENV CGO_ENABLED=0
|
||||
ENV GIN_MODE=release
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
go mod download && \
|
||||
go build -ldflags "-s -w" -o backend ./cmd/main.go
|
||||
|
||||
FROM alpine:3.23.0
|
||||
|
||||
RUN apk add --no-cache curl openssl bash
|
||||
|
||||
COPY --from=builder /app/backend .
|
||||
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "curl --fail http://localhost:8080/health" ]
|
||||
|
||||
CMD ["./backend"]
|
||||
+813
-7
@@ -14,10 +14,816 @@ const docTemplate = `{
|
||||
},
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {},
|
||||
"paths": {
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"description": "Аутентификация по email и паролю. Возвращает access_token (JWT) и refresh_token.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Вход",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Email и пароль",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.LoginRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный вход, токены в ответе",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.AuthResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации полей",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Неверный email или пароль",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/logout": {
|
||||
"post": {
|
||||
"description": "Аннулирование refresh_token. После выхода повторное использование того же refresh_token вернёт 401.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Выход",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Refresh_token для аннулирования",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.LogoutRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{\"message\": \"logged out successfully\"}",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Не указан refresh_token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Refresh_token не найден или уже аннулирован",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/me": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Получение профиля текущего авторизованного пользователя.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Профиль пользователя",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Данные пользователя",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.UserResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Токен не указан или недействителен",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Обновление username текущего пользователя.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Обновление профиля",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Новый username",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.UpdateProfileRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Обновлённый профиль",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации: username от 3 до 30 символов",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Токен не указан или недействителен",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/password": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Изменение пароля текущего пользователя. Требуется указать старый и новый пароль.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Смена пароля",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Старый и новый пароль",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.PasswordChangeRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{\"message\": \"password changed successfully\"}",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации: неверный старый пароль, слабый новый или совпадают",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Токен не указан или недействителен",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/refresh": {
|
||||
"post": {
|
||||
"description": "Получение новой пары токенов по refresh_token. Старый refresh_token становится недействительным (ротация).\nЕсли refresh_token истёк или уже был использован — придёт 401.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Обновление токенов",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Действительный refresh_token",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.RefreshRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Новая пара токенов",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.AuthResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Не указан refresh_token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Refresh_token недействителен или истёк",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/register": {
|
||||
"post": {
|
||||
"description": "Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Регистрация",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Данные для регистрации",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.RegisterRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Пользователь создан, токены в ответе",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.AuthResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации полей (некорректный email, слабый пароль)",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email уже зарегистрирован",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/organizations": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Получение списка всех организаций с пагинацией.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organizations"
|
||||
],
|
||||
"summary": "Список организаций",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Количество записей на странице (по умолчанию 20)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Смещение от начала списка (по умолчанию 0)",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Список организаций",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.OrgListResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Внутренняя ошибка сервера",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Создание новой организации. slug используется в URL и должен быть уникальным.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organizations"
|
||||
],
|
||||
"summary": "Создание организации",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Название и slug организации",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.CreateOrgRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Организация создана",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.OrgResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации полей",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Slug уже занят",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/organizations/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Получение информации об организации по её ID.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organizations"
|
||||
],
|
||||
"summary": "Получить организацию",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "UUID организации",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Данные организации",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.OrgResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Организация не найдена",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Обновление названия организации. slug изменить нельзя.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organizations"
|
||||
],
|
||||
"summary": "Обновление организации",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "UUID организации",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Новое название организации",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.UpdateOrgRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Обновлённая организация",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.OrgResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации полей",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Организация не найдена",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Безвозвратное удаление организации по её ID.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organizations"
|
||||
],
|
||||
"summary": "Удаление организации",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "UUID организации",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{\"message\": \"organization deleted\"}",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Организация не найдена",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"auth.AuthResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"example": "eyJhbGciOiJIUzI1NiIs..."
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/definitions/auth.UserPublic"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string",
|
||||
"example": "invalid email or password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.LoginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"email",
|
||||
"password"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"example": "john@example.com"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"example": "secret123"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.LogoutRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"refresh_token"
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.PasswordChangeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"new_password",
|
||||
"old_password"
|
||||
],
|
||||
"properties": {
|
||||
"new_password": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"example": "NewSecret456!"
|
||||
},
|
||||
"old_password": {
|
||||
"type": "string",
|
||||
"example": "Secret123!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.RefreshRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"refresh_token"
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.RegisterRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"email",
|
||||
"password",
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"example": "john@example.com"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"example": "Secret123!"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 30,
|
||||
"minLength": 3,
|
||||
"example": "john"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.UpdateProfileRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 30,
|
||||
"minLength": 3,
|
||||
"example": "john_updated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.UserPublic": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.UserResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {
|
||||
"$ref": "#/definitions/auth.UserPublic"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.CreateOrgRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"slug"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"minLength": 2,
|
||||
"example": "My Corp"
|
||||
},
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"maxLength": 50,
|
||||
"minLength": 2,
|
||||
"example": "my-corp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.OrgListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer"
|
||||
},
|
||||
"organizations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/org.Organization"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.OrgResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"organization": {
|
||||
"$ref": "#/definitions/org.Organization"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.Organization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"slug": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.UpdateOrgRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"minLength": 2,
|
||||
"example": "My Corp Updated"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
"Bearer": {
|
||||
"description": "Type \"Bearer\" followed by a space and the JWT token.",
|
||||
"description": "Введите ` + "`" + `Bearer \u003ctoken\u003e` + "`" + `, где token — access_token из ответа /auth/login или /auth/register",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
@@ -27,12 +833,12 @@ const docTemplate = `{
|
||||
|
||||
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||
var SwaggerInfo = &swag.Spec{
|
||||
Version: "",
|
||||
Version: "1.0",
|
||||
Host: "",
|
||||
BasePath: "",
|
||||
Schemes: []string{},
|
||||
Title: "",
|
||||
Description: "",
|
||||
BasePath: "/api/v1",
|
||||
Schemes: []string{"http"},
|
||||
Title: "AegisGuard API",
|
||||
Description: "API системы управления AegisGuard. Позволяет управлять пользователями и организациями.\nВсе защищённые эндпоинты требуют заголовок `Authorization: Bearer <token>`.\nТокен получается при регистрации или входе.",
|
||||
InfoInstanceName: "swagger",
|
||||
SwaggerTemplate: docTemplate,
|
||||
LeftDelim: "{{",
|
||||
|
||||
+816
-3
@@ -1,12 +1,825 @@
|
||||
{
|
||||
"schemes": [
|
||||
"http"
|
||||
],
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"contact": {}
|
||||
"description": "API системы управления AegisGuard. Позволяет управлять пользователями и организациями.\nВсе защищённые эндпоинты требуют заголовок `Authorization: Bearer \u003ctoken\u003e`.\nТокен получается при регистрации или входе.",
|
||||
"title": "AegisGuard API",
|
||||
"contact": {},
|
||||
"version": "1.0"
|
||||
},
|
||||
"basePath": "/api/v1",
|
||||
"paths": {
|
||||
"/api/v1/auth/login": {
|
||||
"post": {
|
||||
"description": "Аутентификация по email и паролю. Возвращает access_token (JWT) и refresh_token.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Вход",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Email и пароль",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.LoginRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Успешный вход, токены в ответе",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.AuthResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации полей",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Неверный email или пароль",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/logout": {
|
||||
"post": {
|
||||
"description": "Аннулирование refresh_token. После выхода повторное использование того же refresh_token вернёт 401.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Выход",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Refresh_token для аннулирования",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.LogoutRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{\"message\": \"logged out successfully\"}",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Не указан refresh_token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Refresh_token не найден или уже аннулирован",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/me": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Получение профиля текущего авторизованного пользователя.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Профиль пользователя",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Данные пользователя",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.UserResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Токен не указан или недействителен",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Обновление username текущего пользователя.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Обновление профиля",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Новый username",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.UpdateProfileRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Обновлённый профиль",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.UserResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации: username от 3 до 30 символов",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Токен не указан или недействителен",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/password": {
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Изменение пароля текущего пользователя. Требуется указать старый и новый пароль.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Смена пароля",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Старый и новый пароль",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.PasswordChangeRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{\"message\": \"password changed successfully\"}",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации: неверный старый пароль, слабый новый или совпадают",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Токен не указан или недействителен",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/refresh": {
|
||||
"post": {
|
||||
"description": "Получение новой пары токенов по refresh_token. Старый refresh_token становится недействительным (ротация).\nЕсли refresh_token истёк или уже был использован — придёт 401.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Обновление токенов",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Действительный refresh_token",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.RefreshRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Новая пара токенов",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.AuthResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Не указан refresh_token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Refresh_token недействителен или истёк",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/auth/register": {
|
||||
"post": {
|
||||
"description": "Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Регистрация",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Данные для регистрации",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.RegisterRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Пользователь создан, токены в ответе",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.AuthResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации полей (некорректный email, слабый пароль)",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email уже зарегистрирован",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/auth.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/organizations": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Получение списка всех организаций с пагинацией.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organizations"
|
||||
],
|
||||
"summary": "Список организаций",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Количество записей на странице (по умолчанию 20)",
|
||||
"name": "limit",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Смещение от начала списка (по умолчанию 0)",
|
||||
"name": "offset",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Список организаций",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.OrgListResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Внутренняя ошибка сервера",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Создание новой организации. slug используется в URL и должен быть уникальным.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organizations"
|
||||
],
|
||||
"summary": "Создание организации",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Название и slug организации",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.CreateOrgRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Организация создана",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.OrgResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации полей",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Slug уже занят",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/organizations/{id}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Получение информации об организации по её ID.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organizations"
|
||||
],
|
||||
"summary": "Получить организацию",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "UUID организации",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Данные организации",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.OrgResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Организация не найдена",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Обновление названия организации. slug изменить нельзя.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organizations"
|
||||
],
|
||||
"summary": "Обновление организации",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "UUID организации",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Новое название организации",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.UpdateOrgRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Обновлённая организация",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.OrgResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Ошибка валидации полей",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Организация не найдена",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Безвозвратное удаление организации по её ID.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"organizations"
|
||||
],
|
||||
"summary": "Удаление организации",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "UUID организации",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{\"message\": \"organization deleted\"}",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Организация не найдена",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/org.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
"auth.AuthResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="
|
||||
},
|
||||
"token": {
|
||||
"type": "string",
|
||||
"example": "eyJhbGciOiJIUzI1NiIs..."
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/definitions/auth.UserPublic"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string",
|
||||
"example": "invalid email or password"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.LoginRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"email",
|
||||
"password"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"example": "john@example.com"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"example": "secret123"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.LogoutRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"refresh_token"
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.PasswordChangeRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"new_password",
|
||||
"old_password"
|
||||
],
|
||||
"properties": {
|
||||
"new_password": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"example": "NewSecret456!"
|
||||
},
|
||||
"old_password": {
|
||||
"type": "string",
|
||||
"example": "Secret123!"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.RefreshRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"refresh_token"
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.RegisterRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"email",
|
||||
"password",
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"example": "john@example.com"
|
||||
},
|
||||
"password": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"example": "Secret123!"
|
||||
},
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 30,
|
||||
"minLength": 3,
|
||||
"example": "john"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.UpdateProfileRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"username"
|
||||
],
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"maxLength": 30,
|
||||
"minLength": 3,
|
||||
"example": "john_updated"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.UserPublic": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auth.UserResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user": {
|
||||
"$ref": "#/definitions/auth.UserPublic"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.CreateOrgRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"slug"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"minLength": 2,
|
||||
"example": "My Corp"
|
||||
},
|
||||
"slug": {
|
||||
"type": "string",
|
||||
"maxLength": 50,
|
||||
"minLength": 2,
|
||||
"example": "my-corp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.OrgListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": "integer"
|
||||
},
|
||||
"offset": {
|
||||
"type": "integer"
|
||||
},
|
||||
"organizations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/org.Organization"
|
||||
}
|
||||
},
|
||||
"total": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.OrgResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"organization": {
|
||||
"$ref": "#/definitions/org.Organization"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.Organization": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"slug": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"org.UpdateOrgRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"maxLength": 100,
|
||||
"minLength": 2,
|
||||
"example": "My Corp Updated"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"paths": {},
|
||||
"securityDefinitions": {
|
||||
"Bearer": {
|
||||
"description": "Type \"Bearer\" followed by a space and the JWT token.",
|
||||
"description": "Введите `Bearer \u003ctoken\u003e`, где token — access_token из ответа /auth/login или /auth/register",
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header"
|
||||
|
||||
+562
-2
@@ -1,9 +1,569 @@
|
||||
basePath: /api/v1
|
||||
definitions:
|
||||
auth.AuthResponse:
|
||||
properties:
|
||||
refresh_token:
|
||||
example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=
|
||||
type: string
|
||||
token:
|
||||
example: eyJhbGciOiJIUzI1NiIs...
|
||||
type: string
|
||||
user:
|
||||
$ref: '#/definitions/auth.UserPublic'
|
||||
type: object
|
||||
auth.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
example: invalid email or password
|
||||
type: string
|
||||
type: object
|
||||
auth.LoginRequest:
|
||||
properties:
|
||||
email:
|
||||
example: john@example.com
|
||||
type: string
|
||||
password:
|
||||
example: secret123
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
type: object
|
||||
auth.LogoutRequest:
|
||||
properties:
|
||||
refresh_token:
|
||||
example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=
|
||||
type: string
|
||||
required:
|
||||
- refresh_token
|
||||
type: object
|
||||
auth.PasswordChangeRequest:
|
||||
properties:
|
||||
new_password:
|
||||
example: NewSecret456!
|
||||
minLength: 8
|
||||
type: string
|
||||
old_password:
|
||||
example: Secret123!
|
||||
type: string
|
||||
required:
|
||||
- new_password
|
||||
- old_password
|
||||
type: object
|
||||
auth.RefreshRequest:
|
||||
properties:
|
||||
refresh_token:
|
||||
example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=
|
||||
type: string
|
||||
required:
|
||||
- refresh_token
|
||||
type: object
|
||||
auth.RegisterRequest:
|
||||
properties:
|
||||
email:
|
||||
example: john@example.com
|
||||
type: string
|
||||
password:
|
||||
example: Secret123!
|
||||
minLength: 8
|
||||
type: string
|
||||
username:
|
||||
example: john
|
||||
maxLength: 30
|
||||
minLength: 3
|
||||
type: string
|
||||
required:
|
||||
- email
|
||||
- password
|
||||
- username
|
||||
type: object
|
||||
auth.UpdateProfileRequest:
|
||||
properties:
|
||||
username:
|
||||
example: john_updated
|
||||
maxLength: 30
|
||||
minLength: 3
|
||||
type: string
|
||||
required:
|
||||
- username
|
||||
type: object
|
||||
auth.UserPublic:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
email:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
username:
|
||||
type: string
|
||||
type: object
|
||||
auth.UserResponse:
|
||||
properties:
|
||||
user:
|
||||
$ref: '#/definitions/auth.UserPublic'
|
||||
type: object
|
||||
org.CreateOrgRequest:
|
||||
properties:
|
||||
name:
|
||||
example: My Corp
|
||||
maxLength: 100
|
||||
minLength: 2
|
||||
type: string
|
||||
slug:
|
||||
example: my-corp
|
||||
maxLength: 50
|
||||
minLength: 2
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- slug
|
||||
type: object
|
||||
org.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
type: object
|
||||
org.OrgListResponse:
|
||||
properties:
|
||||
limit:
|
||||
type: integer
|
||||
offset:
|
||||
type: integer
|
||||
organizations:
|
||||
items:
|
||||
$ref: '#/definitions/org.Organization'
|
||||
type: array
|
||||
total:
|
||||
type: integer
|
||||
type: object
|
||||
org.OrgResponse:
|
||||
properties:
|
||||
organization:
|
||||
$ref: '#/definitions/org.Organization'
|
||||
type: object
|
||||
org.Organization:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
slug:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
org.UpdateOrgRequest:
|
||||
properties:
|
||||
name:
|
||||
example: My Corp Updated
|
||||
maxLength: 100
|
||||
minLength: 2
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
info:
|
||||
contact: {}
|
||||
paths: {}
|
||||
description: |-
|
||||
API системы управления AegisGuard. Позволяет управлять пользователями и организациями.
|
||||
Все защищённые эндпоинты требуют заголовок `Authorization: Bearer <token>`.
|
||||
Токен получается при регистрации или входе.
|
||||
title: AegisGuard API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/api/v1/auth/login:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Аутентификация по email и паролю. Возвращает access_token (JWT)
|
||||
и refresh_token.
|
||||
parameters:
|
||||
- description: Email и пароль
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.LoginRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Успешный вход, токены в ответе
|
||||
schema:
|
||||
$ref: '#/definitions/auth.AuthResponse'
|
||||
"400":
|
||||
description: Ошибка валидации полей
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
"401":
|
||||
description: Неверный email или пароль
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
summary: Вход
|
||||
tags:
|
||||
- auth
|
||||
/api/v1/auth/logout:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Аннулирование refresh_token. После выхода повторное использование
|
||||
того же refresh_token вернёт 401.
|
||||
parameters:
|
||||
- description: Refresh_token для аннулирования
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.LogoutRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: '{"message": "logged out successfully"}'
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Не указан refresh_token
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
"401":
|
||||
description: Refresh_token не найден или уже аннулирован
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
summary: Выход
|
||||
tags:
|
||||
- auth
|
||||
/api/v1/auth/me:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Получение профиля текущего авторизованного пользователя.
|
||||
**Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Данные пользователя
|
||||
schema:
|
||||
$ref: '#/definitions/auth.UserResponse'
|
||||
"401":
|
||||
description: Токен не указан или недействителен
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Профиль пользователя
|
||||
tags:
|
||||
- auth
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Обновление username текущего пользователя.
|
||||
**Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
parameters:
|
||||
- description: Новый username
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.UpdateProfileRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Обновлённый профиль
|
||||
schema:
|
||||
$ref: '#/definitions/auth.UserResponse'
|
||||
"400":
|
||||
description: 'Ошибка валидации: username от 3 до 30 символов'
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
"401":
|
||||
description: Токен не указан или недействителен
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Обновление профиля
|
||||
tags:
|
||||
- auth
|
||||
/api/v1/auth/password:
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Изменение пароля текущего пользователя. Требуется указать старый и новый пароль.
|
||||
**Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.
|
||||
parameters:
|
||||
- description: Старый и новый пароль
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.PasswordChangeRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: '{"message": "password changed successfully"}'
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: 'Ошибка валидации: неверный старый пароль, слабый новый или
|
||||
совпадают'
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
"401":
|
||||
description: Токен не указан или недействителен
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Смена пароля
|
||||
tags:
|
||||
- auth
|
||||
/api/v1/auth/refresh:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Получение новой пары токенов по refresh_token. Старый refresh_token становится недействительным (ротация).
|
||||
Если refresh_token истёк или уже был использован — придёт 401.
|
||||
parameters:
|
||||
- description: Действительный refresh_token
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.RefreshRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Новая пара токенов
|
||||
schema:
|
||||
$ref: '#/definitions/auth.AuthResponse'
|
||||
"400":
|
||||
description: Не указан refresh_token
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
"401":
|
||||
description: Refresh_token недействителен или истёк
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
summary: Обновление токенов
|
||||
tags:
|
||||
- auth
|
||||
/api/v1/auth/register:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token.
|
||||
Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.
|
||||
parameters:
|
||||
- description: Данные для регистрации
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/auth.RegisterRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Пользователь создан, токены в ответе
|
||||
schema:
|
||||
$ref: '#/definitions/auth.AuthResponse'
|
||||
"400":
|
||||
description: Ошибка валидации полей (некорректный email, слабый пароль)
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
"409":
|
||||
description: Email уже зарегистрирован
|
||||
schema:
|
||||
$ref: '#/definitions/auth.ErrorResponse'
|
||||
summary: Регистрация
|
||||
tags:
|
||||
- auth
|
||||
/api/v1/organizations:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Получение списка всех организаций с пагинацией.
|
||||
**Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
parameters:
|
||||
- description: Количество записей на странице (по умолчанию 20)
|
||||
in: query
|
||||
name: limit
|
||||
type: integer
|
||||
- description: Смещение от начала списка (по умолчанию 0)
|
||||
in: query
|
||||
name: offset
|
||||
type: integer
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Список организаций
|
||||
schema:
|
||||
$ref: '#/definitions/org.OrgListResponse'
|
||||
"500":
|
||||
description: Внутренняя ошибка сервера
|
||||
schema:
|
||||
$ref: '#/definitions/org.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Список организаций
|
||||
tags:
|
||||
- organizations
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Создание новой организации. slug используется в URL и должен быть уникальным.
|
||||
**Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
parameters:
|
||||
- description: Название и slug организации
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/org.CreateOrgRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Организация создана
|
||||
schema:
|
||||
$ref: '#/definitions/org.OrgResponse'
|
||||
"400":
|
||||
description: Ошибка валидации полей
|
||||
schema:
|
||||
$ref: '#/definitions/org.ErrorResponse'
|
||||
"409":
|
||||
description: Slug уже занят
|
||||
schema:
|
||||
$ref: '#/definitions/org.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Создание организации
|
||||
tags:
|
||||
- organizations
|
||||
/api/v1/organizations/{id}:
|
||||
delete:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Безвозвратное удаление организации по её ID.
|
||||
**Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
parameters:
|
||||
- description: UUID организации
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: '{"message": "organization deleted"}'
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"404":
|
||||
description: Организация не найдена
|
||||
schema:
|
||||
$ref: '#/definitions/org.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Удаление организации
|
||||
tags:
|
||||
- organizations
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Получение информации об организации по её ID.
|
||||
**Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
parameters:
|
||||
- description: UUID организации
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Данные организации
|
||||
schema:
|
||||
$ref: '#/definitions/org.OrgResponse'
|
||||
"404":
|
||||
description: Организация не найдена
|
||||
schema:
|
||||
$ref: '#/definitions/org.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Получить организацию
|
||||
tags:
|
||||
- organizations
|
||||
put:
|
||||
consumes:
|
||||
- application/json
|
||||
description: |-
|
||||
Обновление названия организации. slug изменить нельзя.
|
||||
**Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
parameters:
|
||||
- description: UUID организации
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Новое название организации
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/org.UpdateOrgRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Обновлённая организация
|
||||
schema:
|
||||
$ref: '#/definitions/org.OrgResponse'
|
||||
"400":
|
||||
description: Ошибка валидации полей
|
||||
schema:
|
||||
$ref: '#/definitions/org.ErrorResponse'
|
||||
"404":
|
||||
description: Организация не найдена
|
||||
schema:
|
||||
$ref: '#/definitions/org.ErrorResponse'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Обновление организации
|
||||
tags:
|
||||
- organizations
|
||||
schemes:
|
||||
- http
|
||||
securityDefinitions:
|
||||
Bearer:
|
||||
description: Type "Bearer" followed by a space and the JWT token.
|
||||
description: Введите `Bearer <token>`, где token — access_token из ответа /auth/login
|
||||
или /auth/register
|
||||
in: header
|
||||
name: Authorization
|
||||
type: apiKey
|
||||
|
||||
@@ -2,60 +2,64 @@ module gitea.d3m0k1d.ru/HellreigN/Control-plane
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/pressly/goose/v3 v3.24.2
|
||||
github.com/swaggo/files v1.0.1
|
||||
github.com/swaggo/gin-swagger v1.6.1
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/crypto v0.53.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/PuerkitoBio/purell v1.2.2 // indirect
|
||||
github.com/PuerkitoBio/purell v1.1.1 // indirect
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.23.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.6 // indirect
|
||||
github.com/go-openapi/spec v0.22.5 // indirect
|
||||
github.com/go-openapi/swag v0.26.1 // indirect
|
||||
github.com/go-openapi/swag/conv v0.26.1 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.26.1 // indirect
|
||||
github.com/go-openapi/swag/jsonutils v0.26.1 // indirect
|
||||
github.com/go-openapi/swag/loading v0.26.1 // indirect
|
||||
github.com/go-openapi/swag/stringutils v0.26.1 // indirect
|
||||
github.com/go-openapi/swag/typeutils v0.26.1 // indirect
|
||||
github.com/go-openapi/swag/yamlutils v0.26.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.19.6 // indirect
|
||||
github.com/go-openapi/spec v0.20.4 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.9.2 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
github.com/swaggo/gin-swagger v1.6.1 // indirect
|
||||
github.com/swaggo/swag v1.16.6 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/sethvargo/go-retry v0.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/urfave/cli/v2 v2.27.7 // indirect
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/arch v0.28.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.21.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
@@ -63,5 +67,4 @@ require (
|
||||
golang.org/x/tools v0.45.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,49 +1,43 @@
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/PuerkitoBio/purell v1.2.2 h1:irlAloIzZaJ5RP/+UcaT1Nw0H9on2HKWdRehCsbJWJw=
|
||||
github.com/PuerkitoBio/purell v1.2.2/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||
github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY=
|
||||
github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y=
|
||||
github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY=
|
||||
github.com/go-openapi/spec v0.22.5 h1:KhO7RBlKQfonUWX2WzQCoLIXVA6AcNqDGZ3a1Dutdlo=
|
||||
github.com/go-openapi/spec v0.22.5/go.mod h1:vxpOtMya5TXtENXKE5bKqv5NjocVhyhxHrlZfvKnZ74=
|
||||
github.com/go-openapi/swag v0.26.1 h1:l5sVEyVpwj+DDYeZyo7wQI/Ebn/mKYIyGB/pFwAfGoQ=
|
||||
github.com/go-openapi/swag v0.26.1/go.mod h1:yNY38BbIVthxbkDtq1UHBCGasBqjakW3lCR6ANzdBEw=
|
||||
github.com/go-openapi/swag/conv v0.26.1 h1:slr5FVkg9Wc3Y5zcwenD8Sd/PQ94b2I/QJI7N7KTBpg=
|
||||
github.com/go-openapi/swag/conv v0.26.1/go.mod h1:mvQXgPptZk9GTrFgGwWvT4q+dN+zQej9JfmGwnipz1A=
|
||||
github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE=
|
||||
github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc=
|
||||
github.com/go-openapi/swag/jsonutils v0.26.1 h1:2hdBfFkHg+7Wrz2VsCbeyR6hzkRDs7AztnMR2u84yOY=
|
||||
github.com/go-openapi/swag/jsonutils v0.26.1/go.mod h1:U+RMJH3wa+6BRiphuRtIyI8fW9HPFqFQ4sHk2oRx0UQ=
|
||||
github.com/go-openapi/swag/loading v0.26.1 h1:E9K4wqXeROlhjFQ13K9zMz6ojFGXIggGe+ad1odrK9w=
|
||||
github.com/go-openapi/swag/loading v0.26.1/go.mod h1:3qvRIlWzWdq1HvmldwmuJ2ohpcAryN6xVt2OTKd0/7E=
|
||||
github.com/go-openapi/swag/stringutils v0.26.1 h1:f88uYyTso7TnHrKM/bUBsQ5e2wKf37cpgo6pvbzd9yU=
|
||||
github.com/go-openapi/swag/stringutils v0.26.1/go.mod h1:Sc6d3bU8fgk5AyZR8/8jEQ+Is/Ald+TD/IIggPN8UJk=
|
||||
github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3cBarXa3OVnU=
|
||||
github.com/go-openapi/swag/typeutils v0.26.1/go.mod h1:VfnV+oUtSP2vCSCn2aJgnr8OevUYemyIzzS1VOzS10o=
|
||||
github.com/go-openapi/swag/yamlutils v0.26.1 h1:0TSLK+lXs9vfIhAWzBeI/lOzEnIoot6WTCO1aAeWFTk=
|
||||
github.com/go-openapi/swag/yamlutils v0.26.1/go.mod h1:7W5b7PRX9MxwL7TjeG7H8HkyBGRsIDRObhyMWFgBI2M=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.6 h1:UBIxjkht+AWIgYzCDSv2GN+E/togfwXUJFRTWhl2Jjs=
|
||||
github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns=
|
||||
github.com/go-openapi/spec v0.20.4 h1:O8hJrt0UMnhHcluhIdUgCLRWyM2x7QkBXRvOs7m+O1M=
|
||||
github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
@@ -54,45 +48,87 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.24.2 h1:c/ie0Gm8rnIVKvnDQ/scHErv46jrDv9b4I0WRcFJzYU=
|
||||
github.com/pressly/goose/v3 v3.24.2/go.mod h1:kjefwFB0eR4w30Td2Gj2Mznyw94vSP+2jJYkOVNbD1k=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
|
||||
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||
@@ -103,28 +139,27 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
|
||||
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
|
||||
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
@@ -135,6 +170,7 @@ golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -146,6 +182,7 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
@@ -159,9 +196,26 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
|
||||
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g=
|
||||
modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.36.2 h1:vjcSazuoFve9Wm0IVNHgmJECoOXLZM1KfMXbcX2axHA=
|
||||
modernc.org/sqlite v1.36.2/go.mod h1:ADySlx7K4FdY5MaJcEv86hTJ0PjedAloTUuif0YS3ws=
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package api
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func GetUserID(c *gin.Context) string {
|
||||
raw, _ := c.Get("user_id")
|
||||
id, _ := raw.(string)
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func GenerateToken(userID, email string, secret []byte, expiration time.Duration) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString(secret)
|
||||
}
|
||||
|
||||
func ValidateToken(tokenString string, secret []byte) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(
|
||||
tokenString,
|
||||
&Claims{},
|
||||
func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return secret, nil
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/api"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// @Summary Регистрация
|
||||
// @Description Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token.
|
||||
// @Description Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RegisterRequest true "Данные для регистрации"
|
||||
// @Success 201 {object} AuthResponse "Пользователь создан, токены в ответе"
|
||||
// @Failure 400 {object} ErrorResponse "Ошибка валидации полей (некорректный email, слабый пароль)"
|
||||
// @Failure 409 {object} ErrorResponse "Email уже зарегистрирован"
|
||||
// @Router /api/v1/auth/register [post]
|
||||
func (h *Handler) Register(c *gin.Context) {
|
||||
var req RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Register(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrEmailExists) {
|
||||
c.JSON(http.StatusConflict, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrWeakPassword) {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("register error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, resp)
|
||||
}
|
||||
|
||||
// @Summary Вход
|
||||
// @Description Аутентификация по email и паролю. Возвращает access_token (JWT) и refresh_token.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body LoginRequest true "Email и пароль"
|
||||
// @Success 200 {object} AuthResponse "Успешный вход, токены в ответе"
|
||||
// @Failure 400 {object} ErrorResponse "Ошибка валидации полей"
|
||||
// @Failure 401 {object} ErrorResponse "Неверный email или пароль"
|
||||
// @Router /api/v1/auth/login [post]
|
||||
func (h *Handler) Login(c *gin.Context) {
|
||||
var req LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Login(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidCreds) {
|
||||
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("login error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// @Summary Обновление токенов
|
||||
// @Description Получение новой пары токенов по refresh_token. Старый refresh_token становится недействительным (ротация).
|
||||
// @Description Если refresh_token истёк или уже был использован — придёт 401.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RefreshRequest true "Действительный refresh_token"
|
||||
// @Success 200 {object} AuthResponse "Новая пара токенов"
|
||||
// @Failure 400 {object} ErrorResponse "Не указан refresh_token"
|
||||
// @Failure 401 {object} ErrorResponse "Refresh_token недействителен или истёк"
|
||||
// @Router /api/v1/auth/refresh [post]
|
||||
func (h *Handler) Refresh(c *gin.Context) {
|
||||
var req RefreshRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.service.Refresh(c.Request.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrInvalidRefresh) || errors.Is(err, ErrRefreshExpired) {
|
||||
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("refresh error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// @Summary Выход
|
||||
// @Description Аннулирование refresh_token. После выхода повторное использование того же refresh_token вернёт 401.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body LogoutRequest true "Refresh_token для аннулирования"
|
||||
// @Success 200 {object} map[string]string "{"message": "logged out successfully"}"
|
||||
// @Failure 400 {object} ErrorResponse "Не указан refresh_token"
|
||||
// @Failure 401 {object} ErrorResponse "Refresh_token не найден или уже аннулирован"
|
||||
// @Router /api/v1/auth/logout [post]
|
||||
func (h *Handler) Logout(c *gin.Context) {
|
||||
var req LogoutRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.Logout(c.Request.Context(), req.RefreshToken); err != nil {
|
||||
if errors.Is(err, ErrLogoutInvalid) {
|
||||
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("logout error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out successfully"})
|
||||
}
|
||||
|
||||
// @Summary Профиль пользователя
|
||||
// @Description Получение профиля текущего авторизованного пользователя.
|
||||
// @Description **Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Success 200 {object} UserResponse "Данные пользователя"
|
||||
// @Failure 401 {object} ErrorResponse "Токен не указан или недействителен"
|
||||
// @Router /api/v1/auth/me [get]
|
||||
func (h *Handler) Me(c *gin.Context) {
|
||||
userID := api.GetUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.service.GetUserByID(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) || errors.Is(err, ErrInvalidUserID) {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("me error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, UserResponse{User: *user})
|
||||
}
|
||||
|
||||
// @Summary Смена пароля
|
||||
// @Description Изменение пароля текущего пользователя. Требуется указать старый и новый пароль.
|
||||
// @Description **Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
// @Description Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param request body PasswordChangeRequest true "Старый и новый пароль"
|
||||
// @Success 200 {object} map[string]string "{"message": "password changed successfully"}"
|
||||
// @Failure 400 {object} ErrorResponse "Ошибка валидации: неверный старый пароль, слабый новый или совпадают"
|
||||
// @Failure 401 {object} ErrorResponse "Токен не указан или недействителен"
|
||||
// @Router /api/v1/auth/password [put]
|
||||
func (h *Handler) ChangePassword(c *gin.Context) {
|
||||
userID := api.GetUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var req PasswordChangeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.ChangePassword(c.Request.Context(), userID, req); err != nil {
|
||||
if errors.Is(err, ErrWrongPassword) || errors.Is(err, ErrSamePassword) ||
|
||||
errors.Is(err, ErrWeakPassword) {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrUserNotFound) || errors.Is(err, ErrInvalidUserID) {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("change password error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "password changed successfully"})
|
||||
}
|
||||
|
||||
// @Summary Обновление профиля
|
||||
// @Description Обновление username текущего пользователя.
|
||||
// @Description **Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param request body UpdateProfileRequest true "Новый username"
|
||||
// @Success 200 {object} UserResponse "Обновлённый профиль"
|
||||
// @Failure 400 {object} ErrorResponse "Ошибка валидации: username от 3 до 30 символов"
|
||||
// @Failure 401 {object} ErrorResponse "Токен не указан или недействителен"
|
||||
// @Router /api/v1/auth/me [put]
|
||||
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
userID := api.GetUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateProfileRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.service.UpdateProfile(c.Request.Context(), userID, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) || errors.Is(err, ErrInvalidUserID) {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("update profile error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, UserResponse{User: *user})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func AuthMiddleware(jwtSecret []byte) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
c.AbortWithStatusJSON(
|
||||
http.StatusUnauthorized,
|
||||
ErrorResponse{Error: "authorization header required"},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
c.AbortWithStatusJSON(
|
||||
http.StatusUnauthorized,
|
||||
ErrorResponse{Error: "invalid authorization header format"},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := ValidateToken(parts[1], jwtSecret)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(
|
||||
http.StatusUnauthorized,
|
||||
ErrorResponse{Error: "invalid or expired token"},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", claims.UserID)
|
||||
c.Set("email", claims.Email)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
Username string `gorm:"type:text;not null" json:"username"`
|
||||
Email string `gorm:"type:text;not null;uniqueIndex" json:"email"`
|
||||
PasswordHash string `gorm:"column:password_hash;type:text;not null" json:"-"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=30" example:"john"`
|
||||
Email string `json:"email" binding:"required,email" example:"john@example.com"`
|
||||
Password string `json:"password" binding:"required,min=8" example:"Secret123!"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email" example:"john@example.com"`
|
||||
Password string `json:"password" binding:"required" example:"secret123"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."`
|
||||
RefreshToken string `json:"refresh_token" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="`
|
||||
User UserPublic `json:"user"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="`
|
||||
}
|
||||
|
||||
type LogoutRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="`
|
||||
}
|
||||
|
||||
type RefreshTokenDoc struct {
|
||||
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
UserID string `gorm:"column:user_id;type:uuid;not null;index" json:"user_id"`
|
||||
TokenHash string `gorm:"column:token_hash;type:text;not null;uniqueIndex" json:"token_hash"`
|
||||
ExpiresAt time.Time `gorm:"column:expires_at;type:timestamptz;not null" json:"expires_at"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
func (RefreshTokenDoc) TableName() string { return "refresh_tokens" }
|
||||
|
||||
type UserPublic struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func NewUserPublic(u *User) UserPublic {
|
||||
return UserPublic{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Email: u.Email,
|
||||
CreatedAt: u.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
type UserResponse struct {
|
||||
User UserPublic `json:"user"`
|
||||
}
|
||||
|
||||
type PasswordChangeRequest struct {
|
||||
OldPassword string `json:"old_password" binding:"required" example:"Secret123!"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=8" example:"NewSecret456!"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=30" example:"john_updated"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error" example:"invalid email or password"`
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
CreateUser(ctx context.Context, user *User) error
|
||||
FindByEmail(ctx context.Context, email string) (*User, error)
|
||||
FindByID(ctx context.Context, id string) (*User, error)
|
||||
CreateRefreshToken(ctx context.Context, doc *RefreshTokenDoc) error
|
||||
FindRefreshTokenByHash(ctx context.Context, hash string) (*RefreshTokenDoc, error)
|
||||
DeleteRefreshToken(ctx context.Context, id string) error
|
||||
DeleteRefreshTokenByHash(ctx context.Context, hash string) (bool, error)
|
||||
UpdateUserUsername(ctx context.Context, id, username string) error
|
||||
UpdateUserPassword(ctx context.Context, id, passwordHash string) error
|
||||
DeleteExpiredRefreshTokens(ctx context.Context) error
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) CreateUser(ctx context.Context, user *User) error {
|
||||
user.ID = uuid.New().String()
|
||||
user.CreatedAt = time.Now().UTC()
|
||||
return r.db.WithContext(ctx).Create(user).Error
|
||||
}
|
||||
|
||||
func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, error) {
|
||||
var user User
|
||||
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindByID(ctx context.Context, id string) (*User, error) {
|
||||
var user User
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *Repository) CreateRefreshToken(ctx context.Context, doc *RefreshTokenDoc) error {
|
||||
doc.ID = uuid.New().String()
|
||||
doc.CreatedAt = time.Now().UTC()
|
||||
return r.db.WithContext(ctx).Create(doc).Error
|
||||
}
|
||||
|
||||
func (r *Repository) FindRefreshTokenByHash(
|
||||
ctx context.Context,
|
||||
hash string,
|
||||
) (*RefreshTokenDoc, error) {
|
||||
var doc RefreshTokenDoc
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("token_hash = ? AND expires_at > NOW()", hash).
|
||||
First(&doc).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &doc, nil
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteRefreshToken(ctx context.Context, id string) error {
|
||||
return r.db.WithContext(ctx).Where("id = ?", id).Delete(&RefreshTokenDoc{}).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteRefreshTokenByHash(ctx context.Context, hash string) (bool, error) {
|
||||
result := r.db.WithContext(ctx).Where("token_hash = ?", hash).Delete(&RefreshTokenDoc{})
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateUserUsername(ctx context.Context, id, username string) error {
|
||||
return r.db.WithContext(ctx).Model(&User{}).Where("id = ?", id).
|
||||
Update("username", username).Error
|
||||
}
|
||||
|
||||
func (r *Repository) UpdateUserPassword(ctx context.Context, id, passwordHash string) error {
|
||||
return r.db.WithContext(ctx).Model(&User{}).Where("id = ?", id).
|
||||
Update("password_hash", passwordHash).Error
|
||||
}
|
||||
|
||||
func (r *Repository) DeleteExpiredRefreshTokens(ctx context.Context) error {
|
||||
return r.db.WithContext(ctx).
|
||||
Where("expires_at <= NOW()").Delete(&RefreshTokenDoc{}).Error
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/db"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmailExists = errors.New("email already registered")
|
||||
ErrInvalidCreds = errors.New("invalid email or password")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrInvalidUserID = errors.New("invalid user ID")
|
||||
ErrInvalidRefresh = errors.New("invalid refresh token")
|
||||
ErrRefreshExpired = errors.New("refresh token expired")
|
||||
ErrLogoutInvalid = errors.New("refresh token not found or already used")
|
||||
ErrWrongPassword = errors.New("current password is incorrect")
|
||||
ErrWeakPassword = errors.New(
|
||||
"password must be at least 8 characters with uppercase, lowercase, and digit",
|
||||
)
|
||||
ErrSamePassword = errors.New("new password must differ from current password")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo UserRepository
|
||||
jwtSecret []byte
|
||||
jwtExp time.Duration
|
||||
refreshExp time.Duration
|
||||
}
|
||||
|
||||
func NewService(repo UserRepository, jwtSecret string, jwtExp, refreshExp time.Duration) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
jwtSecret: []byte(jwtSecret),
|
||||
jwtExp: jwtExp,
|
||||
refreshExp: refreshExp,
|
||||
}
|
||||
}
|
||||
|
||||
func sha256Hex(data string) string {
|
||||
h := sha256.Sum256([]byte(data))
|
||||
return fmt.Sprintf("%x", h)
|
||||
}
|
||||
|
||||
func generateRandomToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func validatePasswordStrength(password string) error {
|
||||
if len(password) < 8 {
|
||||
return ErrWeakPassword
|
||||
}
|
||||
var hasUpper, hasLower, hasDigit bool
|
||||
for _, ch := range password {
|
||||
switch {
|
||||
case unicode.IsUpper(ch):
|
||||
hasUpper = true
|
||||
case unicode.IsLower(ch):
|
||||
hasLower = true
|
||||
case unicode.IsDigit(ch):
|
||||
hasDigit = true
|
||||
}
|
||||
}
|
||||
if !hasUpper || !hasLower || !hasDigit {
|
||||
return ErrWeakPassword
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) issueTokenPair(ctx context.Context, user *User) (*AuthResponse, error) {
|
||||
accessToken, err := GenerateToken(user.ID, user.Email, s.jwtSecret, s.jwtExp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
|
||||
rawRefresh, err := generateRandomToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
|
||||
}
|
||||
|
||||
refreshDoc := &RefreshTokenDoc{
|
||||
UserID: user.ID,
|
||||
TokenHash: sha256Hex(rawRefresh),
|
||||
ExpiresAt: time.Now().UTC().Add(s.refreshExp),
|
||||
}
|
||||
|
||||
if err := s.repo.CreateRefreshToken(ctx, refreshDoc); err != nil {
|
||||
return nil, fmt.Errorf("failed to store refresh token: %w", err)
|
||||
}
|
||||
|
||||
return &AuthResponse{
|
||||
Token: accessToken,
|
||||
RefreshToken: rawRefresh,
|
||||
User: NewUserPublic(user),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error) {
|
||||
if err := validatePasswordStrength(req.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Email = strings.ToLower(req.Email)
|
||||
|
||||
existing, err := s.repo.FindByEmail(ctx, req.Email)
|
||||
if err != nil && !errors.Is(err, db.ErrNoRows) {
|
||||
return nil, fmt.Errorf("failed to check existing user: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
return nil, ErrEmailExists
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
user := &User{
|
||||
Username: req.Username,
|
||||
Email: req.Email,
|
||||
PasswordHash: string(hash),
|
||||
}
|
||||
|
||||
if err := s.repo.CreateUser(ctx, user); err != nil {
|
||||
if isPGUniqueViolation(err) {
|
||||
return nil, ErrEmailExists
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
|
||||
return s.issueTokenPair(ctx, user)
|
||||
}
|
||||
|
||||
func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) {
|
||||
req.Email = strings.ToLower(req.Email)
|
||||
user, err := s.repo.FindByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoRows) {
|
||||
return nil, ErrInvalidCreds
|
||||
}
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
[]byte(user.PasswordHash),
|
||||
[]byte(req.Password),
|
||||
); err != nil {
|
||||
return nil, ErrInvalidCreds
|
||||
}
|
||||
|
||||
return s.issueTokenPair(ctx, user)
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, rawRefresh string) (*AuthResponse, error) {
|
||||
hash := sha256Hex(rawRefresh)
|
||||
|
||||
doc, err := s.repo.FindRefreshTokenByHash(ctx, hash)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoRows) {
|
||||
return nil, ErrInvalidRefresh
|
||||
}
|
||||
return nil, fmt.Errorf("failed to find refresh token: %w", err)
|
||||
}
|
||||
|
||||
if err := s.repo.DeleteRefreshToken(ctx, doc.ID); err != nil {
|
||||
return nil, fmt.Errorf("failed to delete old refresh token: %w", err)
|
||||
}
|
||||
|
||||
user, err := s.repo.FindByID(ctx, doc.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
return s.issueTokenPair(ctx, user)
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, rawRefresh string) error {
|
||||
hash := sha256Hex(rawRefresh)
|
||||
|
||||
found, err := s.repo.DeleteRefreshTokenByHash(ctx, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete refresh token: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return ErrLogoutInvalid
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetUserByID(ctx context.Context, userID string) (*UserPublic, error) {
|
||||
if userID == "" {
|
||||
return nil, ErrInvalidUserID
|
||||
}
|
||||
|
||||
user, err := s.repo.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
public := NewUserPublic(user)
|
||||
return &public, nil
|
||||
}
|
||||
|
||||
func (s *Service) ChangePassword(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
req PasswordChangeRequest,
|
||||
) error {
|
||||
if userID == "" {
|
||||
return ErrInvalidUserID
|
||||
}
|
||||
|
||||
user, err := s.repo.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoRows) {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(
|
||||
[]byte(user.PasswordHash),
|
||||
[]byte(req.OldPassword),
|
||||
); err != nil {
|
||||
return ErrWrongPassword
|
||||
}
|
||||
|
||||
if req.OldPassword == req.NewPassword {
|
||||
return ErrSamePassword
|
||||
}
|
||||
|
||||
if err := validatePasswordStrength(req.NewPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateUserPassword(ctx, userID, string(hash)); err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProfile(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
req UpdateProfileRequest,
|
||||
) (*UserPublic, error) {
|
||||
if userID == "" {
|
||||
return nil, ErrInvalidUserID
|
||||
}
|
||||
|
||||
user, err := s.repo.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoRows) {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to find user: %w", err)
|
||||
}
|
||||
|
||||
if err := s.repo.UpdateUserUsername(ctx, userID, req.Username); err != nil {
|
||||
return nil, fmt.Errorf("failed to update username: %w", err)
|
||||
}
|
||||
|
||||
user.Username = req.Username
|
||||
public := NewUserPublic(user)
|
||||
return &public, nil
|
||||
}
|
||||
|
||||
func isPGUniqueViolation(err error) bool {
|
||||
return err != nil &&
|
||||
(strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505"))
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ServerPort string
|
||||
DatabaseURL string
|
||||
JWTSecret string
|
||||
JWTExpiration time.Duration
|
||||
JWTRefreshExpiration time.Duration
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
if err := godotenv.Load(); err != nil {
|
||||
log.Printf("warning: .env file not loaded: %v", err)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||
DatabaseURL: getEnv(
|
||||
"DATABASE_URL",
|
||||
"postgres://localhost:5432/aegisguard?sslmode=disable",
|
||||
),
|
||||
JWTSecret: getEnv("JWT_SECRET", ""),
|
||||
JWTExpiration: 24 * time.Hour,
|
||||
JWTRefreshExpiration: 7 * 24 * time.Hour,
|
||||
}
|
||||
|
||||
if cfg.JWTSecret == "" {
|
||||
return nil, fmt.Errorf("JWT_SECRET is required in .env file")
|
||||
}
|
||||
if len(cfg.JWTSecret) < 32 {
|
||||
return nil, fmt.Errorf("JWT_SECRET must be at least 32 characters long")
|
||||
}
|
||||
|
||||
if expStr := os.Getenv("JWT_EXPIRATION"); expStr != "" {
|
||||
d, err := time.ParseDuration(expStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid JWT_EXPIRATION: %w", err)
|
||||
}
|
||||
cfg.JWTExpiration = d
|
||||
}
|
||||
|
||||
if expStr := os.Getenv("JWT_REFRESH_EXPIRATION"); expStr != "" {
|
||||
d, err := time.ParseDuration(expStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid JWT_REFRESH_EXPIRATION: %w", err)
|
||||
}
|
||||
cfg.JWTRefreshExpiration = d
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package db
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
var ErrNoRows = gorm.ErrRecordNotFound
|
||||
@@ -0,0 +1,168 @@
|
||||
package org
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// @Summary Создание организации
|
||||
// @Description Создание новой организации. slug используется в URL и должен быть уникальным.
|
||||
// @Description **Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
// @Tags organizations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param request body CreateOrgRequest true "Название и slug организации"
|
||||
// @Success 201 {object} OrgResponse "Организация создана"
|
||||
// @Failure 400 {object} ErrorResponse "Ошибка валидации полей"
|
||||
// @Failure 409 {object} ErrorResponse "Slug уже занят"
|
||||
// @Router /api/v1/organizations [post]
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req CreateOrgRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
org, err := h.service.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSlugExists) {
|
||||
c.JSON(http.StatusConflict, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("create org error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, OrgResponse{Organization: *org})
|
||||
}
|
||||
|
||||
// @Summary Получить организацию
|
||||
// @Description Получение информации об организации по её ID.
|
||||
// @Description **Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
// @Tags organizations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param id path string true "UUID организации"
|
||||
// @Success 200 {object} OrgResponse "Данные организации"
|
||||
// @Failure 404 {object} ErrorResponse "Организация не найдена"
|
||||
// @Router /api/v1/organizations/{id} [get]
|
||||
func (h *Handler) GetByID(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
org, err := h.service.GetByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("get org error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, OrgResponse{Organization: *org})
|
||||
}
|
||||
|
||||
// @Summary Список организаций
|
||||
// @Description Получение списка всех организаций с пагинацией.
|
||||
// @Description **Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
// @Tags organizations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param limit query int false "Количество записей на странице (по умолчанию 20)"
|
||||
// @Param offset query int false "Смещение от начала списка (по умолчанию 0)"
|
||||
// @Success 200 {object} OrgListResponse "Список организаций"
|
||||
// @Failure 500 {object} ErrorResponse "Внутренняя ошибка сервера"
|
||||
// @Router /api/v1/organizations [get]
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
|
||||
resp, err := h.service.List(c.Request.Context(), limit, offset)
|
||||
if err != nil {
|
||||
log.Printf("list orgs error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// @Summary Обновление организации
|
||||
// @Description Обновление названия организации. slug изменить нельзя.
|
||||
// @Description **Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
// @Tags organizations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param id path string true "UUID организации"
|
||||
// @Param request body UpdateOrgRequest true "Новое название организации"
|
||||
// @Success 200 {object} OrgResponse "Обновлённая организация"
|
||||
// @Failure 400 {object} ErrorResponse "Ошибка валидации полей"
|
||||
// @Failure 404 {object} ErrorResponse "Организация не найдена"
|
||||
// @Router /api/v1/organizations/{id} [put]
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
var req UpdateOrgRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
org, err := h.service.Update(c.Request.Context(), id, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("update org error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, OrgResponse{Organization: *org})
|
||||
}
|
||||
|
||||
// @Summary Удаление организации
|
||||
// @Description Безвозвратное удаление организации по её ID.
|
||||
// @Description **Требуется:** заголовок `Authorization: Bearer <token>`.
|
||||
// @Tags organizations
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security Bearer
|
||||
// @Param id path string true "UUID организации"
|
||||
// @Success 200 {object} map[string]string "{"message": "organization deleted"}"
|
||||
// @Failure 404 {object} ErrorResponse "Организация не найдена"
|
||||
// @Router /api/v1/organizations/{id} [delete]
|
||||
func (h *Handler) Delete(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
|
||||
if err := h.service.Delete(c.Request.Context(), id); err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("delete org error: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "organization deleted"})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org
|
||||
|
||||
import "time"
|
||||
|
||||
type Organization struct {
|
||||
ID string `gorm:"type:uuid;primaryKey" json:"id"`
|
||||
Name string `gorm:"type:text;not null" json:"name"`
|
||||
Slug string `gorm:"type:text;not null;uniqueIndex" json:"slug"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
type CreateOrgRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2,max=100" example:"My Corp"`
|
||||
Slug string `json:"slug" binding:"required,min=2,max=50" example:"my-corp"`
|
||||
}
|
||||
|
||||
type UpdateOrgRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2,max=100" example:"My Corp Updated"`
|
||||
}
|
||||
|
||||
type OrgResponse struct {
|
||||
Organization Organization `json:"organization"`
|
||||
}
|
||||
|
||||
type OrgListResponse struct {
|
||||
Organizations []Organization `json:"organizations"`
|
||||
Total int `json:"total"`
|
||||
Limit int `json:"limit"`
|
||||
Offset int `json:"offset"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package org
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OrgRepository interface {
|
||||
Create(ctx context.Context, org *Organization) error
|
||||
FindByID(ctx context.Context, id string) (*Organization, error)
|
||||
FindAll(ctx context.Context, limit, offset int) ([]Organization, error)
|
||||
Count(ctx context.Context) (int, error)
|
||||
Update(ctx context.Context, org *Organization) error
|
||||
Delete(ctx context.Context, id string) (bool, error)
|
||||
}
|
||||
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
}
|
||||
|
||||
func (r *Repository) Create(ctx context.Context, org *Organization) error {
|
||||
org.ID = uuid.New().String()
|
||||
now := time.Now().UTC()
|
||||
org.CreatedAt = now
|
||||
org.UpdatedAt = now
|
||||
return r.db.WithContext(ctx).Create(org).Error
|
||||
}
|
||||
|
||||
func (r *Repository) FindByID(ctx context.Context, id string) (*Organization, error) {
|
||||
var org Organization
|
||||
err := r.db.WithContext(ctx).Where("id = ?", id).First(&org).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &org, nil
|
||||
}
|
||||
|
||||
func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]Organization, error) {
|
||||
var orgs []Organization
|
||||
err := r.db.WithContext(ctx).
|
||||
Order("created_at DESC").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&orgs).Error
|
||||
return orgs, err
|
||||
}
|
||||
|
||||
func (r *Repository) Count(ctx context.Context) (int, error) {
|
||||
var total int64
|
||||
err := r.db.WithContext(ctx).Model(&Organization{}).Count(&total).Error
|
||||
return int(total), err
|
||||
}
|
||||
|
||||
func (r *Repository) Update(ctx context.Context, org *Organization) error {
|
||||
return r.db.WithContext(ctx).Model(org).Update("name", org.Name).Error
|
||||
}
|
||||
|
||||
func (r *Repository) Delete(ctx context.Context, id string) (bool, error) {
|
||||
result := r.db.WithContext(ctx).Delete(&Organization{}, "id = ?", id)
|
||||
if result.Error != nil {
|
||||
return false, result.Error
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package org
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/db"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("organization not found")
|
||||
ErrSlugExists = errors.New("slug already taken")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo OrgRepository
|
||||
}
|
||||
|
||||
func NewService(repo OrgRepository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, req CreateOrgRequest) (*Organization, error) {
|
||||
req.Slug = strings.ToLower(strings.TrimSpace(req.Slug))
|
||||
|
||||
org := &Organization{
|
||||
Name: req.Name,
|
||||
Slug: req.Slug,
|
||||
}
|
||||
|
||||
if err := s.repo.Create(ctx, org); err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrSlugExists
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create organization: %w", err)
|
||||
}
|
||||
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetByID(ctx context.Context, id string) (*Organization, error) {
|
||||
org, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to find organization: %w", err)
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, limit, offset int) (*OrgListResponse, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
total, err := s.repo.Count(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to count organizations: %w", err)
|
||||
}
|
||||
|
||||
orgs, err := s.repo.FindAll(ctx, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list organizations: %w", err)
|
||||
}
|
||||
if orgs == nil {
|
||||
orgs = []Organization{}
|
||||
}
|
||||
return &OrgListResponse{
|
||||
Organizations: orgs,
|
||||
Total: total,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
req UpdateOrgRequest,
|
||||
) (*Organization, error) {
|
||||
org, err := s.repo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to find organization: %w", err)
|
||||
}
|
||||
|
||||
org.Name = req.Name
|
||||
|
||||
if err := s.repo.Update(ctx, org); err != nil {
|
||||
return nil, fmt.Errorf("failed to update organization: %w", err)
|
||||
}
|
||||
|
||||
return org, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
found, err := s.repo.Delete(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete organization: %w", err)
|
||||
}
|
||||
if !found {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUniqueViolation(err error) bool {
|
||||
return err != nil &&
|
||||
(strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505"))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id UUID PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS refresh_tokens;
|
||||
DROP TABLE IF EXISTS users;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- +goose Up
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id UUID PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS organizations;
|
||||
Reference in New Issue
Block a user