diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 0a71109..808558e 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -1,4 +1,4 @@ -package cmd +package main import ( "context" @@ -27,17 +27,37 @@ func main() { } cfg, err := config.ImportSettings(cfg_path) if err != nil { - log.Fatalf("Err loading config") + log.Fatalf("Err loading config: %v", err) } db, err := storage.Open(cfg.Database.Token_db) if err != nil { - log.Fatalf("Err opening database") + log.Fatalf("Err opening database: %v", err) } defer db.Close() h := handlers.New(db) agents := handlers.AgentsGroup{Handlers: h} + auth := handlers.AuthGroup{Handlers: h} + + // Create admin user from config if not exists + if cfg.Admin.Admin_login != "" && cfg.Admin.Admin_password != "" { + if !h.Repo.ExistsByLogin(cfg.Admin.Admin_login) { + _, err := h.Repo.CreateToken(repository.TokenCreate{ + Name: cfg.Admin.Admin_name, + LastName: cfg.Admin.Admin_last_name, + Login: cfg.Admin.Admin_login, + Password: cfg.Admin.Admin_password, + PermissionView: true, + PermissionAdmin: true, + }) + if err != nil { + log.Printf("Warning: failed to create admin user: %v", err) + } else { + log.Println("Admin user created from config") + } + } + } router := gin.Default() docs.SwaggerInfo.BasePath = "/api/v1" @@ -49,12 +69,33 @@ func main() { v1 := router.Group("/api/v1") { + // Auth routes (public) + authGroup := v1.Group("/auth") + { + authGroup.POST("/login", auth.Login) + } + + // Auth token management (requires auth) + authTokenGroup := v1.Group("/auth") + authTokenGroup.Use(auth.AuthMiddleware()) + { + authTokenGroup.POST("/token", handlers.RequireAdmin(), auth.CreateToken) + authTokenGroup.GET("/validate", auth.ValidateToken) + authTokenGroup.GET("/tokens", handlers.RequireAdmin(), auth.ListTokens) + authTokenGroup.DELETE("/token", auth.DeleteMyToken) + authTokenGroup.DELETE("/tokens/:login", handlers.RequireAdmin(), auth.DeleteToken) + } + + // Agents (requires manage_agent permission) agentsGroup := v1.Group("/agents") + agentsGroup.Use(auth.AuthMiddleware(), handlers.RequireManageAgent()) { agentsGroup.GET("", agents.List) } + // Logs (requires view permission) logsGroup := v1.Group("/logs") + logsGroup.Use(auth.AuthMiddleware(), handlers.RequireView()) { if cfg.Database.Clickhouse_host != "" { chConn, err := storage.OpenClickHouse(storage.ClickHouseConfig{ diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go new file mode 100644 index 0000000..1ba27ef --- /dev/null +++ b/backend/internal/handlers/auth.go @@ -0,0 +1,182 @@ +package handlers + +import ( + "errors" + "net/http" + "strings" + + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" + "github.com/gin-gonic/gin" +) + +// AuthGroup handles authentication routes. +type AuthGroup struct { + *Handlers +} + +// Login authenticates a user by login and password, returns a token. +// @Summary Login +// @Description Authenticate with login and password, returns a token and permissions +// @Tags auth +// @Accept json +// @Param request body repository.LoginRequest true "Login credentials" +// @Success 200 {object} repository.LoginResponse +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Router /auth/login [post] +func (ag *AuthGroup) Login(c *gin.Context) { + var req repository.LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + resp, err := ag.Repo.Login(req.Login, req.Password) + if err != nil { + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to authenticate"}) + return + } + + c.JSON(http.StatusOK, resp) +} + +// CreateToken creates a new user. +// @Summary Create user +// @Description Creates a new user with permissions +// @Tags auth +// @Accept json +// @Param request body repository.TokenCreate true "User data" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /auth/token [post] +func (ag *AuthGroup) CreateToken(c *gin.Context) { + var tc repository.TokenCreate + if err := c.ShouldBindJSON(&tc); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if _, err := ag.Repo.CreateToken(tc); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "user created"}) +} + +// ValidateToken validates the current Bearer token and returns user info. +// @Summary Validate token +// @Description Check if the provided Bearer token is valid and return its permissions +// @Tags auth +// @Produce json +// @Success 200 {object} repository.Tokens +// @Failure 401 {object} map[string]string +// @Router /auth/validate [get] +func (ag *AuthGroup) ValidateToken(c *gin.Context) { + tokenVal, exists := c.Get(string(tokenContextKey)) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + + token, ok := tokenVal.(*repository.Tokens) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token context"}) + return + } + + c.JSON(http.StatusOK, token) +} + +// ListTokens returns all users. +// @Summary List users +// @Description Returns list of all users with their permissions +// @Tags auth +// @Produce json +// @Success 200 {array} repository.Tokens +// @Failure 500 {object} map[string]string +// @Router /auth/tokens [get] +func (ag *AuthGroup) ListTokens(c *gin.Context) { + tokens, err := ag.Repo.ListTokens() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"}) + return + } + c.JSON(http.StatusOK, tokens) +} + +// DeleteToken deletes a user by login from URL path. +// @Summary Delete user +// @Description Deletes a user by their login +// @Tags auth +// @Param login path string true "Login of the user to delete" +// @Success 200 {object} map[string]string +// @Failure 400 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /auth/tokens/:login [delete] +func (ag *AuthGroup) DeleteToken(c *gin.Context) { + login := c.Param("login") + if login == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "login required"}) + return + } + + if err := ag.Repo.DeleteTokenByLogin(login); err != nil { + if errors.Is(err, repository.ErrNotFound) { + c.JSON(http.StatusNotFound, gin.H{"error": "user not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "user deleted"}) +} + +// DeleteMyToken deletes the current user's account. +// @Summary Delete my account +// @Description Deletes the current authenticated user +// @Tags auth +// @Success 200 {object} map[string]string +// @Failure 401 {object} map[string]string +// @Failure 500 {object} map[string]string +// @Router /auth/token [delete] +func (ag *AuthGroup) DeleteMyToken(c *gin.Context) { + tokenVal, exists := c.Get(string(tokenContextKey)) + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"}) + return + } + + token, ok := tokenVal.(*repository.Tokens) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token context"}) + return + } + + if err := ag.Repo.DeleteToken(token.Token); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete account"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "account deleted"}) +} + +// getTokenFromHeader extracts the Bearer token from the Authorization header. +func getTokenFromHeader(c *gin.Context) string { + auth := c.GetHeader("Authorization") + if auth == "" { + return "" + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") { + return "" + } + return parts[1] +} diff --git a/backend/internal/handlers/middlewares.go b/backend/internal/handlers/middlewares.go new file mode 100644 index 0000000..48c3c64 --- /dev/null +++ b/backend/internal/handlers/middlewares.go @@ -0,0 +1,86 @@ +package handlers + +import ( + "net/http" + + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository" + "github.com/gin-gonic/gin" +) + +// TokenContextKey is the context key for storing authenticated token info. +type TokenContextKey string + +const tokenContextKey TokenContextKey = "token" + +// AuthMiddleware validates that a Bearer token exists and is valid. +// It stores the token info in the context for later use. +// Returns 401 if token is missing or invalid. +func (ag *AuthGroup) AuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + token := getTokenFromHeader(c) + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"}) + c.Abort() + return + } + + // Look up user by token value + tokens, err := ag.Repo.GetToken(token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + c.Abort() + return + } + + c.Set(string(tokenContextKey), tokens) + c.Next() + } +} + +// RequirePermission is a generic permission checker. +func RequirePermission(check func(*repository.Tokens) bool) gin.HandlerFunc { + return func(c *gin.Context) { + tokenVal, exists := c.Get(string(tokenContextKey)) + if !exists { + c.JSON(http.StatusForbidden, gin.H{"error": "authentication required"}) + c.Abort() + return + } + + token, ok := tokenVal.(*repository.Tokens) + if !ok { + c.JSON(http.StatusForbidden, gin.H{"error": "invalid token context"}) + c.Abort() + return + } + + if !check(token) { + c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"}) + c.Abort() + return + } + + c.Next() + } +} + +// RequireView requires permission_view. +func RequireView() gin.HandlerFunc { + return RequirePermission(func(t *repository.Tokens) bool { + return t.PermissionView + }) +} + +// RequireManageAgent requires permission_manage_agent. +func RequireManageAgent() gin.HandlerFunc { + return RequirePermission(func(t *repository.Tokens) bool { + return t.PermissionManage + }) +} + +// RequireAdmin requires permission_admin. +func RequireAdmin() gin.HandlerFunc { + return RequirePermission(func(t *repository.Tokens) bool { + return t.PermissionAdmin + }) +} diff --git a/backend/internal/repository/models.go b/backend/internal/repository/models.go new file mode 100644 index 0000000..4de72c2 --- /dev/null +++ b/backend/internal/repository/models.go @@ -0,0 +1,41 @@ +package repository + +// Tokens represents a user record with info and permissions. +type Tokens struct { + ID int64 `json:"id"` + Name string `json:"name"` + LastName string `json:"last_name"` + Login string `json:"login"` + Token string `json:"token"` + PermissionView bool `json:"permission_view"` + PermissionManage bool `json:"permission_manage_agent"` + PermissionAdmin bool `json:"permission_admin"` +} + +// TokenCreate is the request body for creating a new user. +type TokenCreate struct { + Name string `json:"name" binding:"required"` + LastName string `json:"last_name" binding:"required"` + Login string `json:"login" binding:"required"` + Password string `json:"password" binding:"required"` + PermissionView bool `json:"permission_view"` + PermissionManage bool `json:"permission_manage_agent"` + PermissionAdmin bool `json:"permission_admin"` +} + +// LoginRequest is the request body for login. +type LoginRequest struct { + Login string `json:"login" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// LoginResponse is returned after successful login. +type LoginResponse struct { + Token string `json:"token"` + Name string `json:"name"` + LastName string `json:"last_name"` + Login string `json:"login"` + PermissionView bool `json:"permission_view"` + PermissionManage bool `json:"permission_manage_agent"` + PermissionAdmin bool `json:"permission_admin"` +} diff --git a/backend/internal/repository/repository.go b/backend/internal/repository/repository.go index 8af44aa..8033e2c 100644 --- a/backend/internal/repository/repository.go +++ b/backend/internal/repository/repository.go @@ -1,11 +1,187 @@ package repository -import "database/sql" +import ( + "database/sql" + "errors" + "strconv" + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage" + "gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/utils" + "golang.org/x/crypto/bcrypt" +) + +// Repository wraps a SQLite database connection. type Repository struct { DB *sql.DB } +// New creates a new Repository. func New(db *sql.DB) *Repository { return &Repository{DB: db} } + +var ErrNotFound = errors.New("not found") + +// Init creates the tokens table if it does not exist. +func (r *Repository) Init() error { + _, err := r.DB.Exec(storage.CreateSqlite) + return err +} + +// CreateToken inserts a new user record with hashed password and generated token. +func (r *Repository) CreateToken(tc TokenCreate) (string, error) { + hashed, err := bcrypt.GenerateFromPassword([]byte(tc.Password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + + token, err := utils.RandomToken() + if err != nil { + return "", err + } + + result, err := r.DB.Exec( + `INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + tc.Name, tc.LastName, tc.Login, string(hashed), token, + tc.PermissionView, tc.PermissionManage, tc.PermissionAdmin, + ) + if err != nil { + return "", err + } + + id, err := result.LastInsertId() + if err != nil { + return "", err + } + return strconv.FormatInt(id, 10), nil +} + +// Login authenticates by login/password, generates a new token, and returns LoginResponse. +func (r *Repository) Login(login, password string) (*LoginResponse, error) { + var t Tokens + var hashedPassword string + + err := r.DB.QueryRow( + `SELECT id, name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin + FROM tokens WHERE login = ?`, + login, + ).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &hashedPassword, &t.Token, + &t.PermissionView, &t.PermissionManage, &t.PermissionAdmin) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)); err != nil { + return nil, ErrNotFound + } + + // Generate new token on each login + newToken, err := utils.RandomToken() + if err != nil { + return nil, err + } + + _, err = r.DB.Exec(`UPDATE tokens SET token = ? WHERE id = ?`, newToken, t.ID) + if err != nil { + return nil, err + } + + return &LoginResponse{ + Token: newToken, + Name: t.Name, + LastName: t.LastName, + Login: t.Login, + PermissionView: t.PermissionView, + PermissionManage: t.PermissionManage, + PermissionAdmin: t.PermissionAdmin, + }, nil +} + +// GetTokenByToken retrieves a user record by token value. +func (r *Repository) GetToken(token string) (*Tokens, error) { + var t Tokens + err := r.DB.QueryRow( + `SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin + FROM tokens WHERE token = ?`, + token, + ).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token, + &t.PermissionView, &t.PermissionManage, &t.PermissionAdmin) + + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &t, nil +} + +// ListTokens returns all users without password and token. +func (r *Repository) ListTokens() ([]Tokens, error) { + rows, err := r.DB.Query( + `SELECT id, name, last_name, login, permission_view, permission_manage_agent, permission_admin + FROM tokens`, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var tokens []Tokens + for rows.Next() { + var t Tokens + if err := rows.Scan(&t.ID, &t.Name, &t.LastName, &t.Login, + &t.PermissionView, &t.PermissionManage, &t.PermissionAdmin); err != nil { + return nil, err + } + tokens = append(tokens, t) + } + return tokens, rows.Err() +} + +// DeleteToken deletes a user by token value. +func (r *Repository) DeleteToken(token string) error { + result, err := r.DB.Exec(`DELETE FROM tokens WHERE token = ?`, token) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} + +// DeleteTokenByLogin deletes a user by login. +func (r *Repository) DeleteTokenByLogin(login string) error { + result, err := r.DB.Exec(`DELETE FROM tokens WHERE login = ?`, login) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} + +// ExistsByLogin checks if a user with given login exists. +func (r *Repository) ExistsByLogin(login string) bool { + var count int + err := r.DB.QueryRow(`SELECT COUNT(*) FROM tokens WHERE login = ?`, login).Scan(&count) + if err != nil { + return false + } + return count > 0 +} diff --git a/backend/internal/storage/migrations.go b/backend/internal/storage/migrations.go index 9bed7e6..15e9946 100644 --- a/backend/internal/storage/migrations.go +++ b/backend/internal/storage/migrations.go @@ -5,11 +5,12 @@ const CreateSqlite = ` id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, last_name TEXT NOT NULL, - login TEXT NOT NULL, + login TEXT NOT NULL UNIQUE, password TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, permission_view BOOL NOT NULL, permission_manage_agent BOOL NOT NULL, - permission_tokens BOOL NOT NULL + permission_admin BOOL NOT NULL ); ` diff --git a/backend/internal/storage/sqlite.go b/backend/internal/storage/sqlite.go index 0666482..6a4fb1b 100644 --- a/backend/internal/storage/sqlite.go +++ b/backend/internal/storage/sqlite.go @@ -30,5 +30,11 @@ func Open(path string) (*sql.DB, error) { if err != nil { return nil, err } + + // Run migrations + if _, err := db.Exec(CreateSqlite); err != nil { + return nil, fmt.Errorf("migrate: %w", err) + } + return db, nil } diff --git a/backend/internal/utils/token_gen.go b/backend/internal/utils/token_gen.go index a9eeb97..506e583 100644 --- a/backend/internal/utils/token_gen.go +++ b/backend/internal/utils/token_gen.go @@ -1,4 +1,4 @@ -package initial +package utils import ( "crypto/rand" diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..1ba2a8a --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,3 @@ +services: + backend: +