mirror of
https://github.com/bitechdev/ResolveSpec.git
synced 2026-02-01 07:24:25 +00:00
feat(auth): ✨ add user registration functionality
* Implemented resolvespec_register stored procedure for user registration. * Added RegisterRequest struct for registration data. * Created Register method in DatabaseAuthenticator. * Updated tests for successful registration and error handling for duplicate usernames and emails.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user