* Implement EventLogsPage for viewing system activity logs with search and filter capabilities. * Create HooksPage for managing webhook configurations with create, edit, and delete functionalities. * Develop LoginPage for user authentication with error handling and loading states. * Add UsersPage for managing system users, including role assignment and status toggling. * Introduce authStore for managing user authentication state and actions. * Define TypeScript types for User, Hook, EventLog, and other entities. * Set up TypeScript configuration for the project. * Configure Vite for development with proxy settings for API calls. * Update dependencies for improved functionality and security.
281 lines
8.2 KiB
Go
281 lines
8.2 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.warky.dev/wdevs/whatshooked/pkg/config"
|
|
"git.warky.dev/wdevs/whatshooked/pkg/storage"
|
|
"github.com/bitechdev/ResolveSpec/pkg/security"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/uptrace/bun"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
// SecurityProvider implements ResolveSpec SecurityProvider interface
|
|
type SecurityProvider struct {
|
|
jwtSecret []byte
|
|
userRepo *storage.UserRepository
|
|
sessionRepo *storage.SessionRepository
|
|
config *config.Config // Add config for Phase 1 auth
|
|
}
|
|
|
|
// NewSecurityProvider creates a new security provider
|
|
func NewSecurityProvider(jwtSecret string, db *bun.DB, cfg *config.Config) security.SecurityProvider {
|
|
return &SecurityProvider{
|
|
jwtSecret: []byte(jwtSecret),
|
|
userRepo: storage.NewUserRepository(db),
|
|
sessionRepo: storage.NewSessionRepository(db),
|
|
config: cfg,
|
|
}
|
|
}
|
|
|
|
// Claims represents JWT claims
|
|
type Claims struct {
|
|
UserID string `json:"user_id"` // Changed from int to string for UUID support
|
|
Username string `json:"username"`
|
|
Role string `json:"role"`
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// GenerateToken generates a JWT token
|
|
func (sp *SecurityProvider) GenerateToken(userID string, username, role string) (string, error) {
|
|
expirationTime := time.Now().Add(24 * time.Hour)
|
|
|
|
claims := &Claims{
|
|
UserID: userID,
|
|
Username: username,
|
|
Role: role,
|
|
RegisteredClaims: jwt.RegisteredClaims{
|
|
ExpiresAt: jwt.NewNumericDate(expirationTime),
|
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
|
NotBefore: jwt.NewNumericDate(time.Now()),
|
|
Issuer: "whatshooked",
|
|
Subject: userID,
|
|
},
|
|
}
|
|
|
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
|
return token.SignedString(sp.jwtSecret)
|
|
}
|
|
|
|
// ValidateToken validates a JWT token and returns the claims
|
|
func (sp *SecurityProvider) ValidateToken(tokenString string) (*Claims, error) {
|
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
|
}
|
|
return sp.jwtSecret, nil
|
|
})
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
|
return claims, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("invalid token")
|
|
}
|
|
|
|
// Login authenticates a user (implements security.Authenticator)
|
|
func (sp *SecurityProvider) Login(ctx context.Context, req security.LoginRequest) (*security.LoginResponse, error) {
|
|
// Get user by username
|
|
user, err := sp.userRepo.GetByUsername(ctx, req.Username)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
|
|
// Check if user is active
|
|
if !user.Active {
|
|
return nil, fmt.Errorf("user is inactive")
|
|
}
|
|
|
|
// Verify password
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.Password.String()), []byte(req.Password)); err != nil {
|
|
return nil, fmt.Errorf("invalid credentials")
|
|
}
|
|
|
|
// Generate JWT token
|
|
token, err := sp.GenerateToken(user.ID.String(), req.Username, user.Role.String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate token: %w", err)
|
|
}
|
|
|
|
// Build user context
|
|
userCtx := &security.UserContext{
|
|
UserID: 0, // Keep as 0 for compatibility, actual ID is in claims
|
|
UserName: req.Username,
|
|
Email: user.Email.String(),
|
|
Roles: []string{user.Role.String()},
|
|
Claims: map[string]any{
|
|
"role": user.Role.String(),
|
|
"user_id": user.ID.String(), // Store actual UUID here
|
|
},
|
|
}
|
|
|
|
return &security.LoginResponse{
|
|
Token: token,
|
|
User: userCtx,
|
|
ExpiresIn: int64(24 * time.Hour.Seconds()),
|
|
}, nil
|
|
}
|
|
|
|
// Logout logs out a user (implements security.Authenticator)
|
|
func (sp *SecurityProvider) Logout(ctx context.Context, req security.LogoutRequest) error {
|
|
// For JWT, we can implement token blacklisting if needed
|
|
// For now, just return success (JWT will expire naturally)
|
|
return nil
|
|
}
|
|
|
|
// Authenticate authenticates an HTTP request (implements security.Authenticator)
|
|
func (sp *SecurityProvider) Authenticate(r *http.Request) (*security.UserContext, error) {
|
|
// Try JWT authentication first
|
|
token := extractBearerToken(r)
|
|
if token != "" {
|
|
claims, err := sp.ValidateToken(token)
|
|
if err == nil {
|
|
// Get user from database
|
|
user, err := sp.userRepo.GetByID(r.Context(), claims.UserID)
|
|
if err == nil && user.Active {
|
|
return &security.UserContext{
|
|
UserID: 0, // Keep as 0 for compatibility
|
|
UserName: claims.Username,
|
|
Email: user.Email.String(),
|
|
Roles: []string{user.Role.String()},
|
|
Claims: map[string]any{
|
|
"role": user.Role.String(),
|
|
"user_id": claims.UserID, // Store actual UUID here
|
|
},
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try Phase 1 authentication (API key, basic auth)
|
|
if sp.validatePhase1Auth(r) {
|
|
// Create a generic user context for Phase 1 auth
|
|
// Use username from config or "api-user" if using API key
|
|
username := "api-user"
|
|
if sp.config.Server.Username != "" {
|
|
username = sp.config.Server.Username
|
|
}
|
|
|
|
// Check if using basic auth to get actual username
|
|
if basicUser, _, ok := r.BasicAuth(); ok && basicUser != "" {
|
|
username = basicUser
|
|
}
|
|
|
|
return &security.UserContext{
|
|
UserID: 0, // No user ID for Phase 1 auth
|
|
UserName: username,
|
|
Email: "",
|
|
Roles: []string{"admin"}, // Phase 1 auth gets admin role
|
|
Claims: map[string]any{
|
|
"role": "admin",
|
|
"legacy": true, // Mark as legacy auth
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("authentication failed")
|
|
}
|
|
|
|
// validatePhase1Auth checks Phase 1 authentication (API key or basic auth)
|
|
func (sp *SecurityProvider) validatePhase1Auth(r *http.Request) bool {
|
|
// Check if any Phase 1 authentication is configured
|
|
hasAuth := sp.config.Server.AuthKey != "" ||
|
|
sp.config.Server.Username != "" ||
|
|
sp.config.Server.Password != ""
|
|
|
|
if !hasAuth {
|
|
// No Phase 1 authentication configured
|
|
return false
|
|
}
|
|
|
|
// Check for API key authentication (x-api-key header)
|
|
if sp.config.Server.AuthKey != "" {
|
|
apiKey := r.Header.Get("x-api-key")
|
|
if apiKey == sp.config.Server.AuthKey {
|
|
return true
|
|
}
|
|
|
|
// Also check Authorization header for bearer token (API key)
|
|
authHeader := r.Header.Get("Authorization")
|
|
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
|
token := authHeader[7:]
|
|
if token == sp.config.Server.AuthKey {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for username/password authentication (HTTP Basic Auth)
|
|
if sp.config.Server.Username != "" && sp.config.Server.Password != "" {
|
|
username, password, ok := r.BasicAuth()
|
|
if ok && username == sp.config.Server.Username && password == sp.config.Server.Password {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// GetColumnSecurity returns column security rules (implements security.ColumnSecurityProvider)
|
|
func (sp *SecurityProvider) GetColumnSecurity(ctx context.Context, userID int, schema, table string) ([]security.ColumnSecurity, error) {
|
|
// Return empty - no column-level security for now
|
|
return []security.ColumnSecurity{}, nil
|
|
}
|
|
|
|
// GetRowSecurity returns row security rules (implements security.RowSecurityProvider)
|
|
func (sp *SecurityProvider) GetRowSecurity(ctx context.Context, userID int, schema, table string) (security.RowSecurity, error) {
|
|
// If userID is 0, it's JWT auth with UUID - allow admin access for now
|
|
if userID == 0 {
|
|
return security.RowSecurity{
|
|
Template: "", // Empty template means no filtering (admin access)
|
|
}, nil
|
|
}
|
|
|
|
// Get user to check role
|
|
user, err := sp.userRepo.GetByID(ctx, fmt.Sprintf("%d", userID))
|
|
if err != nil {
|
|
return security.RowSecurity{}, err
|
|
}
|
|
|
|
// Admin can access all rows
|
|
if user.Role.String() == "admin" {
|
|
return security.RowSecurity{
|
|
Template: "", // Empty template means no filtering
|
|
}, nil
|
|
}
|
|
|
|
// Regular users can only access their own data
|
|
// Apply user_id filter for tables that have user_id column
|
|
userTables := []string{"api_keys", "hooks", "whatsapp_accounts"}
|
|
for _, userTable := range userTables {
|
|
if table == userTable {
|
|
return security.RowSecurity{
|
|
Template: fmt.Sprintf("user_id = %d", userID),
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
// For other tables, no additional filtering
|
|
return security.RowSecurity{
|
|
Template: "",
|
|
}, nil
|
|
}
|
|
|
|
// extractBearerToken extracts Bearer token from Authorization header
|
|
func extractBearerToken(r *http.Request) string {
|
|
auth := r.Header.Get("Authorization")
|
|
if len(auth) > 7 && strings.HasPrefix(auth, "Bearer ") {
|
|
return auth[7:]
|
|
}
|
|
return ""
|
|
}
|