This commit adds comprehensive mobile app support to enable a native Android app that won't timeout or sleep when the screen goes dark.
New Features:
- JWT-based authentication system (no WordPress session dependency)
- REST API endpoints for mobile app (agent status, queue management, call control)
- Server-Sent Events (SSE) for real-time updates to mobile app
- Firebase Cloud Messaging (FCM) integration for push notifications
- Gitea-based automatic plugin updates
- Mobile app admin settings page
New Files:
- includes/class-twp-mobile-auth.php - JWT authentication with login/refresh/logout
- includes/class-twp-mobile-api.php - REST API endpoints under /twilio-mobile/v1
- includes/class-twp-mobile-sse.php - Real-time event streaming
- includes/class-twp-fcm.php - Push notification handling
- includes/class-twp-auto-updater.php - Gitea-based auto-updates
- admin/mobile-app-settings.php - Admin configuration page
Modified Files:
- includes/class-twp-activator.php - Added twp_mobile_sessions table
- includes/class-twp-core.php - Load and initialize mobile classes
- admin/class-twp-admin.php - Added Mobile App menu item and settings page
Database Changes:
- New table: twp_mobile_sessions (stores JWT refresh tokens and FCM tokens)
API Endpoints:
- POST /twilio-mobile/v1/auth/login
- POST /twilio-mobile/v1/auth/refresh
- POST /twilio-mobile/v1/auth/logout
- GET/POST /twilio-mobile/v1/agent/status
- GET /twilio-mobile/v1/queues/state
- POST /twilio-mobile/v1/calls/{call_sid}/accept
- GET /twilio-mobile/v1/stream/events (SSE)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
458 lines
14 KiB
PHP
458 lines
14 KiB
PHP
<?php
|
|
/**
|
|
* Mobile App JWT Authentication Handler
|
|
*
|
|
* Handles JWT token generation, validation, and refresh for Android/iOS apps
|
|
*/
|
|
class TWP_Mobile_Auth {
|
|
|
|
private $secret_key;
|
|
private $token_expiry = 86400; // 24 hours in seconds
|
|
private $refresh_expiry = 2592000; // 30 days in seconds
|
|
|
|
/**
|
|
* Constructor
|
|
*/
|
|
public function __construct() {
|
|
$this->secret_key = $this->get_secret_key();
|
|
}
|
|
|
|
/**
|
|
* Get or generate JWT secret key
|
|
*/
|
|
private function get_secret_key() {
|
|
$key = get_option('twp_mobile_jwt_secret');
|
|
|
|
if (empty($key)) {
|
|
// Generate a secure random key
|
|
$key = bin2hex(random_bytes(32));
|
|
update_option('twp_mobile_jwt_secret', $key);
|
|
}
|
|
|
|
return $key;
|
|
}
|
|
|
|
/**
|
|
* Register REST API endpoints
|
|
*/
|
|
public function register_endpoints() {
|
|
add_action('rest_api_init', function() {
|
|
// Login endpoint
|
|
register_rest_route('twilio-mobile/v1', '/auth/login', array(
|
|
'methods' => 'POST',
|
|
'callback' => array($this, 'handle_login'),
|
|
'permission_callback' => '__return_true'
|
|
));
|
|
|
|
// Refresh token endpoint
|
|
register_rest_route('twilio-mobile/v1', '/auth/refresh', array(
|
|
'methods' => 'POST',
|
|
'callback' => array($this, 'handle_refresh'),
|
|
'permission_callback' => '__return_true'
|
|
));
|
|
|
|
// Logout endpoint
|
|
register_rest_route('twilio-mobile/v1', '/auth/logout', array(
|
|
'methods' => 'POST',
|
|
'callback' => array($this, 'handle_logout'),
|
|
'permission_callback' => array($this, 'verify_token')
|
|
));
|
|
|
|
// Validate token endpoint (for debugging)
|
|
register_rest_route('twilio-mobile/v1', '/auth/validate', array(
|
|
'methods' => 'GET',
|
|
'callback' => array($this, 'handle_validate'),
|
|
'permission_callback' => array($this, 'verify_token')
|
|
));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle login request
|
|
*/
|
|
public function handle_login($request) {
|
|
$username = $request->get_param('username');
|
|
$password = $request->get_param('password');
|
|
$fcm_token = $request->get_param('fcm_token'); // Optional
|
|
$device_info = $request->get_param('device_info'); // Optional
|
|
|
|
if (empty($username) || empty($password)) {
|
|
return new WP_Error('missing_credentials', 'Username and password are required', array('status' => 400));
|
|
}
|
|
|
|
// Authenticate user
|
|
$user = wp_authenticate($username, $password);
|
|
|
|
if (is_wp_error($user)) {
|
|
return new WP_Error('invalid_credentials', 'Invalid username or password', array('status' => 401));
|
|
}
|
|
|
|
// Check if user has phone agent capabilities
|
|
if (!user_can($user->ID, 'twp_access_browser_phone') && !user_can($user->ID, 'manage_options')) {
|
|
return new WP_Error('insufficient_permissions', 'User does not have phone agent access', array('status' => 403));
|
|
}
|
|
|
|
// Generate tokens
|
|
$access_token = $this->generate_token($user->ID, 'access');
|
|
$refresh_token = $this->generate_token($user->ID, 'refresh');
|
|
|
|
// Store session in database
|
|
$this->store_session($user->ID, $refresh_token, $fcm_token, $device_info);
|
|
|
|
// Get user data
|
|
$user_data = $this->get_user_data($user->ID);
|
|
|
|
return new WP_REST_Response(array(
|
|
'success' => true,
|
|
'access_token' => $access_token,
|
|
'refresh_token' => $refresh_token,
|
|
'expires_in' => $this->token_expiry,
|
|
'user' => $user_data
|
|
), 200);
|
|
}
|
|
|
|
/**
|
|
* Handle token refresh request
|
|
*/
|
|
public function handle_refresh($request) {
|
|
$refresh_token = $request->get_param('refresh_token');
|
|
|
|
if (empty($refresh_token)) {
|
|
return new WP_Error('missing_token', 'Refresh token is required', array('status' => 400));
|
|
}
|
|
|
|
// Verify refresh token
|
|
$payload = $this->decode_token($refresh_token);
|
|
|
|
if (!$payload || $payload->type !== 'refresh') {
|
|
return new WP_Error('invalid_token', 'Invalid refresh token', array('status' => 401));
|
|
}
|
|
|
|
// Check if session exists and is valid
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
|
|
|
$session = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT * FROM $table WHERE user_id = %d AND refresh_token = %s AND is_active = 1 AND expires_at > NOW()",
|
|
$payload->user_id,
|
|
$refresh_token
|
|
));
|
|
|
|
if (!$session) {
|
|
return new WP_Error('invalid_session', 'Session expired or invalid', array('status' => 401));
|
|
}
|
|
|
|
// Generate new access token
|
|
$access_token = $this->generate_token($payload->user_id, 'access');
|
|
|
|
// Update last_used timestamp
|
|
$wpdb->update(
|
|
$table,
|
|
array('last_used' => current_time('mysql')),
|
|
array('id' => $session->id),
|
|
array('%s'),
|
|
array('%d')
|
|
);
|
|
|
|
return new WP_REST_Response(array(
|
|
'success' => true,
|
|
'access_token' => $access_token,
|
|
'expires_in' => $this->token_expiry
|
|
), 200);
|
|
}
|
|
|
|
/**
|
|
* Handle logout request
|
|
*/
|
|
public function handle_logout($request) {
|
|
$user_id = $this->get_current_user_id();
|
|
|
|
if (!$user_id) {
|
|
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
|
|
}
|
|
|
|
// Get refresh token from request
|
|
$refresh_token = $request->get_param('refresh_token');
|
|
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
|
|
|
if ($refresh_token) {
|
|
// Invalidate specific session
|
|
$wpdb->update(
|
|
$table,
|
|
array('is_active' => 0),
|
|
array('user_id' => $user_id, 'refresh_token' => $refresh_token),
|
|
array('%d'),
|
|
array('%d', '%s')
|
|
);
|
|
} else {
|
|
// Invalidate all sessions for this user
|
|
$wpdb->update(
|
|
$table,
|
|
array('is_active' => 0),
|
|
array('user_id' => $user_id),
|
|
array('%d'),
|
|
array('%d')
|
|
);
|
|
}
|
|
|
|
return new WP_REST_Response(array(
|
|
'success' => true,
|
|
'message' => 'Logged out successfully'
|
|
), 200);
|
|
}
|
|
|
|
/**
|
|
* Handle token validation request
|
|
*/
|
|
public function handle_validate($request) {
|
|
$user_id = $this->get_current_user_id();
|
|
|
|
if (!$user_id) {
|
|
return new WP_Error('unauthorized', 'Invalid token', array('status' => 401));
|
|
}
|
|
|
|
$user_data = $this->get_user_data($user_id);
|
|
|
|
return new WP_REST_Response(array(
|
|
'success' => true,
|
|
'valid' => true,
|
|
'user' => $user_data
|
|
), 200);
|
|
}
|
|
|
|
/**
|
|
* Generate JWT token
|
|
*/
|
|
private function generate_token($user_id, $type = 'access') {
|
|
$issued_at = time();
|
|
$expiry = $type === 'refresh' ? $this->refresh_expiry : $this->token_expiry;
|
|
|
|
$payload = array(
|
|
'iat' => $issued_at,
|
|
'exp' => $issued_at + $expiry,
|
|
'user_id' => $user_id,
|
|
'type' => $type
|
|
);
|
|
|
|
return $this->encode_token($payload);
|
|
}
|
|
|
|
/**
|
|
* Simple JWT encoding (header.payload.signature)
|
|
*/
|
|
private function encode_token($payload) {
|
|
$header = array('typ' => 'JWT', 'alg' => 'HS256');
|
|
|
|
$segments = array();
|
|
$segments[] = $this->base64url_encode(json_encode($header));
|
|
$segments[] = $this->base64url_encode(json_encode($payload));
|
|
|
|
$signing_input = implode('.', $segments);
|
|
$signature = hash_hmac('sha256', $signing_input, $this->secret_key, true);
|
|
$segments[] = $this->base64url_encode($signature);
|
|
|
|
return implode('.', $segments);
|
|
}
|
|
|
|
/**
|
|
* Simple JWT decoding
|
|
*/
|
|
private function decode_token($token) {
|
|
$segments = explode('.', $token);
|
|
|
|
if (count($segments) !== 3) {
|
|
return false;
|
|
}
|
|
|
|
list($header_b64, $payload_b64, $signature_b64) = $segments;
|
|
|
|
// Verify signature
|
|
$signing_input = $header_b64 . '.' . $payload_b64;
|
|
$signature = $this->base64url_decode($signature_b64);
|
|
$expected_signature = hash_hmac('sha256', $signing_input, $this->secret_key, true);
|
|
|
|
if (!hash_equals($signature, $expected_signature)) {
|
|
return false;
|
|
}
|
|
|
|
// Decode payload
|
|
$payload = json_decode($this->base64url_decode($payload_b64));
|
|
|
|
if (!$payload) {
|
|
return false;
|
|
}
|
|
|
|
// Check expiration
|
|
if (isset($payload->exp) && $payload->exp < time()) {
|
|
return false;
|
|
}
|
|
|
|
return $payload;
|
|
}
|
|
|
|
/**
|
|
* Base64 URL encode
|
|
*/
|
|
private function base64url_encode($data) {
|
|
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
|
|
}
|
|
|
|
/**
|
|
* Base64 URL decode
|
|
*/
|
|
private function base64url_decode($data) {
|
|
return base64_decode(strtr($data, '-_', '+/'));
|
|
}
|
|
|
|
/**
|
|
* Verify token (permission callback)
|
|
*/
|
|
public function verify_token($request) {
|
|
$auth_header = $request->get_header('Authorization');
|
|
|
|
if (empty($auth_header)) {
|
|
return false;
|
|
}
|
|
|
|
// Extract token from "Bearer <token>"
|
|
if (preg_match('/Bearer\s+(.*)$/i', $auth_header, $matches)) {
|
|
$token = $matches[1];
|
|
} else {
|
|
return false;
|
|
}
|
|
|
|
$payload = $this->decode_token($token);
|
|
|
|
if (!$payload || $payload->type !== 'access') {
|
|
return false;
|
|
}
|
|
|
|
// Store user ID for later use
|
|
$request->set_param('_twp_user_id', $payload->user_id);
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Get current user ID from token
|
|
*/
|
|
public function get_current_user_id() {
|
|
$request = rest_get_server()->get_request();
|
|
return $request->get_param('_twp_user_id');
|
|
}
|
|
|
|
/**
|
|
* Store session in database
|
|
*/
|
|
private function store_session($user_id, $refresh_token, $fcm_token = null, $device_info = null) {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
|
|
|
$wpdb->insert(
|
|
$table,
|
|
array(
|
|
'user_id' => $user_id,
|
|
'refresh_token' => $refresh_token,
|
|
'fcm_token' => $fcm_token,
|
|
'device_info' => $device_info,
|
|
'created_at' => current_time('mysql'),
|
|
'expires_at' => date('Y-m-d H:i:s', time() + $this->refresh_expiry),
|
|
'last_used' => current_time('mysql'),
|
|
'is_active' => 1
|
|
),
|
|
array('%d', '%s', '%s', '%s', '%s', '%s', '%s', '%d')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get user data for response
|
|
*/
|
|
private function get_user_data($user_id) {
|
|
$user = get_userdata($user_id);
|
|
|
|
if (!$user) {
|
|
return null;
|
|
}
|
|
|
|
// Get agent phone number
|
|
$agent_number = get_user_meta($user_id, 'twp_agent_phone', true);
|
|
|
|
// Get agent status
|
|
global $wpdb;
|
|
$status_table = $wpdb->prefix . 'twp_agent_status';
|
|
$status = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT status, is_logged_in, current_call_sid FROM $status_table WHERE user_id = %d",
|
|
$user_id
|
|
));
|
|
|
|
// Get user extension
|
|
$ext_table = $wpdb->prefix . 'twp_user_extensions';
|
|
$extension = $wpdb->get_row($wpdb->prepare(
|
|
"SELECT extension, direct_dial_number FROM $ext_table WHERE user_id = %d",
|
|
$user_id
|
|
));
|
|
|
|
return array(
|
|
'id' => $user->ID,
|
|
'username' => $user->user_login,
|
|
'display_name' => $user->display_name,
|
|
'email' => $user->user_email,
|
|
'phone_number' => $agent_number,
|
|
'extension' => $extension ? $extension->extension : null,
|
|
'direct_dial' => $extension ? $extension->direct_dial_number : null,
|
|
'status' => $status ? $status->status : 'offline',
|
|
'is_logged_in' => $status ? (bool)$status->is_logged_in : false,
|
|
'current_call_sid' => $status ? $status->current_call_sid : null,
|
|
'capabilities' => array(
|
|
'can_access_browser_phone' => user_can($user_id, 'twp_access_browser_phone'),
|
|
'can_access_voicemails' => user_can($user_id, 'twp_access_voicemails'),
|
|
'can_access_call_log' => user_can($user_id, 'twp_access_call_log'),
|
|
'can_access_agent_queue' => user_can($user_id, 'twp_access_agent_queue'),
|
|
'can_access_sms_inbox' => user_can($user_id, 'twp_access_sms_inbox'),
|
|
'is_admin' => user_can($user_id, 'manage_options')
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update FCM token for existing session
|
|
*/
|
|
public function update_fcm_token($user_id, $refresh_token, $fcm_token) {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
|
|
|
$wpdb->update(
|
|
$table,
|
|
array('fcm_token' => $fcm_token),
|
|
array('user_id' => $user_id, 'refresh_token' => $refresh_token, 'is_active' => 1),
|
|
array('%s'),
|
|
array('%d', '%s', '%d')
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get all active FCM tokens for a user
|
|
*/
|
|
public function get_user_fcm_tokens($user_id) {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
|
|
|
return $wpdb->get_col($wpdb->prepare(
|
|
"SELECT fcm_token FROM $table WHERE user_id = %d AND is_active = 1 AND fcm_token IS NOT NULL AND expires_at > NOW()",
|
|
$user_id
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Clean up expired sessions
|
|
*/
|
|
public static function cleanup_expired_sessions() {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . 'twp_mobile_sessions';
|
|
|
|
$wpdb->query("UPDATE $table SET is_active = 0 WHERE expires_at < NOW() AND is_active = 1");
|
|
}
|
|
}
|