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 +}