2024-11-25 15:25:00 +01:00
<? php
session_start ();
2025-09-20 12:43:26 +02:00
error_reporting ( E_ALL );
ini_set ( 'display_errors' , 1 );
2024-11-25 15:25:00 +01:00
2025-09-20 22:00:24 +02:00
// Define whether running in local/PHP-Desktop environment
2025-10-17 14:25:57 +02:00
define ( 'LOCAL_APP' , 1 ); // Set to 1 for PHP-Desktop, 0 for web server
2025-09-20 22:00:24 +02:00
2026-06-12 09:37:18 +02:00
2026-06-16 21:52:45 +02:00
// HTTP proxy support (leave $proxy empty if not needed).
// For an authenticated proxy set $proxy_userpwd to "username:password".
$proxy = '' ; // e.g. 'http://proxy.company.com:8080'
$proxy_userpwd = '' ; // e.g. 'username:password'
2026-06-12 09:37:18 +02:00
2026-06-16 21:52:45 +02:00
function applyProxySettings ( $ch ) {
global $proxy , $proxy_userpwd ;
if ( ! empty ( $proxy )) {
curl_setopt ( $ch , CURLOPT_PROXY , $proxy );
curl_setopt ( $ch , CURLOPT_PROXYTYPE , CURLPROXY_HTTP );
if ( ! empty ( $proxy_userpwd )) {
curl_setopt ( $ch , CURLOPT_PROXYUSERPWD , $proxy_userpwd );
}
}
}
2026-06-12 09:37:18 +02:00
2026-06-10 05:17:04 +02:00
// Server-side debug logging. When enabled, diagnostic messages are appended
// to the file below instead of being sent to stderr/error_log: on some
// SAPIs (notably IIS FastCGI with fastcgi.logging=1) any stderr output
// fails the request with HTTP 500 and leaks the message into the response.
// The default location is the system temp directory so the log never lands
// inside the web root or the git repository.
define ( 'DEBUG_LOG' , 0 ); // Set to 1 to enable diagnostic logging
define ( 'DEBUG_LOG_FILE' , sys_get_temp_dir () . DIRECTORY_SEPARATOR . 'token2_debug.log' );
function debugLog ( $message )
{
if ( DEBUG_LOG ) {
@ file_put_contents ( DEBUG_LOG_FILE , '[' . date ( 'Y-m-d H:i:s' ) . '] ' . $message . PHP_EOL , FILE_APPEND | LOCK_EX );
}
}
2024-11-25 15:25:00 +01:00
// Handle logout
if ( isset ( $_GET [ 'logout' ])) {
session_destroy ();
2025-09-20 22:00:24 +02:00
// Clear credential cookies
setcookie ( 'tenantId' , '' , time () - 3600 , '/' , '' , true , true );
setcookie ( 'clientId' , '' , time () - 3600 , '/' , '' , true , true );
2024-11-25 15:25:00 +01:00
header ( 'Location: ' . $_SERVER [ 'PHP_SELF' ]);
exit ;
}
2025-09-20 12:43:26 +02:00
// Handle settings update via form submission
if ( isset ( $_GET [ 'action' ]) && $_GET [ 'action' ] === 'update_settings_form' ) {
2024-11-25 15:25:00 +01:00
if ( $_SERVER [ 'REQUEST_METHOD' ] === 'POST' ) {
2025-09-20 22:00:24 +02:00
$tenantId = trim ( $_POST [ 'tenantId' ]);
$clientId = trim ( $_POST [ 'clientId' ]);
// Save tenant ID and client ID in cookies (30 days)
setcookie ( 'tenantId' , $tenantId , time () + ( 30 * 24 * 60 * 60 ), '/' , '' , true , true );
setcookie ( 'clientId' , $clientId , time () + ( 30 * 24 * 60 * 60 ), '/' , '' , true , true );
2026-06-02 18:14:53 +02:00
// Store credentials in session (never in cookies)
saveAuthFromPost ( $_POST );
2025-09-20 12:43:26 +02:00
$_SESSION [ 'showLogs' ] = isset ( $_POST [ 'showLogs' ]) ? true : false ;
// Redirect back to main page with success message
header ( 'Location: ' . $_SERVER [ 'PHP_SELF' ] . '?settings_updated=1' );
exit ;
2024-11-25 15:25:00 +01:00
}
2025-09-20 12:43:26 +02:00
}
2024-11-25 15:25:00 +01:00
2025-09-20 12:43:26 +02:00
// Handle settings update via AJAX (keeping both for compatibility)
if ( isset ( $_GET [ 'action' ]) && $_GET [ 'action' ] === 'update_settings' ) {
header ( 'Content-Type: application/json' );
if ( $_SERVER [ 'REQUEST_METHOD' ] === 'POST' ) {
2025-09-20 22:00:24 +02:00
$tenantId = trim ( $_POST [ 'tenantId' ]);
$clientId = trim ( $_POST [ 'clientId' ]);
// Save tenant ID and client ID in cookies (30 days)
setcookie ( 'tenantId' , $tenantId , time () + ( 30 * 24 * 60 * 60 ), '/' , '' , true , true );
setcookie ( 'clientId' , $clientId , time () + ( 30 * 24 * 60 * 60 ), '/' , '' , true , true );
2026-06-02 18:14:53 +02:00
// Store credentials in session (never in cookies)
saveAuthFromPost ( $_POST );
2025-09-20 12:43:26 +02:00
$_SESSION [ 'showLogs' ] = isset ( $_POST [ 'showLogs' ]) ? true : false ;
2025-09-20 22:00:24 +02:00
2025-09-20 12:43:26 +02:00
echo json_encode ([ 'success' => true , 'message' => 'Settings updated successfully' ]);
} else {
echo json_encode ([ 'success' => false , 'message' => 'Invalid request method' ]);
}
2024-11-25 15:25:00 +01:00
exit ;
}
2025-09-20 22:00:24 +02:00
// Check for saved credentials in cookies
$savedTenantId = $_COOKIE [ 'tenantId' ] ?? '' ;
$savedClientId = $_COOKIE [ 'clientId' ] ?? '' ;
2025-09-20 12:43:26 +02:00
// Ask for Microsoft credentials if not stored
2026-06-02 18:14:53 +02:00
if ( empty ( $savedTenantId ) || empty ( $savedClientId ) || ! authConfigured ()) {
2025-09-20 12:43:26 +02:00
if ( $_SERVER [ 'REQUEST_METHOD' ] === 'POST' && isset ( $_POST [ 'tenantId' ])) {
2025-09-20 22:00:24 +02:00
$tenantId = trim ( $_POST [ 'tenantId' ]);
$clientId = trim ( $_POST [ 'clientId' ]);
// Save tenant ID and client ID in cookies (30 days)
setcookie ( 'tenantId' , $tenantId , time () + ( 30 * 24 * 60 * 60 ), '/' , '' , true , true );
setcookie ( 'clientId' , $clientId , time () + ( 30 * 24 * 60 * 60 ), '/' , '' , true , true );
2026-06-02 18:14:53 +02:00
// Store credentials in session (never in cookies)
saveAuthFromPost ( $_POST );
2025-09-20 12:43:26 +02:00
$_SESSION [ 'showLogs' ] = isset ( $_POST [ 'showLogs' ]) ? true : false ;
2025-09-20 22:00:24 +02:00
2025-09-20 12:43:26 +02:00
header ( 'Location: ' . $_SERVER [ 'PHP_SELF' ]);
exit ;
}
echo '<!DOCTYPE html>
<html>
<head>
<title>Enter Credentials</title>
<link rel="stylesheet" href="assets/bootstrap.min.css">
<script src="assets/bootstrap.bundle.min.js"></script>
<link rel="shortcut icon" href="favico.png">
</head>
<body>
<div class="container mt-4">
<div class="row">
<!-- Form column -->
<div class="col-md-6">
<h3>Enter Microsoft Graph Credentials</h3>
<form method="POST">
<div class="form-group mb-3">
<label>Tenant ID:</label>
2025-09-20 22:00:24 +02:00
<input type="text" name="tenantId" class="form-control" value="' . htmlspecialchars ( $savedTenantId ) . '" required>
2025-09-20 12:43:26 +02:00
</div>
<div class="form-group mb-3">
<label>Client ID:</label>
2025-09-20 22:00:24 +02:00
<input type="text" name="clientId" class="form-control" value="' . htmlspecialchars ( $savedClientId ) . '" required>
2025-09-20 12:43:26 +02:00
</div>
<div class="form-group mb-3">
2026-06-02 18:14:53 +02:00
<label>Authentication method:</label>
<select name="authMethod" id="authMethod" class="form-control" onchange="toggleAuthFields()">
<option value="secret" selected>Client Secret</option>
<option value="certificate">Certificate</option>
</select>
</div>
<div id="secretFields">
<div class="form-group mb-3">
<label>Client Secret:</label>
<input type="password" name="clientSecret" class="form-control">
</div>
</div>
<div id="certFields" style="display:none;">
<div class="form-group mb-3">
<label>Private key file path (PEM):</label>
<input type="text" name="privateKeyPath" class="form-control" placeholder="C:\\path\\to\\private_key.pem">
</div>
<div class="form-group mb-3">
<label>Private key passphrase (optional):</label>
<input type="password" name="privateKeyPassphrase" class="form-control">
</div>
<div class="form-group mb-3">
<label>Certificate thumbprint (hex, from Entra ID):</label>
<input type="text" name="certThumbprint" class="form-control" placeholder="e.g. A1B2C3...">
</div>
2025-09-20 12:43:26 +02:00
</div>
<div class="form-group mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="showLogs" name="showLogs" checked>
<label class="form-check-label" for="showLogs">
Show operation logs
</label>
</div>
</div>
<button type="submit" class="btn btn-primary mt-2">Save & Continue</button>
</form>
2026-06-02 18:14:53 +02:00
<script>
function toggleAuthFields() {
var m = document.getElementById("authMethod").value;
document.getElementById("secretFields").style.display = (m === "secret") ? "block" : "none";
document.getElementById("certFields").style.display = (m === "certificate") ? "block" : "none";
}
toggleAuthFields();
</script>
2025-09-20 12:43:26 +02:00
</div>
2024-11-25 15:25:00 +01:00
2025-09-20 12:43:26 +02:00
<!-- README Tabs column -->
<div class="col-md-6">
<h4><br></h4>
<div class="step">
<strong>API permissions required:</strong>
<ul>
<li><code>Policy.ReadWrite.AuthenticationMethod</code></li>
<li><code>UserAuthenticationMethod.ReadWrite.All</code></li>
<li><code>User.Read.All</code></li>
<li><code>Directory.Read.All</code></li>
</ul>
<strong>Grant admin consent for all permissions</strong>
</div>
</div>
</div>
2025-09-20 22:00:24 +02:00
</div><div style="position: fixed; bottom: 10px; right: 10px; font-size: 14px; color: #555;">
2025-10-17 14:25:57 +02:00
© Token2 | <a target=_blank href=https://www.token2.swiss/site/page/totp-token-inventory-automate-token2-classic-oath-token-activation-in-microsoft-entra-id>🛈 About this tool</a>
2025-09-20 12:43:26 +02:00
</div>
</body>
</html>' ;
exit ;
2024-11-25 15:25:00 +01:00
2025-09-20 12:43:26 +02:00
}
2024-11-25 15:25:00 +01:00
2025-09-20 12:43:26 +02:00
// Initialize showLogs setting if not set
if ( ! isset ( $_SESSION [ 'showLogs' ])) {
$_SESSION [ 'showLogs' ] = true ;
}
2025-09-20 22:00:24 +02:00
// Credentials - get from cookies and session
$tenantId = $_COOKIE [ 'tenantId' ] ?? '' ;
$clientId = $_COOKIE [ 'clientId' ] ?? '' ;
$clientSecret = $_SESSION [ 'clientSecret' ] ?? '' ;
2025-09-20 12:43:26 +02:00
2026-06-02 18:14:53 +02:00
/**
* Persist auth settings from a POST array into the session.
* Secret OR certificate config is kept server-side only (never in cookies).
*/
function saveAuthFromPost ( $post )
{
$method = ( $post [ 'authMethod' ] ?? 'secret' ) === 'certificate' ? 'certificate' : 'secret' ;
$_SESSION [ 'authMethod' ] = $method ;
if ( $method === 'certificate' ) {
$_SESSION [ 'clientSecret' ] = '' ;
$_SESSION [ 'privateKeyPath' ] = trim ( $post [ 'privateKeyPath' ] ?? '' );
$_SESSION [ 'certThumbprint' ] = trim ( $post [ 'certThumbprint' ] ?? '' );
// Only overwrite the passphrase if a new one was supplied
if ( isset ( $post [ 'privateKeyPassphrase' ]) && $post [ 'privateKeyPassphrase' ] !== '' ) {
$_SESSION [ 'privateKeyPassphrase' ] = $post [ 'privateKeyPassphrase' ];
}
} else {
// Only overwrite the secret if a new one was supplied (lets users
// save settings without re-typing it every time)
if ( isset ( $post [ 'clientSecret' ]) && trim ( $post [ 'clientSecret' ]) !== '' ) {
$_SESSION [ 'clientSecret' ] = trim ( $post [ 'clientSecret' ]);
}
$_SESSION [ 'privateKeyPath' ] = '' ;
$_SESSION [ 'certThumbprint' ] = '' ;
$_SESSION [ 'privateKeyPassphrase' ] = '' ;
}
}
/**
* True when enough credentials are stored for the selected auth method.
*/
function authConfigured ()
{
$method = $_SESSION [ 'authMethod' ] ?? 'secret' ;
if ( $method === 'certificate' ) {
return ! empty ( $_SESSION [ 'privateKeyPath' ]) && ! empty ( $_SESSION [ 'certThumbprint' ]);
}
return ! empty ( $_SESSION [ 'clientSecret' ]);
}
/**
* Build a signed JWT client assertion (RS256) for certificate-based auth.
* Uses only the built-in openssl extension - no Composer dependencies.
*/
function buildClientAssertion ( $tenantId , $clientId )
{
$path = $_SESSION [ 'privateKeyPath' ] ?? '' ;
$thumbprintHex = str_replace ([ ':' , ' ' ], '' , $_SESSION [ 'certThumbprint' ] ?? '' );
$passphrase = $_SESSION [ 'privateKeyPassphrase' ] ?? null ;
if ( empty ( $path ) || ! is_readable ( $path )) {
throw new Exception ( "Private key file not found or not readable: $path " );
}
if ( empty ( $thumbprintHex )) {
throw new Exception ( "Certificate thumbprint is required for certificate auth." );
}
$keyContents = file_get_contents ( $path );
$keyResource = openssl_pkey_get_private ( $keyContents , $passphrase );
if ( $keyResource === false ) {
throw new Exception ( "Could not load private key (wrong file or passphrase)." );
}
$b64url = function ( $data ) {
return rtrim ( strtr ( base64_encode ( $data ), '+/' , '-_' ), '=' );
};
// x5t header = base64url( raw bytes of SHA-1 thumbprint )
$x5t = $b64url ( hex2bin ( $thumbprintHex ));
$now = time ();
$header = [ 'alg' => 'RS256' , 'typ' => 'JWT' , 'x5t' => $x5t ];
$payload = [
'aud' => "https://login.microsoftonline.com/ $tenantId /oauth2/v2.0/token" ,
'iss' => $clientId ,
'sub' => $clientId ,
'jti' => bin2hex ( random_bytes ( 16 )),
'nbf' => $now ,
'exp' => $now + 600 ,
'iat' => $now ,
];
$signingInput = $b64url ( json_encode ( $header )) . '.' . $b64url ( json_encode ( $payload ));
$signature = '' ;
if ( ! openssl_sign ( $signingInput , $signature , $keyResource , OPENSSL_ALGO_SHA256 )) {
throw new Exception ( "Failed to sign client assertion with the private key." );
}
return $signingInput . '.' . $b64url ( $signature );
}
// Get Access Token (supports client secret OR certificate auth).
// Reads the chosen method and credentials from the session, so existing
// call sites passing ($tenantId, $clientId, $clientSecret) keep working.
2025-09-20 12:43:26 +02:00
function getAccessToken ( $tenantId , $clientId , $clientSecret )
{
2024-11-25 15:25:00 +01:00
$url = "https://login.microsoftonline.com/ $tenantId /oauth2/v2.0/token" ;
2026-06-02 18:14:53 +02:00
$method = $_SESSION [ 'authMethod' ] ?? 'secret' ;
if ( $method === 'certificate' ) {
$assertion = buildClientAssertion ( $tenantId , $clientId );
$data = [
'grant_type' => 'client_credentials' ,
'client_id' => $clientId ,
'scope' => 'https://graph.microsoft.com/.default' ,
'client_assertion_type' => 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' ,
'client_assertion' => $assertion ,
];
} else {
$data = [
'grant_type' => 'client_credentials' ,
'client_id' => $clientId ,
'client_secret' => $clientSecret ,
'scope' => 'https://graph.microsoft.com/.default' ,
];
}
2025-09-20 22:00:24 +02:00
$ch = curl_init (); if ( LOCAL_APP == 1 ) { curl_setopt ( $ch , CURLOPT_SSL_VERIFYPEER , false );
curl_setopt ( $ch , CURLOPT_SSL_VERIFYHOST , false ); }
2026-06-16 21:52:45 +02:00
applyProxySettings ( $ch );
2024-11-25 15:25:00 +01:00
curl_setopt ( $ch , CURLOPT_URL , $url );
curl_setopt ( $ch , CURLOPT_POST , true );
curl_setopt ( $ch , CURLOPT_POSTFIELDS , http_build_query ( $data ));
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
curl_setopt ( $ch , CURLOPT_HTTPHEADER , [ 'Content-Type: application/x-www-form-urlencoded' ]);
$response = curl_exec ( $ch );
2025-09-20 12:43:26 +02:00
$httpCode = curl_getinfo ( $ch , CURLINFO_HTTP_CODE );
2024-11-25 15:25:00 +01:00
curl_close ( $ch );
2025-09-20 12:43:26 +02:00
$resp = json_decode ( $response , true );
// Check for authentication errors
if ( $httpCode !== 200 || ! isset ( $resp [ 'access_token' ])) {
$error = $resp [ 'error' ] ?? 'unknown_error' ;
$errorDescription = $resp [ 'error_description' ] ?? 'Unknown authentication error' ;
// Log the specific error for debugging
2026-06-10 05:17:04 +02:00
debugLog ( "Authentication failed: HTTP $httpCode - Error: $error - Description: $errorDescription " );
2025-09-20 12:43:26 +02:00
// Check for common credential errors
if ( strpos ( $errorDescription , 'AADSTS70002' ) !== false || strpos ( $errorDescription , 'invalid_client' ) !== false ) {
throw new Exception ( "Invalid Client ID or Client Secret. Please check your credentials in Settings." );
} elseif ( strpos ( $errorDescription , 'AADSTS90002' ) !== false || strpos ( $errorDescription , 'invalid_tenant' ) !== false ) {
throw new Exception ( "Invalid Tenant ID. Please check your tenant ID in Settings." );
} elseif ( strpos ( $error , 'invalid_client' ) !== false ) {
throw new Exception ( "Invalid credentials. Please verify your Tenant ID, Client ID, and Client Secret in Settings." );
} else {
throw new Exception ( "Authentication failed: " . $errorDescription );
}
}
return $resp [ 'access_token' ];
2024-11-25 15:25:00 +01:00
}
2025-09-20 12:43:26 +02:00
// Graph API functions
2025-12-15 11:11:41 +01:00
function fetchTokens ( $accessToken , $nextLink = '' )
2025-09-20 12:43:26 +02:00
{
2024-11-25 15:25:00 +01:00
$url = "https://graph.microsoft.com/beta/directory/authenticationMethodDevices/hardwareOathDevices" ;
2025-12-15 11:10:14 +01:00
if ( ! empty ( $nextLink )) {
$url = $nextLink ;
}
2025-09-20 22:00:24 +02:00
$ch = curl_init (); if ( LOCAL_APP == 1 ) { curl_setopt ( $ch , CURLOPT_SSL_VERIFYPEER , false );
curl_setopt ( $ch , CURLOPT_SSL_VERIFYHOST , false ); }
2026-06-16 21:52:45 +02:00
applyProxySettings ( $ch );
2024-11-25 15:25:00 +01:00
curl_setopt ( $ch , CURLOPT_URL , $url );
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
2025-09-20 12:43:26 +02:00
curl_setopt ( $ch , CURLOPT_HTTPHEADER , [ "Authorization: Bearer $accessToken " , "Content-Type: application/json" ]);
2024-11-25 15:25:00 +01:00
$response = curl_exec ( $ch );
2025-09-20 12:43:26 +02:00
$httpCode = curl_getinfo ( $ch , CURLINFO_HTTP_CODE );
2024-11-25 15:25:00 +01:00
curl_close ( $ch );
2025-09-20 12:43:26 +02:00
$resp = json_decode ( $response , true );
// Check for permission errors
if ( $httpCode === 403 || $httpCode === 401 ) {
$error = $resp [ 'error' ] ?? [];
$errorCode = $error [ 'code' ] ?? '' ;
$errorMessage = $error [ 'message' ] ?? '' ;
// Detect specific permission issues
if ( strpos ( $errorCode , 'Authorization_RequestDenied' ) !== false ||
strpos ( $errorMessage , 'Insufficient privileges' ) !== false ||
strpos ( $errorMessage , 'Access denied' ) !== false ) {
throw new Exception ( "Missing API Permissions: Your application doesn't have the required Microsoft Graph permissions. Please ensure these permissions are granted and admin consent is provided: \n\n " .
"Required permissions for Hardware OATH Tokens: \n " .
"• Policy.ReadWrite.AuthenticationMethod (Application) \n " .
"• AuthenticationMethodDevice.ReadWrite.All (Application) \n " .
"• Directory.Read.All (Application) \n " .
"• User.Read.All (Application) \n\n " .
"Steps to fix: \n " .
"1. Go to Azure Portal → App Registrations → Your App \n " .
"2. Select 'API permissions' \n " .
"3. Add the missing permissions listed above \n " .
"4. Click 'Grant admin consent for [tenant]' \n\n " .
"Technical details: " . $errorMessage );
}
// Generic permission error
throw new Exception ( "Permission Error: " . $errorMessage . " (HTTP $httpCode )" );
}
2025-12-15 11:10:14 +01:00
if ( isset ( $resp [ "@odata.nextLink" ])) {
2025-12-15 11:11:41 +01:00
$data = fetchTokens ( $accessToken , $resp [ "@odata.nextLink" ]);
2025-12-15 11:10:14 +01:00
return [
'value' => array_merge ( $data [ 'value' ] ?? [], $resp [ 'value' ] ?? []),
'log' => $resp
];
}
2025-09-20 12:43:26 +02:00
return [ 'value' => $resp [ 'value' ] ?? [], 'log' => $resp ];
2024-11-25 15:25:00 +01:00
}
2025-09-20 12:43:26 +02:00
function importTokens ( $accessToken , $data )
{
2024-11-25 15:25:00 +01:00
$url = "https://graph.microsoft.com/beta/directory/authenticationMethodDevices/hardwareOathDevices" ;
2025-09-20 22:00:24 +02:00
$ch = curl_init (); if ( LOCAL_APP == 1 ) { curl_setopt ( $ch , CURLOPT_SSL_VERIFYPEER , false );
curl_setopt ( $ch , CURLOPT_SSL_VERIFYHOST , false ); }
2026-06-16 21:52:45 +02:00
applyProxySettings ( $ch );
2024-11-25 15:25:00 +01:00
curl_setopt ( $ch , CURLOPT_URL , $url );
curl_setopt ( $ch , CURLOPT_CUSTOMREQUEST , "PATCH" );
2025-09-20 12:43:26 +02:00
curl_setopt ( $ch , CURLOPT_POSTFIELDS , json_encode ( $data ));
curl_setopt ( $ch , CURLOPT_HTTPHEADER , [ "Authorization: Bearer $accessToken " , "Content-Type: application/json" ]);
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
$resp = json_decode ( curl_exec ( $ch ), true );
curl_close ( $ch );
return $resp ;
}
function importCSVTokens ( $accessToken , $csvData , $importMode = 'import_assign_activate' ) {
$results = [];
$lines = explode ( " \n " , $csvData );
// Remove BOM if present
$lines [ 0 ] = preg_replace ( '/^\xEF\xBB\xBF/' , '' , $lines [ 0 ]);
$headers = str_getcsv ( array_shift ( $lines ));
// Trim whitespace from headers
$headers = array_map ( 'trim' , $headers );
foreach ( $lines as $lineNum => $line ) {
if ( empty ( trim ( $line ))) continue ;
$data = str_getcsv ( $line );
// Skip if not enough columns
if ( count ( $data ) < count ( $headers )) {
$results [ "line_" . ( $lineNum + 2 )] = [
'import_success' => false ,
'import_log' => [ 'error' => 'Insufficient columns in CSV line' ],
'token_id' => null ,
];
continue ;
}
$data = array_combine ( $headers , $data );
$upn = trim ( $data [ 'upn' ] ?? '' );
$serialNumber = trim ( $data [ 'serial number' ] ?? '' );
$secretKey = trim ( $data [ 'secret key' ] ?? '' );
$timeInterval = ( int )( $data [ 'timeinterval' ] ?? 30 );
$manufacturer = trim ( $data [ 'manufacturer' ] ?? '' );
$model = trim ( $data [ 'model' ] ?? '' );
// Validate required fields
if ( empty ( $serialNumber ) || empty ( $secretKey )) {
$results [ $serialNumber ?: "line_" . ( $lineNum + 2 )] = [
'import_success' => false ,
'import_log' => [ 'error' => 'Missing required fields: serial number or secret key' ],
'token_id' => null ,
];
continue ;
}
// For modes that require assignment, validate UPN
if (( $importMode === 'import_assign' || $importMode === 'import_assign_activate' ) && empty ( $upn )) {
$results [ $serialNumber ] = [
'import_success' => false ,
'import_log' => [ 'error' => 'UPN is required for assignment mode' ],
'token_id' => null ,
];
continue ;
}
// Process secret key - remove spaces and convert to uppercase
$secretKey = strtoupper ( str_replace ( ' ' , '' , $secretKey ));
// Validate base32 format
if ( ! preg_match ( '/^[A-Z2-7]+$/' , $secretKey )) {
$results [ $serialNumber ] = [
'import_success' => false ,
'import_log' => [ 'error' => 'Invalid secret key format. Must be Base32 (A-Z, 2-7)' ],
'token_id' => null ,
];
continue ;
}
// Remove any existing padding and ensure proper length
$secretKey = rtrim ( $secretKey , '=' );
// Validate minimum length (TOTP secrets should be at least 16 characters)
if ( strlen ( $secretKey ) < 16 ) {
$results [ $serialNumber ] = [
'import_success' => false ,
'import_log' => [ 'error' => 'Secret key too short. Must be at least 16 characters' ],
'token_id' => null ,
];
continue ;
}
// Determine hash function based on secret key length
$hashFunction = strlen ( $secretKey ) <= 32 ? 'hmacsha1' : 'hmacsha256' ;
$tokenData = [
"displayName" => " $manufacturer $model - $serialNumber " , // Required property
"serialNumber" => $serialNumber ,
"manufacturer" => $manufacturer ,
"model" => $model ,
"secretKey" => $secretKey , // Use raw base32 string
"timeIntervalInSeconds" => $timeInterval ,
"hashFunction" => $hashFunction , // Set based on secret key length
];
$url = "https://graph.microsoft.com/beta/directory/authenticationMethodDevices/hardwareOathDevices" ;
2025-09-20 22:00:24 +02:00
$ch = curl_init (); if ( LOCAL_APP == 1 ) { curl_setopt ( $ch , CURLOPT_SSL_VERIFYPEER , false );
curl_setopt ( $ch , CURLOPT_SSL_VERIFYHOST , false ); }
2026-06-16 21:52:45 +02:00
applyProxySettings ( $ch );
2025-09-20 12:43:26 +02:00
curl_setopt ( $ch , CURLOPT_URL , $url );
curl_setopt ( $ch , CURLOPT_POST , true );
curl_setopt ( $ch , CURLOPT_POSTFIELDS , json_encode ( $tokenData ));
curl_setopt ( $ch , CURLOPT_HTTPHEADER , [ "Authorization: Bearer $accessToken " , "Content-Type: application/json" ]);
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
$resp = json_decode ( curl_exec ( $ch ), true );
$httpCode = curl_getinfo ( $ch , CURLINFO_HTTP_CODE );
curl_close ( $ch );
$tokenId = $resp [ 'id' ] ?? null ;
$results [ $serialNumber ] = [
'import_success' => ( $httpCode === 200 || $httpCode === 201 ),
'import_log' => $resp ,
'token_id' => $tokenId ,
'http_code' => $httpCode ,
'request_data' => $tokenData , // Include request for debugging
'import_mode' => $importMode ,
];
// Only proceed with assignment if import was successful and mode requires it
if ( $tokenId && ( $importMode === 'import_assign' || $importMode === 'import_assign_activate' ) && $upn ) {
// Assign the token to the user
$assignUrl = "https://graph.microsoft.com/beta/users/ $upn /authentication/hardwareOathMethods" ;
$assignData = [ "device" => [ "id" => $tokenId ]];
2025-10-17 14:25:57 +02:00
$assignCh = curl_init (); if ( LOCAL_APP == 1 ) { curl_setopt ( $assignCh , CURLOPT_SSL_VERIFYPEER , false ); curl_setopt ( $assignCh , CURLOPT_SSL_VERIFYHOST , false ); }
2026-06-16 21:52:45 +02:00
applyProxySettings ( $assignCh );
2025-09-20 12:43:26 +02:00
curl_setopt ( $assignCh , CURLOPT_URL , $assignUrl );
curl_setopt ( $assignCh , CURLOPT_POST , true );
curl_setopt ( $assignCh , CURLOPT_POSTFIELDS , json_encode ( $assignData ));
curl_setopt ( $assignCh , CURLOPT_HTTPHEADER , [ "Authorization: Bearer $accessToken " , "Content-Type: application/json" ]);
curl_setopt ( $assignCh , CURLOPT_RETURNTRANSFER , true );
$assignResp = json_decode ( curl_exec ( $assignCh ), true );
$assignHttpCode = curl_getinfo ( $assignCh , CURLINFO_HTTP_CODE );
curl_close ( $assignCh );
$results [ $serialNumber ][ 'assign_success' ] = ( $assignHttpCode === 200 || $assignHttpCode === 201 );
$results [ $serialNumber ][ 'assign_log' ] = $assignResp ;
$results [ $serialNumber ][ 'assign_http_code' ] = $assignHttpCode ;
// Only attempt activation if assignment was successful and mode requires it
if ( $results [ $serialNumber ][ 'assign_success' ] && $importMode === 'import_assign_activate' ) {
// Activate the token
$activateUrl = "https://graph.microsoft.com/beta/users/ $upn /authentication/hardwareOathMethods/ $tokenId /activate" ;
$activateData = [ "verificationCode" => generateTOTPCode ( $secretKey )]; //
2025-10-17 14:25:57 +02:00
$activateCh = curl_init (); if ( LOCAL_APP == 1 ) { curl_setopt ( $activateCh , CURLOPT_SSL_VERIFYPEER , false ); curl_setopt ( $activateCh , CURLOPT_SSL_VERIFYHOST , false ); }
2026-06-16 21:52:45 +02:00
applyProxySettings ( $activateCh );
2025-09-20 12:43:26 +02:00
curl_setopt ( $activateCh , CURLOPT_URL , $activateUrl );
curl_setopt ( $activateCh , CURLOPT_POST , true );
curl_setopt ( $activateCh , CURLOPT_POSTFIELDS , json_encode ( $activateData ));
curl_setopt ( $activateCh , CURLOPT_HTTPHEADER , [ "Authorization: Bearer $accessToken " , "Content-Type: application/json" ]);
curl_setopt ( $activateCh , CURLOPT_RETURNTRANSFER , true );
$activateResp = json_decode ( curl_exec ( $activateCh ), true );
$activateHttpCode = curl_getinfo ( $activateCh , CURLINFO_HTTP_CODE );
curl_close ( $activateCh );
$results [ $serialNumber ][ 'activate_success' ] = ( $activateHttpCode === 200 || $activateHttpCode === 204 );
$results [ $serialNumber ][ 'activate_log' ] = $activateResp ;
$results [ $serialNumber ][ 'activate_http_code' ] = $activateHttpCode ;
}
}
}
return $results ;
}
function getUsers ( $accessToken , $query = '' ) {
// Enhanced debugging function
$debugInfo = [
'query' => $query ,
'access_token_length' => strlen ( $accessToken ),
'timestamp' => date ( 'Y-m-d H:i:s' )
];
// Build the filter properly
if ( ! empty ( $query )) {
$filter = "startswith(displayName,' $query ') or startswith(userPrincipalName,' $query ')" ;
$url = "https://graph.microsoft.com/v1.0/users? \$ top=50& \$ filter=" . urlencode ( $filter );
} else {
// If no query, get all users (first 50)
$url = "https://graph.microsoft.com/v1.0/users? \$ top=50" ;
}
$debugInfo [ 'url' ] = $url ;
2025-09-20 22:00:24 +02:00
$ch = curl_init (); if ( LOCAL_APP == 1 ) { curl_setopt ( $ch , CURLOPT_SSL_VERIFYPEER , false ); curl_setopt ( $ch , CURLOPT_SSL_VERIFYHOST , false ); } else { curl_setopt ( $ch , CURLOPT_SSL_VERIFYPEER , true ); }
2026-06-16 21:52:45 +02:00
applyProxySettings ( $ch );
2025-09-20 12:43:26 +02:00
curl_setopt ( $ch , CURLOPT_URL , $url );
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
curl_setopt ( $ch , CURLOPT_HTTPHEADER , [ "Authorization: Bearer $accessToken " , "Content-Type: application/json" ]);
2025-09-20 22:00:24 +02:00
2025-09-20 12:43:26 +02:00
curl_setopt ( $ch , CURLOPT_TIMEOUT , 30 );
2026-06-10 05:17:04 +02:00
2025-09-20 12:43:26 +02:00
$response = curl_exec ( $ch );
$httpCode = curl_getinfo ( $ch , CURLINFO_HTTP_CODE );
$curlError = curl_error ( $ch );
curl_close ( $ch );
$debugInfo [ 'http_code' ] = $httpCode ;
$debugInfo [ 'curl_error' ] = $curlError ;
$debugInfo [ 'response_length' ] = strlen ( $response );
$debugInfo [ 'response_preview' ] = substr ( $response , 0 , 200 );
// Log debug info
2026-06-10 05:17:04 +02:00
debugLog ( "getUsers Debug: " . json_encode ( $debugInfo ));
2025-09-20 12:43:26 +02:00
// Check for curl errors
if ( $response === false ) {
2026-06-10 05:17:04 +02:00
debugLog ( "CURL Error in getUsers: " . $curlError );
2025-09-20 12:43:26 +02:00
return [
'debug' => $debugInfo ,
'error' => 'CURL Error: ' . $curlError ,
'users' => []
];
}
// Check for HTTP errors
if ( $httpCode !== 200 ) {
2026-06-10 05:17:04 +02:00
debugLog ( "HTTP Error in getUsers: HTTP $httpCode - Response: " . $response );
2025-09-20 12:43:26 +02:00
// Parse response for permission errors
$resp = json_decode ( $response , true );
if ( $httpCode === 403 || $httpCode === 401 ) {
$error = $resp [ 'error' ] ?? [];
$errorCode = $error [ 'code' ] ?? '' ;
$errorMessage = $error [ 'message' ] ?? '' ;
if ( strpos ( $errorCode , 'Authorization_RequestDenied' ) !== false ||
strpos ( $errorMessage , 'Insufficient privileges' ) !== false ) {
return [
'debug' => $debugInfo ,
'error' => 'Missing Permission: Your application needs User.Read.All permission to list users. Please add this permission in Azure Portal → App Registrations → API permissions.' ,
'permission_error' => true ,
'users' => []
];
}
}
return [
'debug' => $debugInfo ,
'error' => "HTTP $httpCode " ,
'response' => $response ,
'users' => []
];
}
$resp = json_decode ( $response , true );
// Check for JSON decode errors
if ( json_last_error () !== JSON_ERROR_NONE ) {
2026-06-10 05:17:04 +02:00
debugLog ( "JSON Decode Error in getUsers: " . json_last_error_msg ());
2025-09-20 12:43:26 +02:00
return [
'debug' => $debugInfo ,
'error' => 'JSON Error: ' . json_last_error_msg (),
'users' => []
];
}
$debugInfo [ 'user_count' ] = count ( $resp [ 'value' ] ?? []);
// SUCCESS - return users without debug info
return [
'users' => $resp [ 'value' ] ?? [],
'success' => true
];
}
function assignToken ( $accessToken , $userId , $tokenId )
{
$url = "https://graph.microsoft.com/beta/users/ $userId /authentication/hardwareOathMethods" ;
$data = [ "device" => [ "id" => $tokenId ]];
2025-09-20 22:00:24 +02:00
$ch = curl_init (); if ( LOCAL_APP == 1 ) { curl_setopt ( $ch , CURLOPT_SSL_VERIFYPEER , false );
curl_setopt ( $ch , CURLOPT_SSL_VERIFYHOST , false ); }
2026-06-16 21:52:45 +02:00
applyProxySettings ( $ch );
2025-09-20 12:43:26 +02:00
curl_setopt ( $ch , CURLOPT_URL , $url );
curl_setopt ( $ch , CURLOPT_POST , true );
curl_setopt ( $ch , CURLOPT_POSTFIELDS , json_encode ( $data ));
curl_setopt ( $ch , CURLOPT_HTTPHEADER , [ "Authorization: Bearer $accessToken " , "Content-Type: application/json" ]);
2024-11-25 15:25:00 +01:00
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
$response = curl_exec ( $ch );
2025-09-20 12:43:26 +02:00
$httpCode = curl_getinfo ( $ch , CURLINFO_HTTP_CODE );
2024-11-25 15:25:00 +01:00
curl_close ( $ch );
2025-09-20 12:43:26 +02:00
$resp = json_decode ( $response , true );
// Check for permission errors
if ( $httpCode === 403 || $httpCode === 401 ) {
$error = $resp [ 'error' ] ?? [];
$errorMessage = $error [ 'message' ] ?? '' ;
if ( strpos ( $errorMessage , 'Insufficient privileges' ) !== false ) {
return [
'success' => false ,
'log' => $resp ,
'permission_error' => 'Missing Permission: Your application needs AuthenticationMethodDevice.ReadWrite.All permission to assign tokens to users.'
];
}
}
return [ 'success' => ( $httpCode === 200 || $httpCode === 201 ), 'log' => $resp ];
2024-11-25 15:25:00 +01:00
}
2025-09-20 12:43:26 +02:00
function activateToken ( $accessToken , $userId , $tokenId , $code )
{
$url = "https://graph.microsoft.com/beta/users/ $userId /authentication/hardwareOathMethods/ $tokenId /activate" ;
$data = [ "verificationCode" => $code ];
2025-09-20 22:00:24 +02:00
$ch = curl_init (); if ( LOCAL_APP == 1 ) { curl_setopt ( $ch , CURLOPT_SSL_VERIFYPEER , false );
curl_setopt ( $ch , CURLOPT_SSL_VERIFYHOST , false ); }
2026-06-16 21:52:45 +02:00
applyProxySettings ( $ch );
2025-09-20 12:43:26 +02:00
curl_setopt ( $ch , CURLOPT_URL , $url );
curl_setopt ( $ch , CURLOPT_POST , true );
curl_setopt ( $ch , CURLOPT_POSTFIELDS , json_encode ( $data ));
curl_setopt ( $ch , CURLOPT_HTTPHEADER , [ "Authorization: Bearer $accessToken " , "Content-Type: application/json" ]);
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
$response = curl_exec ( $ch );
$httpCode = curl_getinfo ( $ch , CURLINFO_HTTP_CODE );
curl_close ( $ch );
$resp = json_decode ( $response , true );
// Check for permission errors
if ( $httpCode === 403 || $httpCode === 401 ) {
$error = $resp [ 'error' ] ?? [];
$errorMessage = $error [ 'message' ] ?? '' ;
if ( strpos ( $errorMessage , 'Insufficient privileges' ) !== false ) {
return [
'success' => false ,
'log' => $resp ,
'permission_error' => 'Missing Permission: Your application needs AuthenticationMethodDevice.ReadWrite.All permission to activate tokens.'
];
}
}
return [ 'success' => ( $httpCode === 200 || $httpCode === 204 ), 'log' => $resp ];
2024-11-25 15:25:00 +01:00
}
2025-09-20 12:43:26 +02:00
function unassignToken ( $accessToken , $userId , $tokenId )
{
$url = "https://graph.microsoft.com/beta/users/ $userId /authentication/hardwareOathMethods/ $tokenId " ;
2025-09-20 22:00:24 +02:00
$ch = curl_init (); if ( LOCAL_APP == 1 ) { curl_setopt ( $ch , CURLOPT_SSL_VERIFYPEER , false );
curl_setopt ( $ch , CURLOPT_SSL_VERIFYHOST , false ); }
2026-06-16 21:52:45 +02:00
applyProxySettings ( $ch );
2025-09-20 12:43:26 +02:00
curl_setopt ( $ch , CURLOPT_URL , $url );
curl_setopt ( $ch , CURLOPT_CUSTOMREQUEST , "DELETE" );
curl_setopt ( $ch , CURLOPT_HTTPHEADER , [ "Authorization: Bearer $accessToken " ]);
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
$response = curl_exec ( $ch );
$httpCode = curl_getinfo ( $ch , CURLINFO_HTTP_CODE );
curl_close ( $ch );
$resp = json_decode ( $response , true );
// Check for permission errors
if ( $httpCode === 403 || $httpCode === 401 ) {
$error = $resp [ 'error' ] ?? [];
$errorMessage = $error [ 'message' ] ?? '' ;
if ( strpos ( $errorMessage , 'Insufficient privileges' ) !== false ) {
return [
'success' => false ,
'log' => $resp ,
'permission_error' => 'Missing Permission: Your application needs AuthenticationMethodDevice.ReadWrite.All permission to unassign tokens.'
];
}
}
return [ 'success' => ( $httpCode === 200 || $httpCode === 204 ), 'log' => $resp ];
}
2024-11-25 15:25:00 +01:00
2025-09-20 12:43:26 +02:00
function deleteToken ( $accessToken , $tokenId )
{
$url = "https://graph.microsoft.com/beta/directory/authenticationMethodDevices/hardwareOathDevices/ $tokenId " ;
2025-09-20 22:00:24 +02:00
$ch = curl_init (); if ( LOCAL_APP == 1 ) { curl_setopt ( $ch , CURLOPT_SSL_VERIFYPEER , false );
curl_setopt ( $ch , CURLOPT_SSL_VERIFYHOST , false ); }
2026-06-16 21:52:45 +02:00
applyProxySettings ( $ch );
2025-09-20 12:43:26 +02:00
curl_setopt ( $ch , CURLOPT_URL , $url );
curl_setopt ( $ch , CURLOPT_CUSTOMREQUEST , "DELETE" );
curl_setopt ( $ch , CURLOPT_HTTPHEADER , [ "Authorization: Bearer $accessToken " ]);
curl_setopt ( $ch , CURLOPT_RETURNTRANSFER , true );
$response = curl_exec ( $ch );
$httpCode = curl_getinfo ( $ch , CURLINFO_HTTP_CODE );
curl_close ( $ch );
$resp = json_decode ( $response , true );
// Check for permission errors
if ( $httpCode === 403 || $httpCode === 401 ) {
$error = $resp [ 'error' ] ?? [];
$errorMessage = $error [ 'message' ] ?? '' ;
if ( strpos ( $errorMessage , 'Insufficient privileges' ) !== false ) {
return [
'success' => false ,
'log' => $resp ,
'permission_error' => 'Missing Permission: Your application needs Policy.ReadWrite.AuthenticationMethod permission to delete tokens.'
];
}
}
return [ 'success' => ( $httpCode === 200 || $httpCode === 204 ), 'log' => $resp ];
}
function generateTOTPCode ( $base32Secret , $timeInterval = 30 ) {
try {
// Determine hash algorithm based on secret length (like JavaScript version)
$algorithm = strlen ( $base32Secret ) <= 32 ? 'sha1' : 'sha256' ;
// Base32 decode
$base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' ;
$base32CharsFlipped = array_flip ( str_split ( $base32Chars ));
$paddedSecret = $base32Secret ;
$remainder = strlen ( $paddedSecret ) % 8 ;
if ( $remainder > 0 ) {
$paddedSecret .= str_repeat ( '=' , 8 - $remainder );
}
$binaryString = '' ;
for ( $i = 0 ; $i < strlen ( $paddedSecret ); $i += 8 ) {
$chunk = substr ( $paddedSecret , $i , 8 );
if ( $chunk === '========' ) break ;
$binaryChunk = '' ;
for ( $j = 0 ; $j < strlen ( $chunk ); $j ++ ) {
if ( $chunk [ $j ] === '=' ) break ;
if ( ! isset ( $base32CharsFlipped [ $chunk [ $j ]])) {
return false ; // Invalid character
}
$binaryChunk .= str_pad ( decbin ( $base32CharsFlipped [ $chunk [ $j ]]), 5 , '0' , STR_PAD_LEFT );
}
// Convert binary string to bytes
for ( $k = 0 ; $k < strlen ( $binaryChunk ); $k += 8 ) {
if ( strlen ( $binaryChunk ) - $k >= 8 ) {
$binaryString .= chr ( bindec ( substr ( $binaryChunk , $k , 8 )));
}
}
}
// Get current time counter
$timeCounter = floor ( time () / $timeInterval );
// Create time counter as 8-byte big-endian
$timeBytes = pack ( 'N*' , 0 , $timeCounter );
// Generate HMAC with appropriate algorithm
$hash = hash_hmac ( $algorithm , $timeBytes , $binaryString , true );
// Dynamic truncation
$hashLength = strlen ( $hash );
$offset = ord ( $hash [ $hashLength - 1 ]) & 0x0f ;
$code = (
(( ord ( $hash [ $offset ]) & 0x7f ) << 24 ) |
(( ord ( $hash [ $offset + 1 ]) & 0xff ) << 16 ) |
(( ord ( $hash [ $offset + 2 ]) & 0xff ) << 8 ) |
( ord ( $hash [ $offset + 3 ]) & 0xff )
) % 1000000 ;
return str_pad ( $code , 6 , '0' , STR_PAD_LEFT );
} catch ( Exception $e ) {
2026-06-10 05:17:04 +02:00
debugLog ( "TOTP generation error: " . $e -> getMessage ());
2025-09-20 12:43:26 +02:00
return false ;
2024-11-25 15:25:00 +01:00
}
}
2025-09-20 12:43:26 +02:00
// Initialize message variables
$message = '' ;
$csvMessage = '' ;
$importLog = null ;
$csvImportLog = null ;
$showMessages = false ; // Only show messages immediately after the relevant operation
$initialMessage = '' ;
// Handle AJAX - these should not show persistent messages
if ( isset ( $_GET [ 'action' ])) {
header ( 'Content-Type: application/json' );
try {
$accessToken = getAccessToken ( $tenantId , $clientId , $clientSecret );
} catch ( Exception $e ) {
echo json_encode ([ 'success' => false , 'error' => $e -> getMessage (), 'credential_error' => true ]);
exit ;
}
$post = $_POST ;
$res = [ 'success' => false , 'log' => '' ];
switch ( $_GET [ 'action' ]) {
case 'get_users' :
$query = $_GET [ 'query' ] ?? '' ;
$result = getUsers ( $accessToken , $query );
// Check if the result contains success indicator
if ( isset ( $result [ 'success' ]) && $result [ 'success' ]) {
$res = [ 'success' => true , 'users' => $result [ 'users' ]];
} else {
// Error case - has debug info
$res = [
'success' => false ,
'users' => $result [ 'users' ] ?? [],
'debug' => $result [ 'debug' ] ?? null ,
'error' => $result [ 'error' ] ?? 'Unknown error'
];
}
break ;
case 'assign_token' :
$res = assignToken ( $accessToken , $post [ 'user_id' ] ?? '' , $post [ 'token_id' ] ?? '' );
break ;
case 'activate_token' :
$res = activateToken ( $accessToken , $post [ 'user_id' ] ?? '' , $post [ 'token_id' ] ?? '' , $post [ 'verification_code' ] ?? '' );
break ;
case 'unassign_token' :
$res = unassignToken ( $accessToken , $post [ 'user_id' ] ?? '' , $post [ 'token_id' ] ?? '' );
break ;
case 'delete_token' :
$res = deleteToken ( $accessToken , $post [ 'token_id' ] ?? '' );
break ;
case 'import_csv' :
$csvData = $post [ 'csv_data' ] ?? '' ;
$importMode = $post [ 'import_mode' ] ?? 'import_assign_activate' ;
$res = [ 'success' => true , 'log' => importCSVTokens ( $accessToken , $csvData , $importMode )];
break ;
}
echo json_encode ( $res );
exit ;
}
// Handle file uploads - these SHOULD show persistent messages only for their specific operations
if ( $_SERVER [ 'REQUEST_METHOD' ] === 'POST' ) {
try {
// Handle JSON import
if ( isset ( $_FILES [ 'importFile' ])) {
$data = json_decode ( file_get_contents ( $_FILES [ 'importFile' ][ 'tmp_name' ]), true );
if ( $data ) {
$accessToken = getAccessToken ( $tenantId , $clientId , $clientSecret );
$importLog = [
'action' => 'Import Tokens' ,
'request' => $data ,
'response' => importTokens ( $accessToken , $data ),
];
$message = ( $importLog [ 'response' ] ? 'Tokens imported' : 'Failed import' );
$showMessages = true ; // Flag to show this specific message
} else {
$message = 'Invalid JSON' ;
$showMessages = true ;
}
}
// Handle CSV file upload
elseif ( isset ( $_FILES [ 'csvFile' ])) {
$csvData = file_get_contents ( $_FILES [ 'csvFile' ][ 'tmp_name' ]);
$importMode = $_POST [ 'import_mode' ] ?? 'import_assign_activate' ;
if ( $csvData !== false ) {
$accessToken = getAccessToken ( $tenantId , $clientId , $clientSecret );
$csvImportLog = [
'action' => 'Import CSV Tokens' ,
'filename' => $_FILES [ 'csvFile' ][ 'name' ],
'import_mode' => $importMode ,
'response' => importCSVTokens ( $accessToken , $csvData , $importMode ),
];
$csvMessage = 'CSV Tokens imported from file: ' . $_FILES [ 'csvFile' ][ 'name' ] . ' (Mode: ' . $importMode . ')' ;
$showMessages = true ; // Flag to show this specific message
} else {
$csvMessage = 'Failed to read CSV file' ;
$showMessages = true ;
}
}
// Handle textarea CSV import (keeping both options) - but only if no file was uploaded
elseif ( isset ( $_POST [ 'csv_data' ])) {
$csvData = $_POST [ 'csv_data' ];
$importMode = $_POST [ 'import_mode' ] ?? 'import_assign_activate' ;
$accessToken = getAccessToken ( $tenantId , $clientId , $clientSecret );
$csvImportLog = [
'action' => 'Import CSV Tokens' ,
'request' => $csvData ,
'import_mode' => $importMode ,
'response' => importCSVTokens ( $accessToken , $csvData , $importMode ),
];
$csvMessage = 'CSV Tokens imported from textarea (Mode: ' . $importMode . ')' ;
$showMessages = true ; // Flag to show this specific message
}
} catch ( Exception $e ) {
// Handle credential errors
$credentialError = $e -> getMessage ();
if ( isset ( $_FILES [ 'importFile' ])) {
$message = 'Authentication failed: ' . $credentialError ;
$showMessages = true ;
} elseif ( isset ( $_FILES [ 'csvFile' ]) || isset ( $_POST [ 'csv_data' ])) {
$csvMessage = 'Authentication failed: ' . $credentialError ;
$showMessages = true ;
}
}
}
// Fetch tokens and capture the response for logging
$credentialError = null ;
$tokens = [];
$initialLog = null ;
try {
$fetchResult = fetchTokens ( getAccessToken ( $tenantId , $clientId , $clientSecret ));
$tokens = $fetchResult [ 'value' ];
$initialLog = [
'action' => 'Initial Load' ,
'endpoint' => 'fetchTokens' ,
'response' => $fetchResult [ 'log' ],
];
} catch ( Exception $e ) {
$credentialError = $e -> getMessage ();
$initialLog = [
'action' => 'Initial Load' ,
'endpoint' => 'fetchTokens' ,
'error' => $credentialError ,
];
}
2024-11-25 15:25:00 +01:00
?>
2025-09-20 12:43:26 +02:00
2024-11-25 15:25:00 +01:00
<!DOCTYPE html>
2025-09-20 12:43:26 +02:00
<html>
2024-11-25 15:25:00 +01:00
<head>
<meta charset="UTF-8">
2025-09-20 12:43:26 +02:00
<title>Hardware Tokens</title>
2024-11-25 15:25:00 +01:00
<link rel="stylesheet" href="assets/bootstrap.min.css">
2025-09-20 12:43:26 +02:00
<link rel="stylesheet" href="assets/dataTables.bootstrap4.min.css">
2024-11-25 15:25:00 +01:00
<script src="assets/jquery-3.6.0.min.js"></script>
<script src="assets/jquery.dataTables.min.js"></script>
<script src="assets/dataTables.bootstrap4.min.js"></script>
<script src="assets/bootstrap.min.js"></script>
2025-09-20 12:43:26 +02:00
<link rel="shortcut icon" href="favico.png">
2024-11-25 15:25:00 +01:00
<style>
2025-09-20 22:00:24 +02:00
body{
background-image:url('assets/bg.png');
background-attachment:fixed;
background-repeat: no-repeat;
background-size: cover;
}
2025-09-20 12:43:26 +02:00
.log-container {
max-height: 200px;
overflow-y: auto;
background: #f8f9fa;
padding: 10px;
border-radius: 5px;
}
.loading-spinner {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.7);
z-index: 1050;
justify-content: center;
align-items: center;
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: #007bff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.log-entry {
border-bottom: 1px solid #dee2e6;
padding: 15px 0;
}
.log-entry:last-child {
border-bottom: none;
}
.log-timestamp {
font-size: 0.85em;
color: #6c757d;
}
.log-status {
font-weight: bold;
}
.log-status.success {
color: #28a745;
}
.log-status.error {
color: #dc3545;
}
.log-status.info {
color: #17a2b8;
}
.log-details {
background: #f8f9fa;
border-radius: 4px;
padding: 10px;
margin-top: 10px;
font-family: monospace;
font-size: 0.85em;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.auto-refresh-notice {
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 4px;
padding: 10px;
margin: 10px 0;
text-align: center;
color: #1565c0;
}
.import-tabs {
border-bottom: 1px solid #dee2e6;
margin-bottom: 20px;
}
.import-tabs .nav-link {
border-bottom: 2px solid transparent;
}
.import-tabs .nav-link.active {
border-bottom-color: #007bff;
background: none;
}
#userResults {
max-height: 200px;
overflow-y: auto;
margin-top: -1px;
border: 1px solid #ced4da;
border-radius: 0 0 4px 4px;
background: white;
position: absolute;
width: calc(100% - 30px);
z-index: 1000;
}
#userResults .list-group-item {
cursor: pointer;
padding: 8px 15px;
border: none;
border-bottom: 1px solid #ced4da;
}
#userResults .list-group-item:hover {
background: #f8f9fa;
}
#userResults .list-group-item:last-child {
border-bottom: none;
}
.import-mode-section {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
margin-bottom: 15px;
}
2024-11-25 15:25:00 +01:00
</style>
</head>
<body>
2025-09-20 12:43:26 +02:00
<div class="container mt-4"><h4><img src="favico.png"> TOTP Tokens Inventory</h4>
<div class="float-left">
2025-09-20 22:03:37 +02:00
<a href=index.php class="btn btn-outline-danger" title=refresh>
2025-09-20 22:02:33 +02:00
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-arrow-clockwise" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 3a5 5 0 1 0 4.546 2.914.5.5 0 0 1 .908-.417A6 6 0 1 1 8 2z"/>
<path d="M8 4.466V.534a.25.25 0 0 1 .41-.192l2.36 1.966c.12.1.12.284 0 .384L8.41 4.658A.25.25 0 0 1 8 4.466"/>
2025-09-20 22:03:04 +02:00
</svg></a>
2025-09-20 12:43:26 +02:00
</div>
<div class="float-right">
<button class="btn btn-secondary mb-3" data-toggle="modal" data-target="#settingsModal">⚙️ Settings</button>
<button class="btn btn-primary mb-3" data-toggle="modal" data-target="#importModal">JSON Operations</button>
<button class="btn btn-info mb-3" data-toggle="modal" data-target="#importCSVModal">CSV Operations</button>
<a href="?logout" class="btn btn-danger mb-3">Clear session</a>
</div>
<br style="clear:both">
<!-- Only show messages for file upload operations, not AJAX operations -->
<?php if (isset($_GET['settings_updated'])) : ?>
<div class="alert alert-success alert-dismissible fade show">
Settings updated successfully!
<button type="button" class="close" data-dismiss="alert">×</button>
</div>
2024-11-25 15:25:00 +01:00
<?php endif; ?>
2025-09-20 12:43:26 +02:00
<?php if ($credentialError) : ?>
<div class="alert alert-danger alert-dismissible fade show">
<strong>Authentication Error:</strong> <?= htmlspecialchars($credentialError) ?>
<br><small>Please check your credentials in <button class="btn btn-link p-0" data-toggle="modal" data-target="#settingsModal">Settings</button></small>
<button type="button" class="close" data-dismiss="alert">×</button>
</div>
<?php endif; ?>
<?php if ($message && ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['importFile']))) : ?>
<div class="alert alert-<?= strpos($message, 'Failed') !== false || strpos($message, 'Authentication failed') !== false ? 'danger' : 'success' ?> alert-dismissible fade show">
<?= htmlspecialchars($message) ?>
<?php if (strpos($message, 'Authentication failed') !== false) : ?>
<br><small>Please check your credentials in <button class="btn btn-link p-0" data-toggle="modal" data-target="#settingsModal">Settings</button></small>
<?php endif; ?>
<button type="button" class="close" data-dismiss="alert">×</button>
</div>
<?php endif; ?>
<?php if ($csvMessage && ($_SERVER['REQUEST_METHOD'] === 'POST' && (isset($_FILES['csvFile']) || isset($_POST['csv_data'])))) : ?>
<div class="alert alert-<?= strpos($csvMessage, 'Authentication failed') !== false ? 'danger' : 'success' ?> alert-dismissible fade show">
<?= htmlspecialchars($csvMessage) ?>
<?php if (strpos($csvMessage, 'Authentication failed') !== false) : ?>
<br><small>Please check your credentials in <button class="btn btn-link p-0" data-toggle="modal" data-target="#settingsModal">Settings</button></small>
<?php endif; ?>
<button type="button" class="close" data-dismiss="alert">×</button>
</div>
<?php endif; ?>
<br style="clear:both">
<!-- Logs Section -->
<div class="card mb-4" id="logsSection" style="display: none;">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">Operation Logs</h6>
<div>
<button class="btn btn-sm btn-outline-secondary" onclick="clearLogs()">Clear Logs</button>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleLogs()">Hide</button>
</div>
</div>
<div class="card-body">
<div id="logsContainer" style="max-height: 400px; overflow-y: auto;">
<!-- Logs will be populated here -->
</div>
</div>
</div>
<!-- Settings Modal -->
<div class="modal fade" id="settingsModal">
<div class="modal-dialog">
2024-11-25 15:25:00 +01:00
<div class="modal-content">
<div class="modal-header">
2025-09-20 12:43:26 +02:00
<h5>Settings</h5>
<button class="close" data-dismiss="modal">×</button>
</div>
<form method="POST" action="?action=update_settings_form">
<div class="modal-body">
<div class="form-group">
<label>Tenant ID:</label>
<input type="text" name="tenantId" class="form-control" value="<?= htmlspecialchars($tenantId) ?>" required>
</div>
<div class="form-group">
<label>Client ID:</label>
<input type="text" name="clientId" class="form-control" value="<?= htmlspecialchars($clientId) ?>" required>
</div>
<div class="form-group">
2026-06-02 18:14:53 +02:00
<label>Authentication method:</label>
<select name="authMethod" id="authMethodSettings" class="form-control" onchange="toggleAuthFieldsSettings()">
<option value="secret" <?= (($_SESSION['authMethod'] ?? 'secret') !== 'certificate') ? 'selected' : '' ?>>Client Secret</option>
<option value="certificate" <?= (($_SESSION['authMethod'] ?? 'secret') === 'certificate') ? 'selected' : '' ?>>Certificate</option>
</select>
2025-09-20 12:43:26 +02:00
</div>
2026-06-02 18:14:53 +02:00
<div id="secretFieldsSettings">
<div class="form-group">
<label>Client Secret:</label>
<input type="password" name="clientSecret" class="form-control" value="<?= htmlspecialchars($clientSecret) ?>">
<small class="form-text text-muted">Leave blank to keep the existing secret.</small>
</div>
</div>
<div id="certFieldsSettings">
<div class="form-group">
<label>Private key file path (PEM):</label>
<input type="text" name="privateKeyPath" class="form-control" value="<?= htmlspecialchars($_SESSION['privateKeyPath'] ?? '') ?>" placeholder="C:\path\to\private_key.pem">
</div>
<div class="form-group">
<label>Private key passphrase (optional):</label>
<input type="password" name="privateKeyPassphrase" class="form-control" placeholder="Leave blank to keep existing">
</div>
<div class="form-group">
<label>Certificate thumbprint (hex, from Entra ID):</label>
<input type="text" name="certThumbprint" class="form-control" value="<?= htmlspecialchars($_SESSION['certThumbprint'] ?? '') ?>">
</div>
</div>
<script>
function toggleAuthFieldsSettings() {
var m = document.getElementById("authMethodSettings").value;
document.getElementById("secretFieldsSettings").style.display = (m === "secret") ? "block" : "none";
document.getElementById("certFieldsSettings").style.display = (m === "certificate") ? "block" : "none";
}
toggleAuthFieldsSettings();
</script>
2025-09-20 12:43:26 +02:00
<hr>
<div class="form-group">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="showLogsSettings" name="showLogs" value="1" <?= $_SESSION['showLogs'] ? 'checked' : '' ?>>
<label class="form-check-label" for="showLogsSettings">
Show operation logs after each action
</label>
<small class="form-text text-muted">
When enabled, detailed logs will be displayed after operations. When disabled, only success/error messages will be shown.
</small>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Settings</button>
</div>
</form>
</div>
</div>
</div>
<!-- Import Modal -->
<div class="modal fade" id="importModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5>Import Tokens</h5>
<button class="close" data-dismiss="modal">×</button>
2024-11-25 15:25:00 +01:00
</div>
<div class="modal-body">
<form method="POST" enctype="multipart/form-data">
2025-09-20 12:43:26 +02:00
<input type="file" name="importFile" accept="application/json" class="form-control" required><br>
2024-11-25 15:25:00 +01:00
<button type="submit" class="btn btn-primary">Upload</button>
</form>
</div>
</div>
</div>
</div>
2025-09-20 12:43:26 +02:00
<!-- Import CSV Modal -->
<div class="modal fade" id="importCSVModal">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5>Import CSV Tokens</h5>
<button class="close" data-dismiss="modal">×</button>
</div>
<div class="modal-body">
<!-- Import Mode Selection -->
<div class="import-mode-section">
<h6>Import Mode</h6>
<div class="form-group">
<div class="form-check">
<input class="form-check-input" type="radio" name="import_mode" id="import_only" value="import_only">
<label class="form-check-label" for="import_only">
<strong>Import Only</strong> - Just create tokens in the tenant (UPN optional)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="import_mode" id="import_assign" value="import_assign">
<label class="form-check-label" for="import_assign">
<strong>Import & Assign</strong> - Create tokens and assign to users (UPN required)
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="import_mode" id="import_assign_activate" value="import_assign_activate" checked>
<label class="form-check-label" for="import_assign_activate">
<strong>Import, Assign & Activate</strong> - Full process including activation (UPN required)
</label>
</div>
</div>
</div>
<!-- Import method tabs -->
<ul class="nav nav-tabs import-tabs" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="file-tab" data-toggle="tab" href="#file-import" role="tab">Upload File</a>
</li>
<li class="nav-item">
<a class="nav-link" id="text-tab" data-toggle="tab" href="#text-import" role="tab">Paste Text</a>
</li>
</ul>
<!-- Tab content -->
<div class="tab-content mt-3">
<!-- File Upload Tab -->
<div class="tab-pane fade show active" id="file-import" role="tabpanel">
<form method="POST" enctype="multipart/form-data" id="csvFileForm">
<input type="hidden" name="import_mode" id="file_import_mode" value="import_assign_activate">
<div class="form-group">
<label>Select CSV File:</label>
<input type="file" name="csvFile" accept=".csv,.txt" class="form-control" required>
</div>
<button type="submit" class="btn btn-primary">Upload & Import</button>
</form>
</div>
<!-- Text Area Tab -->
<div class="tab-pane fade" id="text-import" role="tabpanel">
<form id="importCSVForm">
<div class="form-group">
<label>CSV Data:</label>
<textarea class="form-control" name="csv_data" rows="10" placeholder="upn,serial number,secret key,timeinterval,manufacturer,model
user@token2.com,1100000000000,JBSWY3DPEHPK3PXP,30,Token2,miniOTP-1" required></textarea>
</div>
<button type="submit" class="btn btn-primary">Import</button>
</form>
</div>
</div>
<hr>
<div class="alert alert-info">
<strong>CSV Format Requirements:</strong>
<div id="csvFormatInfo">
<ul class="mb-0 mt-2">
<li><strong>upn:</strong> <span id="upnRequirement">User's email address (user@domain.com) - Required for assignment modes</span></li>
<li><strong>serial number:</strong> Unique token identifier - Always required</li>
<li><strong>secret key:</strong> Base32 encoded secret (A-Z, 2-7 characters only) - Always required</li>
<li><strong>timeinterval:</strong> Time interval in seconds (usually 30) - Always required</li>
<li><strong>manufacturer:</strong> Token manufacturer name - Always required</li>
<li><strong>model:</strong> Token model name - Always required</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Assign/Activate Modal -->
<div class="modal fade" id="actionModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 id="actionTitle"></h5>
<button class="close" data-dismiss="modal">×</button>
</div>
<div class="modal-body" id="actionBody"></div>
<div class="modal-footer">
<button class="btn btn-secondary" data-dismiss="modal">Close</button>
<button class="btn btn-primary" id="actionBtn"></button>
</div>
</div>
</div>
</div>
2025-09-20 22:00:24 +02:00
<!-- General Message Modal -->
<div class="modal fade" id="messageModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 id="messageTitle" class="modal-title"></h5>
<button type="button" class="close" data-dismiss="modal">×</button>
</div>
<div class="modal-body">
<div id="messageIcon" class="text-center mb-3"></div>
<p id="messageText"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<!-- Confirmation Modal -->
<div class="modal fade" id="confirmModal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 id="confirmTitle" class="modal-title">Confirm Action</h5>
<button type="button" class="close" data-dismiss="modal">×</button>
</div>
<div class="modal-body">
<div class="text-center mb-3">
<i class="text-warning" style="font-size: 3rem;">⚠️</i>
</div>
<p id="confirmText"></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmActionBtn">Confirm</button>
</div>
</div>
</div>
</div>
2025-09-20 12:43:26 +02:00
<!-- Response Modal -->
<div class="modal fade" id="responseModal">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 id="responseTitle"></h5>
<button class="close" data-dismiss="modal">×</button>
</div>
<div class="modal-body">
<p id="responseMessage"></p>
<div id="responseLogSection" style="display: none;">
<button class="btn btn-link" data-toggle="collapse" data-target="#responseLog">Show/Hide Log</button>
<div id="responseLog" class="collapse log-container"></div>
</div>
</div>
<div class="modal-footer">
<!-- <button class="btn btn-secondary" data-dismiss="modal">Close</button> -->
<button class="btn btn-primary" onclick="location.reload()">Continue </button>
</div>
</div>
</div>
</div>
<!-- Response Modal Initial -->
<div class="modal fade" id="responseModal1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 id="responseTitle1"></h5>
<button class="close" data-dismiss="modal">×</button>
</div>
<div class="modal-body">
<p id="responseMessage1"></p>
<div id="responseLogSection1" style="display: none;">
<button class="btn btn-link" data-toggle="collapse" data-target="#responseLog1">Show/Hide Log</button>
<div id="responseLog1" class="collapse log-container"></div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" data-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<!-- Tokens Table -->
2024-11-25 15:25:00 +01:00
<table id="tokensTable" class="table table-striped">
2025-09-20 12:43:26 +02:00
<thead>
2024-11-25 15:25:00 +01:00
<tr>
2025-09-20 12:43:26 +02:00
<th>Serial</th>
2024-11-25 15:25:00 +01:00
<th>Device</th>
2025-09-20 12:43:26 +02:00
<th>Hash</th>
2025-09-20 22:00:24 +02:00
<th>Time</th>
2025-09-20 12:43:26 +02:00
<th>User</th>
2024-11-25 15:25:00 +01:00
<th>Status</th>
2025-09-20 22:00:24 +02:00
<th>Seen</th>
2025-09-20 12:43:26 +02:00
<th>Actions</th>
2024-11-25 15:25:00 +01:00
</tr>
</thead>
<tbody>
2025-09-20 12:43:26 +02:00
<?php foreach ($tokens as $t) : ?>
<tr data-id="<?= htmlspecialchars($t['id']) ?>">
<td><?= htmlspecialchars($t['serialNumber']) ?></td>
<td><?= htmlspecialchars($t['manufacturer'] . '/' . $t['model']) ?></td>
<td><?= htmlspecialchars($t['hashFunction']) ?></td>
<td><?= htmlspecialchars($t['timeIntervalInSeconds']) ?>s</td>
<td><?= isset($t['assignedTo']['displayName']) ? htmlspecialchars($t['assignedTo']['displayName']) : 'Unassigned' ?></td>
<td><?= htmlspecialchars($t['status']) ?></td>
<td><?= htmlspecialchars($t['lastUsedDateTime'] ?? 'Never') ?></td>
2025-09-20 22:00:24 +02:00
<td width=33%>
2025-09-20 12:43:26 +02:00
<?php if (!isset($t['assignedTo']['id'])): ?>
<button class="btn btn-sm btn-primary" onclick="openAssign('<?= $t['id'] ?>', '<?= $t['serialNumber'] ?>')">Assign</button>
<button class="btn btn-sm btn-danger" onclick="deleteToken('<?= $t['id'] ?>')">Delete</button>
<?php else: ?>
<?php if ($t['status'] !== 'activated'): ?>
<button class="btn btn-sm btn-success" onclick="openActivate('<?= $t['id'] ?>', '<?= $t['serialNumber'] ?>', '<?= htmlspecialchars($t['assignedTo']['displayName'] ?? '', ENT_QUOTES) ?>', '<?= $t['assignedTo']['id'] ?? '' ?>')">Activate</button>
<?php endif; ?>
<button class="btn btn-sm btn-warning" onclick="unassignToken('<?= $t['id'] ?>', '<?= $t['assignedTo']['id'] ?? '' ?>')">Unassign</button>
<?php endif; ?>
</td>
2024-11-25 15:25:00 +01:00
</tr>
<?php endforeach; ?>
</tbody>
</table>
2025-09-20 22:00:24 +02:00
</div><div style="position: fixed; bottom: 10px; right: 10px; font-size: 14px; color: #555;">
2025-10-17 14:25:57 +02:00
© Token2 | <a target=_blank href=https://www.token2.swiss/site/page/totp-token-inventory-automate-token2-classic-oath-token-activation-in-microsoft-entra-id>🛈 About this tool</a>
2025-09-20 22:00:24 +02:00
</div>
2025-09-20 12:43:26 +02:00
<!-- Loading Spinner -->
<div class="loading-spinner" id="loadingSpinner">
<div class="spinner"></div>
</div>
2024-11-25 15:25:00 +01:00
<script>
2025-09-20 12:43:26 +02:00
let currentToken = '', currentUser = '';
// Global setting for showing logs
let showLogsEnabled = <?= json_encode($_SESSION['showLogs']) ?>;
// Completely disable jQuery AJAX to prevent conflicts
if (typeof $ !== 'undefined') {
$.fn.post = function() { console.error('jQuery post blocked'); return false; };
$.post = function() { console.error('jQuery post blocked'); return false; };
$.get = function() { console.error('jQuery get blocked'); return false; };
$.ajax = function() { console.error('jQuery ajax blocked'); return false; };
}
2025-09-20 22:00:24 +02:00
// Modal helper functions
function showMessage(title, message, type = 'info') {
const icons = {
'success': '✅',
'error': '❌',
'warning': '⚠️',
'info': 'ℹ ️'
};
document.getElementById('messageTitle').textContent = title;
document.getElementById('messageText').textContent = message;
document.getElementById('messageIcon').innerHTML = `<span style="font-size: 3rem;">${icons[type] || icons.info}</span>`;
$('#messageModal').modal('show');
}
function showConfirm(title, message, onConfirm, confirmText = 'Confirm', confirmClass = 'btn-danger') {
document.getElementById('confirmTitle').textContent = title;
document.getElementById('confirmText').textContent = message;
const confirmBtn = document.getElementById('confirmActionBtn');
confirmBtn.textContent = confirmText;
confirmBtn.className = `btn ${confirmClass}`;
// Remove any existing event listeners
const newBtn = confirmBtn.cloneNode(true);
confirmBtn.parentNode.replaceChild(newBtn, confirmBtn);
// Add new event listener
newBtn.addEventListener('click', function() {
$('#confirmModal').modal('hide');
onConfirm();
});
$('#confirmModal').modal('show');
}
function validateRequired(fieldName, value, customMessage = null) {
if (!value || value.trim() === '') {
showMessage('Validation Error', customMessage || `${fieldName} is required.`, 'error');
return false;
}
return true;
}
function validateCode(code) {
if (!code || code.length !== 6 || !/^\d{6}$/.test(code)) {
showMessage('Validation Error', 'Please enter a valid 6-digit verification code.', 'error');
return false;
}
return true;
}
2025-09-20 12:43:26 +02:00
document.addEventListener('DOMContentLoaded', function() {
$('#tokensTable').DataTable({
pageLength: 25,
order: [
[4, 'desc']
]
});
// Handle import mode changes
document.querySelectorAll('input[name="import_mode"]').forEach(function(radio) {
radio.addEventListener('change', function() {
let mode = this.value;
document.getElementById('file_import_mode').value = mode;
updateCSVFormatInfo(mode);
});
});
// Initialize CSV format info
updateCSVFormatInfo('import_assign_activate');
// Only show initial load log for regular page loads (not after form submissions) and if logs are enabled
<?php if (isset($initialLog) && $_SERVER['REQUEST_METHOD'] !== 'POST') : ?>
if (showLogsEnabled) {
showResponse1(
'Initial Load: fetchTokens',
'Initial tokens loaded',
<?php echo json_encode($initialLog); ?>
);
}
<?php endif; ?>
// Show import log only for JSON import and if logs are enabled
<?php if (isset($importLog) && isset($_FILES['importFile'])) : ?>
if (showLogsEnabled) {
showResponse1(
'Import Tokens',
'<?= $message ?>',
<?php echo json_encode($importLog); ?>
);
}
<?php endif; ?>
// Show CSV import log only for file uploads and textarea submissions and if logs are enabled
<?php if (isset($csvImportLog) && (isset($_FILES['csvFile']) || isset($_POST['csv_data']))) : ?>
if (showLogsEnabled) {
showResponse1(
'Import CSV Tokens',
'<?= $csvMessage ?>',
<?php echo json_encode($csvImportLog); ?>
);
}
<?php endif; ?>
// Handle CSV import form submission (textarea) - VANILLA JS
document.getElementById('importCSVForm').addEventListener('submit', function(e) {
e.preventDefault();
let csvData = this.querySelector('textarea[name="csv_data"]').value;
let importMode = document.querySelector('input[name="import_mode"]:checked').value;
2025-09-20 22:00:24 +02:00
if (!validateRequired('CSV data', csvData)) {
2025-09-20 12:43:26 +02:00
return;
}
2025-09-20 22:00:24 +02:00
2025-09-20 12:43:26 +02:00
showLoading();
// Use vanilla XMLHttpRequest to avoid jQuery issues
var xhr = new XMLHttpRequest();
xhr.open('POST', '?action=import_csv', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
hideLoading();
if (xhr.status === 200) {
try {
var d = JSON.parse(xhr.responseText);
$('#importCSVModal').modal('hide');
if (showLogsEnabled) {
showResponse('Import CSV Tokens', 'CSV Tokens imported (Mode: ' + importMode + ')', d.log);
} else {
2025-09-20 22:00:24 +02:00
showMessage('Import Successful', 'CSV Tokens imported successfully (Mode: ' + importMode + ')', 'success');
setTimeout(() => location.reload(), 1500);
2025-09-20 12:43:26 +02:00
}
} catch (e) {
2025-09-20 22:00:24 +02:00
showMessage('Import Completed', 'Import completed but response was invalid', 'warning');
setTimeout(() => location.reload(), 1500);
2025-09-20 12:43:26 +02:00
}
} else {
if (showLogsEnabled) {
showResponse('Error', 'Failed to import CSV tokens', {});
} else {
2025-09-20 22:00:24 +02:00
showMessage('Import Failed', 'Failed to import CSV tokens', 'error');
2025-09-20 12:43:26 +02:00
}
}
}
};
var formData = 'csv_data=' + encodeURIComponent(csvData) +
'&import_mode=' + encodeURIComponent(importMode);
xhr.send(formData);
});
// Handle CSV file upload form submission - VANILLA JS
document.getElementById('csvFileForm').addEventListener('submit', function(e) {
let fileInput = this.querySelector('input[name="csvFile"]');
if (!fileInput.files.length) {
e.preventDefault();
2025-09-20 22:00:24 +02:00
showMessage('File Required', 'Please select a CSV file.', 'error');
2025-09-20 12:43:26 +02:00
return;
}
showLoading();
// Let the form submit naturally for file upload
});
2024-11-25 15:25:00 +01:00
});
2025-09-20 12:43:26 +02:00
function updateCSVFormatInfo(mode) {
let upnText = '';
switch(mode) {
case 'import_only':
upnText = 'User\'s email address (user@domain.com) - Optional for import only mode';
break;
case 'import_assign':
case 'import_assign_activate':
upnText = 'User\'s email address (user@domain.com) - Required for assignment modes';
break;
}
$('#upnRequirement').text(upnText);
}
function showLoading() {
$('#loadingSpinner').css('display', 'flex');
}
function hideLoading() {
$('#loadingSpinner').hide();
}
function openAssign(token, serial) {
currentToken = token;
$('#actionTitle').text('Assign Token: ' + serial);
let html = `
<div class="form-group" style="position: relative;">
<label>Search User:</label>
<input type="text" id="userSearch" class="form-control" placeholder="Type to search users..." autocomplete="off">
<div id="userResults" class="list-group" style="display: none;"></div>
<input type="hidden" id="selectedUserId">
</div>
`;
$('#actionBody').html(html);
$('#actionBtn').text('Assign').off().click(assignTokenAction);
$('#actionModal').modal('show');
// Bind search event
$('#userSearch').on('input', function() {
let query = $(this).val().trim();
if (query.length >= 2) {
searchUsers(query);
} else {
$('#userResults').hide().empty();
}
});
// Hide results when clicking outside
$(document).on('click', function(e) {
if (!$(e.target).closest('#userSearch, #userResults').length) {
$('#userResults').hide();
}
});
}
function searchUsers(query) {
// Use vanilla XMLHttpRequest to avoid jQuery issues
var xhr = new XMLHttpRequest();
xhr.open('GET', '?action=get_users&query=' + encodeURIComponent(query), true);
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
console.log('Search users response:', data); // Debug logging
if (data.success && data.users && data.users.length > 0) {
let users = data.users;
let html = '';
users.forEach(u => {
html += `
<a href="#" class="list-group-item list-group-item-action"
data-id="${u.id}"
data-name="${u.displayName || u.userPrincipalName}">
<strong>${u.displayName || u.userPrincipalName}</strong><br>
<small class="text-muted">${u.userPrincipalName}</small>
</a>
`;
});
$('#userResults').html(html).show();
} else if (data.success && data.users && data.users.length === 0) {
$('#userResults').html('<div class="list-group-item">No users found</div>').show();
} else {
// Check for permission errors
if (data.permission_error) {
$('#userResults').html(`<div class="list-group-item text-danger">
<strong>Permission Error:</strong><br>
${data.error}<br>
<small><a href="#" data-toggle="modal" data-target="#settingsModal">Check Settings</a></small>
</div>`).show();
} else {
// Show debug information
let errorMsg = 'Failed to load users';
if (data.error) {
errorMsg += ': ' + data.error;
}
if (data.debug) {
console.error('Debug info:', data.debug);
errorMsg += ' (Check browser console for details)';
}
$('#userResults').html(`<div class="list-group-item text-danger">${errorMsg}</div>`).show();
}
}
} catch (e) {
console.error('JSON parse error:', e);
$('#userResults').html('<div class="list-group-item text-danger">Invalid response from server</div>').show();
}
} else {
console.error('HTTP error:', xhr.status, xhr.responseText);
$('#userResults').html(`<div class="list-group-item text-danger">HTTP Error: ${xhr.status}</div>`).show();
}
}
};
xhr.send();
}
$(document).on('click', '#userResults a', function(e) {
e.preventDefault();
let userId = $(this).data('id');
let userName = $(this).data('name');
$('#userSearch').val(userName);
$('#selectedUserId').val(userId);
$('#userResults').hide().empty();
});
function openActivate(token, serial, userName, userId) {
currentToken = token;
currentUser = userId;
$('#actionTitle').text('Activate Token: ' + serial + ' for ' + userName);
$('#actionBody').html(`
<div class="form-group">
<label>Verification Code:</label>
<input id="verCode" class="form-control" placeholder="Enter 6-digit code" maxlength="6">
</div>
<hr>
<div class="form-group">
<label>Secret Key (optional - for auto-generation):</label>
<input id="secretInput" class="form-control" placeholder="Enter secret key to auto-generate code">
</div>
`);
$('#actionBtn').text('Activate').off().click(activateTokenAction);
$('#actionModal').modal('show');
// Auto-generate code when secret is entered
$('#secretInput').on('input paste', function() {
setTimeout(() => {
let secret = this.value.trim();
if (secret) {
totp(secret).then(code => {
$('#verCode').val(code);
}).catch(err => {
console.error('TOTP generation error:', err);
});
}
}, 10);
});
}
function assignTokenAction() {
let uid = document.getElementById('selectedUserId').value;
2025-09-20 22:00:24 +02:00
if (!validateRequired('User selection', uid, 'Please select a user.')) {
2025-09-20 12:43:26 +02:00
return;
}
2025-09-20 22:00:24 +02:00
2025-09-20 12:43:26 +02:00
showLoading();
// Use vanilla XMLHttpRequest to avoid jQuery issues
var xhr = new XMLHttpRequest();
xhr.open('POST', '?action=assign_token', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
hideLoading();
$('#actionModal').modal('hide'); // Properly hide Bootstrap modal
if (xhr.status === 200) {
try {
var d = JSON.parse(xhr.responseText);
// Check for credential errors
if (d.credential_error) {
2025-09-20 22:00:24 +02:00
showMessage('Authentication Error', d.error + '\n\nPlease check your credentials in Settings.', 'error');
2025-09-20 12:43:26 +02:00
return;
}
// Check for permission errors
if (d.permission_error) {
2025-09-20 22:00:24 +02:00
showMessage('Permission Error', d.permission_error + '\n\nPlease add the required permissions in Azure Portal → App Registrations → API permissions.', 'error');
2025-09-20 12:43:26 +02:00
return;
}
if (showLogsEnabled) {
showResponse('Assign Token', d.success ? 'Token assigned successfully!' : 'Failed to assign token', d.log);
} else {
2025-09-20 22:00:24 +02:00
showMessage('Assignment ' + (d.success ? 'Successful' : 'Failed'),
d.success ? 'Token assigned successfully!' : 'Failed to assign token',
d.success ? 'success' : 'error');
if (d.success) setTimeout(() => location.reload(), 1500);
2025-09-20 12:43:26 +02:00
}
} catch (e) {
2025-09-20 22:00:24 +02:00
showMessage('Assignment Completed', 'Assignment completed but response was invalid', 'warning');
setTimeout(() => location.reload(), 1500);
2025-09-20 12:43:26 +02:00
}
} else {
if (showLogsEnabled) {
showResponse('Error', 'Failed to assign token', {});
} else {
2025-09-20 22:00:24 +02:00
showMessage('Assignment Failed', 'Failed to assign token', 'error');
2025-09-20 12:43:26 +02:00
}
}
}
};
var formData = 'token_id=' + encodeURIComponent(currentToken) +
'&user_id=' + encodeURIComponent(uid);
xhr.send(formData);
}
function activateTokenAction() {
let code = document.getElementById('verCode').value;
2025-09-20 22:00:24 +02:00
if (!validateCode(code)) {
2025-09-20 12:43:26 +02:00
return;
}
2025-09-20 22:00:24 +02:00
2025-09-20 12:43:26 +02:00
showLoading();
// Use vanilla XMLHttpRequest to avoid jQuery issues
var xhr = new XMLHttpRequest();
xhr.open('POST', '?action=activate_token', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
hideLoading();
$('#actionModal').modal('hide'); // Properly hide Bootstrap modal
if (xhr.status === 200) {
try {
var d = JSON.parse(xhr.responseText);
// Check for credential errors
if (d.credential_error) {
2025-09-20 22:00:24 +02:00
showMessage('Authentication Error', d.error + '\n\nPlease check your credentials in Settings.', 'error');
2025-09-20 12:43:26 +02:00
return;
}
// Check for permission errors
if (d.permission_error) {
2025-09-20 22:00:24 +02:00
showMessage('Permission Error', d.permission_error + '\n\nPlease add the required permissions in Azure Portal → App Registrations → API permissions.', 'error');
2025-09-20 12:43:26 +02:00
return;
}
if (showLogsEnabled) {
showResponse('Activate Token', d.success ? 'Token activated successfully!' : 'Failed to activate token', d.log);
} else {
2025-09-20 22:00:24 +02:00
showMessage('Activation ' + (d.success ? 'Successful' : 'Failed'),
d.success ? 'Token activated successfully!' : 'Failed to activate token',
d.success ? 'success' : 'error');
if (d.success) setTimeout(() => location.reload(), 1500);
2025-09-20 12:43:26 +02:00
}
} catch (e) {
2025-09-20 22:00:24 +02:00
showMessage('Activation Completed', 'Activation completed but response was invalid', 'warning');
setTimeout(() => location.reload(), 1500);
2025-09-20 12:43:26 +02:00
}
} else {
if (showLogsEnabled) {
showResponse('Error', 'Failed to activate token', {});
} else {
2025-09-20 22:00:24 +02:00
showMessage('Activation Failed', 'Failed to activate token', 'error');
2025-09-20 12:43:26 +02:00
}
}
}
};
var formData = 'token_id=' + encodeURIComponent(currentToken) +
'&user_id=' + encodeURIComponent(currentUser) +
'&verification_code=' + encodeURIComponent(code);
xhr.send(formData);
}
function unassignToken(token, user) {
2025-09-20 22:00:24 +02:00
showConfirm(
'Unassign Token',
'Are you sure you want to unassign this token?',
function() {
showLoading();
// Use vanilla XMLHttpRequest to avoid jQuery issues
var xhr = new XMLHttpRequest();
xhr.open('POST', '?action=unassign_token', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
hideLoading();
if (xhr.status === 200) {
try {
var d = JSON.parse(xhr.responseText);
// Check for credential errors
if (d.credential_error) {
showMessage('Authentication Error', d.error + '\n\nPlease check your credentials in Settings.', 'error');
return;
}
// Check for permission errors
if (d.permission_error) {
showMessage('Permission Error', d.permission_error + '\n\nPlease add the required permissions in Azure Portal → App Registrations → API permissions.', 'error');
return;
}
if (showLogsEnabled) {
showResponse('Unassign Token', d.success ? 'Token unassigned successfully!' : 'Failed to unassign token', d.log);
} else {
showMessage('Unassignment ' + (d.success ? 'Successful' : 'Failed'),
d.success ? 'Token unassigned successfully!' : 'Failed to unassign token',
d.success ? 'success' : 'error');
if (d.success) setTimeout(() => location.reload(), 1500);
}
} catch (e) {
showMessage('Unassignment Completed', 'Unassignment completed but response was invalid', 'warning');
setTimeout(() => location.reload(), 1500);
2025-09-20 12:43:26 +02:00
}
} else {
2025-09-20 22:00:24 +02:00
if (showLogsEnabled) {
showResponse('Error', 'Failed to unassign token', {});
} else {
showMessage('Unassignment Failed', 'Failed to unassign token', 'error');
}
2025-09-20 12:43:26 +02:00
}
}
2025-09-20 22:00:24 +02:00
};
var formData = 'token_id=' + encodeURIComponent(token) +
'&user_id=' + encodeURIComponent(user);
xhr.send(formData);
},
'Unassign',
'btn-warning'
);
2025-09-20 12:43:26 +02:00
}
function deleteToken(token) {
2025-09-20 22:00:24 +02:00
showConfirm(
'Delete Token',
'Are you sure you want to delete this token? This action cannot be undone.',
function() {
showLoading();
// Use vanilla XMLHttpRequest to avoid jQuery issues
var xhr = new XMLHttpRequest();
xhr.open('POST', '?action=delete_token', true);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
hideLoading();
if (xhr.status === 200) {
try {
var d = JSON.parse(xhr.responseText);
// Check for credential errors
if (d.credential_error) {
showMessage('Authentication Error', d.error + '\n\nPlease check your credentials in Settings.', 'error');
return;
}
// Check for permission errors
if (d.permission_error) {
showMessage('Permission Error', d.permission_error + '\n\nPlease add the required permissions in Azure Portal → App Registrations → API permissions.', 'error');
return;
}
if (showLogsEnabled) {
showResponse('Delete Token', d.success ? 'Token deleted successfully!' : 'Failed to delete token', d.log);
} else {
showMessage('Deletion ' + (d.success ? 'Successful' : 'Failed'),
d.success ? 'Token deleted successfully!' : 'Failed to delete token',
d.success ? 'success' : 'error');
if (d.success) setTimeout(() => location.reload(), 1500);
}
} catch (e) {
showMessage('Deletion Completed', 'Deletion completed but response was invalid', 'warning');
setTimeout(() => location.reload(), 1500);
2025-09-20 12:43:26 +02:00
}
} else {
2025-09-20 22:00:24 +02:00
if (showLogsEnabled) {
showResponse('Error', 'Failed to delete token', {});
} else {
showMessage('Deletion Failed', 'Failed to delete token', 'error');
}
2025-09-20 12:43:26 +02:00
}
}
2025-09-20 22:00:24 +02:00
};
var formData = 'token_id=' + encodeURIComponent(token);
xhr.send(formData);
},
'Delete',
'btn-danger'
);
2025-09-20 12:43:26 +02:00
}
function showResponse(title, message, log) {
$('#responseTitle').text(title);
$('#responseMessage').text(message);
if (showLogsEnabled && log) {
$('#responseLog').text(JSON.stringify(log, null, 2));
$('#responseLogSection').show();
} else {
$('#responseLogSection').hide();
}
$('#responseModal').modal('show');
}
function showResponse1(title, message, log) {
$('#responseTitle1').text(title);
$('#responseMessage1').text(message);
if (showLogsEnabled && log) {
$('#responseLog1').text(JSON.stringify(log, null, 2));
$('#responseLogSection1').show();
} else {
$('#responseLogSection1').hide();
}
$('#responseModal1').modal('show');
}
function totp(secret, timeStep = 30) {
// Auto-detect hash algorithm by secret length
const algorithm = secret.length <= 32 ? 'SHA-1' : 'SHA-256';
// Base32 decode
const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = '', bytes = [];
for (let char of secret.toUpperCase()) {
if (base32Chars.indexOf(char) === -1) {
throw new Error('Invalid Base32 character: ' + char);
}
bits += base32Chars.indexOf(char).toString(2).padStart(5, '0');
}
for (let i = 0; i < bits.length; i += 8) {
if (bits.length - i >= 8) {
bytes.push(parseInt(bits.substr(i, 8), 2));
}
}
// Time counter (30-second window)
const counter = Math.floor(Date.now() / 1000 / timeStep);
const counterBytes = new ArrayBuffer(8);
new DataView(counterBytes).setBigUint64(0, BigInt(counter));
// HMAC calculation
return crypto.subtle.importKey('raw', new Uint8Array(bytes), {name: 'HMAC', hash: algorithm}, false, ['sign'])
.then(key => crypto.subtle.sign('HMAC', key, counterBytes))
.then(signature => {
const hash = new Uint8Array(signature);
const offset = hash[hash.length - 1] & 0x0f;
const code = ((hash[offset] & 0x7f) << 24) |
((hash[offset + 1] & 0xff) << 16) |
((hash[offset + 2] & 0xff) << 8) |
(hash[offset + 3] & 0xff);
return (code % 1000000).toString().padStart(6, '0');
});
}
2024-11-25 15:25:00 +01:00
</script>
</body>
2025-09-20 21:59:58 +02:00
</html>