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:
2026-01-31 21:50:32 +02:00
parent 0cc3635466
commit 0b8d11361c
5 changed files with 266 additions and 1 deletions

View File

@@ -30,6 +30,7 @@ router.Use(security.SetSecurityMiddleware(securityList))
```go ```go
// DatabaseAuthenticator uses these stored procedures: // DatabaseAuthenticator uses these stored procedures:
resolvespec_login(jsonb) // Login with credentials resolvespec_login(jsonb) // Login with credentials
resolvespec_register(jsonb) // Register new user
resolvespec_logout(jsonb) // Invalidate session resolvespec_logout(jsonb) // Invalidate session
resolvespec_session(text, text) // Validate session token resolvespec_session(text, text) // Validate session token
resolvespec_session_update(text, jsonb) // Update activity timestamp 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 ```go
func SetupAuthRoutes(router *mux.Router, securityList *security.SecurityList) { 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 // Login
router.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) { router.HandleFunc("/auth/login", func(w http.ResponseWriter, r *http.Request) {
var req security.LoginRequest var req security.LoginRequest

View File

@@ -396,10 +396,113 @@ BEGIN
END; END;
$$ LANGUAGE plpgsql; $$ 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 -- 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 -- Test login
-- SELECT * FROM resolvespec_login('{"username": "admin", "password": "test123", "claims": {"ip_address": "127.0.0.1", "user_agent": "test"}}'::jsonb); -- SELECT * FROM resolvespec_login('{"username": "admin", "password": "test123", "claims": {"ip_address": "127.0.0.1", "user_agent": "test"}}'::jsonb);

View File

@@ -27,6 +27,17 @@ type LoginRequest struct {
Meta map[string]any `json:"meta"` // Additional metadata to be set on user context 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 // LoginResponse contains the result of a login attempt
type LoginResponse struct { type LoginResponse struct {
Token string `json:"token"` Token string `json:"token"`
@@ -55,6 +66,12 @@ type Authenticator interface {
Authenticate(r *http.Request) (*UserContext, error) 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) // ColumnSecurityProvider handles column-level security (masking/hiding)
type ColumnSecurityProvider interface { type ColumnSecurityProvider interface {
// GetColumnSecurity loads column security rules for a user and entity // GetColumnSecurity loads column security rules for a user and entity

View File

@@ -132,6 +132,41 @@ func (a *DatabaseAuthenticator) Login(ctx context.Context, req LoginRequest) (*L
return &response, nil 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 { func (a *DatabaseAuthenticator) Logout(ctx context.Context, req LogoutRequest) error {
// Convert LogoutRequest to JSON // Convert LogoutRequest to JSON
reqJSON, err := json.Marshal(req) reqJSON, err := json.Marshal(req)

View File

@@ -635,6 +635,94 @@ func TestDatabaseAuthenticator(t *testing.T) {
t.Errorf("unfulfilled expectations: %v", err) 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 // Test DatabaseAuthenticator RefreshToken