diff --git a/pkg/security/QUICK_REFERENCE.md b/pkg/security/QUICK_REFERENCE.md index 9eb1979..34dca47 100644 --- a/pkg/security/QUICK_REFERENCE.md +++ b/pkg/security/QUICK_REFERENCE.md @@ -30,6 +30,7 @@ router.Use(security.SetSecurityMiddleware(securityList)) ```go // DatabaseAuthenticator uses these stored procedures: resolvespec_login(jsonb) // Login with credentials +resolvespec_register(jsonb) // Register new user resolvespec_logout(jsonb) // Invalidate session resolvespec_session(text, text) // Validate session token resolvespec_session_update(text, jsonb) // Update activity timestamp @@ -502,10 +503,31 @@ func (p *MyProvider) GetColumnSecurity(ctx context.Context, userID int, schema, --- -## Login/Logout Endpoints +## Login/Logout/Register Endpoints ```go func SetupAuthRoutes(router *mux.Router, securityList *security.SecurityList) { + // Register + router.HandleFunc("/auth/register", func(w http.ResponseWriter, r *http.Request) { + var req security.RegisterRequest + json.NewDecoder(r.Body).Decode(&req) + + // Check if provider supports registration + registrable, ok := securityList.Provider().(security.Registrable) + if !ok { + http.Error(w, "Registration not supported", http.StatusNotImplemented) + return + } + + resp, err := registrable.Register(r.Context(), req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + json.NewEncoder(w).Encode(resp) + }).Methods("POST") + // Login router.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) { var req security.LoginRequest diff --git a/pkg/security/database_schema.sql b/pkg/security/database_schema.sql index a66a787..7b5b04e 100644 --- a/pkg/security/database_schema.sql +++ b/pkg/security/database_schema.sql @@ -396,10 +396,113 @@ BEGIN END; $$ LANGUAGE plpgsql; +-- 10. resolvespec_register - Registers a new user and creates session +-- Input: RegisterRequest as jsonb {username: string, password: string, email: string, user_level: int, roles: array, claims: object, meta: object} +-- Output: p_success (bool), p_error (text), p_data (LoginResponse as jsonb) +CREATE OR REPLACE FUNCTION resolvespec_register(p_request jsonb) +RETURNS TABLE(p_success boolean, p_error text, p_data jsonb) AS $$ +DECLARE + v_user_id INTEGER; + v_username TEXT; + v_email TEXT; + v_password TEXT; + v_user_level INTEGER; + v_roles TEXT; + v_session_token TEXT; + v_expires_at TIMESTAMP; + v_ip_address TEXT; + v_user_agent TEXT; + v_roles_array TEXT[]; +BEGIN + -- Extract registration request fields + v_username := p_request->>'username'; + v_email := p_request->>'email'; + v_password := p_request->>'password'; + v_user_level := COALESCE((p_request->>'user_level')::integer, 0); + v_ip_address := p_request->'claims'->>'ip_address'; + v_user_agent := p_request->'claims'->>'user_agent'; + + -- Convert roles array from JSON to comma-separated string + SELECT array_to_string(ARRAY(SELECT jsonb_array_elements_text(p_request->'roles')), ',') + INTO v_roles; + + -- Validate required fields + IF v_username IS NULL OR v_username = '' THEN + RETURN QUERY SELECT false, 'Username is required'::text, NULL::jsonb; + RETURN; + END IF; + + IF v_email IS NULL OR v_email = '' THEN + RETURN QUERY SELECT false, 'Email is required'::text, NULL::jsonb; + RETURN; + END IF; + + IF v_password IS NULL OR v_password = '' THEN + RETURN QUERY SELECT false, 'Password is required'::text, NULL::jsonb; + RETURN; + END IF; + + -- Check if username already exists + IF EXISTS (SELECT 1 FROM users WHERE username = v_username) THEN + RETURN QUERY SELECT false, 'Username already exists'::text, NULL::jsonb; + RETURN; + END IF; + + -- Check if email already exists + IF EXISTS (SELECT 1 FROM users WHERE email = v_email) THEN + RETURN QUERY SELECT false, 'Email already exists'::text, NULL::jsonb; + RETURN; + END IF; + + -- TODO: Hash password using pgcrypto extension + -- Enable pgcrypto: CREATE EXTENSION IF NOT EXISTS pgcrypto; + -- v_password := crypt(v_password, gen_salt('bf')); + + -- Create new user + INSERT INTO users (username, email, password, user_level, roles, is_active, created_at, updated_at) + VALUES (v_username, v_email, v_password, v_user_level, v_roles, true, now(), now()) + RETURNING id INTO v_user_id; + + -- Generate session token + v_session_token := 'sess_' || encode(gen_random_bytes(32), 'hex') || '_' || extract(epoch from now())::bigint::text; + v_expires_at := now() + interval '24 hours'; + + -- Create session + INSERT INTO user_sessions (session_token, user_id, expires_at, ip_address, user_agent, last_activity_at) + VALUES (v_session_token, v_user_id, v_expires_at, v_ip_address, v_user_agent, now()); + + -- Update last login time + UPDATE users SET last_login_at = now() WHERE id = v_user_id; + + -- Return success with LoginResponse + RETURN QUERY SELECT + true, + NULL::text, + jsonb_build_object( + 'token', v_session_token, + 'user', jsonb_build_object( + 'user_id', v_user_id, + 'user_name', v_username, + 'email', v_email, + 'user_level', v_user_level, + 'roles', string_to_array(COALESCE(v_roles, ''), ','), + 'session_id', v_session_token + ), + 'expires_in', 86400 -- 24 hours in seconds + ); +EXCEPTION + WHEN OTHERS THEN + RETURN QUERY SELECT false, SQLERRM::text, NULL::jsonb; +END; +$$ LANGUAGE plpgsql; + -- ============================================ -- Example: Test stored procedures -- ============================================ +-- Test register +-- SELECT * FROM resolvespec_register('{"username": "newuser", "password": "test123", "email": "newuser@example.com", "user_level": 1, "roles": ["user"], "claims": {"ip_address": "127.0.0.1", "user_agent": "test"}}'::jsonb); + -- Test login -- SELECT * FROM resolvespec_login('{"username": "admin", "password": "test123", "claims": {"ip_address": "127.0.0.1", "user_agent": "test"}}'::jsonb); diff --git a/pkg/security/interfaces.go b/pkg/security/interfaces.go index 012cafb..8b1a7f1 100644 --- a/pkg/security/interfaces.go +++ b/pkg/security/interfaces.go @@ -27,6 +27,17 @@ type LoginRequest struct { Meta map[string]any `json:"meta"` // Additional metadata to be set on user context } +// RegisterRequest contains information for new user registration +type RegisterRequest struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + UserLevel int `json:"user_level"` + Roles []string `json:"roles"` + Claims map[string]any `json:"claims"` // Additional registration data + Meta map[string]any `json:"meta"` // Additional metadata +} + // LoginResponse contains the result of a login attempt type LoginResponse struct { Token string `json:"token"` @@ -55,6 +66,12 @@ type Authenticator interface { Authenticate(r *http.Request) (*UserContext, error) } +// Registrable allows providers to support user registration +type Registrable interface { + // Register creates a new user account + Register(ctx context.Context, req RegisterRequest) (*LoginResponse, error) +} + // ColumnSecurityProvider handles column-level security (masking/hiding) type ColumnSecurityProvider interface { // GetColumnSecurity loads column security rules for a user and entity diff --git a/pkg/security/providers.go b/pkg/security/providers.go index df8fc12..c94dc2b 100644 --- a/pkg/security/providers.go +++ b/pkg/security/providers.go @@ -132,6 +132,41 @@ func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*L return &response, nil } +// Register implements Registrable interface +func (a *DatabaseAuthenticator) Register(ctx context.Context, req RegisterRequest) (*LoginResponse, error) { + // Convert RegisterRequest to JSON + reqJSON, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal register request: %w", err) + } + + // Call resolvespec_register stored procedure + var success bool + var errorMsg sql.NullString + var dataJSON sql.NullString + + query := `SELECT p_success, p_error, p_data::text FROM resolvespec_register($1::jsonb)` + err = a.db.QueryRowContext(ctx, query, string(reqJSON)).Scan(&success, &errorMsg, &dataJSON) + if err != nil { + return nil, fmt.Errorf("register query failed: %w", err) + } + + if !success { + if errorMsg.Valid { + return nil, fmt.Errorf("%s", errorMsg.String) + } + return nil, fmt.Errorf("registration failed") + } + + // Parse response + var response LoginResponse + if err := json.Unmarshal([]byte(dataJSON.String), &response); err != nil { + return nil, fmt.Errorf("failed to parse register response: %w", err) + } + + return &response, nil +} + func (a *DatabaseAuthenticator) Logout(ctx context.Context, req LogoutRequest) error { // Convert LogoutRequest to JSON reqJSON, err := json.Marshal(req) diff --git a/pkg/security/providers_test.go b/pkg/security/providers_test.go index 27d497b..b20f193 100644 --- a/pkg/security/providers_test.go +++ b/pkg/security/providers_test.go @@ -635,6 +635,94 @@ func TestDatabaseAuthenticator(t *testing.T) { t.Errorf("unfulfilled expectations: %v", err) } }) + + t.Run("successful registration", func(t *testing.T) { + ctx := context.Background() + req := RegisterRequest{ + Username: "newuser", + Password: "password123", + Email: "newuser@example.com", + UserLevel: 1, + Roles: []string{"user"}, + } + + rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}). + AddRow(true, nil, `{"token":"abc123","user":{"user_id":1,"user_name":"newuser","email":"newuser@example.com"},"expires_in":86400}`) + + mock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_register`). + WithArgs(sqlmock.AnyArg()). + WillReturnRows(rows) + + resp, err := auth.Register(ctx, req) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if resp.Token != "abc123" { + t.Errorf("expected token abc123, got %s", resp.Token) + } + if resp.User.UserName != "newuser" { + t.Errorf("expected username newuser, got %s", resp.User.UserName) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } + }) + + t.Run("registration with duplicate username", func(t *testing.T) { + ctx := context.Background() + req := RegisterRequest{ + Username: "existinguser", + Password: "password123", + Email: "new@example.com", + UserLevel: 1, + Roles: []string{"user"}, + } + + rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}). + AddRow(false, "Username already exists", nil) + + mock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_register`). + WithArgs(sqlmock.AnyArg()). + WillReturnRows(rows) + + _, err := auth.Register(ctx, req) + if err == nil { + t.Fatal("expected error for duplicate username") + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } + }) + + t.Run("registration with duplicate email", func(t *testing.T) { + ctx := context.Background() + req := RegisterRequest{ + Username: "newuser2", + Password: "password123", + Email: "existing@example.com", + UserLevel: 1, + Roles: []string{"user"}, + } + + rows := sqlmock.NewRows([]string{"p_success", "p_error", "p_data"}). + AddRow(false, "Email already exists", nil) + + mock.ExpectQuery(`SELECT p_success, p_error, p_data::text FROM resolvespec_register`). + WithArgs(sqlmock.AnyArg()). + WillReturnRows(rows) + + _, err := auth.Register(ctx, req) + if err == nil { + t.Fatal("expected error for duplicate email") + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %v", err) + } + }) } // Test DatabaseAuthenticator RefreshToken