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;