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 int `json:"user_id"` Username string `json:"username"` Role string `json:"role"` jwt.RegisteredClaims } // GenerateToken generates a JWT token func (sp *SecurityProvider) GenerateToken(userID int, 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: fmt.Sprintf("%d", 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(int(user.ID.Int64()), 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: int(user.ID.Int64()), UserName: req.Username, Email: user.Email.String(), Roles: []string{user.Role.String()}, Claims: map[string]any{ "role": user.Role.String(), }, } 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(), fmt.Sprintf("%d", claims.UserID)) if err == nil && user.Active { return &security.UserContext{ UserID: claims.UserID, UserName: claims.Username, Email: user.Email.String(), Roles: []string{user.Role.String()}, Claims: map[string]any{ "role": user.Role.String(), }, }, 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) { // 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 "" }