- Add users table for dashboard authentication (email, password_hash, role) - Add api_keys table for agent authentication (key_hash, prefix, name) - Implement JWT auth with 24h expiration - Implement API key auth with SHA256 hashing - Add auth endpoints: POST /api/v1/auth/login, POST /api/v1/auth/register - Add API key endpoints: GET/POST/DELETE /api/v1/api-keys - Protect all /api/v1/* routes (except /health and /auth/*) - Create default admin user (admin@ophion.local) - First registered user automatically becomes admin - Use bcrypt for password hashing (cost 12) - Use SHA256 for API key hashing - Add security headers middleware
458 lines
13 KiB
Go
458 lines
13 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"log"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/bigtux/ophion/internal/security"
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🔐 AUTH HANDLERS
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
type AuthHandler struct {
|
|
db *sql.DB
|
|
authService *AuthService
|
|
}
|
|
|
|
type User struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
PasswordHash string `json:"-"`
|
|
Role string `json:"role"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type APIKey struct {
|
|
ID string `json:"id"`
|
|
KeyHash string `json:"-"`
|
|
Prefix string `json:"prefix"`
|
|
Name string `json:"name"`
|
|
UserID string `json:"user_id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
LastUsed *time.Time `json:"last_used,omitempty"`
|
|
}
|
|
|
|
// NewAuthHandler cria novo handler de autenticação
|
|
func NewAuthHandler(db *sql.DB, jwtSecret string) *AuthHandler {
|
|
config := AuthConfig{
|
|
JWTSecret: []byte(jwtSecret),
|
|
JWTExpiration: 24 * time.Hour,
|
|
RefreshExpiration: 7 * 24 * time.Hour,
|
|
Issuer: "ophion",
|
|
}
|
|
|
|
// Implementação simples do APIKeyStore
|
|
store := &DBAPIKeyStore{db: db}
|
|
|
|
authService := NewAuthService(config, nil, store)
|
|
|
|
return &AuthHandler{
|
|
db: db,
|
|
authService: authService,
|
|
}
|
|
}
|
|
|
|
// GetAuthService retorna o serviço de autenticação
|
|
func (h *AuthHandler) GetAuthService() *AuthService {
|
|
return h.authService
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Login
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
type LoginRequest struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
type LoginResponse struct {
|
|
Token string `json:"token"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
User struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
} `json:"user"`
|
|
}
|
|
|
|
func (h *AuthHandler) Login(c *fiber.Ctx) error {
|
|
var req LoginRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
if req.Email == "" || req.Password == "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Email and password are required"})
|
|
}
|
|
|
|
// Sanitize email
|
|
email, err := security.SanitizeEmail(req.Email)
|
|
if err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid email format"})
|
|
}
|
|
|
|
// Check brute force protection
|
|
allowed, remaining, _ := h.authService.CheckLoginAllowed(email)
|
|
if !allowed {
|
|
return c.Status(429).JSON(fiber.Map{
|
|
"error": "Too many failed attempts",
|
|
"message": "Account temporarily locked. Try again in " + remaining.String(),
|
|
})
|
|
}
|
|
|
|
// Find user
|
|
var user User
|
|
err = h.db.QueryRow(`
|
|
SELECT id, email, password_hash, role, created_at
|
|
FROM users WHERE email = $1
|
|
`, email).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.Role, &user.CreatedAt)
|
|
|
|
if err == sql.ErrNoRows {
|
|
h.authService.RecordLoginAttempt(email, false)
|
|
return c.Status(401).JSON(fiber.Map{"error": "Invalid credentials"})
|
|
}
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Database error"})
|
|
}
|
|
|
|
// Verify password
|
|
if !security.VerifyPassword(req.Password, user.PasswordHash) {
|
|
blocked, remaining := h.authService.RecordLoginAttempt(email, false)
|
|
if blocked {
|
|
return c.Status(429).JSON(fiber.Map{
|
|
"error": "Too many failed attempts",
|
|
"message": "Account temporarily locked",
|
|
})
|
|
}
|
|
return c.Status(401).JSON(fiber.Map{
|
|
"error": "Invalid credentials",
|
|
"remaining_attempts": remaining,
|
|
})
|
|
}
|
|
|
|
// Successful login
|
|
h.authService.RecordLoginAttempt(email, true)
|
|
|
|
// Generate token
|
|
token, _, err := h.authService.GenerateTokenPair(user.ID, "", user.Email, user.Role)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to generate token"})
|
|
}
|
|
|
|
resp := LoginResponse{
|
|
Token: token,
|
|
ExpiresIn: 86400, // 24 hours
|
|
}
|
|
resp.User.ID = user.ID
|
|
resp.User.Email = user.Email
|
|
resp.User.Role = user.Role
|
|
|
|
return c.JSON(resp)
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Register
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
type RegisterRequest struct {
|
|
Email string `json:"email"`
|
|
Password string `json:"password"`
|
|
}
|
|
|
|
func (h *AuthHandler) Register(c *fiber.Ctx) error {
|
|
var req RegisterRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
if req.Email == "" || req.Password == "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Email and password are required"})
|
|
}
|
|
|
|
// Sanitize email
|
|
email, err := security.SanitizeEmail(req.Email)
|
|
if err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid email format"})
|
|
}
|
|
|
|
// Check password (relaxed policy for simplicity)
|
|
if len(req.Password) < 6 {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Password must be at least 6 characters"})
|
|
}
|
|
|
|
// Check if any users exist (first user becomes admin)
|
|
var userCount int
|
|
h.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
|
|
|
|
role := "user"
|
|
if userCount == 0 {
|
|
role = "admin"
|
|
}
|
|
|
|
// Check if email already exists
|
|
var existingID string
|
|
err = h.db.QueryRow(`SELECT id FROM users WHERE email = $1`, email).Scan(&existingID)
|
|
if err != sql.ErrNoRows {
|
|
return c.Status(409).JSON(fiber.Map{"error": "Email already registered"})
|
|
}
|
|
|
|
// Hash password
|
|
passwordHash, err := security.HashPassword(req.Password)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to hash password"})
|
|
}
|
|
|
|
// Create user
|
|
userID := uuid.New().String()
|
|
_, err = h.db.Exec(`
|
|
INSERT INTO users (id, email, password_hash, role, created_at)
|
|
VALUES ($1, $2, $3, $4, NOW())
|
|
`, userID, email, passwordHash, role)
|
|
if err != nil {
|
|
log.Printf("Error creating user: %v", err)
|
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to create user"})
|
|
}
|
|
|
|
// Generate token
|
|
token, _, err := h.authService.GenerateTokenPair(userID, "", email, role)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to generate token"})
|
|
}
|
|
|
|
return c.Status(201).JSON(fiber.Map{
|
|
"message": "User registered successfully",
|
|
"token": token,
|
|
"user": fiber.Map{
|
|
"id": userID,
|
|
"email": email,
|
|
"role": role,
|
|
},
|
|
})
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// API Keys
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
type CreateAPIKeyRequest struct {
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
func (h *AuthHandler) CreateAPIKey(c *fiber.Ctx) error {
|
|
var req CreateAPIKeyRequest
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Invalid request body"})
|
|
}
|
|
|
|
if req.Name == "" {
|
|
return c.Status(400).JSON(fiber.Map{"error": "Name is required"})
|
|
}
|
|
|
|
userID := GetUserID(c)
|
|
if userID == "" {
|
|
return c.Status(401).JSON(fiber.Map{"error": "Unauthorized"})
|
|
}
|
|
|
|
// Generate API key
|
|
key, keyHash, prefix := security.GenerateAPIKey()
|
|
|
|
keyID := uuid.New().String()
|
|
_, err := h.db.Exec(`
|
|
INSERT INTO api_keys (id, key_hash, prefix, name, user_id, created_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
`, keyID, keyHash, prefix, req.Name, userID)
|
|
if err != nil {
|
|
log.Printf("Error creating API key: %v", err)
|
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to create API key"})
|
|
}
|
|
|
|
return c.Status(201).JSON(fiber.Map{
|
|
"message": "API key created successfully",
|
|
"key": key, // Only shown once!
|
|
"id": keyID,
|
|
"name": req.Name,
|
|
"prefix": prefix,
|
|
})
|
|
}
|
|
|
|
func (h *AuthHandler) ListAPIKeys(c *fiber.Ctx) error {
|
|
userID := GetUserID(c)
|
|
role := GetRole(c)
|
|
|
|
var rows *sql.Rows
|
|
var err error
|
|
|
|
// Admin can see all keys
|
|
if role == "admin" {
|
|
rows, err = h.db.Query(`
|
|
SELECT id, prefix, name, user_id, created_at, last_used
|
|
FROM api_keys ORDER BY created_at DESC
|
|
`)
|
|
} else {
|
|
rows, err = h.db.Query(`
|
|
SELECT id, prefix, name, user_id, created_at, last_used
|
|
FROM api_keys WHERE user_id = $1 ORDER BY created_at DESC
|
|
`, userID)
|
|
}
|
|
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Database error"})
|
|
}
|
|
defer rows.Close()
|
|
|
|
var keys []fiber.Map
|
|
for rows.Next() {
|
|
var k APIKey
|
|
if err := rows.Scan(&k.ID, &k.Prefix, &k.Name, &k.UserID, &k.CreatedAt, &k.LastUsed); err == nil {
|
|
keys = append(keys, fiber.Map{
|
|
"id": k.ID,
|
|
"prefix": k.Prefix,
|
|
"name": k.Name,
|
|
"user_id": k.UserID,
|
|
"created_at": k.CreatedAt,
|
|
"last_used": k.LastUsed,
|
|
})
|
|
}
|
|
}
|
|
|
|
if keys == nil {
|
|
keys = []fiber.Map{}
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"api_keys": keys})
|
|
}
|
|
|
|
func (h *AuthHandler) DeleteAPIKey(c *fiber.Ctx) error {
|
|
keyID := c.Params("id")
|
|
userID := GetUserID(c)
|
|
role := GetRole(c)
|
|
|
|
var ownerID string
|
|
err := h.db.QueryRow(`SELECT user_id FROM api_keys WHERE id = $1`, keyID).Scan(&ownerID)
|
|
if err == sql.ErrNoRows {
|
|
return c.Status(404).JSON(fiber.Map{"error": "API key not found"})
|
|
}
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Database error"})
|
|
}
|
|
|
|
// Check permission
|
|
if role != "admin" && ownerID != userID {
|
|
return c.Status(403).JSON(fiber.Map{"error": "Permission denied"})
|
|
}
|
|
|
|
_, err = h.db.Exec(`DELETE FROM api_keys WHERE id = $1`, keyID)
|
|
if err != nil {
|
|
return c.Status(500).JSON(fiber.Map{"error": "Failed to delete API key"})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{"message": "API key deleted"})
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────
|
|
// Me (current user)
|
|
// ─────────────────────────────────────────────────────────────
|
|
|
|
func (h *AuthHandler) Me(c *fiber.Ctx) error {
|
|
userID := GetUserID(c)
|
|
email := c.Locals("email")
|
|
role := GetRole(c)
|
|
|
|
return c.JSON(fiber.Map{
|
|
"id": userID,
|
|
"email": email,
|
|
"role": role,
|
|
})
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🗄️ DB API KEY STORE
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
type DBAPIKeyStore struct {
|
|
db *sql.DB
|
|
}
|
|
|
|
func (s *DBAPIKeyStore) ValidateKey(ctx context.Context, keyHash string) (*APIKeyInfo, error) {
|
|
var info APIKeyInfo
|
|
err := s.db.QueryRowContext(ctx, `
|
|
SELECT id, name FROM api_keys WHERE key_hash = $1
|
|
`, keyHash).Scan(&info.ID, &info.Name)
|
|
|
|
if err == sql.ErrNoRows {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
info.Scopes = []string{"*"} // Full access for now
|
|
return &info, nil
|
|
}
|
|
|
|
func (s *DBAPIKeyStore) UpdateLastUsed(ctx context.Context, keyID string) error {
|
|
_, err := s.db.ExecContext(ctx, `
|
|
UPDATE api_keys SET last_used = NOW() WHERE id = $1
|
|
`, keyID)
|
|
return err
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════
|
|
// 🔧 ADMIN USER SETUP
|
|
// ═══════════════════════════════════════════════════════════
|
|
|
|
// CreateDefaultAdmin cria usuário admin padrão se não existir
|
|
func CreateDefaultAdmin(db *sql.DB) error {
|
|
// Check if any users exist
|
|
var count int
|
|
if err := db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil {
|
|
// Table might not exist yet
|
|
return nil
|
|
}
|
|
|
|
if count > 0 {
|
|
return nil // Admin already exists
|
|
}
|
|
|
|
adminEmail := "admin@ophion.local"
|
|
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
|
if adminPassword == "" {
|
|
adminPassword = "ophion123"
|
|
}
|
|
|
|
passwordHash, err := security.HashPassword(adminPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
userID := uuid.New().String()
|
|
_, err = db.Exec(`
|
|
INSERT INTO users (id, email, password_hash, role, created_at)
|
|
VALUES ($1, $2, $3, 'admin', NOW())
|
|
ON CONFLICT (email) DO NOTHING
|
|
`, userID, adminEmail, passwordHash)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Printf("✓ Default admin user created: %s", adminEmail)
|
|
return nil
|
|
}
|
|
|
|
// HashAPIKey creates SHA256 hash of API key
|
|
func HashAPIKey(key string) string {
|
|
hash := sha256.Sum256([]byte(key))
|
|
return hex.EncodeToString(hash[:])
|
|
}
|