From 321cba3f9b4760accbb5356c32c637145bd322e7 Mon Sep 17 00:00:00 2001 From: Mephimeow Date: Fri, 12 Jun 2026 09:12:18 +0000 Subject: [PATCH 01/10] JWT proto with login & registration --- .env.example | 5 + cmd/backend/main.go | 94 ++++++++++++++- docs/docs.go | 227 +++++++++++++++++++++++++++++++++++- docs/swagger.json | 227 +++++++++++++++++++++++++++++++++++- docs/swagger.yaml | 150 +++++++++++++++++++++++- go.mod | 35 +++--- go.sum | 70 ++++++----- internal/auth/auth.go | 47 ++++++++ internal/auth/handler.go | 107 +++++++++++++++++ internal/auth/middleware.go | 34 ++++++ internal/auth/models.go | 55 +++++++++ internal/auth/repository.go | 53 +++++++++ internal/auth/service.go | 103 ++++++++++++++++ internal/config/config.go | 50 ++++++++ 14 files changed, 1199 insertions(+), 58 deletions(-) create mode 100644 .env.example create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/handler.go create mode 100644 internal/auth/middleware.go create mode 100644 internal/auth/models.go create mode 100644 internal/auth/repository.go create mode 100644 internal/auth/service.go create mode 100644 internal/config/config.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a0662e6 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +SERVER_PORT=8080 +MONGO_URI=mongodb://localhost:27017 +MONGO_DB=aegisguard +JWT_SECRET=bebebe_rewritemeeee +JWT_EXPIRATION=24h diff --git a/cmd/backend/main.go b/cmd/backend/main.go index e887927..8a1fee1 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -1,28 +1,112 @@ 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" "github.com/gin-gonic/gin" "github.com/swaggo/files" "github.com/swaggo/gin-swagger" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" ) +// @title AegisGuard API +// @version 1.0 +// @description API for AegisGuard control plane +// @schemes http +// // @securityDefinitions.apikey Bearer // @in header // @name Authorization // @description Type "Bearer" followed by a space and the JWT token. func main() { - r := gin.Default() + 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() + + client, err := mongo.Connect(options.Client().ApplyURI(cfg.MongoURI)) + if err != nil { + log.Fatalf("failed to create mongodb client: %v", err) + } + + if err := client.Ping(ctx, nil); err != nil { + log.Fatalf("failed to ping mongodb: %v", err) + } + log.Println("connected to mongodb") + + db := client.Database(cfg.MongoDB) + + repo := auth.NewRepository(db) + + if err := repo.EnsureIndexes(ctx); err != nil { + log.Printf("warning: failed to ensure indexes: %v", err) + } + + svc := auth.NewService(repo, cfg.JWTSecret, cfg.JWTExpiration) + handler := auth.NewHandler(svc) + + 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 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", - }) + c.JSON(200, gin.H{"status": "ok"}) }) - r.Run(":8080") + + api := r.Group("/api/auth") + { + api.POST("/register", handler.Register) + api.POST("/login", handler.Login) + api.GET("/me", auth.AuthMiddleware([]byte(cfg.JWTSecret)), handler.Me) + } + + srv := &http.Server{ + Addr: ":" + cfg.ServerPort, + Handler: r, + } + + 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) + } + + if err := client.Disconnect(shutdownCtx); err != nil { + log.Printf("failed to disconnect mongodb: %v", err) + } + + log.Println("server stopped") } diff --git a/docs/docs.go b/docs/docs.go index a91df67..2fc0495 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -14,7 +14,224 @@ const docTemplate = `{ }, "host": "{{.Host}}", "basePath": "{{.BasePath}}", - "paths": {}, + "paths": { + "/api/auth/login": { + "post": { + "description": "Authenticate user with email and password, returns JWT token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Epta login", + "parameters": [ + { + "description": "Login credentials", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_auth.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, + "/api/auth/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get authenticated user's profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Epta get current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_auth.UserResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, + "/api/auth/register": { + "post": { + "description": "Create user account with username, email, password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Epta registration", + "parameters": [ + { + "description": "Registration details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_auth.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "internal_auth.AuthResponse": { + "type": "object", + "properties": { + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIs..." + }, + "user": { + "$ref": "#/definitions/internal_auth.UserPublic" + } + } + }, + "internal_auth.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "invalid email or password" + } + } + }, + "internal_auth.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "password": { + "type": "string", + "example": "secret123" + } + } + }, + "internal_auth.RegisterRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "password": { + "type": "string", + "minLength": 6, + "example": "secret123" + }, + "username": { + "type": "string", + "maxLength": 30, + "minLength": 3, + "example": "john" + } + } + }, + "internal_auth.UserPublic": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "internal_auth.UserResponse": { + "type": "object", + "properties": { + "user": { + "$ref": "#/definitions/internal_auth.UserPublic" + } + } + } + }, "securityDefinitions": { "Bearer": { "description": "Type \"Bearer\" followed by a space and the JWT token.", @@ -27,12 +244,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: "", + Schemes: []string{"http"}, + Title: "AegisGuard API", + Description: "API for AegisGuard control plane", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index 5173354..1ce4369 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1,9 +1,232 @@ { + "schemes": [ + "http" + ], "swagger": "2.0", "info": { - "contact": {} + "description": "API for AegisGuard control plane", + "title": "AegisGuard API", + "contact": {}, + "version": "1.0" + }, + "paths": { + "/api/auth/login": { + "post": { + "description": "Authenticate user with email and password, returns JWT token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Epta login", + "parameters": [ + { + "description": "Login credentials", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.LoginRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_auth.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, + "/api/auth/me": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get authenticated user's profile", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Epta get current user", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_auth.UserResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, + "/api/auth/register": { + "post": { + "description": "Create user account with username, email, password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Epta registration", + "parameters": [ + { + "description": "Registration details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.RegisterRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/internal_auth.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + } + }, + "definitions": { + "internal_auth.AuthResponse": { + "type": "object", + "properties": { + "token": { + "type": "string", + "example": "eyJhbGciOiJIUzI1NiIs..." + }, + "user": { + "$ref": "#/definitions/internal_auth.UserPublic" + } + } + }, + "internal_auth.ErrorResponse": { + "type": "object", + "properties": { + "error": { + "type": "string", + "example": "invalid email or password" + } + } + }, + "internal_auth.LoginRequest": { + "type": "object", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "password": { + "type": "string", + "example": "secret123" + } + } + }, + "internal_auth.RegisterRequest": { + "type": "object", + "required": [ + "email", + "password", + "username" + ], + "properties": { + "email": { + "type": "string", + "example": "john@example.com" + }, + "password": { + "type": "string", + "minLength": 6, + "example": "secret123" + }, + "username": { + "type": "string", + "maxLength": 30, + "minLength": 3, + "example": "john" + } + } + }, + "internal_auth.UserPublic": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "email": { + "type": "string" + }, + "id": { + "type": "string" + }, + "username": { + "type": "string" + } + } + }, + "internal_auth.UserResponse": { + "type": "object", + "properties": { + "user": { + "$ref": "#/definitions/internal_auth.UserPublic" + } + } + } }, - "paths": {}, "securityDefinitions": { "Bearer": { "description": "Type \"Bearer\" followed by a space and the JWT token.", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 0dbe493..5b882f8 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,6 +1,154 @@ +definitions: + internal_auth.AuthResponse: + properties: + token: + example: eyJhbGciOiJIUzI1NiIs... + type: string + user: + $ref: '#/definitions/internal_auth.UserPublic' + type: object + internal_auth.ErrorResponse: + properties: + error: + example: invalid email or password + type: string + type: object + internal_auth.LoginRequest: + properties: + email: + example: john@example.com + type: string + password: + example: secret123 + type: string + required: + - email + - password + type: object + internal_auth.RegisterRequest: + properties: + email: + example: john@example.com + type: string + password: + example: secret123 + minLength: 6 + type: string + username: + example: john + maxLength: 30 + minLength: 3 + type: string + required: + - email + - password + - username + type: object + internal_auth.UserPublic: + properties: + created_at: + type: string + email: + type: string + id: + type: string + username: + type: string + type: object + internal_auth.UserResponse: + properties: + user: + $ref: '#/definitions/internal_auth.UserPublic' + type: object info: contact: {} -paths: {} + description: API for AegisGuard control plane + title: AegisGuard API + version: "1.0" +paths: + /api/auth/login: + post: + consumes: + - application/json + description: Authenticate user with email and password, returns JWT token + parameters: + - description: Login credentials + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_auth.LoginRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_auth.AuthResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + summary: Epta login + tags: + - auth + /api/auth/me: + get: + consumes: + - application/json + description: Get authenticated user's profile + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_auth.UserResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + security: + - Bearer: [] + summary: Epta get current user + tags: + - auth + /api/auth/register: + post: + consumes: + - application/json + description: Create user account with username, email, password + parameters: + - description: Registration details + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_auth.RegisterRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/internal_auth.UserResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + summary: Epta registration + tags: + - auth +schemes: +- http securityDefinitions: Bearer: description: Type "Bearer" followed by a space and the JWT token. diff --git a/go.mod b/go.mod index a6791c5..413f0b3 100644 --- a/go.mod +++ b/go.mod @@ -2,22 +2,28 @@ 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/joho/godotenv v1.5.1 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + go.mongodb.org/mongo-driver/v2 v2.6.0 + golang.org/x/crypto v0.53.0 +) + require ( github.com/KyleBanks/depth v1.2.1 // indirect - github.com/PuerkitoBio/purell v1.2.2 // indirect - github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/bytedance/gopkg v0.1.4 // 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 @@ -30,31 +36,24 @@ require ( 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/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.6 // 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/mattn/go-isatty v0.0.22 // 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/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 + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect go.yaml.in/yaml/v3 v3.0.4 // 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/net v0.56.0 // indirect golang.org/x/sync v0.21.0 // indirect @@ -62,6 +61,4 @@ require ( golang.org/x/text v0.38.0 // indirect 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 ) diff --git a/go.sum b/go.sum index 6fdb5f6..45e4aff 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,5 @@ 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/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/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo= @@ -12,12 +8,13 @@ github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NI 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/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/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= @@ -28,14 +25,15 @@ github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQ 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 v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= 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/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= 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= @@ -44,6 +42,12 @@ github.com/go-openapi/swag/typeutils v0.26.1 h1:yg42FgMzRR6PVQ3M3qHz1s+Y6/P4HoJ3 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/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= +github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= +github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +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,18 +58,25 @@ 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/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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= +github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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/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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -75,15 +86,16 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= 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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 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= @@ -93,6 +105,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ 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,15 +117,19 @@ 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/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= 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.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ= @@ -147,6 +165,7 @@ 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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= @@ -159,9 +178,8 @@ 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/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +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.v3 v3.0.0-20200313102051-9f266ea9e77c/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= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..c1c4721 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,47 @@ +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{ + 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 +} diff --git a/internal/auth/handler.go b/internal/auth/handler.go new file mode 100644 index 0000000..e26b0bb --- /dev/null +++ b/internal/auth/handler.go @@ -0,0 +1,107 @@ +package auth + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// @Summary Epta registration +// @Description Create user account with username, email, password +// @Tags auth +// @Accept json +// @Produce json +// @Param request body RegisterRequest true "Registration details" +// @Success 201 {object} UserResponse +// @Failure 400 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Router /api/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 + } + + user, 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 + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusCreated, UserResponse{User: *user}) +} + +// @Summary Epta login +// @Description Authenticate user with email and password, returns JWT token +// @Tags auth +// @Accept json +// @Produce json +// @Param request body LoginRequest true "Login credentials" +// @Success 200 {object} AuthResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Router /api/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 { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()}) + return + } + + c.JSON(http.StatusOK, resp) +} + +// @Summary Epta get current user +// @Description Get authenticated user's profile +// @Tags auth +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} UserResponse +// @Failure 401 {object} ErrorResponse +// @Router /api/auth/me [get] +func (h *Handler) Me(c *gin.Context) { + rawUserID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"}) + return + } + + userID, ok := rawUserID.(string) + if !ok { + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "invalid user ID in context"}) + 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 + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, UserResponse{User: *user}) +} diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..591fefe --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,34 @@ +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 || 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() + } +} diff --git a/internal/auth/models.go b/internal/auth/models.go new file mode 100644 index 0000000..6b11c3d --- /dev/null +++ b/internal/auth/models.go @@ -0,0 +1,55 @@ +package auth + +import ( + "time" + + "go.mongodb.org/mongo-driver/v2/bson" +) + +type User struct { + ID bson.ObjectID `json:"id" bson:"_id"` + Username string `json:"username" bson:"username"` + Email string `json:"email" bson:"email"` + PasswordHash string `json:"-" bson:"password_hash"` + CreatedAt time.Time `json:"created_at" bson:"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=6" 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..."` + User UserPublic `json:"user"` +} + +type UserPublic struct { + ID bson.ObjectID `json:"id" bson:"_id"` + Username string `json:"username" bson:"username"` + Email string `json:"email" bson:"email"` + CreatedAt time.Time `json:"created_at" bson:"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 ErrorResponse struct { + Error string `json:"error" example:"invalid email or password"` +} diff --git a/internal/auth/repository.go b/internal/auth/repository.go new file mode 100644 index 0000000..e9829ff --- /dev/null +++ b/internal/auth/repository.go @@ -0,0 +1,53 @@ +package auth + +import ( + "context" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type Repository struct { + collection *mongo.Collection +} + +func NewRepository(db *mongo.Database) *Repository { + return &Repository{ + collection: db.Collection("users"), + } +} + +func (r *Repository) EnsureIndexes(ctx context.Context) error { + _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "email", Value: 1}}, + Options: options.Index().SetUnique(true), + }) + return err +} + +func (r *Repository) Create(ctx context.Context, user *User) error { + user.ID = bson.NewObjectID() + user.CreatedAt = time.Now().UTC() + _, err := r.collection.InsertOne(ctx, user) + return err +} + +func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, error) { + var user User + err := r.collection.FindOne(ctx, bson.M{"email": email}).Decode(&user) + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *Repository) FindByID(ctx context.Context, id bson.ObjectID) (*User, error) { + var user User + err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user) + if err != nil { + return nil, err + } + return &user, nil +} diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..7791b77 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,103 @@ +package auth + +import ( + "context" + "errors" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "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") +) + +type Service struct { + repo *Repository + jwtSecret []byte + jwtExp time.Duration +} + +func NewService(repo *Repository, jwtSecret string, jwtExp time.Duration) *Service { + return &Service{ + repo: repo, + jwtSecret: []byte(jwtSecret), + jwtExp: jwtExp, + } +} + +func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPublic, error) { + existing, err := s.repo.FindByEmail(ctx, req.Email) + if err != nil && !errors.Is(err, mongo.ErrNoDocuments) { + 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.Create(ctx, user); err != nil { + return nil, fmt.Errorf("failed to create user: %w", err) + } + + public := NewUserPublic(user) + return &public, nil +} + +func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) { + user, err := s.repo.FindByEmail(ctx, req.Email) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + 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 + } + + token, err := GenerateToken(user.ID.Hex(), user.Email, s.jwtSecret, s.jwtExp) + if err != nil { + return nil, fmt.Errorf("failed to generate token: %w", err) + } + + return &AuthResponse{ + Token: token, + User: NewUserPublic(user), + }, nil +} + +func (s *Service) GetUserByID(ctx context.Context, userID string) (*UserPublic, error) { + id, err := bson.ObjectIDFromHex(userID) + if err != nil { + return nil, ErrInvalidUserID + } + + user, err := s.repo.FindByID(ctx, id) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("failed to find user: %w", err) + } + + public := NewUserPublic(user) + return &public, nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..59457b2 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,50 @@ +package config + +import ( + "fmt" + "os" + "time" + + "github.com/joho/godotenv" +) + +type Config struct { + ServerPort string + MongoURI string + MongoDB string + JWTSecret string + JWTExpiration time.Duration +} + +func Load() (*Config, error) { + godotenv.Load() + + cfg := &Config{ + ServerPort: getEnv("SERVER_PORT", "8080"), + MongoURI: getEnv("MONGO_URI", "mongodb://localhost:27017"), + MongoDB: getEnv("MONGO_DB", "aegisguard"), + JWTSecret: getEnv("JWT_SECRET", ""), + JWTExpiration: 24 * time.Hour, + } + + if cfg.JWTSecret == "" { + return nil, fmt.Errorf("JWT_SECRET is required in .env file") + } + + 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 + } + + return cfg, nil +} + +func getEnv(key, defaultVal string) string { + if val := os.Getenv(key); val != "" { + return val + } + return defaultVal +} -- 2.52.0 From a822d8c3b6f412c0b34dcf3b5c26a42b59f5ee7a Mon Sep 17 00:00:00 2001 From: Mephimeow Date: Fri, 12 Jun 2026 10:01:21 +0000 Subject: [PATCH 02/10] added refresh tocken --- .env.example | 3 +- cmd/backend/main.go | 3 +- docs/docs.go | 62 ++++++++++++++++++++++++ docs/swagger.json | 62 ++++++++++++++++++++++++ docs/swagger.yaml | 41 ++++++++++++++++ internal/auth/handler.go | 30 ++++++++++++ internal/auth/models.go | 17 ++++++- internal/auth/repository.go | 53 +++++++++++++++++--- internal/auth/service.go | 96 +++++++++++++++++++++++++++++++------ internal/config/config.go | 30 ++++++++---- 10 files changed, 362 insertions(+), 35 deletions(-) diff --git a/.env.example b/.env.example index a0662e6..0c8e13c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ SERVER_PORT=8080 MONGO_URI=mongodb://localhost:27017 MONGO_DB=aegisguard -JWT_SECRET=bebebe_rewritemeeee +JWT_SECRET=bebebe_rewritemeeee_openssl_rand JWT_EXPIRATION=24h +JWT_REFRESH_EXPIRATION=168h diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 8a1fee1..675545b 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -56,7 +56,7 @@ func main() { log.Printf("warning: failed to ensure indexes: %v", err) } - svc := auth.NewService(repo, cfg.JWTSecret, cfg.JWTExpiration) + svc := auth.NewService(repo, cfg.JWTSecret, cfg.JWTExpiration, cfg.JWTRefreshExpiration) handler := auth.NewHandler(svc) gin.SetMode(gin.ReleaseMode) @@ -76,6 +76,7 @@ func main() { { api.POST("/register", handler.Register) api.POST("/login", handler.Login) + api.POST("/refresh", handler.Refresh) api.GET("/me", auth.AuthMiddleware([]byte(cfg.JWTSecret)), handler.Me) } diff --git a/docs/docs.go b/docs/docs.go index 2fc0495..29eb1e3 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -95,6 +95,52 @@ const docTemplate = `{ } } }, + "/api/auth/refresh": { + "post": { + "description": "Get a new access token using a refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh epta token", + "parameters": [ + { + "description": "Refresh token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_auth.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, "/api/auth/register": { "post": { "description": "Create user account with username, email, password", @@ -146,6 +192,10 @@ const docTemplate = `{ "internal_auth.AuthResponse": { "type": "object", "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + }, "token": { "type": "string", "example": "eyJhbGciOiJIUzI1NiIs..." @@ -181,6 +231,18 @@ const docTemplate = `{ } } }, + "internal_auth.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, "internal_auth.RegisterRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 1ce4369..4395985 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -90,6 +90,52 @@ } } }, + "/api/auth/refresh": { + "post": { + "description": "Get a new access token using a refresh token", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Refresh epta token", + "parameters": [ + { + "description": "Refresh token", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.RefreshRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/internal_auth.AuthResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, "/api/auth/register": { "post": { "description": "Create user account with username, email, password", @@ -141,6 +187,10 @@ "internal_auth.AuthResponse": { "type": "object", "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + }, "token": { "type": "string", "example": "eyJhbGciOiJIUzI1NiIs..." @@ -176,6 +226,18 @@ } } }, + "internal_auth.RefreshRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, "internal_auth.RegisterRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 5b882f8..49ef4f7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,6 +1,9 @@ definitions: internal_auth.AuthResponse: properties: + refresh_token: + example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4= + type: string token: example: eyJhbGciOiJIUzI1NiIs... type: string @@ -25,6 +28,14 @@ definitions: - email - password type: object + internal_auth.RefreshRequest: + properties: + refresh_token: + example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4= + type: string + required: + - refresh_token + type: object internal_auth.RegisterRequest: properties: email: @@ -117,6 +128,36 @@ paths: summary: Epta get current user tags: - auth + /api/auth/refresh: + post: + consumes: + - application/json + description: Get a new access token using a refresh token + parameters: + - description: Refresh token + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_auth.RefreshRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/internal_auth.AuthResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + summary: Refresh epta token + tags: + - auth /api/auth/register: post: consumes: diff --git a/internal/auth/handler.go b/internal/auth/handler.go index e26b0bb..039c2fb 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -71,6 +71,36 @@ func (h *Handler) Login(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// @Summary Refresh epta token +// @Description Get a new access token using a refresh token +// @Tags auth +// @Accept json +// @Produce json +// @Param request body RefreshRequest true "Refresh token" +// @Success 200 {object} AuthResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Router /api/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 + } + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) + return + } + + c.JSON(http.StatusOK, resp) +} + // @Summary Epta get current user // @Description Get authenticated user's profile // @Tags auth diff --git a/internal/auth/models.go b/internal/auth/models.go index 6b11c3d..52a5c3d 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -26,8 +26,21 @@ type LoginRequest struct { } type AuthResponse struct { - Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."` - User UserPublic `json:"user"` + 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 RefreshTokenDoc struct { + ID bson.ObjectID `json:"id" bson:"_id"` + UserID bson.ObjectID `json:"user_id" bson:"user_id"` + TokenHash string `json:"token_hash" bson:"token_hash"` + ExpiresAt time.Time `json:"expires_at" bson:"expires_at"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` } type UserPublic struct { diff --git a/internal/auth/repository.go b/internal/auth/repository.go index e9829ff..945db7f 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -10,33 +10,49 @@ import ( ) type Repository struct { - collection *mongo.Collection + usersCollection *mongo.Collection + refreshTokensCollection *mongo.Collection } func NewRepository(db *mongo.Database) *Repository { return &Repository{ - collection: db.Collection("users"), + usersCollection: db.Collection("users"), + refreshTokensCollection: db.Collection("refresh_tokens"), } } func (r *Repository) EnsureIndexes(ctx context.Context) error { - _, err := r.collection.Indexes().CreateOne(ctx, mongo.IndexModel{ + _, err := r.usersCollection.Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "email", Value: 1}}, Options: options.Index().SetUnique(true), }) + if err != nil { + return err + } + + _, err = r.refreshTokensCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ + { + Keys: bson.D{{Key: "token_hash", Value: 1}}, + Options: options.Index().SetUnique(true), + }, + { + Keys: bson.D{{Key: "expires_at", Value: 1}}, + Options: options.Index().SetExpireAfterSeconds(0), + }, + }) return err } -func (r *Repository) Create(ctx context.Context, user *User) error { +func (r *Repository) CreateUser(ctx context.Context, user *User) error { user.ID = bson.NewObjectID() user.CreatedAt = time.Now().UTC() - _, err := r.collection.InsertOne(ctx, user) + _, err := r.usersCollection.InsertOne(ctx, user) return err } func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, error) { var user User - err := r.collection.FindOne(ctx, bson.M{"email": email}).Decode(&user) + err := r.usersCollection.FindOne(ctx, bson.M{"email": email}).Decode(&user) if err != nil { return nil, err } @@ -45,9 +61,32 @@ func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, erro func (r *Repository) FindByID(ctx context.Context, id bson.ObjectID) (*User, error) { var user User - err := r.collection.FindOne(ctx, bson.M{"_id": id}).Decode(&user) + err := r.usersCollection.FindOne(ctx, bson.M{"_id": id}).Decode(&user) if err != nil { return nil, err } return &user, nil } + +//Refresh + +func (r *Repository) CreateRefreshToken(ctx context.Context, doc *RefreshTokenDoc) error { + doc.ID = bson.NewObjectID() + doc.CreatedAt = time.Now().UTC() + _, err := r.refreshTokensCollection.InsertOne(ctx, doc) + return err +} + +func (r *Repository) FindRefreshTokenByHash(ctx context.Context, hash string) (*RefreshTokenDoc, error) { + var doc RefreshTokenDoc + err := r.refreshTokensCollection.FindOne(ctx, bson.M{"token_hash": hash}).Decode(&doc) + if err != nil { + return nil, err + } + return &doc, nil +} + +func (r *Repository) DeleteRefreshToken(ctx context.Context, id bson.ObjectID) error { + _, err := r.refreshTokensCollection.DeleteOne(ctx, bson.M{"_id": id}) + return err +} diff --git a/internal/auth/service.go b/internal/auth/service.go index 7791b77..242a485 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -2,6 +2,9 @@ package auth import ( "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" "errors" "fmt" "time" @@ -16,22 +19,67 @@ var ( 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") ) type Service struct { - repo *Repository - jwtSecret []byte - jwtExp time.Duration + repo *Repository + jwtSecret []byte + jwtExp time.Duration + refreshExp time.Duration } -func NewService(repo *Repository, jwtSecret string, jwtExp time.Duration) *Service { +func NewService(repo *Repository, jwtSecret string, jwtExp, refreshExp time.Duration) *Service { return &Service{ - repo: repo, - jwtSecret: []byte(jwtSecret), - jwtExp: jwtExp, + 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 (s *Service) issueTokenPair(ctx context.Context, user *User) (*AuthResponse, error) { + accessToken, err := GenerateToken(user.ID.Hex(), 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) (*UserPublic, error) { existing, err := s.repo.FindByEmail(ctx, req.Email) if err != nil && !errors.Is(err, mongo.ErrNoDocuments) { @@ -52,7 +100,7 @@ func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPubli PasswordHash: string(hash), } - if err := s.repo.Create(ctx, user); err != nil { + if err := s.repo.CreateUser(ctx, user); err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } @@ -73,15 +121,35 @@ func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponse, e return nil, ErrInvalidCreds } - token, err := GenerateToken(user.ID.Hex(), user.Email, s.jwtSecret, s.jwtExp) + 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 { - return nil, fmt.Errorf("failed to generate token: %w", err) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrInvalidRefresh + } + return nil, fmt.Errorf("failed to find refresh token: %w", err) } - return &AuthResponse{ - Token: token, - User: NewUserPublic(user), - }, nil + if time.Now().UTC().After(doc.ExpiresAt) { + s.repo.DeleteRefreshToken(ctx, doc.ID) + return nil, ErrRefreshExpired + } + + 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) GetUserByID(ctx context.Context, userID string) (*UserPublic, error) { diff --git a/internal/config/config.go b/internal/config/config.go index 59457b2..185cb30 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,22 +9,24 @@ import ( ) type Config struct { - ServerPort string - MongoURI string - MongoDB string - JWTSecret string - JWTExpiration time.Duration + ServerPort string + MongoURI string + MongoDB string + JWTSecret string + JWTExpiration time.Duration + JWTRefreshExpiration time.Duration } func Load() (*Config, error) { godotenv.Load() cfg := &Config{ - ServerPort: getEnv("SERVER_PORT", "8080"), - MongoURI: getEnv("MONGO_URI", "mongodb://localhost:27017"), - MongoDB: getEnv("MONGO_DB", "aegisguard"), - JWTSecret: getEnv("JWT_SECRET", ""), - JWTExpiration: 24 * time.Hour, + ServerPort: getEnv("SERVER_PORT", "8080"), + MongoURI: getEnv("MONGO_URI", "mongodb://localhost:27017"), + MongoDB: getEnv("MONGO_DB", "aegisguard"), + JWTSecret: getEnv("JWT_SECRET", ""), + JWTExpiration: 24 * time.Hour, + JWTRefreshExpiration: 7 * 24 * time.Hour, } if cfg.JWTSecret == "" { @@ -39,6 +41,14 @@ func Load() (*Config, error) { 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 } -- 2.52.0 From f1308b3be7913da684f7d274d85549897e049d5b Mon Sep 17 00:00:00 2001 From: Mephimeow Date: Fri, 12 Jun 2026 10:18:04 +0000 Subject: [PATCH 03/10] added logout --- cmd/backend/main.go | 3 +- docs/docs.go | 61 +++++++++++++++++++++++++++++++++++++ docs/swagger.json | 61 +++++++++++++++++++++++++++++++++++++ docs/swagger.yaml | 40 ++++++++++++++++++++++++ internal/auth/auth.go | 1 + internal/auth/handler.go | 41 ++++++++++++++++++++++++- internal/auth/middleware.go | 2 +- internal/auth/models.go | 4 +++ internal/auth/repository.go | 8 +++++ internal/auth/service.go | 23 +++++++++++++- internal/config/config.go | 5 ++- 11 files changed, 244 insertions(+), 5 deletions(-) diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 675545b..bee7a99 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -38,7 +38,7 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - client, err := mongo.Connect(options.Client().ApplyURI(cfg.MongoURI)) + client, err := mongo.Connect(options.Client().ApplyURI(cfg.MongoURI).SetTimeout(10 * time.Second)) if err != nil { log.Fatalf("failed to create mongodb client: %v", err) } @@ -77,6 +77,7 @@ func main() { api.POST("/register", handler.Register) api.POST("/login", handler.Login) api.POST("/refresh", handler.Refresh) + api.POST("/logout", handler.Logout) api.GET("/me", auth.AuthMiddleware([]byte(cfg.JWTSecret)), handler.Me) } diff --git a/docs/docs.go b/docs/docs.go index 29eb1e3..3834145 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -61,6 +61,55 @@ const docTemplate = `{ } } }, + "/api/auth/logout": { + "post": { + "description": "Invalidate a refresh token (logout)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout epta", + "parameters": [ + { + "description": "Refresh token to invalidate", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.LogoutRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, "/api/auth/me": { "get": { "security": [ @@ -231,6 +280,18 @@ const docTemplate = `{ } } }, + "internal_auth.LogoutRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, "internal_auth.RefreshRequest": { "type": "object", "required": [ diff --git a/docs/swagger.json b/docs/swagger.json index 4395985..909302e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -56,6 +56,55 @@ } } }, + "/api/auth/logout": { + "post": { + "description": "Invalidate a refresh token (logout)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Logout epta", + "parameters": [ + { + "description": "Refresh token to invalidate", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/internal_auth.LogoutRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/internal_auth.ErrorResponse" + } + } + } + } + }, "/api/auth/me": { "get": { "security": [ @@ -226,6 +275,18 @@ } } }, + "internal_auth.LogoutRequest": { + "type": "object", + "required": [ + "refresh_token" + ], + "properties": { + "refresh_token": { + "type": "string", + "example": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" + } + } + }, "internal_auth.RefreshRequest": { "type": "object", "required": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 49ef4f7..6b394de 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -28,6 +28,14 @@ definitions: - email - password type: object + internal_auth.LogoutRequest: + properties: + refresh_token: + example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4= + type: string + required: + - refresh_token + type: object internal_auth.RefreshRequest: properties: refresh_token: @@ -107,6 +115,38 @@ paths: summary: Epta login tags: - auth + /api/auth/logout: + post: + consumes: + - application/json + description: Invalidate a refresh token (logout) + parameters: + - description: Refresh token to invalidate + in: body + name: request + required: true + schema: + $ref: '#/definitions/internal_auth.LogoutRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/internal_auth.ErrorResponse' + summary: Logout epta + tags: + - auth /api/auth/me: get: consumes: diff --git a/internal/auth/auth.go b/internal/auth/auth.go index c1c4721..1ddc136 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -18,6 +18,7 @@ func GenerateToken(userID, email string, secret []byte, expiration time.Duration UserID: userID, Email: email, RegisteredClaims: jwt.RegisteredClaims{ + Subject: userID, ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiration)), IssuedAt: jwt.NewNumericDate(time.Now()), }, diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 039c2fb..ce8d427 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -2,6 +2,7 @@ package auth import ( "errors" + "log" "net/http" "github.com/gin-gonic/gin" @@ -38,6 +39,7 @@ func (h *Handler) Register(c *gin.Context) { c.JSON(http.StatusConflict, ErrorResponse{Error: err.Error()}) return } + log.Printf("register error: %v", err) c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "internal server error"}) return } @@ -64,7 +66,12 @@ func (h *Handler) Login(c *gin.Context) { resp, err := h.service.Login(c.Request.Context(), req) if err != nil { - c.JSON(http.StatusUnauthorized, ErrorResponse{Error: err.Error()}) + 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 } @@ -94,6 +101,7 @@ func (h *Handler) Refresh(c *gin.Context) { 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 } @@ -101,6 +109,36 @@ func (h *Handler) Refresh(c *gin.Context) { c.JSON(http.StatusOK, resp) } +// @Summary Logout epta +// @Description Invalidate a refresh token (logout) +// @Tags auth +// @Accept json +// @Produce json +// @Param request body LogoutRequest true "Refresh token to invalidate" +// @Success 200 {object} map[string]string +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Router /api/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 Epta get current user // @Description Get authenticated user's profile // @Tags auth @@ -129,6 +167,7 @@ func (h *Handler) Me(c *gin.Context) { 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 } diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 591fefe..5f0b24a 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -16,7 +16,7 @@ func AuthMiddleware(jwtSecret []byte) gin.HandlerFunc { } parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || parts[0] != "Bearer" { + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") { c.AbortWithStatusJSON(http.StatusUnauthorized, ErrorResponse{Error: "invalid authorization header format"}) return } diff --git a/internal/auth/models.go b/internal/auth/models.go index 52a5c3d..6985feb 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -35,6 +35,10 @@ 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 bson.ObjectID `json:"id" bson:"_id"` UserID bson.ObjectID `json:"user_id" bson:"user_id"` diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 945db7f..69186e5 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -90,3 +90,11 @@ func (r *Repository) DeleteRefreshToken(ctx context.Context, id bson.ObjectID) e _, err := r.refreshTokensCollection.DeleteOne(ctx, bson.M{"_id": id}) return err } + +func (r *Repository) DeleteRefreshTokenByHash(ctx context.Context, hash string) (bool, error) { + res, err := r.refreshTokensCollection.DeleteOne(ctx, bson.M{"token_hash": hash}) + if err != nil { + return false, err + } + return res.DeletedCount > 0, nil +} diff --git a/internal/auth/service.go b/internal/auth/service.go index 242a485..dd50087 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -7,6 +7,8 @@ import ( "encoding/base64" "errors" "fmt" + "log" + "strings" "time" "go.mongodb.org/mongo-driver/v2/bson" @@ -21,6 +23,7 @@ var ( 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") ) type Service struct { @@ -81,6 +84,7 @@ func (s *Service) issueTokenPair(ctx context.Context, user *User) (*AuthResponse } func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPublic, error) { + req.Email = strings.ToLower(req.Email) existing, err := s.repo.FindByEmail(ctx, req.Email) if err != nil && !errors.Is(err, mongo.ErrNoDocuments) { return nil, fmt.Errorf("failed to check existing user: %w", err) @@ -109,6 +113,7 @@ func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPubli } 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, mongo.ErrNoDocuments) { @@ -136,7 +141,9 @@ func (s *Service) Refresh(ctx context.Context, rawRefresh string) (*AuthResponse } if time.Now().UTC().After(doc.ExpiresAt) { - s.repo.DeleteRefreshToken(ctx, doc.ID) + if err := s.repo.DeleteRefreshToken(ctx, doc.ID); err != nil { + log.Printf("failed to cleanup expired refresh token: %v", err) + } return nil, ErrRefreshExpired } @@ -152,6 +159,20 @@ func (s *Service) Refresh(ctx context.Context, rawRefresh string) (*AuthResponse 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) { id, err := bson.ObjectIDFromHex(userID) if err != nil { diff --git a/internal/config/config.go b/internal/config/config.go index 185cb30..6507d50 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "log" "os" "time" @@ -18,7 +19,9 @@ type Config struct { } func Load() (*Config, error) { - godotenv.Load() + if err := godotenv.Load(); err != nil { + log.Printf("warning: .env file not loaded: %v", err) + } cfg := &Config{ ServerPort: getEnv("SERVER_PORT", "8080"), -- 2.52.0 From 56ab5832232229c53c2571419c8f4cfc76fa3b18 Mon Sep 17 00:00:00 2001 From: Mephimeow Date: Sat, 13 Jun 2026 17:30:14 +0000 Subject: [PATCH 04/10] postgres --- .env.example | 3 +- cmd/backend/main.go | 27 +++++----- go.mod | 38 +++++++------- go.sum | 102 +++++++++++++++++++----------------- internal/auth/models.go | 30 +++++------ internal/auth/repository.go | 98 +++++++++++++++++++--------------- internal/auth/service.go | 17 +++--- internal/config/config.go | 6 +-- 8 files changed, 163 insertions(+), 158 deletions(-) diff --git a/.env.example b/.env.example index 0c8e13c..03960aa 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,5 @@ SERVER_PORT=8080 -MONGO_URI=mongodb://localhost:27017 -MONGO_DB=aegisguard +DATABASE_URL=postgres://postgres:postgres@localhost:5432/aegisguard?sslmode=disable JWT_SECRET=bebebe_rewritemeeee_openssl_rand JWT_EXPIRATION=24h JWT_REFRESH_EXPIRATION=168h diff --git a/cmd/backend/main.go b/cmd/backend/main.go index bee7a99..5c51249 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -13,10 +13,9 @@ import ( "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/auth" "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/config" "github.com/gin-gonic/gin" + "github.com/jackc/pgx/v5/pgxpool" "github.com/swaggo/files" "github.com/swaggo/gin-swagger" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" ) // @title AegisGuard API @@ -38,23 +37,23 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - client, err := mongo.Connect(options.Client().ApplyURI(cfg.MongoURI).SetTimeout(10 * time.Second)) + pool, err := pgxpool.New(ctx, cfg.DatabaseURL) if err != nil { - log.Fatalf("failed to create mongodb client: %v", err) + log.Fatalf("failed to create postgres pool: %v", err) } + defer pool.Close() - if err := client.Ping(ctx, nil); err != nil { - log.Fatalf("failed to ping mongodb: %v", err) + if err := pool.Ping(ctx); err != nil { + log.Fatalf("failed to ping postgres: %v", err) } - log.Println("connected to mongodb") + log.Println("connected to postgres") - db := client.Database(cfg.MongoDB) + repo := auth.NewRepository(pool) - repo := auth.NewRepository(db) - - if err := repo.EnsureIndexes(ctx); err != nil { - log.Printf("warning: failed to ensure indexes: %v", err) + if err := repo.Migrate(ctx); err != nil { + log.Fatalf("failed to run migrations: %v", err) } + log.Println("migrations applied") svc := auth.NewService(repo, cfg.JWTSecret, cfg.JWTExpiration, cfg.JWTRefreshExpiration) handler := auth.NewHandler(svc) @@ -106,9 +105,7 @@ func main() { log.Fatalf("server forced to shutdown: %v", err) } - if err := client.Disconnect(shutdownCtx); err != nil { - log.Printf("failed to disconnect mongodb: %v", err) - } + pool.Close() log.Println("server stopped") } diff --git a/go.mod b/go.mod index 413f0b3..fae639d 100644 --- a/go.mod +++ b/go.mod @@ -5,60 +5,58 @@ 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/jackc/pgx/v5 v5.7.4 github.com/joho/godotenv v1.5.1 github.com/swaggo/files v1.0.1 github.com/swaggo/gin-swagger v1.6.1 github.com/swaggo/swag v1.16.6 - go.mongodb.org/mongo-driver/v2 v2.6.0 golang.org/x/crypto v0.53.0 ) require ( github.com/KyleBanks/depth v1.2.1 // indirect - github.com/bytedance/gopkg v0.1.4 // 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.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/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.1 // 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/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/puddle/v2 v2.2.2 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.17.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.6 // indirect github.com/mattn/go-isatty v0.0.22 // 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/quic-go/quic-go v0.59.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect - github.com/xdg-go/pbkdf2 v1.0.0 // indirect - github.com/xdg-go/scram v1.2.0 // indirect - github.com/xdg-go/stringprep v1.0.4 // indirect - github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.28.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 golang.org/x/text v0.38.0 // indirect golang.org/x/tools v0.45.0 // indirect google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index 45e4aff..1dadf71 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,18 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= -github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= -github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= +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.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/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= @@ -19,33 +24,16 @@ 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/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/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/jsonutils/fixtures_test v0.26.1 h1:1CD7NiLLb/TXl3tOnFYU4b+mNfb5rtgHkaA+q7RMYYQ= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.1/go.mod h1:ZWafc8nMdYzTE3uYY6W86f0n46+IF0g4uUyRhJw/kXc= -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/testify/enable/yaml/v2 v2.5.1 h1:q9NtHwK4qHF7yZziBPvZyv7zWAIk8ok88Gh2mR6Jpc8= -github.com/go-openapi/testify/enable/yaml/v2 v2.5.1/go.mod h1:JW0MXIotCYps/XsgJnG3a8Q7rE5xAiBwoOD5OfaIQBk= -github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= -github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +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= @@ -63,20 +51,37 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs 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/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/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI= -github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= 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.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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -84,16 +89,15 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w 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/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/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= -github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= 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/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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -101,6 +105,8 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS 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= @@ -117,21 +123,11 @@ 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/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= -github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= -github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= -github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= -github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= 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.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.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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= @@ -139,10 +135,11 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= 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= @@ -153,6 +150,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= @@ -164,8 +162,8 @@ 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.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= @@ -178,8 +176,14 @@ 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= diff --git a/internal/auth/models.go b/internal/auth/models.go index 6985feb..f94c3fc 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -2,16 +2,14 @@ package auth import ( "time" - - "go.mongodb.org/mongo-driver/v2/bson" ) type User struct { - ID bson.ObjectID `json:"id" bson:"_id"` - Username string `json:"username" bson:"username"` - Email string `json:"email" bson:"email"` - PasswordHash string `json:"-" bson:"password_hash"` - CreatedAt time.Time `json:"created_at" bson:"created_at"` + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"created_at"` } type RegisterRequest struct { @@ -40,18 +38,18 @@ type LogoutRequest struct { } type RefreshTokenDoc struct { - ID bson.ObjectID `json:"id" bson:"_id"` - UserID bson.ObjectID `json:"user_id" bson:"user_id"` - TokenHash string `json:"token_hash" bson:"token_hash"` - ExpiresAt time.Time `json:"expires_at" bson:"expires_at"` - CreatedAt time.Time `json:"created_at" bson:"created_at"` + ID string `json:"id"` + UserID string `json:"user_id"` + TokenHash string `json:"token_hash"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` } type UserPublic struct { - ID bson.ObjectID `json:"id" bson:"_id"` - Username string `json:"username" bson:"username"` - Email string `json:"email" bson:"email"` - CreatedAt time.Time `json:"created_at" bson:"created_at"` + ID string `json:"id"` + Username string `json:"username"` + Email string `json:"email"` + CreatedAt time.Time `json:"created_at"` } func NewUserPublic(u *User) UserPublic { diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 69186e5..8765ae6 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -4,97 +4,111 @@ import ( "context" "time" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" - "go.mongodb.org/mongo-driver/v2/mongo/options" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) type Repository struct { - usersCollection *mongo.Collection - refreshTokensCollection *mongo.Collection + pool *pgxpool.Pool } -func NewRepository(db *mongo.Database) *Repository { - return &Repository{ - usersCollection: db.Collection("users"), - refreshTokensCollection: db.Collection("refresh_tokens"), - } +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} } -func (r *Repository) EnsureIndexes(ctx context.Context) error { - _, err := r.usersCollection.Indexes().CreateOne(ctx, mongo.IndexModel{ - Keys: bson.D{{Key: "email", Value: 1}}, - Options: options.Index().SetUnique(true), - }) - if err != nil { - return err - } +func (r *Repository) Migrate(ctx context.Context) error { + schema := ` + 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() + ); - _, err = r.refreshTokensCollection.Indexes().CreateMany(ctx, []mongo.IndexModel{ - { - Keys: bson.D{{Key: "token_hash", Value: 1}}, - Options: options.Index().SetUnique(true), - }, - { - Keys: bson.D{{Key: "expires_at", Value: 1}}, - Options: options.Index().SetExpireAfterSeconds(0), - }, - }) + 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); + ` + _, err := r.pool.Exec(ctx, schema) return err } func (r *Repository) CreateUser(ctx context.Context, user *User) error { - user.ID = bson.NewObjectID() + user.ID = uuid.New().String() user.CreatedAt = time.Now().UTC() - _, err := r.usersCollection.InsertOne(ctx, user) + _, err := r.pool.Exec(ctx, + `INSERT INTO users (id, username, email, password_hash, created_at) VALUES ($1, $2, $3, $4, $5)`, + user.ID, user.Username, user.Email, user.PasswordHash, user.CreatedAt, + ) return err } func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, error) { var user User - err := r.usersCollection.FindOne(ctx, bson.M{"email": email}).Decode(&user) + err := r.pool.QueryRow(ctx, + `SELECT id, username, email, password_hash, created_at FROM users WHERE email = $1`, email, + ).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.CreatedAt) if err != nil { return nil, err } return &user, nil } -func (r *Repository) FindByID(ctx context.Context, id bson.ObjectID) (*User, error) { +func (r *Repository) FindByID(ctx context.Context, id string) (*User, error) { var user User - err := r.usersCollection.FindOne(ctx, bson.M{"_id": id}).Decode(&user) + err := r.pool.QueryRow(ctx, + `SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1`, id, + ).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.CreatedAt) if err != nil { return nil, err } return &user, nil } -//Refresh - func (r *Repository) CreateRefreshToken(ctx context.Context, doc *RefreshTokenDoc) error { - doc.ID = bson.NewObjectID() + doc.ID = uuid.New().String() doc.CreatedAt = time.Now().UTC() - _, err := r.refreshTokensCollection.InsertOne(ctx, doc) + _, err := r.pool.Exec(ctx, + `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, created_at) VALUES ($1, $2, $3, $4, $5)`, + doc.ID, doc.UserID, doc.TokenHash, doc.ExpiresAt, doc.CreatedAt, + ) return err } func (r *Repository) FindRefreshTokenByHash(ctx context.Context, hash string) (*RefreshTokenDoc, error) { var doc RefreshTokenDoc - err := r.refreshTokensCollection.FindOne(ctx, bson.M{"token_hash": hash}).Decode(&doc) + err := r.pool.QueryRow(ctx, + `SELECT id, user_id, token_hash, expires_at, created_at FROM refresh_tokens WHERE token_hash = $1`, hash, + ).Scan(&doc.ID, &doc.UserID, &doc.TokenHash, &doc.ExpiresAt, &doc.CreatedAt) if err != nil { return nil, err } return &doc, nil } -func (r *Repository) DeleteRefreshToken(ctx context.Context, id bson.ObjectID) error { - _, err := r.refreshTokensCollection.DeleteOne(ctx, bson.M{"_id": id}) +func (r *Repository) DeleteRefreshToken(ctx context.Context, id string) error { + _, err := r.pool.Exec(ctx, `DELETE FROM refresh_tokens WHERE id = $1`, id) return err } func (r *Repository) DeleteRefreshTokenByHash(ctx context.Context, hash string) (bool, error) { - res, err := r.refreshTokensCollection.DeleteOne(ctx, bson.M{"token_hash": hash}) + tag, err := r.pool.Exec(ctx, `DELETE FROM refresh_tokens WHERE token_hash = $1`, hash) if err != nil { return false, err } - return res.DeletedCount > 0, nil + return tag.RowsAffected() > 0, nil } + +func (r *Repository) EnsureIndexes(ctx context.Context) error { + return r.Migrate(ctx) +} + +var ErrNoRows = pgx.ErrNoRows diff --git a/internal/auth/service.go b/internal/auth/service.go index dd50087..c064693 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -11,8 +11,6 @@ import ( "strings" "time" - "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" "golang.org/x/crypto/bcrypt" ) @@ -56,7 +54,7 @@ func generateRandomToken() (string, error) { } func (s *Service) issueTokenPair(ctx context.Context, user *User) (*AuthResponse, error) { - accessToken, err := GenerateToken(user.ID.Hex(), user.Email, s.jwtSecret, s.jwtExp) + 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) } @@ -86,7 +84,7 @@ func (s *Service) issueTokenPair(ctx context.Context, user *User) (*AuthResponse func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPublic, error) { req.Email = strings.ToLower(req.Email) existing, err := s.repo.FindByEmail(ctx, req.Email) - if err != nil && !errors.Is(err, mongo.ErrNoDocuments) { + if err != nil && !errors.Is(err, ErrNoRows) { return nil, fmt.Errorf("failed to check existing user: %w", err) } if existing != nil { @@ -116,7 +114,7 @@ func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponse, e req.Email = strings.ToLower(req.Email) user, err := s.repo.FindByEmail(ctx, req.Email) if err != nil { - if errors.Is(err, mongo.ErrNoDocuments) { + if errors.Is(err, ErrNoRows) { return nil, ErrInvalidCreds } return nil, fmt.Errorf("failed to find user: %w", err) @@ -134,7 +132,7 @@ func (s *Service) Refresh(ctx context.Context, rawRefresh string) (*AuthResponse doc, err := s.repo.FindRefreshTokenByHash(ctx, hash) if err != nil { - if errors.Is(err, mongo.ErrNoDocuments) { + if errors.Is(err, ErrNoRows) { return nil, ErrInvalidRefresh } return nil, fmt.Errorf("failed to find refresh token: %w", err) @@ -174,14 +172,13 @@ func (s *Service) Logout(ctx context.Context, rawRefresh string) error { } func (s *Service) GetUserByID(ctx context.Context, userID string) (*UserPublic, error) { - id, err := bson.ObjectIDFromHex(userID) - if err != nil { + if userID == "" { return nil, ErrInvalidUserID } - user, err := s.repo.FindByID(ctx, id) + user, err := s.repo.FindByID(ctx, userID) if err != nil { - if errors.Is(err, mongo.ErrNoDocuments) { + if errors.Is(err, ErrNoRows) { return nil, ErrUserNotFound } return nil, fmt.Errorf("failed to find user: %w", err) diff --git a/internal/config/config.go b/internal/config/config.go index 6507d50..cedd6cd 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,8 +11,7 @@ import ( type Config struct { ServerPort string - MongoURI string - MongoDB string + DatabaseURL string JWTSecret string JWTExpiration time.Duration JWTRefreshExpiration time.Duration @@ -25,8 +24,7 @@ func Load() (*Config, error) { cfg := &Config{ ServerPort: getEnv("SERVER_PORT", "8080"), - MongoURI: getEnv("MONGO_URI", "mongodb://localhost:27017"), - MongoDB: getEnv("MONGO_DB", "aegisguard"), + DatabaseURL: getEnv("DATABASE_URL", "postgres://localhost:5432/aegisguard?sslmode=disable"), JWTSecret: getEnv("JWT_SECRET", ""), JWTExpiration: 24 * time.Hour, JWTRefreshExpiration: 7 * 24 * time.Hour, -- 2.52.0 From 57ce3dea5fb205d81c23c1f016afaedb8a82d532 Mon Sep 17 00:00:00 2001 From: Mephimeow Date: Sat, 13 Jun 2026 18:31:22 +0000 Subject: [PATCH 05/10] added some govno to postgres --- .env.example | 2 +- cmd/backend/main.go | 44 +- docs/docs.go | 514 ++++++++++++++++++++-- docs/swagger.json | 514 ++++++++++++++++++++-- docs/swagger.yaml | 357 +++++++++++++-- go.mod | 4 + go.sum | 28 +- internal/auth/JWT_README.md | 108 +++++ internal/auth/handler.go | 107 ++++- internal/auth/models.go | 11 +- internal/auth/ratelimit.go | 77 ++++ internal/auth/repository.go | 45 +- internal/auth/service.go | 123 +++++- internal/config/config.go | 3 + internal/org/handler.go | 157 +++++++ internal/org/models.go | 33 ++ internal/org/repository.go | 77 ++++ internal/org/service.go | 102 +++++ migrations/00001_init.sql | 20 + migrations/00002_create_organizations.sql | 11 + 20 files changed, 2174 insertions(+), 163 deletions(-) create mode 100644 internal/auth/JWT_README.md create mode 100644 internal/auth/ratelimit.go create mode 100644 internal/org/handler.go create mode 100644 internal/org/models.go create mode 100644 internal/org/repository.go create mode 100644 internal/org/service.go create mode 100644 migrations/00001_init.sql create mode 100644 migrations/00002_create_organizations.sql diff --git a/.env.example b/.env.example index 03960aa..b01d17f 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ SERVER_PORT=8080 DATABASE_URL=postgres://postgres:postgres@localhost:5432/aegisguard?sslmode=disable -JWT_SECRET=bebebe_rewritemeeee_openssl_rand +JWT_SECRET=change_me_to_a_random_secret_at_least_32_chars JWT_EXPIRATION=24h JWT_REFRESH_EXPIRATION=168h diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 5c51249..9f753c7 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -12,8 +12,11 @@ import ( 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/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" "github.com/swaggo/files" "github.com/swaggo/gin-swagger" ) @@ -48,16 +51,38 @@ func main() { } log.Println("connected to postgres") - repo := auth.NewRepository(pool) + db := stdlib.OpenDBFromPool(pool) + defer db.Close() - if err := repo.Migrate(ctx); err != nil { + if err := goose.Up(db, "migrations"); err != nil { log.Fatalf("failed to run migrations: %v", err) } log.Println("migrations applied") + repo := auth.NewRepository(pool) + orgRepo := org.NewRepository(pool) + svc := auth.NewService(repo, cfg.JWTSecret, cfg.JWTExpiration, cfg.JWTRefreshExpiration) handler := auth.NewHandler(svc) + orgSvc := org.NewService(orgRepo) + orgHandler := org.NewHandler(orgSvc) + + loginLimiter := auth.NewRateLimiter(10, time.Minute) + 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()) @@ -74,10 +99,21 @@ func main() { api := r.Group("/api/auth") { api.POST("/register", handler.Register) - api.POST("/login", handler.Login) + api.POST("/login", loginLimiter.Middleware(), handler.Login) api.POST("/refresh", handler.Refresh) api.POST("/logout", handler.Logout) - api.GET("/me", auth.AuthMiddleware([]byte(cfg.JWTSecret)), handler.Me) + api.GET("/me", authMW, handler.Me) + api.PUT("/me", authMW, handler.UpdateProfile) + api.PUT("/password", authMW, handler.ChangePassword) + } + + orgs := r.Group("/api/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{ diff --git a/docs/docs.go b/docs/docs.go index 3834145..c7aaf46 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -27,7 +27,7 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Epta login", + "summary": "Login", "parameters": [ { "description": "Login credentials", @@ -35,7 +35,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_auth.LoginRequest" + "$ref": "#/definitions/auth.LoginRequest" } } ], @@ -43,19 +43,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_auth.AuthResponse" + "$ref": "#/definitions/auth.AuthResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } } } @@ -73,7 +73,7 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Logout epta", + "summary": "Logout", "parameters": [ { "description": "Refresh token to invalidate", @@ -81,7 +81,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_auth.LogoutRequest" + "$ref": "#/definitions/auth.LogoutRequest" } } ], @@ -98,13 +98,13 @@ const docTemplate = `{ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } } } @@ -127,18 +127,121 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Epta get current user", + "summary": "Get current user", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_auth.UserResponse" + "$ref": "#/definitions/auth.UserResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Update current user's username", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Update profile", + "parameters": [ + { + "description": "Profile update", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.UpdateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/auth.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/auth/password": { + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Change current user's password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Change password", + "parameters": [ + { + "description": "Password change details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.PasswordChangeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" } } } @@ -156,7 +259,7 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Refresh epta token", + "summary": "Refresh token", "parameters": [ { "description": "Refresh token", @@ -164,7 +267,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_auth.RefreshRequest" + "$ref": "#/definitions/auth.RefreshRequest" } } ], @@ -172,19 +275,19 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_auth.AuthResponse" + "$ref": "#/definitions/auth.AuthResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } } } @@ -202,7 +305,7 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Epta registration", + "summary": "Register", "parameters": [ { "description": "Registration details", @@ -210,7 +313,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_auth.RegisterRequest" + "$ref": "#/definitions/auth.RegisterRequest" } } ], @@ -218,19 +321,245 @@ const docTemplate = `{ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/internal_auth.UserResponse" + "$ref": "#/definitions/auth.AuthResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/organizations": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get all organizations", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "List organizations", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/org.OrgListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Create a new organization", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Create organization", + "parameters": [ + { + "description": "Organization details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/org.CreateOrgRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + } + }, + "/api/organizations/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get organization details", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Get organization by ID", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Update organization name", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Update organization", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "New organization details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/org.UpdateOrgRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Delete an organization", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Delete organization", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" } } } @@ -238,7 +567,7 @@ const docTemplate = `{ } }, "definitions": { - "internal_auth.AuthResponse": { + "auth.AuthResponse": { "type": "object", "properties": { "refresh_token": { @@ -250,11 +579,11 @@ const docTemplate = `{ "example": "eyJhbGciOiJIUzI1NiIs..." }, "user": { - "$ref": "#/definitions/internal_auth.UserPublic" + "$ref": "#/definitions/auth.UserPublic" } } }, - "internal_auth.ErrorResponse": { + "auth.ErrorResponse": { "type": "object", "properties": { "error": { @@ -263,7 +592,7 @@ const docTemplate = `{ } } }, - "internal_auth.LoginRequest": { + "auth.LoginRequest": { "type": "object", "required": [ "email", @@ -280,7 +609,7 @@ const docTemplate = `{ } } }, - "internal_auth.LogoutRequest": { + "auth.LogoutRequest": { "type": "object", "required": [ "refresh_token" @@ -292,7 +621,25 @@ const docTemplate = `{ } } }, - "internal_auth.RefreshRequest": { + "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" @@ -304,7 +651,7 @@ const docTemplate = `{ } } }, - "internal_auth.RegisterRequest": { + "auth.RegisterRequest": { "type": "object", "required": [ "email", @@ -318,8 +665,8 @@ const docTemplate = `{ }, "password": { "type": "string", - "minLength": 6, - "example": "secret123" + "minLength": 8, + "example": "Secret123!" }, "username": { "type": "string", @@ -329,7 +676,21 @@ const docTemplate = `{ } } }, - "internal_auth.UserPublic": { + "auth.UpdateProfileRequest": { + "type": "object", + "required": [ + "username" + ], + "properties": { + "username": { + "type": "string", + "maxLength": 30, + "minLength": 3, + "example": "john_updated" + } + } + }, + "auth.UserPublic": { "type": "object", "properties": { "created_at": { @@ -346,11 +707,96 @@ const docTemplate = `{ } } }, - "internal_auth.UserResponse": { + "auth.UserResponse": { "type": "object", "properties": { "user": { - "$ref": "#/definitions/internal_auth.UserPublic" + "$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": { + "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" } } } diff --git a/docs/swagger.json b/docs/swagger.json index 909302e..4e5a3c9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -22,7 +22,7 @@ "tags": [ "auth" ], - "summary": "Epta login", + "summary": "Login", "parameters": [ { "description": "Login credentials", @@ -30,7 +30,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_auth.LoginRequest" + "$ref": "#/definitions/auth.LoginRequest" } } ], @@ -38,19 +38,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_auth.AuthResponse" + "$ref": "#/definitions/auth.AuthResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } } } @@ -68,7 +68,7 @@ "tags": [ "auth" ], - "summary": "Logout epta", + "summary": "Logout", "parameters": [ { "description": "Refresh token to invalidate", @@ -76,7 +76,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_auth.LogoutRequest" + "$ref": "#/definitions/auth.LogoutRequest" } } ], @@ -93,13 +93,13 @@ "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } } } @@ -122,18 +122,121 @@ "tags": [ "auth" ], - "summary": "Epta get current user", + "summary": "Get current user", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_auth.UserResponse" + "$ref": "#/definitions/auth.UserResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Update current user's username", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Update profile", + "parameters": [ + { + "description": "Profile update", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.UpdateProfileRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/auth.UserResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/auth/password": { + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Change current user's password", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "auth" + ], + "summary": "Change password", + "parameters": [ + { + "description": "Password change details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/auth.PasswordChangeRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/auth.ErrorResponse" } } } @@ -151,7 +254,7 @@ "tags": [ "auth" ], - "summary": "Refresh epta token", + "summary": "Refresh token", "parameters": [ { "description": "Refresh token", @@ -159,7 +262,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_auth.RefreshRequest" + "$ref": "#/definitions/auth.RefreshRequest" } } ], @@ -167,19 +270,19 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/internal_auth.AuthResponse" + "$ref": "#/definitions/auth.AuthResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { "description": "Unauthorized", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } } } @@ -197,7 +300,7 @@ "tags": [ "auth" ], - "summary": "Epta registration", + "summary": "Register", "parameters": [ { "description": "Registration details", @@ -205,7 +308,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/internal_auth.RegisterRequest" + "$ref": "#/definitions/auth.RegisterRequest" } } ], @@ -213,19 +316,245 @@ "201": { "description": "Created", "schema": { - "$ref": "#/definitions/internal_auth.UserResponse" + "$ref": "#/definitions/auth.AuthResponse" } }, "400": { "description": "Bad Request", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" } }, "409": { "description": "Conflict", "schema": { - "$ref": "#/definitions/internal_auth.ErrorResponse" + "$ref": "#/definitions/auth.ErrorResponse" + } + } + } + } + }, + "/api/organizations": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get all organizations", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "List organizations", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/org.OrgListResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "post": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Create a new organization", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Create organization", + "parameters": [ + { + "description": "Organization details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/org.CreateOrgRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + } + }, + "/api/organizations/{id}": { + "get": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Get organization details", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Get organization by ID", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "put": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Update organization name", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Update organization", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "New organization details", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/org.UpdateOrgRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/org.OrgResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" + } + } + } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "description": "Delete an organization", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "organizations" + ], + "summary": "Delete organization", + "parameters": [ + { + "type": "string", + "description": "Organization ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/org.ErrorResponse" } } } @@ -233,7 +562,7 @@ } }, "definitions": { - "internal_auth.AuthResponse": { + "auth.AuthResponse": { "type": "object", "properties": { "refresh_token": { @@ -245,11 +574,11 @@ "example": "eyJhbGciOiJIUzI1NiIs..." }, "user": { - "$ref": "#/definitions/internal_auth.UserPublic" + "$ref": "#/definitions/auth.UserPublic" } } }, - "internal_auth.ErrorResponse": { + "auth.ErrorResponse": { "type": "object", "properties": { "error": { @@ -258,7 +587,7 @@ } } }, - "internal_auth.LoginRequest": { + "auth.LoginRequest": { "type": "object", "required": [ "email", @@ -275,7 +604,7 @@ } } }, - "internal_auth.LogoutRequest": { + "auth.LogoutRequest": { "type": "object", "required": [ "refresh_token" @@ -287,7 +616,25 @@ } } }, - "internal_auth.RefreshRequest": { + "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" @@ -299,7 +646,7 @@ } } }, - "internal_auth.RegisterRequest": { + "auth.RegisterRequest": { "type": "object", "required": [ "email", @@ -313,8 +660,8 @@ }, "password": { "type": "string", - "minLength": 6, - "example": "secret123" + "minLength": 8, + "example": "Secret123!" }, "username": { "type": "string", @@ -324,7 +671,21 @@ } } }, - "internal_auth.UserPublic": { + "auth.UpdateProfileRequest": { + "type": "object", + "required": [ + "username" + ], + "properties": { + "username": { + "type": "string", + "maxLength": 30, + "minLength": 3, + "example": "john_updated" + } + } + }, + "auth.UserPublic": { "type": "object", "properties": { "created_at": { @@ -341,11 +702,96 @@ } } }, - "internal_auth.UserResponse": { + "auth.UserResponse": { "type": "object", "properties": { "user": { - "$ref": "#/definitions/internal_auth.UserPublic" + "$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": { + "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" } } } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6b394de..c344b46 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,5 @@ definitions: - internal_auth.AuthResponse: + auth.AuthResponse: properties: refresh_token: example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4= @@ -8,15 +8,15 @@ definitions: example: eyJhbGciOiJIUzI1NiIs... type: string user: - $ref: '#/definitions/internal_auth.UserPublic' + $ref: '#/definitions/auth.UserPublic' type: object - internal_auth.ErrorResponse: + auth.ErrorResponse: properties: error: example: invalid email or password type: string type: object - internal_auth.LoginRequest: + auth.LoginRequest: properties: email: example: john@example.com @@ -28,7 +28,7 @@ definitions: - email - password type: object - internal_auth.LogoutRequest: + auth.LogoutRequest: properties: refresh_token: example: dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4= @@ -36,7 +36,20 @@ definitions: required: - refresh_token type: object - internal_auth.RefreshRequest: + 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= @@ -44,14 +57,14 @@ definitions: required: - refresh_token type: object - internal_auth.RegisterRequest: + auth.RegisterRequest: properties: email: example: john@example.com type: string password: - example: secret123 - minLength: 6 + example: Secret123! + minLength: 8 type: string username: example: john @@ -63,7 +76,17 @@ definitions: - password - username type: object - internal_auth.UserPublic: + auth.UpdateProfileRequest: + properties: + username: + example: john_updated + maxLength: 30 + minLength: 3 + type: string + required: + - username + type: object + auth.UserPublic: properties: created_at: type: string @@ -74,10 +97,68 @@ definitions: username: type: string type: object - internal_auth.UserResponse: + auth.UserResponse: properties: user: - $ref: '#/definitions/internal_auth.UserPublic' + $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: + 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: {} @@ -96,23 +177,23 @@ paths: name: request required: true schema: - $ref: '#/definitions/internal_auth.LoginRequest' + $ref: '#/definitions/auth.LoginRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_auth.AuthResponse' + $ref: '#/definitions/auth.AuthResponse' "400": description: Bad Request schema: - $ref: '#/definitions/internal_auth.ErrorResponse' + $ref: '#/definitions/auth.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_auth.ErrorResponse' - summary: Epta login + $ref: '#/definitions/auth.ErrorResponse' + summary: Login tags: - auth /api/auth/logout: @@ -126,7 +207,7 @@ paths: name: request required: true schema: - $ref: '#/definitions/internal_auth.LogoutRequest' + $ref: '#/definitions/auth.LogoutRequest' produces: - application/json responses: @@ -139,12 +220,12 @@ paths: "400": description: Bad Request schema: - $ref: '#/definitions/internal_auth.ErrorResponse' + $ref: '#/definitions/auth.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_auth.ErrorResponse' - summary: Logout epta + $ref: '#/definitions/auth.ErrorResponse' + summary: Logout tags: - auth /api/auth/me: @@ -158,14 +239,79 @@ paths: "200": description: OK schema: - $ref: '#/definitions/internal_auth.UserResponse' + $ref: '#/definitions/auth.UserResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_auth.ErrorResponse' + $ref: '#/definitions/auth.ErrorResponse' security: - Bearer: [] - summary: Epta get current user + summary: Get current user + tags: + - auth + put: + consumes: + - application/json + description: Update current user's username + parameters: + - description: Profile update + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.UpdateProfileRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/auth.UserResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/auth.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/auth.ErrorResponse' + security: + - Bearer: [] + summary: Update profile + tags: + - auth + /api/auth/password: + put: + consumes: + - application/json + description: Change current user's password + parameters: + - description: Password change details + in: body + name: request + required: true + schema: + $ref: '#/definitions/auth.PasswordChangeRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/auth.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/auth.ErrorResponse' + security: + - Bearer: [] + summary: Change password tags: - auth /api/auth/refresh: @@ -179,23 +325,23 @@ paths: name: request required: true schema: - $ref: '#/definitions/internal_auth.RefreshRequest' + $ref: '#/definitions/auth.RefreshRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/internal_auth.AuthResponse' + $ref: '#/definitions/auth.AuthResponse' "400": description: Bad Request schema: - $ref: '#/definitions/internal_auth.ErrorResponse' + $ref: '#/definitions/auth.ErrorResponse' "401": description: Unauthorized schema: - $ref: '#/definitions/internal_auth.ErrorResponse' - summary: Refresh epta token + $ref: '#/definitions/auth.ErrorResponse' + summary: Refresh token tags: - auth /api/auth/register: @@ -209,25 +355,168 @@ paths: name: request required: true schema: - $ref: '#/definitions/internal_auth.RegisterRequest' + $ref: '#/definitions/auth.RegisterRequest' produces: - application/json responses: "201": description: Created schema: - $ref: '#/definitions/internal_auth.UserResponse' + $ref: '#/definitions/auth.AuthResponse' "400": description: Bad Request schema: - $ref: '#/definitions/internal_auth.ErrorResponse' + $ref: '#/definitions/auth.ErrorResponse' "409": description: Conflict schema: - $ref: '#/definitions/internal_auth.ErrorResponse' - summary: Epta registration + $ref: '#/definitions/auth.ErrorResponse' + summary: Register tags: - auth + /api/organizations: + get: + consumes: + - application/json + description: Get all organizations + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/org.OrgListResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/org.ErrorResponse' + security: + - Bearer: [] + summary: List organizations + tags: + - organizations + post: + consumes: + - application/json + description: Create a new organization + parameters: + - description: Organization details + in: body + name: request + required: true + schema: + $ref: '#/definitions/org.CreateOrgRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/org.OrgResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/org.ErrorResponse' + "409": + description: Conflict + schema: + $ref: '#/definitions/org.ErrorResponse' + security: + - Bearer: [] + summary: Create organization + tags: + - organizations + /api/organizations/{id}: + delete: + consumes: + - application/json + description: Delete an organization + parameters: + - description: Organization ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + additionalProperties: + type: string + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/org.ErrorResponse' + security: + - Bearer: [] + summary: Delete organization + tags: + - organizations + get: + consumes: + - application/json + description: Get organization details + parameters: + - description: Organization ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/org.OrgResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/org.ErrorResponse' + security: + - Bearer: [] + summary: Get organization by ID + tags: + - organizations + put: + consumes: + - application/json + description: Update organization name + parameters: + - description: Organization ID + in: path + name: id + required: true + type: string + - description: New organization details + in: body + name: request + required: true + schema: + $ref: '#/definitions/org.UpdateOrgRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/org.OrgResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/org.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/org.ErrorResponse' + security: + - Bearer: [] + summary: Update organization + tags: + - organizations schemes: - http securityDefinitions: diff --git a/go.mod b/go.mod index fae639d..58629d8 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.4 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 @@ -42,14 +43,17 @@ require ( github.com/leodido/go-urn v1.4.0 // 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.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 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/mod v0.36.0 // indirect golang.org/x/net v0.56.0 // indirect diff --git a/go.sum b/go.sum index 1dadf71..66f623f 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 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= @@ -84,22 +86,32 @@ 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.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= -github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +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= @@ -128,12 +140,16 @@ go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF 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.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= @@ -187,3 +203,11 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C 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= +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= diff --git a/internal/auth/JWT_README.md b/internal/auth/JWT_README.md new file mode 100644 index 0000000..46d9e2e --- /dev/null +++ b/internal/auth/JWT_README.md @@ -0,0 +1,108 @@ +# JWT Аутентификация — AegisGuard API + +## Схема работы + +- **access_token** — JWT, живёт 24 часа. Передаётся в заголовке `Authorization: Bearer`. +- **refresh_token** — случайная строка, хранится в БД в виде хеша. Используется **один раз** (ротация): при запросе новой пары старый токен удаляется. +- Регистрация сразу возвращает токены — отдельный логин не нужен. + +## Эндпоинты + +### POST /api/auth/register + +Создание аккаунта. + +``` +Запрос: +{ "username": "john", "email": "john@example.com", "password": "Secret123" } + +Ответ 201: +{ + "token": "eyJhbGciOiJIUzI1NiIs...", + "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=", + "user": { + "id": "uuid", + "username": "john", + "email": "john@example.com", + "created_at": "2026-06-13T12:00:00Z" + } +} +``` + +- `username` — 3–30 символов +- `email` — валидный email +- `password` — минимум 8 символов, обязательно заглавная + строчная + цифра + +Ошибки: `400` (валидация), `409` (email уже занят). + +### POST /api/auth/login + +``` +Запрос: +{ "email": "john@example.com", "password": "Secret123" } + +Ответ 200: +{ "token": "...", "refresh_token": "...", "user": { ... } } +``` + +Rate limit: 10 попыток в минуту с одного IP (`429 Too Many Requests`). + +### POST /api/auth/refresh + +Обновить токены по refresh_token. Старый удаляется, выдаётся новая пара. + +``` +Запрос: +{ "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" } + +Ответ 200: +{ "token": "...", "refresh_token": "...", "user": { ... } } +``` + +### POST /api/auth/logout + +Удалить refresh_token из БД. + +``` +Запрос: +{ "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" } + +Ответ 200: +{ "message": "logged out successfully" } +``` + +## Заголовок авторизации + +``` +Authorization: Bearer +``` + +## Формат JWT + +```json +{ + "user_id": "uuid", + "email": "john@example.com", + "sub": "uuid", + "exp": 1718000000, + "iat": 1717913600 +} +``` + +- `user_id` — UUID пользователя +- `email` — Email пользователя +- `sub` — то же, что `user_id` +- `exp` — Unix-timestamp истечения токена +- `iat` — Unix-timestamp выпуска токена + +## Формат ошибок + +```json +{ "error": "описание" } +``` + +- `400` — ошибка валидации +- `401` — неверный email/пароль, токен протух или невалиден +- `409` — email уже зарегистрирован +- `429` — превышен лимит попыток логина +- `500` — внутренняя ошибка сервера diff --git a/internal/auth/handler.go b/internal/auth/handler.go index ce8d427..000b3a4 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -16,13 +16,13 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } -// @Summary Epta registration +// @Summary Register epta // @Description Create user account with username, email, password // @Tags auth // @Accept json // @Produce json // @Param request body RegisterRequest true "Registration details" -// @Success 201 {object} UserResponse +// @Success 201 {object} AuthResponse // @Failure 400 {object} ErrorResponse // @Failure 409 {object} ErrorResponse // @Router /api/auth/register [post] @@ -33,21 +33,25 @@ func (h *Handler) Register(c *gin.Context) { return } - user, err := h.service.Register(c.Request.Context(), req) + 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, UserResponse{User: *user}) + c.JSON(http.StatusCreated, resp) } -// @Summary Epta login +// @Summary Login // @Description Authenticate user with email and password, returns JWT token // @Tags auth // @Accept json @@ -139,7 +143,7 @@ func (h *Handler) Logout(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "logged out successfully"}) } -// @Summary Epta get current user +// @Summary Get epta current user // @Description Get authenticated user's profile // @Tags auth // @Accept json @@ -174,3 +178,94 @@ func (h *Handler) Me(c *gin.Context) { c.JSON(http.StatusOK, UserResponse{User: *user}) } + +// @Summary Change epta password +// @Description Change current user's password +// @Tags auth +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body PasswordChangeRequest true "Password change details" +// @Success 200 {object} map[string]string +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Router /api/auth/password [put] +func (h *Handler) ChangePassword(c *gin.Context) { + rawUserID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"}) + return + } + + userID, ok := rawUserID.(string) + if !ok { + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "invalid user ID in context"}) + 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 Update profile +// @Description Update current user's username +// @Tags auth +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body UpdateProfileRequest true "Profile update" +// @Success 200 {object} UserResponse +// @Failure 400 {object} ErrorResponse +// @Failure 401 {object} ErrorResponse +// @Router /api/auth/me [put] +func (h *Handler) UpdateProfile(c *gin.Context) { + rawUserID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"}) + return + } + + userID, ok := rawUserID.(string) + if !ok { + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "invalid user ID in context"}) + 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}) +} diff --git a/internal/auth/models.go b/internal/auth/models.go index f94c3fc..630b404 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -15,7 +15,7 @@ type User struct { 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=6" example:"secret123"` + Password string `json:"password" binding:"required,min=8" example:"Secret123!"` } type LoginRequest struct { @@ -65,6 +65,15 @@ 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"` } diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go new file mode 100644 index 0000000..3d9af86 --- /dev/null +++ b/internal/auth/ratelimit.go @@ -0,0 +1,77 @@ +package auth + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +type visitor struct { + count int + lastSeen time.Time +} + +type RateLimiter struct { + mu sync.Mutex + visitors map[string]*visitor + rate int + window time.Duration +} + +func NewRateLimiter(rate int, window time.Duration) *RateLimiter { + rl := &RateLimiter{ + visitors: make(map[string]*visitor), + rate: rate, + window: window, + } + + go rl.cleanup() + return rl +} + +func (rl *RateLimiter) cleanup() { + ticker := time.NewTicker(10 * time.Minute) + defer ticker.Stop() + for range ticker.C { + rl.mu.Lock() + now := time.Now() + for ip, v := range rl.visitors { + if now.Sub(v.lastSeen) > rl.window*2 { + delete(rl.visitors, ip) + } + } + rl.mu.Unlock() + } +} + +func (rl *RateLimiter) Middleware() gin.HandlerFunc { + return func(c *gin.Context) { + ip := c.ClientIP() + + rl.mu.Lock() + v, exists := rl.visitors[ip] + now := time.Now() + + if !exists || now.Sub(v.lastSeen) > rl.window { + rl.visitors[ip] = &visitor{count: 1, lastSeen: now} + rl.mu.Unlock() + c.Next() + return + } + + v.count++ + v.lastSeen = now + + if v.count > rl.rate { + rl.mu.Unlock() + c.JSON(http.StatusTooManyRequests, ErrorResponse{Error: "too many requests, try again later"}) + c.Abort() + return + } + + rl.mu.Unlock() + c.Next() + } +} diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 8765ae6..9449e7c 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -17,30 +17,6 @@ func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } -func (r *Repository) Migrate(ctx context.Context) error { - schema := ` - 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); - ` - _, err := r.pool.Exec(ctx, schema) - return err -} - func (r *Repository) CreateUser(ctx context.Context, user *User) error { user.ID = uuid.New().String() user.CreatedAt = time.Now().UTC() @@ -86,7 +62,7 @@ func (r *Repository) CreateRefreshToken(ctx context.Context, doc *RefreshTokenDo func (r *Repository) FindRefreshTokenByHash(ctx context.Context, hash string) (*RefreshTokenDoc, error) { var doc RefreshTokenDoc err := r.pool.QueryRow(ctx, - `SELECT id, user_id, token_hash, expires_at, created_at FROM refresh_tokens WHERE token_hash = $1`, hash, + `SELECT id, user_id, token_hash, expires_at, created_at FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW()`, hash, ).Scan(&doc.ID, &doc.UserID, &doc.TokenHash, &doc.ExpiresAt, &doc.CreatedAt) if err != nil { return nil, err @@ -99,6 +75,21 @@ func (r *Repository) DeleteRefreshToken(ctx context.Context, id string) error { return err } +func (r *Repository) UpdateUserUsername(ctx context.Context, id, username string) error { + _, err := r.pool.Exec(ctx, `UPDATE users SET username = $1 WHERE id = $2`, username, id) + return err +} + +func (r *Repository) UpdateUserPassword(ctx context.Context, id, passwordHash string) error { + _, err := r.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, passwordHash, id) + return err +} + +func (r *Repository) DeleteExpiredRefreshTokens(ctx context.Context) error { + _, err := r.pool.Exec(ctx, `DELETE FROM refresh_tokens WHERE expires_at <= NOW()`) + return err +} + func (r *Repository) DeleteRefreshTokenByHash(ctx context.Context, hash string) (bool, error) { tag, err := r.pool.Exec(ctx, `DELETE FROM refresh_tokens WHERE token_hash = $1`, hash) if err != nil { @@ -107,8 +98,4 @@ func (r *Repository) DeleteRefreshTokenByHash(ctx context.Context, hash string) return tag.RowsAffected() > 0, nil } -func (r *Repository) EnsureIndexes(ctx context.Context) error { - return r.Migrate(ctx) -} - var ErrNoRows = pgx.ErrNoRows diff --git a/internal/auth/service.go b/internal/auth/service.go index c064693..c305fc4 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -7,21 +7,24 @@ import ( "encoding/base64" "errors" "fmt" - "log" "strings" "time" + "unicode" "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") + 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 { @@ -53,6 +56,27 @@ func generateRandomToken() (string, error) { 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 { @@ -81,8 +105,13 @@ func (s *Service) issueTokenPair(ctx context.Context, user *User) (*AuthResponse }, nil } -func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPublic, error) { +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, ErrNoRows) { return nil, fmt.Errorf("failed to check existing user: %w", err) @@ -103,11 +132,13 @@ func (s *Service) Register(ctx context.Context, req RegisterRequest) (*UserPubli } 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) } - public := NewUserPublic(user) - return &public, nil + return s.issueTokenPair(ctx, user) } func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error) { @@ -138,13 +169,6 @@ func (s *Service) Refresh(ctx context.Context, rawRefresh string) (*AuthResponse return nil, fmt.Errorf("failed to find refresh token: %w", err) } - if time.Now().UTC().After(doc.ExpiresAt) { - if err := s.repo.DeleteRefreshToken(ctx, doc.ID); err != nil { - log.Printf("failed to cleanup expired refresh token: %v", err) - } - return nil, ErrRefreshExpired - } - if err := s.repo.DeleteRefreshToken(ctx, doc.ID); err != nil { return nil, fmt.Errorf("failed to delete old refresh token: %w", err) } @@ -187,3 +211,66 @@ func (s *Service) GetUserByID(ctx context.Context, userID string) (*UserPublic, 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, 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, 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")) +} diff --git a/internal/config/config.go b/internal/config/config.go index cedd6cd..7ab9b41 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -33,6 +33,9 @@ func Load() (*Config, error) { 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) diff --git a/internal/org/handler.go b/internal/org/handler.go new file mode 100644 index 0000000..0c88e26 --- /dev/null +++ b/internal/org/handler.go @@ -0,0 +1,157 @@ +package org + +import ( + "errors" + "log" + "net/http" + + "github.com/gin-gonic/gin" +) + +type Handler struct { + service *Service +} + +func NewHandler(service *Service) *Handler { + return &Handler{service: service} +} + +// @Summary Create organization +// @Description Create a new organization +// @Tags organizations +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body CreateOrgRequest true "Organization details" +// @Success 201 {object} OrgResponse +// @Failure 400 {object} ErrorResponse +// @Failure 409 {object} ErrorResponse +// @Router /api/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 Get organization by ID +// @Description Get organization details +// @Tags organizations +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "Organization ID" +// @Success 200 {object} OrgResponse +// @Failure 404 {object} ErrorResponse +// @Router /api/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 List organizations +// @Description Get all organizations +// @Tags organizations +// @Accept json +// @Produce json +// @Security Bearer +// @Success 200 {object} OrgListResponse +// @Failure 500 {object} ErrorResponse +// @Router /api/organizations [get] +func (h *Handler) List(c *gin.Context) { + resp, err := h.service.List(c.Request.Context()) + 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 Update organization +// @Description Update organization name +// @Tags organizations +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "Organization ID" +// @Param request body UpdateOrgRequest true "New organization details" +// @Success 200 {object} OrgResponse +// @Failure 400 {object} ErrorResponse +// @Failure 404 {object} ErrorResponse +// @Router /api/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 Delete organization +// @Description Delete an organization +// @Tags organizations +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "Organization ID" +// @Success 200 {object} map[string]string +// @Failure 404 {object} ErrorResponse +// @Router /api/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"}) +} diff --git a/internal/org/models.go b/internal/org/models.go new file mode 100644 index 0000000..3c811d4 --- /dev/null +++ b/internal/org/models.go @@ -0,0 +1,33 @@ +package org + +import "time" + +type Organization struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `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"` +} + +type ErrorResponse struct { + Error string `json:"error"` +} diff --git a/internal/org/repository.go b/internal/org/repository.go new file mode 100644 index 0000000..61f3245 --- /dev/null +++ b/internal/org/repository.go @@ -0,0 +1,77 @@ +package org + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +var ErrNoRows = pgx.ErrNoRows + +type Repository struct { + pool *pgxpool.Pool +} + +func NewRepository(pool *pgxpool.Pool) *Repository { + return &Repository{pool: pool} +} + +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 + _, err := r.pool.Exec(ctx, + `INSERT INTO organizations (id, name, slug, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)`, + org.ID, org.Name, org.Slug, org.CreatedAt, org.UpdatedAt, + ) + return err +} + +func (r *Repository) FindByID(ctx context.Context, id string) (*Organization, error) { + var org Organization + err := r.pool.QueryRow(ctx, + `SELECT id, name, slug, created_at, updated_at FROM organizations WHERE id = $1`, id, + ).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt, &org.UpdatedAt) + if err != nil { + return nil, err + } + return &org, nil +} + +func (r *Repository) FindAll(ctx context.Context) ([]Organization, error) { + rows, err := r.pool.Query(ctx, + `SELECT id, name, slug, created_at, updated_at FROM organizations ORDER BY created_at DESC`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var orgs []Organization + for rows.Next() { + var org Organization + if err := rows.Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt, &org.UpdatedAt); err != nil { + return nil, err + } + orgs = append(orgs, org) + } + return orgs, rows.Err() +} + +func (r *Repository) Update(ctx context.Context, org *Organization) error { + org.UpdatedAt = time.Now().UTC() + _, err := r.pool.Exec(ctx, + `UPDATE organizations SET name = $1, updated_at = $2 WHERE id = $3`, + org.Name, org.UpdatedAt, org.ID, + ) + return err +} + +func (r *Repository) Delete(ctx context.Context, id string) error { + _, err := r.pool.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, id) + return err +} diff --git a/internal/org/service.go b/internal/org/service.go new file mode 100644 index 0000000..8348941 --- /dev/null +++ b/internal/org/service.go @@ -0,0 +1,102 @@ +package org + +import ( + "context" + "errors" + "fmt" + "strings" +) + +var ( + ErrNotFound = errors.New("organization not found") + ErrSlugExists = errors.New("slug already taken") +) + +type Service struct { + repo *Repository +} + +func NewService(repo *Repository) *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, ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("failed to find organization: %w", err) + } + return org, nil +} + +func (s *Service) List(ctx context.Context) (*OrgListResponse, error) { + orgs, err := s.repo.FindAll(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list organizations: %w", err) + } + if orgs == nil { + orgs = []Organization{} + } + return &OrgListResponse{ + Organizations: orgs, + Total: len(orgs), + }, 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, 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 { + org, err := s.repo.FindByID(ctx, id) + if err != nil { + if errors.Is(err, ErrNoRows) { + return ErrNotFound + } + return fmt.Errorf("failed to find organization: %w", err) + } + + if err := s.repo.Delete(ctx, org.ID); err != nil { + return fmt.Errorf("failed to delete organization: %w", err) + } + + return nil +} + +func isUniqueViolation(err error) bool { + return err != nil && (strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505")) +} diff --git a/migrations/00001_init.sql b/migrations/00001_init.sql new file mode 100644 index 0000000..1e55553 --- /dev/null +++ b/migrations/00001_init.sql @@ -0,0 +1,20 @@ +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); + +DROP TABLE IF EXISTS refresh_tokens; +DROP TABLE IF EXISTS users; diff --git a/migrations/00002_create_organizations.sql b/migrations/00002_create_organizations.sql new file mode 100644 index 0000000..1826931 --- /dev/null +++ b/migrations/00002_create_organizations.sql @@ -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; -- 2.52.0 From 35ddfc938cee64928869d94de3a1ce45c19a8f0c Mon Sep 17 00:00:00 2001 From: Mephimeow Date: Sat, 13 Jun 2026 18:38:20 +0000 Subject: [PATCH 06/10] added readme --- README.md | 155 ++++++++++++++++++++++++++++++++++++++- internal/auth/handler.go | 2 +- 2 files changed, 155 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index baae514..682806d 100644 --- a/README.md +++ b/README.md @@ -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 ` + +### 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 | Внутренняя ошибка | diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 000b3a4..27a1dfe 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -226,7 +226,7 @@ func (h *Handler) ChangePassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "password changed successfully"}) } -// @Summary Update profile +// @Summary Update epta profile // @Description Update current user's username // @Tags auth // @Accept json -- 2.52.0 From 9d2f69898a9dcdded9d7b7599862888bdd23676e Mon Sep 17 00:00:00 2001 From: d3m0k1d Date: Sun, 14 Jun 2026 00:34:37 +0300 Subject: [PATCH 07/10] chore: add dockerfile and ci --- .gitea/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ dockerfile | 22 ++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 dockerfile diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..ce86106 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -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 -o backend ./cmd/main.go diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..63c41a7 --- /dev/null +++ b/dockerfile @@ -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"] -- 2.52.0 From 9da532e9dcc2a9504e32b6cefac022f43f459b9a Mon Sep 17 00:00:00 2001 From: "zero@thinky" Date: Sun, 14 Jun 2026 03:11:20 +0300 Subject: [PATCH 08/10] fix(ci): change main package path in build step --- .gitea/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index ce86106..1d87a18 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -26,4 +26,4 @@ jobs: - name: Run tests run: go test ./... - name: Build - run: go build -o backend ./cmd/main.go + run: go build ./cmd/backend -- 2.52.0 From 2da484d781f22e53cf7bfc2a1e3acea9ee61181d Mon Sep 17 00:00:00 2001 From: Mephimeow Date: Sun, 14 Jun 2026 16:41:33 +0000 Subject: [PATCH 09/10] refactor: migrate from raw pgx to GORM, unify ErrNoRows, cleanup auth --- cmd/backend/main.go | 42 ++++---- go.mod | 6 +- go.sum | 8 ++ internal/api/api.go | 9 ++ internal/auth/JWT_README.md | 108 --------------------- internal/auth/auth.go | 16 +-- internal/auth/handler.go | 60 +++++------- internal/auth/middleware.go | 15 ++- internal/auth/models.go | 34 ++++--- internal/auth/repository.go | 97 +++++++++--------- internal/auth/service.go | 64 +++++++----- internal/config/config.go | 17 ++-- internal/db/errors.go | 5 + internal/{auth => middleware}/ratelimit.go | 30 ++++-- internal/org/handler.go | 10 +- internal/org/models.go | 14 +-- internal/org/repository.go | 78 +++++++-------- internal/org/service.go | 52 ++++++---- migrations/00001_init.sql | 4 +- 19 files changed, 321 insertions(+), 348 deletions(-) create mode 100644 internal/api/api.go delete mode 100644 internal/auth/JWT_README.md create mode 100644 internal/db/errors.go rename internal/{auth => middleware}/ratelimit.go (69%) diff --git a/cmd/backend/main.go b/cmd/backend/main.go index 9f753c7..d0092f4 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -12,18 +12,19 @@ import ( 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/middleware" "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/org" "github.com/gin-gonic/gin" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" - "github.com/swaggo/files" - "github.com/swaggo/gin-swagger" + 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 for AegisGuard control plane +// @description API для AegisGuard control plane // @schemes http // // @securityDefinitions.apikey Bearer @@ -40,27 +41,28 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - pool, err := pgxpool.New(ctx, cfg.DatabaseURL) + gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{}) if err != nil { - log.Fatalf("failed to create postgres pool: %v", err) + log.Fatalf("failed to connect to postgres: %v", err) } - defer pool.Close() - if err := pool.Ping(ctx); err != nil { + 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") - db := stdlib.OpenDBFromPool(pool) - defer db.Close() - - if err := goose.Up(db, "migrations"); err != nil { + if err := goose.Up(sqlDB, "migrations"); err != nil { log.Fatalf("failed to run migrations: %v", err) } log.Println("migrations applied") - repo := auth.NewRepository(pool) - orgRepo := org.NewRepository(pool) + repo := auth.NewRepository(gormDB) + orgRepo := org.NewRepository(gormDB) svc := auth.NewService(repo, cfg.JWTSecret, cfg.JWTExpiration, cfg.JWTRefreshExpiration) handler := auth.NewHandler(svc) @@ -68,7 +70,7 @@ func main() { orgSvc := org.NewService(orgRepo) orgHandler := org.NewHandler(orgSvc) - loginLimiter := auth.NewRateLimiter(10, time.Minute) + loginLimiter := middleware.NewRateLimiter(10, time.Minute) authMW := auth.AuthMiddleware([]byte(cfg.JWTSecret)) go func() { @@ -117,8 +119,9 @@ func main() { } srv := &http.Server{ - Addr: ":" + cfg.ServerPort, - Handler: r, + Addr: ":" + cfg.ServerPort, + Handler: r, + ReadHeaderTimeout: 10 * time.Second, } go func() { @@ -141,7 +144,8 @@ func main() { log.Fatalf("server forced to shutdown: %v", err) } - pool.Close() + loginLimiter.Stop() + _ = sqlDB.Close() log.Println("server stopped") } diff --git a/go.mod b/go.mod index 58629d8..784c449 100644 --- a/go.mod +++ b/go.mod @@ -6,13 +6,14 @@ 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/jackc/pgx/v5 v5.7.4 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 ( @@ -36,7 +37,10 @@ require ( 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 diff --git a/go.sum b/go.sum index 66f623f..4cfb7c0 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,10 @@ 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= @@ -203,6 +207,10 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C 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= +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= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..b7a82d2 --- /dev/null +++ b/internal/api/api.go @@ -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 +} diff --git a/internal/auth/JWT_README.md b/internal/auth/JWT_README.md deleted file mode 100644 index 46d9e2e..0000000 --- a/internal/auth/JWT_README.md +++ /dev/null @@ -1,108 +0,0 @@ -# JWT Аутентификация — AegisGuard API - -## Схема работы - -- **access_token** — JWT, живёт 24 часа. Передаётся в заголовке `Authorization: Bearer`. -- **refresh_token** — случайная строка, хранится в БД в виде хеша. Используется **один раз** (ротация): при запросе новой пары старый токен удаляется. -- Регистрация сразу возвращает токены — отдельный логин не нужен. - -## Эндпоинты - -### POST /api/auth/register - -Создание аккаунта. - -``` -Запрос: -{ "username": "john", "email": "john@example.com", "password": "Secret123" } - -Ответ 201: -{ - "token": "eyJhbGciOiJIUzI1NiIs...", - "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=", - "user": { - "id": "uuid", - "username": "john", - "email": "john@example.com", - "created_at": "2026-06-13T12:00:00Z" - } -} -``` - -- `username` — 3–30 символов -- `email` — валидный email -- `password` — минимум 8 символов, обязательно заглавная + строчная + цифра - -Ошибки: `400` (валидация), `409` (email уже занят). - -### POST /api/auth/login - -``` -Запрос: -{ "email": "john@example.com", "password": "Secret123" } - -Ответ 200: -{ "token": "...", "refresh_token": "...", "user": { ... } } -``` - -Rate limit: 10 попыток в минуту с одного IP (`429 Too Many Requests`). - -### POST /api/auth/refresh - -Обновить токены по refresh_token. Старый удаляется, выдаётся новая пара. - -``` -Запрос: -{ "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" } - -Ответ 200: -{ "token": "...", "refresh_token": "...", "user": { ... } } -``` - -### POST /api/auth/logout - -Удалить refresh_token из БД. - -``` -Запрос: -{ "refresh_token": "dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4=" } - -Ответ 200: -{ "message": "logged out successfully" } -``` - -## Заголовок авторизации - -``` -Authorization: Bearer -``` - -## Формат JWT - -```json -{ - "user_id": "uuid", - "email": "john@example.com", - "sub": "uuid", - "exp": 1718000000, - "iat": 1717913600 -} -``` - -- `user_id` — UUID пользователя -- `email` — Email пользователя -- `sub` — то же, что `user_id` -- `exp` — Unix-timestamp истечения токена -- `iat` — Unix-timestamp выпуска токена - -## Формат ошибок - -```json -{ "error": "описание" } -``` - -- `400` — ошибка валидации -- `401` — неверный email/пароль, токен протух или невалиден -- `409` — email уже зарегистрирован -- `429` — превышен лимит попыток логина -- `500` — внутренняя ошибка сервера diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 1ddc136..05d4639 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -29,12 +29,16 @@ func GenerateToken(userID, email string, secret []byte, expiration time.Duration } 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 - }) + 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 } diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 27a1dfe..7c2852b 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -5,6 +5,7 @@ import ( "log" "net/http" + "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/api" "github.com/gin-gonic/gin" ) @@ -16,8 +17,8 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } -// @Summary Register epta -// @Description Create user account with username, email, password +// @Summary Register +// @Description Создание учетной записи пользователя с полями username, email, password // @Tags auth // @Accept json // @Produce json @@ -52,7 +53,7 @@ func (h *Handler) Register(c *gin.Context) { } // @Summary Login -// @Description Authenticate user with email and password, returns JWT token +// @Description Аунтефикация пользователя с помощью email и password, возвращает JWT token // @Tags auth // @Accept json // @Produce json @@ -82,8 +83,8 @@ func (h *Handler) Login(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// @Summary Refresh epta token -// @Description Get a new access token using a refresh token +// @Summary Refresh token +// @Description Получение ново // @Tags auth // @Accept json // @Produce json @@ -113,8 +114,8 @@ func (h *Handler) Refresh(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// @Summary Logout epta -// @Description Invalidate a refresh token (logout) +// @Summary Logout +// @Description Аннулирует refresh token // @Tags auth // @Accept json // @Produce json @@ -143,8 +144,8 @@ func (h *Handler) Logout(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "logged out successfully"}) } -// @Summary Get epta current user -// @Description Get authenticated user's profile +// @Summary Get current user +// @Description Получить профиль авторизованного пользователя // @Tags auth // @Accept json // @Produce json @@ -153,18 +154,12 @@ func (h *Handler) Logout(c *gin.Context) { // @Failure 401 {object} ErrorResponse // @Router /api/auth/me [get] func (h *Handler) Me(c *gin.Context) { - rawUserID, exists := c.Get("user_id") - if !exists { + userID := api.GetUserID(c) + if userID == "" { c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"}) return } - userID, ok := rawUserID.(string) - if !ok { - c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "invalid user ID in context"}) - return - } - user, err := h.service.GetUserByID(c.Request.Context(), userID) if err != nil { if errors.Is(err, ErrUserNotFound) || errors.Is(err, ErrInvalidUserID) { @@ -179,8 +174,8 @@ func (h *Handler) Me(c *gin.Context) { c.JSON(http.StatusOK, UserResponse{User: *user}) } -// @Summary Change epta password -// @Description Change current user's password +// @Summary Change password +// @Description Изменить текущий password пользователя // @Tags auth // @Accept json // @Produce json @@ -191,18 +186,12 @@ func (h *Handler) Me(c *gin.Context) { // @Failure 401 {object} ErrorResponse // @Router /api/auth/password [put] func (h *Handler) ChangePassword(c *gin.Context) { - rawUserID, exists := c.Get("user_id") - if !exists { + userID := api.GetUserID(c) + if userID == "" { c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"}) return } - userID, ok := rawUserID.(string) - if !ok { - c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "invalid user ID in context"}) - return - } - var req PasswordChangeRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) @@ -210,7 +199,8 @@ func (h *Handler) ChangePassword(c *gin.Context) { } 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) { + if errors.Is(err, ErrWrongPassword) || errors.Is(err, ErrSamePassword) || + errors.Is(err, ErrWeakPassword) { c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) return } @@ -226,8 +216,8 @@ func (h *Handler) ChangePassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "password changed successfully"}) } -// @Summary Update epta profile -// @Description Update current user's username +// @Summary Update profile +// @Description Обновить username текущего пользователя // @Tags auth // @Accept json // @Produce json @@ -238,18 +228,12 @@ func (h *Handler) ChangePassword(c *gin.Context) { // @Failure 401 {object} ErrorResponse // @Router /api/auth/me [put] func (h *Handler) UpdateProfile(c *gin.Context) { - rawUserID, exists := c.Get("user_id") - if !exists { + userID := api.GetUserID(c) + if userID == "" { c.JSON(http.StatusUnauthorized, ErrorResponse{Error: "unauthorized"}) return } - userID, ok := rawUserID.(string) - if !ok { - c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "invalid user ID in context"}) - return - } - var req UpdateProfileRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go index 5f0b24a..6856d87 100644 --- a/internal/auth/middleware.go +++ b/internal/auth/middleware.go @@ -11,19 +11,28 @@ 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"}) + 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"}) + 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"}) + c.AbortWithStatusJSON( + http.StatusUnauthorized, + ErrorResponse{Error: "invalid or expired token"}, + ) return } diff --git a/internal/auth/models.go b/internal/auth/models.go index 630b404..1cb8748 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -5,26 +5,26 @@ import ( ) type User struct { - ID string `json:"id"` - Username string `json:"username"` - Email string `json:"email"` - PasswordHash string `json:"-"` - CreatedAt time.Time `json:"created_at"` + 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!"` + 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"` + 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..."` + Token string `json:"token" example:"eyJhbGciOiJIUzI1NiIs..."` RefreshToken string `json:"refresh_token" example:"dGhpcyBpcyBhIHJlZnJlc2ggdG9rZW4="` User UserPublic `json:"user"` } @@ -38,13 +38,15 @@ type LogoutRequest struct { } type RefreshTokenDoc struct { - ID string `json:"id"` - UserID string `json:"user_id"` - TokenHash string `json:"token_hash"` - ExpiresAt time.Time `json:"expires_at"` - CreatedAt time.Time `json:"created_at"` + 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"` @@ -66,7 +68,7 @@ type UserResponse struct { } type PasswordChangeRequest struct { - OldPassword string `json:"old_password" binding:"required" example:"Secret123!"` + OldPassword string `json:"old_password" binding:"required" example:"Secret123!"` NewPassword string `json:"new_password" binding:"required,min=8" example:"NewSecret456!"` } diff --git a/internal/auth/repository.go b/internal/auth/repository.go index 9449e7c..30c974a 100644 --- a/internal/auth/repository.go +++ b/internal/auth/repository.go @@ -5,33 +5,39 @@ import ( "time" "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" + "gorm.io/gorm" ) -type Repository struct { - pool *pgxpool.Pool +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 } -func NewRepository(pool *pgxpool.Pool) *Repository { - return &Repository{pool: pool} +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() - _, err := r.pool.Exec(ctx, - `INSERT INTO users (id, username, email, password_hash, created_at) VALUES ($1, $2, $3, $4, $5)`, - user.ID, user.Username, user.Email, user.PasswordHash, user.CreatedAt, - ) - return err + return r.db.WithContext(ctx).Create(user).Error } func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, error) { var user User - err := r.pool.QueryRow(ctx, - `SELECT id, username, email, password_hash, created_at FROM users WHERE email = $1`, email, - ).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.CreatedAt) + err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error if err != nil { return nil, err } @@ -40,9 +46,7 @@ func (r *Repository) FindByEmail(ctx context.Context, email string) (*User, erro func (r *Repository) FindByID(ctx context.Context, id string) (*User, error) { var user User - err := r.pool.QueryRow(ctx, - `SELECT id, username, email, password_hash, created_at FROM users WHERE id = $1`, id, - ).Scan(&user.ID, &user.Username, &user.Email, &user.PasswordHash, &user.CreatedAt) + err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error if err != nil { return nil, err } @@ -52,18 +56,18 @@ func (r *Repository) FindByID(ctx context.Context, id string) (*User, error) { func (r *Repository) CreateRefreshToken(ctx context.Context, doc *RefreshTokenDoc) error { doc.ID = uuid.New().String() doc.CreatedAt = time.Now().UTC() - _, err := r.pool.Exec(ctx, - `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at, created_at) VALUES ($1, $2, $3, $4, $5)`, - doc.ID, doc.UserID, doc.TokenHash, doc.ExpiresAt, doc.CreatedAt, - ) - return err + return r.db.WithContext(ctx).Create(doc).Error } -func (r *Repository) FindRefreshTokenByHash(ctx context.Context, hash string) (*RefreshTokenDoc, error) { +func (r *Repository) FindRefreshTokenByHash( + ctx context.Context, + hash string, +) (*RefreshTokenDoc, error) { var doc RefreshTokenDoc - err := r.pool.QueryRow(ctx, - `SELECT id, user_id, token_hash, expires_at, created_at FROM refresh_tokens WHERE token_hash = $1 AND expires_at > NOW()`, hash, - ).Scan(&doc.ID, &doc.UserID, &doc.TokenHash, &doc.ExpiresAt, &doc.CreatedAt) + err := r.db.WithContext(ctx). + Where("token_hash = ? AND expires_at > NOW()", hash). + First(&doc). + Error if err != nil { return nil, err } @@ -71,31 +75,28 @@ func (r *Repository) FindRefreshTokenByHash(ctx context.Context, hash string) (* } func (r *Repository) DeleteRefreshToken(ctx context.Context, id string) error { - _, err := r.pool.Exec(ctx, `DELETE FROM refresh_tokens WHERE id = $1`, id) - return err -} - -func (r *Repository) UpdateUserUsername(ctx context.Context, id, username string) error { - _, err := r.pool.Exec(ctx, `UPDATE users SET username = $1 WHERE id = $2`, username, id) - return err -} - -func (r *Repository) UpdateUserPassword(ctx context.Context, id, passwordHash string) error { - _, err := r.pool.Exec(ctx, `UPDATE users SET password_hash = $1 WHERE id = $2`, passwordHash, id) - return err -} - -func (r *Repository) DeleteExpiredRefreshTokens(ctx context.Context) error { - _, err := r.pool.Exec(ctx, `DELETE FROM refresh_tokens WHERE expires_at <= NOW()`) - return err + return r.db.WithContext(ctx).Where("id = ?", id).Delete(&RefreshTokenDoc{}).Error } func (r *Repository) DeleteRefreshTokenByHash(ctx context.Context, hash string) (bool, error) { - tag, err := r.pool.Exec(ctx, `DELETE FROM refresh_tokens WHERE token_hash = $1`, hash) - if err != nil { - return false, err + result := r.db.WithContext(ctx).Where("token_hash = ?", hash).Delete(&RefreshTokenDoc{}) + if result.Error != nil { + return false, result.Error } - return tag.RowsAffected() > 0, nil + return result.RowsAffected > 0, nil } -var ErrNoRows = pgx.ErrNoRows +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 +} diff --git a/internal/auth/service.go b/internal/auth/service.go index c305fc4..e211c6e 100644 --- a/internal/auth/service.go +++ b/internal/auth/service.go @@ -11,30 +11,33 @@ import ( "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") + 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 *Repository + repo UserRepository jwtSecret []byte jwtExp time.Duration refreshExp time.Duration } -func NewService(repo *Repository, jwtSecret string, jwtExp, refreshExp time.Duration) *Service { +func NewService(repo UserRepository, jwtSecret string, jwtExp, refreshExp time.Duration) *Service { return &Service{ repo: repo, jwtSecret: []byte(jwtSecret), @@ -113,7 +116,7 @@ func (s *Service) Register(ctx context.Context, req RegisterRequest) (*AuthRespo req.Email = strings.ToLower(req.Email) existing, err := s.repo.FindByEmail(ctx, req.Email) - if err != nil && !errors.Is(err, ErrNoRows) { + if err != nil && !errors.Is(err, db.ErrNoRows) { return nil, fmt.Errorf("failed to check existing user: %w", err) } if existing != nil { @@ -145,13 +148,16 @@ func (s *Service) Login(ctx context.Context, req LoginRequest) (*AuthResponse, e req.Email = strings.ToLower(req.Email) user, err := s.repo.FindByEmail(ctx, req.Email) if err != nil { - if errors.Is(err, ErrNoRows) { + 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 { + if err := bcrypt.CompareHashAndPassword( + []byte(user.PasswordHash), + []byte(req.Password), + ); err != nil { return nil, ErrInvalidCreds } @@ -163,7 +169,7 @@ func (s *Service) Refresh(ctx context.Context, rawRefresh string) (*AuthResponse doc, err := s.repo.FindRefreshTokenByHash(ctx, hash) if err != nil { - if errors.Is(err, ErrNoRows) { + if errors.Is(err, db.ErrNoRows) { return nil, ErrInvalidRefresh } return nil, fmt.Errorf("failed to find refresh token: %w", err) @@ -202,7 +208,7 @@ func (s *Service) GetUserByID(ctx context.Context, userID string) (*UserPublic, user, err := s.repo.FindByID(ctx, userID) if err != nil { - if errors.Is(err, ErrNoRows) { + if errors.Is(err, db.ErrNoRows) { return nil, ErrUserNotFound } return nil, fmt.Errorf("failed to find user: %w", err) @@ -212,20 +218,27 @@ func (s *Service) GetUserByID(ctx context.Context, userID string) (*UserPublic, return &public, nil } -func (s *Service) ChangePassword(ctx context.Context, userID string, req PasswordChangeRequest) error { +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, ErrNoRows) { + 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 { + if err := bcrypt.CompareHashAndPassword( + []byte(user.PasswordHash), + []byte(req.OldPassword), + ); err != nil { return ErrWrongPassword } @@ -249,14 +262,18 @@ func (s *Service) ChangePassword(ctx context.Context, userID string, req Passwor return nil } -func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdateProfileRequest) (*UserPublic, error) { +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, ErrNoRows) { + if errors.Is(err, db.ErrNoRows) { return nil, ErrUserNotFound } return nil, fmt.Errorf("failed to find user: %w", err) @@ -272,5 +289,6 @@ func (s *Service) UpdateProfile(ctx context.Context, userID string, req UpdatePr } func isPGUniqueViolation(err error) bool { - return err != nil && (strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505")) + return err != nil && + (strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505")) } diff --git a/internal/config/config.go b/internal/config/config.go index 7ab9b41..839b85a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,11 +10,11 @@ import ( ) type Config struct { - ServerPort string - DatabaseURL string - JWTSecret string - JWTExpiration time.Duration - JWTRefreshExpiration time.Duration + ServerPort string + DatabaseURL string + JWTSecret string + JWTExpiration time.Duration + JWTRefreshExpiration time.Duration } func Load() (*Config, error) { @@ -23,8 +23,11 @@ func Load() (*Config, error) { } cfg := &Config{ - ServerPort: getEnv("SERVER_PORT", "8080"), - DatabaseURL: getEnv("DATABASE_URL", "postgres://localhost:5432/aegisguard?sslmode=disable"), + 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, diff --git a/internal/db/errors.go b/internal/db/errors.go new file mode 100644 index 0000000..6a033de --- /dev/null +++ b/internal/db/errors.go @@ -0,0 +1,5 @@ +package db + +import "gorm.io/gorm" + +var ErrNoRows = gorm.ErrRecordNotFound diff --git a/internal/auth/ratelimit.go b/internal/middleware/ratelimit.go similarity index 69% rename from internal/auth/ratelimit.go rename to internal/middleware/ratelimit.go index 3d9af86..aa96baf 100644 --- a/internal/auth/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -1,4 +1,4 @@ -package auth +package middleware import ( "net/http" @@ -18,6 +18,7 @@ type RateLimiter struct { visitors map[string]*visitor rate int window time.Duration + done chan struct{} } func NewRateLimiter(rate int, window time.Duration) *RateLimiter { @@ -25,24 +26,33 @@ func NewRateLimiter(rate int, window time.Duration) *RateLimiter { visitors: make(map[string]*visitor), rate: rate, window: window, + done: make(chan struct{}), } - go rl.cleanup() return rl } +func (rl *RateLimiter) Stop() { + close(rl.done) +} + func (rl *RateLimiter) cleanup() { ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() - for range ticker.C { - rl.mu.Lock() - now := time.Now() - for ip, v := range rl.visitors { - if now.Sub(v.lastSeen) > rl.window*2 { - delete(rl.visitors, ip) + for { + select { + case <-rl.done: + return + case <-ticker.C: + rl.mu.Lock() + now := time.Now() + for ip, v := range rl.visitors { + if now.Sub(v.lastSeen) > rl.window*2 { + delete(rl.visitors, ip) + } } + rl.mu.Unlock() } - rl.mu.Unlock() } } @@ -66,7 +76,7 @@ func (rl *RateLimiter) Middleware() gin.HandlerFunc { if v.count > rl.rate { rl.mu.Unlock() - c.JSON(http.StatusTooManyRequests, ErrorResponse{Error: "too many requests, try again later"}) + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many requests, try again later"}) c.Abort() return } diff --git a/internal/org/handler.go b/internal/org/handler.go index 0c88e26..deff358 100644 --- a/internal/org/handler.go +++ b/internal/org/handler.go @@ -4,6 +4,7 @@ import ( "errors" "log" "net/http" + "strconv" "github.com/gin-gonic/gin" ) @@ -76,16 +77,21 @@ func (h *Handler) GetByID(c *gin.Context) { } // @Summary List organizations -// @Description Get all organizations +// @Description Get all organizations with pagination // @Tags organizations // @Accept json // @Produce json // @Security Bearer +// @Param limit query int false "Page size (default 20)" +// @Param offset query int false "Offset (default 0)" // @Success 200 {object} OrgListResponse // @Failure 500 {object} ErrorResponse // @Router /api/organizations [get] func (h *Handler) List(c *gin.Context) { - resp, err := h.service.List(c.Request.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"}) diff --git a/internal/org/models.go b/internal/org/models.go index 3c811d4..ed75aa0 100644 --- a/internal/org/models.go +++ b/internal/org/models.go @@ -3,16 +3,16 @@ package org import "time" type Organization struct { - ID string `json:"id"` - Name string `json:"name"` - Slug string `json:"slug"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + 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"` + Slug string `json:"slug" binding:"required,min=2,max=50" example:"my-corp"` } type UpdateOrgRequest struct { @@ -26,6 +26,8 @@ type OrgResponse struct { type OrgListResponse struct { Organizations []Organization `json:"organizations"` Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` } type ErrorResponse struct { diff --git a/internal/org/repository.go b/internal/org/repository.go index 61f3245..ea7554e 100644 --- a/internal/org/repository.go +++ b/internal/org/repository.go @@ -5,18 +5,24 @@ import ( "time" "github.com/google/uuid" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" + "gorm.io/gorm" ) -var ErrNoRows = pgx.ErrNoRows - -type Repository struct { - pool *pgxpool.Pool +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) } -func NewRepository(pool *pgxpool.Pool) *Repository { - return &Repository{pool: pool} +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 { @@ -24,54 +30,42 @@ func (r *Repository) Create(ctx context.Context, org *Organization) error { now := time.Now().UTC() org.CreatedAt = now org.UpdatedAt = now - _, err := r.pool.Exec(ctx, - `INSERT INTO organizations (id, name, slug, created_at, updated_at) VALUES ($1, $2, $3, $4, $5)`, - org.ID, org.Name, org.Slug, org.CreatedAt, org.UpdatedAt, - ) - return err + return r.db.WithContext(ctx).Create(org).Error } func (r *Repository) FindByID(ctx context.Context, id string) (*Organization, error) { var org Organization - err := r.pool.QueryRow(ctx, - `SELECT id, name, slug, created_at, updated_at FROM organizations WHERE id = $1`, id, - ).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt, &org.UpdatedAt) + 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) ([]Organization, error) { - rows, err := r.pool.Query(ctx, - `SELECT id, name, slug, created_at, updated_at FROM organizations ORDER BY created_at DESC`, - ) - if err != nil { - return nil, err - } - defer rows.Close() - +func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]Organization, error) { var orgs []Organization - for rows.Next() { - var org Organization - if err := rows.Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt, &org.UpdatedAt); err != nil { - return nil, err - } - orgs = append(orgs, org) - } - return orgs, rows.Err() + 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 { - org.UpdatedAt = time.Now().UTC() - _, err := r.pool.Exec(ctx, - `UPDATE organizations SET name = $1, updated_at = $2 WHERE id = $3`, - org.Name, org.UpdatedAt, org.ID, - ) - return err + return r.db.WithContext(ctx).Model(org).Update("name", org.Name).Error } -func (r *Repository) Delete(ctx context.Context, id string) error { - _, err := r.pool.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, id) - return err +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 } diff --git a/internal/org/service.go b/internal/org/service.go index 8348941..3e63b7d 100644 --- a/internal/org/service.go +++ b/internal/org/service.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "strings" + + "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/db" ) var ( @@ -13,10 +15,10 @@ var ( ) type Service struct { - repo *Repository + repo OrgRepository } -func NewService(repo *Repository) *Service { +func NewService(repo OrgRepository) *Service { return &Service{repo: repo} } @@ -41,7 +43,7 @@ func (s *Service) Create(ctx context.Context, req CreateOrgRequest) (*Organizati 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, ErrNoRows) { + if errors.Is(err, db.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("failed to find organization: %w", err) @@ -49,8 +51,20 @@ func (s *Service) GetByID(ctx context.Context, id string) (*Organization, error) return org, nil } -func (s *Service) List(ctx context.Context) (*OrgListResponse, error) { - orgs, err := s.repo.FindAll(ctx) +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) } @@ -59,14 +73,20 @@ func (s *Service) List(ctx context.Context) (*OrgListResponse, error) { } return &OrgListResponse{ Organizations: orgs, - Total: len(orgs), + Total: total, + Limit: limit, + Offset: offset, }, nil } -func (s *Service) Update(ctx context.Context, id string, req UpdateOrgRequest) (*Organization, error) { +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, ErrNoRows) { + if errors.Is(err, db.ErrNoRows) { return nil, ErrNotFound } return nil, fmt.Errorf("failed to find organization: %w", err) @@ -82,21 +102,17 @@ func (s *Service) Update(ctx context.Context, id string, req UpdateOrgRequest) ( } func (s *Service) Delete(ctx context.Context, id string) error { - org, err := s.repo.FindByID(ctx, id) + found, err := s.repo.Delete(ctx, id) if err != nil { - if errors.Is(err, ErrNoRows) { - return ErrNotFound - } - return fmt.Errorf("failed to find organization: %w", err) - } - - if err := s.repo.Delete(ctx, org.ID); 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")) + return err != nil && + (strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505")) } diff --git a/migrations/00001_init.sql b/migrations/00001_init.sql index 1e55553..428ae67 100644 --- a/migrations/00001_init.sql +++ b/migrations/00001_init.sql @@ -1,4 +1,5 @@ -CREATE TABLE IF NOT EXISTS users ( +-- +goose Up + CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY, username TEXT NOT NULL, email TEXT NOT NULL UNIQUE, @@ -16,5 +17,6 @@ CREATE TABLE IF NOT EXISTS refresh_tokens ( 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; -- 2.52.0 From ff355ad1d9a35b9947b63223a8ea5bc6e4f1db52 Mon Sep 17 00:00:00 2001 From: Mephimeow Date: Sun, 14 Jun 2026 17:14:37 +0000 Subject: [PATCH 10/10] feat: add API versioning , translate swagger, remove rate limiter --- cmd/backend/main.go | 19 +-- docs/docs.go | 176 +++++++++++++++------------ docs/swagger.json | 175 +++++++++++++++------------ docs/swagger.yaml | 196 +++++++++++++++++++------------ internal/auth/handler.go | 100 ++++++++-------- internal/middleware/ratelimit.go | 87 -------------- internal/org/handler.go | 73 ++++++------ 7 files changed, 417 insertions(+), 409 deletions(-) delete mode 100644 internal/middleware/ratelimit.go diff --git a/cmd/backend/main.go b/cmd/backend/main.go index d0092f4..0c743dd 100644 --- a/cmd/backend/main.go +++ b/cmd/backend/main.go @@ -12,7 +12,6 @@ import ( 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/middleware" "gitea.d3m0k1d.ru/HellreigN/Control-plane/internal/org" "github.com/gin-gonic/gin" "github.com/pressly/goose/v3" @@ -24,13 +23,16 @@ import ( // @title AegisGuard API // @version 1.0 -// @description API для AegisGuard control plane +// @description API системы управления AegisGuard. Позволяет управлять пользователями и организациями. +// @description Все защищённые эндпоинты требуют заголовок `Authorization: Bearer `. +// @description Токен получается при регистрации или входе. // @schemes http +// @BasePath /api/v1 // // @securityDefinitions.apikey Bearer // @in header // @name Authorization -// @description Type "Bearer" followed by a space and the JWT token. +// @description Введите `Bearer `, где token — access_token из ответа /auth/login или /auth/register func main() { cfg, err := config.Load() @@ -70,7 +72,6 @@ func main() { orgSvc := org.NewService(orgRepo) orgHandler := org.NewHandler(orgSvc) - loginLimiter := middleware.NewRateLimiter(10, time.Minute) authMW := auth.AuthMiddleware([]byte(cfg.JWTSecret)) go func() { @@ -91,17 +92,18 @@ func main() { docs.SwaggerInfo.Title = "AegisGuard API" docs.SwaggerInfo.Version = "1.0" - docs.SwaggerInfo.Description = "API for AegisGuard" + 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/auth") + api := r.Group("/api/v1/auth") { api.POST("/register", handler.Register) - api.POST("/login", loginLimiter.Middleware(), handler.Login) + api.POST("/login", handler.Login) api.POST("/refresh", handler.Refresh) api.POST("/logout", handler.Logout) api.GET("/me", authMW, handler.Me) @@ -109,7 +111,7 @@ func main() { api.PUT("/password", authMW, handler.ChangePassword) } - orgs := r.Group("/api/organizations", authMW) + orgs := r.Group("/api/v1/organizations", authMW) { orgs.POST("", orgHandler.Create) orgs.GET("", orgHandler.List) @@ -144,7 +146,6 @@ func main() { log.Fatalf("server forced to shutdown: %v", err) } - loginLimiter.Stop() _ = sqlDB.Close() log.Println("server stopped") diff --git a/docs/docs.go b/docs/docs.go index c7aaf46..a7a108f 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -15,9 +15,9 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/api/auth/login": { + "/api/v1/auth/login": { "post": { - "description": "Authenticate user with email and password, returns JWT token", + "description": "Аутентификация по email и паролю. Возвращает access_token (JWT) и refresh_token.", "consumes": [ "application/json" ], @@ -27,10 +27,10 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Login", + "summary": "Вход", "parameters": [ { - "description": "Login credentials", + "description": "Email и пароль", "name": "request", "in": "body", "required": true, @@ -41,19 +41,19 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "Успешный вход, токены в ответе", "schema": { "$ref": "#/definitions/auth.AuthResponse" } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации полей", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { - "description": "Unauthorized", + "description": "Неверный email или пароль", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -61,9 +61,9 @@ const docTemplate = `{ } } }, - "/api/auth/logout": { + "/api/v1/auth/logout": { "post": { - "description": "Invalidate a refresh token (logout)", + "description": "Аннулирование refresh_token. После выхода повторное использование того же refresh_token вернёт 401.", "consumes": [ "application/json" ], @@ -73,10 +73,10 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Logout", + "summary": "Выход", "parameters": [ { - "description": "Refresh token to invalidate", + "description": "Refresh_token для аннулирования", "name": "request", "in": "body", "required": true, @@ -87,7 +87,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "{\"message\": \"logged out successfully\"}", "schema": { "type": "object", "additionalProperties": { @@ -96,13 +96,13 @@ const docTemplate = `{ } }, "400": { - "description": "Bad Request", + "description": "Не указан refresh_token", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { - "description": "Unauthorized", + "description": "Refresh_token не найден или уже аннулирован", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -110,14 +110,14 @@ const docTemplate = `{ } } }, - "/api/auth/me": { + "/api/v1/auth/me": { "get": { "security": [ { "Bearer": [] } ], - "description": "Get authenticated user's profile", + "description": "Получение профиля текущего авторизованного пользователя.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", "consumes": [ "application/json" ], @@ -127,16 +127,16 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Get current user", + "summary": "Профиль пользователя", "responses": { "200": { - "description": "OK", + "description": "Данные пользователя", "schema": { "$ref": "#/definitions/auth.UserResponse" } }, "401": { - "description": "Unauthorized", + "description": "Токен не указан или недействителен", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -149,7 +149,7 @@ const docTemplate = `{ "Bearer": [] } ], - "description": "Update current user's username", + "description": "Обновление username текущего пользователя.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", "consumes": [ "application/json" ], @@ -159,10 +159,10 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Update profile", + "summary": "Обновление профиля", "parameters": [ { - "description": "Profile update", + "description": "Новый username", "name": "request", "in": "body", "required": true, @@ -173,19 +173,19 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "Обновлённый профиль", "schema": { "$ref": "#/definitions/auth.UserResponse" } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации: username от 3 до 30 символов", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { - "description": "Unauthorized", + "description": "Токен не указан или недействителен", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -193,14 +193,14 @@ const docTemplate = `{ } } }, - "/api/auth/password": { + "/api/v1/auth/password": { "put": { "security": [ { "Bearer": [] } ], - "description": "Change current user's password", + "description": "Изменение пароля текущего пользователя. Требуется указать старый и новый пароль.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.", "consumes": [ "application/json" ], @@ -210,10 +210,10 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Change password", + "summary": "Смена пароля", "parameters": [ { - "description": "Password change details", + "description": "Старый и новый пароль", "name": "request", "in": "body", "required": true, @@ -224,7 +224,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "{\"message\": \"password changed successfully\"}", "schema": { "type": "object", "additionalProperties": { @@ -233,13 +233,13 @@ const docTemplate = `{ } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации: неверный старый пароль, слабый новый или совпадают", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { - "description": "Unauthorized", + "description": "Токен не указан или недействителен", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -247,9 +247,9 @@ const docTemplate = `{ } } }, - "/api/auth/refresh": { + "/api/v1/auth/refresh": { "post": { - "description": "Get a new access token using a refresh token", + "description": "Получение новой пары токенов по refresh_token. Старый refresh_token становится недействительным (ротация).\nЕсли refresh_token истёк или уже был использован — придёт 401.", "consumes": [ "application/json" ], @@ -259,10 +259,10 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Refresh token", + "summary": "Обновление токенов", "parameters": [ { - "description": "Refresh token", + "description": "Действительный refresh_token", "name": "request", "in": "body", "required": true, @@ -273,19 +273,19 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "Новая пара токенов", "schema": { "$ref": "#/definitions/auth.AuthResponse" } }, "400": { - "description": "Bad Request", + "description": "Не указан refresh_token", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { - "description": "Unauthorized", + "description": "Refresh_token недействителен или истёк", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -293,9 +293,9 @@ const docTemplate = `{ } } }, - "/api/auth/register": { + "/api/v1/auth/register": { "post": { - "description": "Create user account with username, email, password", + "description": "Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.", "consumes": [ "application/json" ], @@ -305,10 +305,10 @@ const docTemplate = `{ "tags": [ "auth" ], - "summary": "Register", + "summary": "Регистрация", "parameters": [ { - "description": "Registration details", + "description": "Данные для регистрации", "name": "request", "in": "body", "required": true, @@ -319,19 +319,19 @@ const docTemplate = `{ ], "responses": { "201": { - "description": "Created", + "description": "Пользователь создан, токены в ответе", "schema": { "$ref": "#/definitions/auth.AuthResponse" } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации полей (некорректный email, слабый пароль)", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "409": { - "description": "Conflict", + "description": "Email уже зарегистрирован", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -339,14 +339,14 @@ const docTemplate = `{ } } }, - "/api/organizations": { + "/api/v1/organizations": { "get": { "security": [ { "Bearer": [] } ], - "description": "Get all organizations", + "description": "Получение списка всех организаций с пагинацией.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", "consumes": [ "application/json" ], @@ -356,16 +356,30 @@ const docTemplate = `{ "tags": [ "organizations" ], - "summary": "List organizations", + "summary": "Список организаций", + "parameters": [ + { + "type": "integer", + "description": "Количество записей на странице (по умолчанию 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Смещение от начала списка (по умолчанию 0)", + "name": "offset", + "in": "query" + } + ], "responses": { "200": { - "description": "OK", + "description": "Список организаций", "schema": { "$ref": "#/definitions/org.OrgListResponse" } }, "500": { - "description": "Internal Server Error", + "description": "Внутренняя ошибка сервера", "schema": { "$ref": "#/definitions/org.ErrorResponse" } @@ -378,7 +392,7 @@ const docTemplate = `{ "Bearer": [] } ], - "description": "Create a new organization", + "description": "Создание новой организации. slug используется в URL и должен быть уникальным.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", "consumes": [ "application/json" ], @@ -388,10 +402,10 @@ const docTemplate = `{ "tags": [ "organizations" ], - "summary": "Create organization", + "summary": "Создание организации", "parameters": [ { - "description": "Organization details", + "description": "Название и slug организации", "name": "request", "in": "body", "required": true, @@ -402,19 +416,19 @@ const docTemplate = `{ ], "responses": { "201": { - "description": "Created", + "description": "Организация создана", "schema": { "$ref": "#/definitions/org.OrgResponse" } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации полей", "schema": { "$ref": "#/definitions/org.ErrorResponse" } }, "409": { - "description": "Conflict", + "description": "Slug уже занят", "schema": { "$ref": "#/definitions/org.ErrorResponse" } @@ -422,14 +436,14 @@ const docTemplate = `{ } } }, - "/api/organizations/{id}": { + "/api/v1/organizations/{id}": { "get": { "security": [ { "Bearer": [] } ], - "description": "Get organization details", + "description": "Получение информации об организации по её ID.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", "consumes": [ "application/json" ], @@ -439,11 +453,11 @@ const docTemplate = `{ "tags": [ "organizations" ], - "summary": "Get organization by ID", + "summary": "Получить организацию", "parameters": [ { "type": "string", - "description": "Organization ID", + "description": "UUID организации", "name": "id", "in": "path", "required": true @@ -451,13 +465,13 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "Данные организации", "schema": { "$ref": "#/definitions/org.OrgResponse" } }, "404": { - "description": "Not Found", + "description": "Организация не найдена", "schema": { "$ref": "#/definitions/org.ErrorResponse" } @@ -470,7 +484,7 @@ const docTemplate = `{ "Bearer": [] } ], - "description": "Update organization name", + "description": "Обновление названия организации. slug изменить нельзя.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", "consumes": [ "application/json" ], @@ -480,17 +494,17 @@ const docTemplate = `{ "tags": [ "organizations" ], - "summary": "Update organization", + "summary": "Обновление организации", "parameters": [ { "type": "string", - "description": "Organization ID", + "description": "UUID организации", "name": "id", "in": "path", "required": true }, { - "description": "New organization details", + "description": "Новое название организации", "name": "request", "in": "body", "required": true, @@ -501,19 +515,19 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "Обновлённая организация", "schema": { "$ref": "#/definitions/org.OrgResponse" } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации полей", "schema": { "$ref": "#/definitions/org.ErrorResponse" } }, "404": { - "description": "Not Found", + "description": "Организация не найдена", "schema": { "$ref": "#/definitions/org.ErrorResponse" } @@ -526,7 +540,7 @@ const docTemplate = `{ "Bearer": [] } ], - "description": "Delete an organization", + "description": "Безвозвратное удаление организации по её ID.\n**Требуется:** заголовок ` + "`" + `Authorization: Bearer \u003ctoken\u003e` + "`" + `.", "consumes": [ "application/json" ], @@ -536,11 +550,11 @@ const docTemplate = `{ "tags": [ "organizations" ], - "summary": "Delete organization", + "summary": "Удаление организации", "parameters": [ { "type": "string", - "description": "Organization ID", + "description": "UUID организации", "name": "id", "in": "path", "required": true @@ -548,7 +562,7 @@ const docTemplate = `{ ], "responses": { "200": { - "description": "OK", + "description": "{\"message\": \"organization deleted\"}", "schema": { "type": "object", "additionalProperties": { @@ -557,7 +571,7 @@ const docTemplate = `{ } }, "404": { - "description": "Not Found", + "description": "Организация не найдена", "schema": { "$ref": "#/definitions/org.ErrorResponse" } @@ -747,6 +761,12 @@ const docTemplate = `{ "org.OrgListResponse": { "type": "object", "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, "organizations": { "type": "array", "items": { @@ -803,7 +823,7 @@ const docTemplate = `{ }, "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" @@ -815,10 +835,10 @@ const docTemplate = `{ var SwaggerInfo = &swag.Spec{ Version: "1.0", Host: "", - BasePath: "", + BasePath: "/api/v1", Schemes: []string{"http"}, Title: "AegisGuard API", - Description: "API for AegisGuard control plane", + Description: "API системы управления AegisGuard. Позволяет управлять пользователями и организациями.\nВсе защищённые эндпоинты требуют заголовок `Authorization: Bearer `.\nТокен получается при регистрации или входе.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index 4e5a3c9..d9731e7 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -4,15 +4,16 @@ ], "swagger": "2.0", "info": { - "description": "API for AegisGuard control plane", + "description": "API системы управления AegisGuard. Позволяет управлять пользователями и организациями.\nВсе защищённые эндпоинты требуют заголовок `Authorization: Bearer \u003ctoken\u003e`.\nТокен получается при регистрации или входе.", "title": "AegisGuard API", "contact": {}, "version": "1.0" }, + "basePath": "/api/v1", "paths": { - "/api/auth/login": { + "/api/v1/auth/login": { "post": { - "description": "Authenticate user with email and password, returns JWT token", + "description": "Аутентификация по email и паролю. Возвращает access_token (JWT) и refresh_token.", "consumes": [ "application/json" ], @@ -22,10 +23,10 @@ "tags": [ "auth" ], - "summary": "Login", + "summary": "Вход", "parameters": [ { - "description": "Login credentials", + "description": "Email и пароль", "name": "request", "in": "body", "required": true, @@ -36,19 +37,19 @@ ], "responses": { "200": { - "description": "OK", + "description": "Успешный вход, токены в ответе", "schema": { "$ref": "#/definitions/auth.AuthResponse" } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации полей", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { - "description": "Unauthorized", + "description": "Неверный email или пароль", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -56,9 +57,9 @@ } } }, - "/api/auth/logout": { + "/api/v1/auth/logout": { "post": { - "description": "Invalidate a refresh token (logout)", + "description": "Аннулирование refresh_token. После выхода повторное использование того же refresh_token вернёт 401.", "consumes": [ "application/json" ], @@ -68,10 +69,10 @@ "tags": [ "auth" ], - "summary": "Logout", + "summary": "Выход", "parameters": [ { - "description": "Refresh token to invalidate", + "description": "Refresh_token для аннулирования", "name": "request", "in": "body", "required": true, @@ -82,7 +83,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "{\"message\": \"logged out successfully\"}", "schema": { "type": "object", "additionalProperties": { @@ -91,13 +92,13 @@ } }, "400": { - "description": "Bad Request", + "description": "Не указан refresh_token", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { - "description": "Unauthorized", + "description": "Refresh_token не найден или уже аннулирован", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -105,14 +106,14 @@ } } }, - "/api/auth/me": { + "/api/v1/auth/me": { "get": { "security": [ { "Bearer": [] } ], - "description": "Get authenticated user's profile", + "description": "Получение профиля текущего авторизованного пользователя.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", "consumes": [ "application/json" ], @@ -122,16 +123,16 @@ "tags": [ "auth" ], - "summary": "Get current user", + "summary": "Профиль пользователя", "responses": { "200": { - "description": "OK", + "description": "Данные пользователя", "schema": { "$ref": "#/definitions/auth.UserResponse" } }, "401": { - "description": "Unauthorized", + "description": "Токен не указан или недействителен", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -144,7 +145,7 @@ "Bearer": [] } ], - "description": "Update current user's username", + "description": "Обновление username текущего пользователя.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", "consumes": [ "application/json" ], @@ -154,10 +155,10 @@ "tags": [ "auth" ], - "summary": "Update profile", + "summary": "Обновление профиля", "parameters": [ { - "description": "Profile update", + "description": "Новый username", "name": "request", "in": "body", "required": true, @@ -168,19 +169,19 @@ ], "responses": { "200": { - "description": "OK", + "description": "Обновлённый профиль", "schema": { "$ref": "#/definitions/auth.UserResponse" } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации: username от 3 до 30 символов", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { - "description": "Unauthorized", + "description": "Токен не указан или недействителен", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -188,14 +189,14 @@ } } }, - "/api/auth/password": { + "/api/v1/auth/password": { "put": { "security": [ { "Bearer": [] } ], - "description": "Change current user's password", + "description": "Изменение пароля текущего пользователя. Требуется указать старый и новый пароль.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.", "consumes": [ "application/json" ], @@ -205,10 +206,10 @@ "tags": [ "auth" ], - "summary": "Change password", + "summary": "Смена пароля", "parameters": [ { - "description": "Password change details", + "description": "Старый и новый пароль", "name": "request", "in": "body", "required": true, @@ -219,7 +220,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "{\"message\": \"password changed successfully\"}", "schema": { "type": "object", "additionalProperties": { @@ -228,13 +229,13 @@ } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации: неверный старый пароль, слабый новый или совпадают", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { - "description": "Unauthorized", + "description": "Токен не указан или недействителен", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -242,9 +243,9 @@ } } }, - "/api/auth/refresh": { + "/api/v1/auth/refresh": { "post": { - "description": "Get a new access token using a refresh token", + "description": "Получение новой пары токенов по refresh_token. Старый refresh_token становится недействительным (ротация).\nЕсли refresh_token истёк или уже был использован — придёт 401.", "consumes": [ "application/json" ], @@ -254,10 +255,10 @@ "tags": [ "auth" ], - "summary": "Refresh token", + "summary": "Обновление токенов", "parameters": [ { - "description": "Refresh token", + "description": "Действительный refresh_token", "name": "request", "in": "body", "required": true, @@ -268,19 +269,19 @@ ], "responses": { "200": { - "description": "OK", + "description": "Новая пара токенов", "schema": { "$ref": "#/definitions/auth.AuthResponse" } }, "400": { - "description": "Bad Request", + "description": "Не указан refresh_token", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "401": { - "description": "Unauthorized", + "description": "Refresh_token недействителен или истёк", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -288,9 +289,9 @@ } } }, - "/api/auth/register": { + "/api/v1/auth/register": { "post": { - "description": "Create user account with username, email, password", + "description": "Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token.\nПароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру.", "consumes": [ "application/json" ], @@ -300,10 +301,10 @@ "tags": [ "auth" ], - "summary": "Register", + "summary": "Регистрация", "parameters": [ { - "description": "Registration details", + "description": "Данные для регистрации", "name": "request", "in": "body", "required": true, @@ -314,19 +315,19 @@ ], "responses": { "201": { - "description": "Created", + "description": "Пользователь создан, токены в ответе", "schema": { "$ref": "#/definitions/auth.AuthResponse" } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации полей (некорректный email, слабый пароль)", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } }, "409": { - "description": "Conflict", + "description": "Email уже зарегистрирован", "schema": { "$ref": "#/definitions/auth.ErrorResponse" } @@ -334,14 +335,14 @@ } } }, - "/api/organizations": { + "/api/v1/organizations": { "get": { "security": [ { "Bearer": [] } ], - "description": "Get all organizations", + "description": "Получение списка всех организаций с пагинацией.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", "consumes": [ "application/json" ], @@ -351,16 +352,30 @@ "tags": [ "organizations" ], - "summary": "List organizations", + "summary": "Список организаций", + "parameters": [ + { + "type": "integer", + "description": "Количество записей на странице (по умолчанию 20)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Смещение от начала списка (по умолчанию 0)", + "name": "offset", + "in": "query" + } + ], "responses": { "200": { - "description": "OK", + "description": "Список организаций", "schema": { "$ref": "#/definitions/org.OrgListResponse" } }, "500": { - "description": "Internal Server Error", + "description": "Внутренняя ошибка сервера", "schema": { "$ref": "#/definitions/org.ErrorResponse" } @@ -373,7 +388,7 @@ "Bearer": [] } ], - "description": "Create a new organization", + "description": "Создание новой организации. slug используется в URL и должен быть уникальным.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", "consumes": [ "application/json" ], @@ -383,10 +398,10 @@ "tags": [ "organizations" ], - "summary": "Create organization", + "summary": "Создание организации", "parameters": [ { - "description": "Organization details", + "description": "Название и slug организации", "name": "request", "in": "body", "required": true, @@ -397,19 +412,19 @@ ], "responses": { "201": { - "description": "Created", + "description": "Организация создана", "schema": { "$ref": "#/definitions/org.OrgResponse" } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации полей", "schema": { "$ref": "#/definitions/org.ErrorResponse" } }, "409": { - "description": "Conflict", + "description": "Slug уже занят", "schema": { "$ref": "#/definitions/org.ErrorResponse" } @@ -417,14 +432,14 @@ } } }, - "/api/organizations/{id}": { + "/api/v1/organizations/{id}": { "get": { "security": [ { "Bearer": [] } ], - "description": "Get organization details", + "description": "Получение информации об организации по её ID.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", "consumes": [ "application/json" ], @@ -434,11 +449,11 @@ "tags": [ "organizations" ], - "summary": "Get organization by ID", + "summary": "Получить организацию", "parameters": [ { "type": "string", - "description": "Organization ID", + "description": "UUID организации", "name": "id", "in": "path", "required": true @@ -446,13 +461,13 @@ ], "responses": { "200": { - "description": "OK", + "description": "Данные организации", "schema": { "$ref": "#/definitions/org.OrgResponse" } }, "404": { - "description": "Not Found", + "description": "Организация не найдена", "schema": { "$ref": "#/definitions/org.ErrorResponse" } @@ -465,7 +480,7 @@ "Bearer": [] } ], - "description": "Update organization name", + "description": "Обновление названия организации. slug изменить нельзя.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", "consumes": [ "application/json" ], @@ -475,17 +490,17 @@ "tags": [ "organizations" ], - "summary": "Update organization", + "summary": "Обновление организации", "parameters": [ { "type": "string", - "description": "Organization ID", + "description": "UUID организации", "name": "id", "in": "path", "required": true }, { - "description": "New organization details", + "description": "Новое название организации", "name": "request", "in": "body", "required": true, @@ -496,19 +511,19 @@ ], "responses": { "200": { - "description": "OK", + "description": "Обновлённая организация", "schema": { "$ref": "#/definitions/org.OrgResponse" } }, "400": { - "description": "Bad Request", + "description": "Ошибка валидации полей", "schema": { "$ref": "#/definitions/org.ErrorResponse" } }, "404": { - "description": "Not Found", + "description": "Организация не найдена", "schema": { "$ref": "#/definitions/org.ErrorResponse" } @@ -521,7 +536,7 @@ "Bearer": [] } ], - "description": "Delete an organization", + "description": "Безвозвратное удаление организации по её ID.\n**Требуется:** заголовок `Authorization: Bearer \u003ctoken\u003e`.", "consumes": [ "application/json" ], @@ -531,11 +546,11 @@ "tags": [ "organizations" ], - "summary": "Delete organization", + "summary": "Удаление организации", "parameters": [ { "type": "string", - "description": "Organization ID", + "description": "UUID организации", "name": "id", "in": "path", "required": true @@ -543,7 +558,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "{\"message\": \"organization deleted\"}", "schema": { "type": "object", "additionalProperties": { @@ -552,7 +567,7 @@ } }, "404": { - "description": "Not Found", + "description": "Организация не найдена", "schema": { "$ref": "#/definitions/org.ErrorResponse" } @@ -742,6 +757,12 @@ "org.OrgListResponse": { "type": "object", "properties": { + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, "organizations": { "type": "array", "items": { @@ -798,7 +819,7 @@ }, "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" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c344b46..b2b3947 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,3 +1,4 @@ +basePath: /api/v1 definitions: auth.AuthResponse: properties: @@ -125,6 +126,10 @@ definitions: type: object org.OrgListResponse: properties: + limit: + type: integer + offset: + type: integer organizations: items: $ref: '#/definitions/org.Organization' @@ -162,17 +167,21 @@ definitions: type: object info: contact: {} - description: API for AegisGuard control plane + description: |- + API системы управления AegisGuard. Позволяет управлять пользователями и организациями. + Все защищённые эндпоинты требуют заголовок `Authorization: Bearer `. + Токен получается при регистрации или входе. title: AegisGuard API version: "1.0" paths: - /api/auth/login: + /api/v1/auth/login: post: consumes: - application/json - description: Authenticate user with email and password, returns JWT token + description: Аутентификация по email и паролю. Возвращает access_token (JWT) + и refresh_token. parameters: - - description: Login credentials + - description: Email и пароль in: body name: request required: true @@ -182,27 +191,28 @@ paths: - application/json responses: "200": - description: OK + description: Успешный вход, токены в ответе schema: $ref: '#/definitions/auth.AuthResponse' "400": - description: Bad Request + description: Ошибка валидации полей schema: $ref: '#/definitions/auth.ErrorResponse' "401": - description: Unauthorized + description: Неверный email или пароль schema: $ref: '#/definitions/auth.ErrorResponse' - summary: Login + summary: Вход tags: - auth - /api/auth/logout: + /api/v1/auth/logout: post: consumes: - application/json - description: Invalidate a refresh token (logout) + description: Аннулирование refresh_token. После выхода повторное использование + того же refresh_token вернёт 401. parameters: - - description: Refresh token to invalidate + - description: Refresh_token для аннулирования in: body name: request required: true @@ -212,49 +222,53 @@ paths: - application/json responses: "200": - description: OK + description: '{"message": "logged out successfully"}' schema: additionalProperties: type: string type: object "400": - description: Bad Request + description: Не указан refresh_token schema: $ref: '#/definitions/auth.ErrorResponse' "401": - description: Unauthorized + description: Refresh_token не найден или уже аннулирован schema: $ref: '#/definitions/auth.ErrorResponse' - summary: Logout + summary: Выход tags: - auth - /api/auth/me: + /api/v1/auth/me: get: consumes: - application/json - description: Get authenticated user's profile + description: |- + Получение профиля текущего авторизованного пользователя. + **Требуется:** заголовок `Authorization: Bearer `. produces: - application/json responses: "200": - description: OK + description: Данные пользователя schema: $ref: '#/definitions/auth.UserResponse' "401": - description: Unauthorized + description: Токен не указан или недействителен schema: $ref: '#/definitions/auth.ErrorResponse' security: - Bearer: [] - summary: Get current user + summary: Профиль пользователя tags: - auth put: consumes: - application/json - description: Update current user's username + description: |- + Обновление username текущего пользователя. + **Требуется:** заголовок `Authorization: Bearer `. parameters: - - description: Profile update + - description: Новый username in: body name: request required: true @@ -264,29 +278,32 @@ paths: - application/json responses: "200": - description: OK + description: Обновлённый профиль schema: $ref: '#/definitions/auth.UserResponse' "400": - description: Bad Request + description: 'Ошибка валидации: username от 3 до 30 символов' schema: $ref: '#/definitions/auth.ErrorResponse' "401": - description: Unauthorized + description: Токен не указан или недействителен schema: $ref: '#/definitions/auth.ErrorResponse' security: - Bearer: [] - summary: Update profile + summary: Обновление профиля tags: - auth - /api/auth/password: + /api/v1/auth/password: put: consumes: - application/json - description: Change current user's password + description: |- + Изменение пароля текущего пользователя. Требуется указать старый и новый пароль. + **Требуется:** заголовок `Authorization: Bearer `. + Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру. parameters: - - description: Password change details + - description: Старый и новый пароль in: body name: request required: true @@ -296,31 +313,34 @@ paths: - application/json responses: "200": - description: OK + description: '{"message": "password changed successfully"}' schema: additionalProperties: type: string type: object "400": - description: Bad Request + description: 'Ошибка валидации: неверный старый пароль, слабый новый или + совпадают' schema: $ref: '#/definitions/auth.ErrorResponse' "401": - description: Unauthorized + description: Токен не указан или недействителен schema: $ref: '#/definitions/auth.ErrorResponse' security: - Bearer: [] - summary: Change password + summary: Смена пароля tags: - auth - /api/auth/refresh: + /api/v1/auth/refresh: post: consumes: - application/json - description: Get a new access token using a refresh token + description: |- + Получение новой пары токенов по refresh_token. Старый refresh_token становится недействительным (ротация). + Если refresh_token истёк или уже был использован — придёт 401. parameters: - - description: Refresh token + - description: Действительный refresh_token in: body name: request required: true @@ -330,27 +350,29 @@ paths: - application/json responses: "200": - description: OK + description: Новая пара токенов schema: $ref: '#/definitions/auth.AuthResponse' "400": - description: Bad Request + description: Не указан refresh_token schema: $ref: '#/definitions/auth.ErrorResponse' "401": - description: Unauthorized + description: Refresh_token недействителен или истёк schema: $ref: '#/definitions/auth.ErrorResponse' - summary: Refresh token + summary: Обновление токенов tags: - auth - /api/auth/register: + /api/v1/auth/register: post: consumes: - application/json - description: Create user account with username, email, password + description: |- + Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token. + Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру. parameters: - - description: Registration details + - description: Данные для регистрации in: body name: request required: true @@ -360,47 +382,60 @@ paths: - application/json responses: "201": - description: Created + description: Пользователь создан, токены в ответе schema: $ref: '#/definitions/auth.AuthResponse' "400": - description: Bad Request + description: Ошибка валидации полей (некорректный email, слабый пароль) schema: $ref: '#/definitions/auth.ErrorResponse' "409": - description: Conflict + description: Email уже зарегистрирован schema: $ref: '#/definitions/auth.ErrorResponse' - summary: Register + summary: Регистрация tags: - auth - /api/organizations: + /api/v1/organizations: get: consumes: - application/json - description: Get all organizations + description: |- + Получение списка всех организаций с пагинацией. + **Требуется:** заголовок `Authorization: Bearer `. + parameters: + - description: Количество записей на странице (по умолчанию 20) + in: query + name: limit + type: integer + - description: Смещение от начала списка (по умолчанию 0) + in: query + name: offset + type: integer produces: - application/json responses: "200": - description: OK + description: Список организаций schema: $ref: '#/definitions/org.OrgListResponse' "500": - description: Internal Server Error + description: Внутренняя ошибка сервера schema: $ref: '#/definitions/org.ErrorResponse' security: - Bearer: [] - summary: List organizations + summary: Список организаций tags: - organizations post: consumes: - application/json - description: Create a new organization + description: |- + Создание новой организации. slug используется в URL и должен быть уникальным. + **Требуется:** заголовок `Authorization: Bearer `. parameters: - - description: Organization details + - description: Название и slug организации in: body name: request required: true @@ -410,29 +445,31 @@ paths: - application/json responses: "201": - description: Created + description: Организация создана schema: $ref: '#/definitions/org.OrgResponse' "400": - description: Bad Request + description: Ошибка валидации полей schema: $ref: '#/definitions/org.ErrorResponse' "409": - description: Conflict + description: Slug уже занят schema: $ref: '#/definitions/org.ErrorResponse' security: - Bearer: [] - summary: Create organization + summary: Создание организации tags: - organizations - /api/organizations/{id}: + /api/v1/organizations/{id}: delete: consumes: - application/json - description: Delete an organization + description: |- + Безвозвратное удаление организации по её ID. + **Требуется:** заголовок `Authorization: Bearer `. parameters: - - description: Organization ID + - description: UUID организации in: path name: id required: true @@ -441,26 +478,28 @@ paths: - application/json responses: "200": - description: OK + description: '{"message": "organization deleted"}' schema: additionalProperties: type: string type: object "404": - description: Not Found + description: Организация не найдена schema: $ref: '#/definitions/org.ErrorResponse' security: - Bearer: [] - summary: Delete organization + summary: Удаление организации tags: - organizations get: consumes: - application/json - description: Get organization details + description: |- + Получение информации об организации по её ID. + **Требуется:** заголовок `Authorization: Bearer `. parameters: - - description: Organization ID + - description: UUID организации in: path name: id required: true @@ -469,29 +508,31 @@ paths: - application/json responses: "200": - description: OK + description: Данные организации schema: $ref: '#/definitions/org.OrgResponse' "404": - description: Not Found + description: Организация не найдена schema: $ref: '#/definitions/org.ErrorResponse' security: - Bearer: [] - summary: Get organization by ID + summary: Получить организацию tags: - organizations put: consumes: - application/json - description: Update organization name + description: |- + Обновление названия организации. slug изменить нельзя. + **Требуется:** заголовок `Authorization: Bearer `. parameters: - - description: Organization ID + - description: UUID организации in: path name: id required: true type: string - - description: New organization details + - description: Новое название организации in: body name: request required: true @@ -501,27 +542,28 @@ paths: - application/json responses: "200": - description: OK + description: Обновлённая организация schema: $ref: '#/definitions/org.OrgResponse' "400": - description: Bad Request + description: Ошибка валидации полей schema: $ref: '#/definitions/org.ErrorResponse' "404": - description: Not Found + description: Организация не найдена schema: $ref: '#/definitions/org.ErrorResponse' security: - Bearer: [] - summary: Update organization + summary: Обновление организации tags: - organizations schemes: - http securityDefinitions: Bearer: - description: Type "Bearer" followed by a space and the JWT token. + description: Введите `Bearer `, где token — access_token из ответа /auth/login + или /auth/register in: header name: Authorization type: apiKey diff --git a/internal/auth/handler.go b/internal/auth/handler.go index 7c2852b..a67a465 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -17,16 +17,17 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } -// @Summary Register -// @Description Создание учетной записи пользователя с полями username, email, password +// @Summary Регистрация +// @Description Создание новой учётной записи. После успешной регистрации сразу возвращается access_token и refresh_token. +// @Description Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру. // @Tags auth // @Accept json // @Produce json -// @Param request body RegisterRequest true "Registration details" -// @Success 201 {object} AuthResponse -// @Failure 400 {object} ErrorResponse -// @Failure 409 {object} ErrorResponse -// @Router /api/auth/register [post] +// @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 { @@ -52,16 +53,16 @@ func (h *Handler) Register(c *gin.Context) { c.JSON(http.StatusCreated, resp) } -// @Summary Login -// @Description Аунтефикация пользователя с помощью email и password, возвращает JWT token +// @Summary Вход +// @Description Аутентификация по email и паролю. Возвращает access_token (JWT) и refresh_token. // @Tags auth // @Accept json // @Produce json -// @Param request body LoginRequest true "Login credentials" -// @Success 200 {object} AuthResponse -// @Failure 400 {object} ErrorResponse -// @Failure 401 {object} ErrorResponse -// @Router /api/auth/login [post] +// @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 { @@ -83,16 +84,17 @@ func (h *Handler) Login(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// @Summary Refresh token -// @Description Получение ново +// @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 -// @Failure 401 {object} ErrorResponse -// @Router /api/auth/refresh [post] +// @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 { @@ -114,16 +116,16 @@ func (h *Handler) Refresh(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// @Summary Logout -// @Description Аннулирует refresh token +// @Summary Выход +// @Description Аннулирование refresh_token. После выхода повторное использование того же refresh_token вернёт 401. // @Tags auth // @Accept json // @Produce json -// @Param request body LogoutRequest true "Refresh token to invalidate" -// @Success 200 {object} map[string]string -// @Failure 400 {object} ErrorResponse -// @Failure 401 {object} ErrorResponse -// @Router /api/auth/logout [post] +// @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 { @@ -144,15 +146,16 @@ func (h *Handler) Logout(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "logged out successfully"}) } -// @Summary Get current user -// @Description Получить профиль авторизованного пользователя +// @Summary Профиль пользователя +// @Description Получение профиля текущего авторизованного пользователя. +// @Description **Требуется:** заголовок `Authorization: Bearer `. // @Tags auth // @Accept json // @Produce json // @Security Bearer -// @Success 200 {object} UserResponse -// @Failure 401 {object} ErrorResponse -// @Router /api/auth/me [get] +// @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 == "" { @@ -174,17 +177,19 @@ func (h *Handler) Me(c *gin.Context) { c.JSON(http.StatusOK, UserResponse{User: *user}) } -// @Summary Change password -// @Description Изменить текущий password пользователя +// @Summary Смена пароля +// @Description Изменение пароля текущего пользователя. Требуется указать старый и новый пароль. +// @Description **Требуется:** заголовок `Authorization: Bearer `. +// @Description Пароль должен содержать минимум 8 символов, заглавную букву, строчную букву и цифру. // @Tags auth // @Accept json // @Produce json // @Security Bearer -// @Param request body PasswordChangeRequest true "Password change details" -// @Success 200 {object} map[string]string -// @Failure 400 {object} ErrorResponse -// @Failure 401 {object} ErrorResponse -// @Router /api/auth/password [put] +// @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 == "" { @@ -216,17 +221,18 @@ func (h *Handler) ChangePassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "password changed successfully"}) } -// @Summary Update profile -// @Description Обновить username текущего пользователя +// @Summary Обновление профиля +// @Description Обновление username текущего пользователя. +// @Description **Требуется:** заголовок `Authorization: Bearer `. // @Tags auth // @Accept json // @Produce json // @Security Bearer -// @Param request body UpdateProfileRequest true "Profile update" -// @Success 200 {object} UserResponse -// @Failure 400 {object} ErrorResponse -// @Failure 401 {object} ErrorResponse -// @Router /api/auth/me [put] +// @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 == "" { diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go deleted file mode 100644 index aa96baf..0000000 --- a/internal/middleware/ratelimit.go +++ /dev/null @@ -1,87 +0,0 @@ -package middleware - -import ( - "net/http" - "sync" - "time" - - "github.com/gin-gonic/gin" -) - -type visitor struct { - count int - lastSeen time.Time -} - -type RateLimiter struct { - mu sync.Mutex - visitors map[string]*visitor - rate int - window time.Duration - done chan struct{} -} - -func NewRateLimiter(rate int, window time.Duration) *RateLimiter { - rl := &RateLimiter{ - visitors: make(map[string]*visitor), - rate: rate, - window: window, - done: make(chan struct{}), - } - go rl.cleanup() - return rl -} - -func (rl *RateLimiter) Stop() { - close(rl.done) -} - -func (rl *RateLimiter) cleanup() { - ticker := time.NewTicker(10 * time.Minute) - defer ticker.Stop() - for { - select { - case <-rl.done: - return - case <-ticker.C: - rl.mu.Lock() - now := time.Now() - for ip, v := range rl.visitors { - if now.Sub(v.lastSeen) > rl.window*2 { - delete(rl.visitors, ip) - } - } - rl.mu.Unlock() - } - } -} - -func (rl *RateLimiter) Middleware() gin.HandlerFunc { - return func(c *gin.Context) { - ip := c.ClientIP() - - rl.mu.Lock() - v, exists := rl.visitors[ip] - now := time.Now() - - if !exists || now.Sub(v.lastSeen) > rl.window { - rl.visitors[ip] = &visitor{count: 1, lastSeen: now} - rl.mu.Unlock() - c.Next() - return - } - - v.count++ - v.lastSeen = now - - if v.count > rl.rate { - rl.mu.Unlock() - c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many requests, try again later"}) - c.Abort() - return - } - - rl.mu.Unlock() - c.Next() - } -} diff --git a/internal/org/handler.go b/internal/org/handler.go index deff358..b613d79 100644 --- a/internal/org/handler.go +++ b/internal/org/handler.go @@ -17,17 +17,18 @@ func NewHandler(service *Service) *Handler { return &Handler{service: service} } -// @Summary Create organization -// @Description Create a new organization +// @Summary Создание организации +// @Description Создание новой организации. slug используется в URL и должен быть уникальным. +// @Description **Требуется:** заголовок `Authorization: Bearer `. // @Tags organizations // @Accept json // @Produce json // @Security Bearer -// @Param request body CreateOrgRequest true "Organization details" -// @Success 201 {object} OrgResponse -// @Failure 400 {object} ErrorResponse -// @Failure 409 {object} ErrorResponse -// @Router /api/organizations [post] +// @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 { @@ -49,16 +50,17 @@ func (h *Handler) Create(c *gin.Context) { c.JSON(http.StatusCreated, OrgResponse{Organization: *org}) } -// @Summary Get organization by ID -// @Description Get organization details +// @Summary Получить организацию +// @Description Получение информации об организации по её ID. +// @Description **Требуется:** заголовок `Authorization: Bearer `. // @Tags organizations // @Accept json // @Produce json // @Security Bearer -// @Param id path string true "Organization ID" -// @Success 200 {object} OrgResponse -// @Failure 404 {object} ErrorResponse -// @Router /api/organizations/{id} [get] +// @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") @@ -76,17 +78,18 @@ func (h *Handler) GetByID(c *gin.Context) { c.JSON(http.StatusOK, OrgResponse{Organization: *org}) } -// @Summary List organizations -// @Description Get all organizations with pagination +// @Summary Список организаций +// @Description Получение списка всех организаций с пагинацией. +// @Description **Требуется:** заголовок `Authorization: Bearer `. // @Tags organizations // @Accept json // @Produce json // @Security Bearer -// @Param limit query int false "Page size (default 20)" -// @Param offset query int false "Offset (default 0)" -// @Success 200 {object} OrgListResponse -// @Failure 500 {object} ErrorResponse -// @Router /api/organizations [get] +// @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")) @@ -101,18 +104,19 @@ func (h *Handler) List(c *gin.Context) { c.JSON(http.StatusOK, resp) } -// @Summary Update organization -// @Description Update organization name +// @Summary Обновление организации +// @Description Обновление названия организации. slug изменить нельзя. +// @Description **Требуется:** заголовок `Authorization: Bearer `. // @Tags organizations // @Accept json // @Produce json // @Security Bearer -// @Param id path string true "Organization ID" -// @Param request body UpdateOrgRequest true "New organization details" -// @Success 200 {object} OrgResponse -// @Failure 400 {object} ErrorResponse -// @Failure 404 {object} ErrorResponse -// @Router /api/organizations/{id} [put] +// @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") @@ -136,16 +140,17 @@ func (h *Handler) Update(c *gin.Context) { c.JSON(http.StatusOK, OrgResponse{Organization: *org}) } -// @Summary Delete organization -// @Description Delete an organization +// @Summary Удаление организации +// @Description Безвозвратное удаление организации по её ID. +// @Description **Требуется:** заголовок `Authorization: Bearer `. // @Tags organizations // @Accept json // @Produce json // @Security Bearer -// @Param id path string true "Organization ID" -// @Success 200 {object} map[string]string -// @Failure 404 {object} ErrorResponse -// @Router /api/organizations/{id} [delete] +// @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") -- 2.52.0