refactor(API): ✨ Relspect integration
This commit is contained in:
271
pkg/api/security.go
Normal file
271
pkg/api/security.go
Normal file
@@ -0,0 +1,271 @@
|
||||
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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user