I am having some issues in creating a Discord bot. I want it to be able to respond to slash commands, but to do so I need to verify an endpoint.
I am using PHP 7.4, and can't use any external libraries (hosting on a server that does not allow them).
I have found documents for PHP, but they do require libraries to work.
I tried taking the documents from Node.JS and "converting" them to PHP.
Here's my code:
<?php
$public_key = "shh-it's-a-seekrit";
$headers = getallheaders();
$signature = $headers["X-Signature-Ed25519"];
$timestamp = $headers["X-Signature-Timestamp"];
$raw_body = file_get_contents('php://input');
/* To compute the signature, we need the following
* 1. Message ($timestamp + $body)
* 2. $signature
* 3. $public_key
* The algorythm is SHA-512
*/
$message = $timestamp . $raw_body;
$hash_signature = hash_hmac('sha512', $message, $public_key);
if (!hash_equals($signature, $hash_signature)) {
header("HTTP/1.1 401 Unauthorized", true, 401);
die("Request is not properly authorized!");
}
$return_array = [
'type' => 1,
];
echo json_encode($return_array);
?>
When I put the address the file is uploaded to and try to save the changes, Discord says the following:
Validation errors:
interactions_endpoint_url: The specified interactions endpoint URL could not be verified.
This is a method that works for me on PHP 8.1.
Pass in the headers and raw JSON body, and it returns an array with the response code and payload to send back through whatever you are using to handle the response. Note the response must be JSON encoded.
$discord_public is the public key from the Discord app.
public function authorize(array $headers, string $body, string $discord_public): array
{
$res = [
'code' => 200,
'payload' => []
];
if (!isset($headers['x-signature-ed25519']) || !isset($headers['x-signature-timestamp'])) {
$res['code'] = 401;
return $res;
}
$signature = $headers['x-signature-ed25519'];
$timestamp = $headers['x-signature-timestamp'];
if (!trim($signature, '0..9A..Fa..f') == '') {
$res['code'] = 401;
return $res;
}
$message = $timestamp . $body;
$binary_signature = sodium_hex2bin($signature);
$binary_key = sodium_hex2bin($discord_public);
if (!sodium_crypto_sign_verify_detached($binary_signature, $message, $binary_key)) {
$res['code'] = 401;
return $res;
}
$payload = json_decode($body, true);
switch ($payload['type']) {
case 1:
$res['payload']['type'] = 1;
break;
case 2:
$res['payload']['type'] = 2;
break;
default:
$res['code'] = 400;
return $res;
}
return $res;
}
For completeness, adding the missing code to #Coder1 answer:
<?php
$payload = file_get_contents('php://input');
$result = endpointVerify($_SERVER, $payload, 'discord app public key');
http_response_code($result['code']);
echo json_encode($result['payload']);
function endpointVerify(array $headers, string $payload, string $publicKey): array
{
if (
!isset($headers['HTTP_X_SIGNATURE_ED25519'])
|| !isset($headers['HTTP_X_SIGNATURE_TIMESTAMP'])
)
return ['code' => 401, 'payload' => null];
$signature = $headers['HTTP_X_SIGNATURE_ED25519'];
$timestamp = $headers['HTTP_X_SIGNATURE_TIMESTAMP'];
if (!trim($signature, '0..9A..Fa..f') == '')
return ['code' => 401, 'payload' => null];
$message = $timestamp . $payload;
$binarySignature = sodium_hex2bin($signature);
$binaryKey = sodium_hex2bin($publicKey);
if (!sodium_crypto_sign_verify_detached($binarySignature, $message, $binaryKey))
return ['code' => 401, 'payload' => null];
$payload = json_decode($payload, true);
switch ($payload['type']) {
case 1:
return ['code' => 200, 'payload' => ['type' => 1]];
case 2:
return ['code' => 200, 'payload' => ['type' => 2]];
default:
return ['code' => 400, 'payload' => null];
}
}
According to Discord documentation, interactions endpoint must do two things:
Your endpoint must be prepared to ACK a PING message
Your endpoint must be set up to properly handle signature headers--more on that in Security and Authorization
The first part is very simple:
So, to properly ACK the payload, return a 200 response with a payload of type: 1:
Thus, you need to return JSON object and not an array (maybe that's one of the problems with your code).
Additionally, endpoint must be able to respond to invalid requests as:
We will also do automated, routine security checks against your endpoint, including purposefully sending you invalid signatures. If you fail the validation, we will remove your interactions URL in the future and alert you via email and System DM.
One thing I noticed is that Discord sends their headers lowercased! Another problem is that you use:
$raw_body = file_get_contents('php://input'); that doesn't look right.
Finally, if you know node.JS take a look at my working example: https://github.com/iaforek/discord-interactions
Related
I have integration with other servers using API
but I can not get value from the response header to complete authentication and get the session of the server
I try this code
$init = curl_init();
$Auth = [];
$response_headers = [];
$header_callback = function($init,$Auth) use (&$response_headers){
$len = strlen($Auth);
$response_headers[] = $Auth;
return $len;
};
curl_setopt_array($init, [
CURLOPT_URL => 'http://ip-api/v1/rest/auth',
CURLOPT_HEADER => true,
CURLOPT_HEADERFUNCTION => $header_callback
]);
$output = curl_exec($init);
$info = curl_getinfo($init);
var_dump($Auth);
echo $Auth[0];
curl_close($init);
but the result is
Warning: Undefined array key 0 in //path
how can get this value
The data you need is within $response_headers, not $Auth.
e.g.
echo $response_headers [11];
would get you the specific value.
You are looking this php function, if I understand right apache_request_headers().
If you have apache this will work. An you will get an array with your headers that you can loop over and take the ['Auth-Nonce'] key. If apache doesn't have the $header['Auth-Nonce'] in the specific HTTP call, null will be returned.
$headers = apache_request_headers();
echo $header['Auth-Nonce'];
In case of Nginx and other webservers try this getallheaders(). If you get an error that says the function doesn't exist do it custom like this:
if (!function_exists('getallheaders')) {
function getallheaders()
{
if (!is_array($_SERVER)) {
return array();
}
$headers = array();
foreach ($_SERVER as $name => $value) {
if (substr($name, 0, 5) == 'HTTP_') {
$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
}
}
return $headers;
}
}
In any case you will get your webserver headers on your call.
So I'm using Lexik JWT Authentication Bundle (Symfony 2.6) and successful created user and token using this code:
$userRegistration = new UserRegistration();
$userRegistration->setPassword($request->request->get('password'));
$userRegistration->setEmail($request->request->filter('email'));
$userRegistration->setName($request->request->filter('name'));
$token = $this->jwtManager->create($RegisteredUser);
return $this->responseProvider->ok((object) [
'userId' => $RegisteredUser->getId(),
'token' => $token
]);
Response :
"data": {
"userId": 24,
"token": "eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiXSwidXNlcm5hbWUiOiJqYWdhZHVAaHViaWktbmV0d29yay5jb20iLCJleHAiOjE1MzYxNTI0MDAsImlhdCI6MTUzNTU0NzYwMH0.B7gnfGdW1ijAIlo9xUI0DwkGaajQAQPBkRx4ChILXRNtpLdwgEl_9gvWdiidFbSXJseS8jslOfuAFUIWATmbNBoWVa3nc8SxkIrKI29xZuN6hB7R-63RH2BKsAVPsEjgTIJoqkkCrfrSum-_d3LEf36jcXqZb8M-GRKI477IwSDDwG_7YK5v0mu8N4TATXhN0tZGNYxp8Y27EI-g0Gmj9BIiobxnqVVoBWHN5J8d-UCrXRq94ifhEiQBxkG9r_eacMscB80n1VsiN2ouKH2kX-HRxRJmcgmydxvR7RcEW-P6koTxkaZJGO6mv7auSudTFlDENpwD4OD7gtn_wMUDS_OuN8WT7rZp8lwKY9f8J9fiGyq5J-8C_HmyjW-h8WhuJmTUaKhCZ-eLgDm4Vs2IQGYkHJEDFumnIZ607MAa1CW1ChAvurqvUqJ3G4TTN4wYqAHpSKz4y8SAMLjO91cedBPH6K5i9lh5htF-mW_htem7e5ornicU_djSccgHbxfXHQYTHCnqLp7-ONfl_p4nmhIEK0wcF0gkBXbIitzeTjy7C_uf_FV1sLPE5cY3PUP42DmHrG4PuXHLv_L1EjErkrpna7pChKA_TPeiZjqMcQoE70sZw8rr8KnRF2hpABdU_M2ZXOt_vF5-T8mLmKqs0LHxE089vVC3xsAh0mUr4FE"
}
The problem started when I try to decode current token in other controller using jwtManager->decode method :
$jwt = "eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiXSwidXNlcm5hbWUiOiJqYWdhZHVAaHViaWktbmV0d29yay5jb20iLCJleHAiOjE1MzYxNTI0MDAsImlhdCI6MTUzNTU0NzYwMH0.B7gnfGdW1ijAIlo9xUI0DwkGaajQAQPBkRx4ChILXRNtpLdwgEl_9gvWdiidFbSXJseS8jslOfuAFUIWATmbNBoWVa3nc8SxkIrKI29xZuN6hB7R-63RH2BKsAVPsEjgTIJoqkkCrfrSum-_d3LEf36jcXqZb8M-GRKI477IwSDDwG_7YK5v0mu8N4TATXhN0tZGNYxp8Y27EI-g0Gmj9BIiobxnqVVoBWHN5J8d-UCrXRq94ifhEiQBxkG9r_eacMscB80n1VsiN2ouKH2kX-HRxRJmcgmydxvR7RcEW-P6koTxkaZJGO6mv7auSudTFlDENpwD4OD7gtn_wMUDS_OuN8WT7rZp8lwKY9f8J9fiGyq5J-8C_HmyjW-h8WhuJmTUaKhCZ-eLgDm4Vs2IQGYkHJEDFumnIZ607MAa1CW1ChAvurqvUqJ3G4TTN4wYqAHpSKz4y8SAMLjO91cedBPH6K5i9lh5htF-mW_htem7e5ornicU_djSccgHbxfXHQYTHCnqLp7-ONfl_p4nmhIEK0wcF0gkBXbIitzeTjy7C_uf_FV1sLPE5cY3PUP42DmHrG4PuXHLv_L1EjErkrpna7pChKA_TPeiZjqMcQoE70sZw8rr8KnRF2hpABdU_M2ZXOt_vF5-T8mLmKqs0LHxE089vVC3xsAh0mUr4FE";
$test = $this->jwtManager->decode($jwt);
But the JWTManager::decode() must implement interface Symfony\Component\Security\Core\Authentication\Token\TokenInterface and not expect string given.
My goal is just to get userId form this token but can't find any information how to decode the token. Does anyone have any idea how to simply decode the token?
Manually getting the information out of a token without using a JWT lib is quite simple.
A JWT string consists of 3 parts:
The base64url encoded header and payload, both are JSON 'objects', and the signature. All 3 parts are separated by a ..
So you just need to split the token into its 3 parts, done here with explode, then decode the base64url encoded strings (base64_decode) and finally decode the JSON (json_decode):
$token = "eyJhbGciOiJSUzI1NiJ9.eyJyb2xlcyI6WyJST0xFX1VTRVIiXSwidXNlcm5hbWUiOiJqYWdhZHVAaHViaWktbmV0d29yay5jb20iLCJleHAiOjE1MzYxNTI0MDAsImlhdCI6MTUzNTU0NzYwMH0.B7gnfGdW1ijAIlo9xUI0DwkGaajQAQPBkRx4ChILXRNtpLdwgEl_9gvWdiidFbSXJseS8jslOfuAFUIWATmbNBoWVa3nc8SxkIrKI29xZuN6hB7R-63RH2BKsAVPsEjgTIJoqkkCrfrSum-_d3LEf36jcXqZb8M-GRKI477IwSDDwG_7YK5v0mu8N4TATXhN0tZGNYxp8Y27EI-g0Gmj9BIiobxnqVVoBWHN5J8d-UCrXRq94ifhEiQBxkG9r_eacMscB80n1VsiN2ouKH2kX-HRxRJmcgmydxvR7RcEW-P6koTxkaZJGO6mv7auSudTFlDENpwD4OD7gtn_wMUDS_OuN8WT7rZp8lwKY9f8J9fiGyq5J-8C_HmyjW-h8WhuJmTUaKhCZ-eLgDm4Vs2IQGYkHJEDFumnIZ607MAa1CW1ChAvurqvUqJ3G4TTN4wYqAHpSKz4y8SAMLjO91cedBPH6K5i9lh5htF-mW_htem7e5ornicU_djSccgHbxfXHQYTHCnqLp7-ONfl_p4nmhIEK0wcF0gkBXbIitzeTjy7C_uf_FV1sLPE5cY3PUP42DmHrG4PuXHLv_L1EjErkrpna7pChKA_TPeiZjqMcQoE70sZw8rr8KnRF2hpABdU_M2ZXOt_vF5-T8mLmKqs0LHxE089vVC3xsAh0mUr4FE";
$tokenParts = explode(".", $token);
$tokenHeader = base64_decode($tokenParts[0]);
$tokenPayload = base64_decode($tokenParts[1]);
$jwtHeader = json_decode($tokenHeader);
$jwtPayload = json_decode($tokenPayload);
print $jwtPayload->username;
In the last line you have the desired information.
You can also inspect your token on https://jwt.io to see which fields are in the payload. There's also a good introduction about JWT on that site.
You can use the JWTEncoder service for this.
The service name is lexik_jwt_authentication.jws_provider.lcobucci
Or if you want class named services use Lexik\Bundle\JWTAuthenticationBundle\Encoder\JWTEncoderInterface
The method you are looking for is decode()
$jwtEncoder->decode($yourToken);
Please find my code for doing this without any libraries.
function decodeJWTPayloadOnly($token){
$tks = explode('.', $token);
if (count($tks) != 3) {
return null;
}
list($headb64, $bodyb64, $cryptob64) = $tks;
$input=$bodyb64;
$remainder = strlen($input) % 4;
if ($remainder) {
$padlen = 4 - $remainder;
$input .= str_repeat('=', $padlen);
}
$input = (base64_decode(strtr($input, '-_', '+/')));
if (version_compare(PHP_VERSION, '5.4.0', '>=') && !(defined('JSON_C_VERSION') && PHP_INT_SIZE > 4)) {
$obj = json_decode($input, false, 512, JSON_BIGINT_AS_STRING);
} else {
$max_int_length = strlen((string) PHP_INT_MAX) - 1;
$json_without_bigints = preg_replace('/:\s*(-?\d{'.$max_int_length.',})/', ': "$1"', $input);
$obj = json_decode($json_without_bigints);
}
return $obj;
}
There is a difference between a PUT and POST request I send through a REST CLIENT in my API. It is implemented in CodeIgniter with Phil Sturgeon's REST server.
function station_put(){
$data = array(
'name' => $this->input->post('name'),
'number' => $this->input->post('number'),
'longitude' => $this->input->post('longitude'),
'lat' => $this->input->post('latitude'),
'typecode' => $this->input->post('typecode'),
'description' => $this->input->post('description'),
'height' => $this->input->post('height'),
'mult' => $this->input->post('mult'),
'exp' => $this->input->post('exp'),
'elevation' => $this->input->post('elevation')
);
$id_returned = $this->station_model->add_station($data);
$this->response(array('id'=>$id_returned,'message'=>"Successfully created."),201);
}
this request successfully inserts data into the server BUT - it renders the rest of the values NULL except for the id.
But if you change the function name into station_post, it inserts the data correctly.
Would somebody please point out why the PUT request does not work? I am using the latest version of google chrome.
Btw this API will be integrated to a BackBone handled app. Do I really need to use PUT? Or is there another workaround with the model saving function in backbone when using post?
Finally answered. Instead of $this->input->post or $this->input->put, it must be $this->put or $this->post because the data is not coming from a form.
Codeigniter put_stream also failing to fetch put data, therefore I had to handle php PUT request and I found this function useful enough to save someone's time:
function parsePutRequest()
{
// Fetch content and determine boundary
$raw_data = file_get_contents('php://input');
$boundary = substr($raw_data, 0, strpos($raw_data, "\r\n"));
// Fetch each part
$parts = array_slice(explode($boundary, $raw_data), 1);
$data = array();
foreach ($parts as $part) {
// If this is the last part, break
if ($part == "--\r\n") break;
// Separate content from headers
$part = ltrim($part, "\r\n");
list($raw_headers, $body) = explode("\r\n\r\n", $part, 2);
// Parse the headers list
$raw_headers = explode("\r\n", $raw_headers);
$headers = array();
foreach ($raw_headers as $header) {
list($name, $value) = explode(':', $header);
$headers[strtolower($name)] = ltrim($value, ' ');
}
// Parse the Content-Disposition to get the field name, etc.
if (isset($headers['content-disposition'])) {
$filename = null;
preg_match(
'/^(.+); *name="([^"]+)"(; *filename="([^"]+)")?/',
$headers['content-disposition'],
$matches
);
list(, $type, $name) = $matches;
isset($matches[4]) and $filename = $matches[4];
// handle your fields here
switch ($name) {
// this is a file upload
case 'userfile':
file_put_contents($filename, $body);
break;
// default for all other files is to populate $data
default:
$data[$name] = substr($body, 0, strlen($body) - 2);
break;
}
}
}
return $data;
}
I am trying to download a rapidshare file using its "download" subroutine as a free user. The following is the code that I use to get response from the subroutine.
function rs_download($params)
{
$url = "http://api.rapidshare.com/cgi-bin/rsapi.cgi?sub=download&fileid=".$params['fileid']."&filename=".$params['filename'];
$reply = #file_get_contents($url);
if(!$reply)
{
return false;
}
$result_arr = array();
$result_keys = array(0=> 'hostname', 1=>'dlauth', 2=>'countdown_time', 3=>'md5hex');
if( preg_match("/DL:(.*)/", $reply, $reply_matches) )
{
$reply_altered = $reply_matches[1];
}
else
{
return false;
}
foreach( explode(',', $reply_altered) as $index => $value )
{
$result_arr[ $result_keys[$index] ] = $value;
}
return $result_arr;
}
For instance; trying to download this...
http://rapidshare.com/files/440817141/AutoRun__live-down.com_Champ.rar
I pass the fileid(440817141) and filename(AutoRun__live-down.com_Champ.rar) to rs_download(...) and I get a response just as rapidshare's api doc says.
The rapidshare api doc (see "sub=download") says call the server hostname with the download authentication string but I couldn't figure out what form the url should take.
Any suggestions?, I tried
$download_url = "http://$the-hostname/$the-dlauth-string/files/$fileid/$filename"
and a couple other variations of the above, nothing worked.
I use curl to download the file, like the following;
$cr = curl_init();
$fp = fopen ("d:/downloaded_files/file1.rar", "w");
// set curl options
$curl_options = array(
CURLOPT_URL => $download_url
,CURLOPT_FILE => $fp
,CURLOPT_HEADER => false
,CURLOPT_CONNECTTIMEOUT => 0
,CURLOPT_FOLLOWLOCATION => true
);
curl_setopt_array($cr, $curl_options);
curl_exec($cr);
curl_close($cr);
fclose($fp);
The above curl code doesn't seem to work, nothing gets downloaded. Probably its the download url that is incorrect.
Also tried this format for the download url:
"http://rs$serverid$shorthost.rapidshare.com/files/$fileid/$filename"
With this curl writes a file entry but that is all it does(writes a 0/1 kb file).
Here is the code that I use to get the serverid, shorthost, among a few other values from rapidshare.
function rs_checkfile($params)
{
$url = "http://api.rapidshare.com/cgi-bin/rsapi.cgi?sub=checkfiles_v1&files=".$params['fileids']."&filenames=".$params['filenames'];
// the response from rapishare would a string something like:
// 440817141,AutoRun__live-down.com_Champ.rar,47768,20,1,l3,0
$reply = #file_get_contents($url);
if(!$reply)
{
return false;
}
$result_arr = array();
$result_keys = array(0=> 'file_id', 1=>'file_name', 2=>'file_size', 3=>'server_id', 4=>'file_status', 5=>'short_host'
, 6=>'md5');
foreach( explode(',', $reply) as $index => $value )
{
$result_arr[ $result_keys[$index] ] = $value;
}
return $result_arr;
}
rs_checkfile(...) takes comma seperated fileids and filenames(no commas if calling for a single file)
Thanks in advance for any suggestions.
You start by requesting ?sub=download&fileid=X&filename=Y, and it returns $hostname,$dlauth,$countdown,$md5hex.. since you're a free user you have to delay for $countdown seconds, and then call ?sub=download&fileid=X&filename=Y&dlauth=Z to perform the download.
There's a working implementation in python here that would probably answer any of your other questions.
Ive cracked oAuth and have my class file for it. I'm at the last stage of posting a tweet and all works except all the words are joined with a plus sign in the tweet.
Changing anything results in the signature been incorrect and twitter returns 401 error.
So how does one remove the pluses? Post function below:
function post($token, $tokenSecret, $status)
{
// Default params
$params = array(
"oauth_version" => "1.0",
"oauth_nonce" => time(),
"oauth_timestamp" => time(),
"oauth_consumer_key" => $this->key,
"oauth_signature_method" => "HMAC-SHA1",
"oauth_token" => $token,
"status" => $status
);
uksort($params, 'strcmp');
// convert params to string
foreach ($params as $k => $v) {$pairs[] = $this->_urlencode_rfc3986($k).'='.$this->_urlencode_rfc3986($v);}
$concatenatedParams = implode('&', $pairs);
// form base string (first key)
$baseString= "POST&".$this->_urlencode_rfc3986($this->request_statuses_url)."&".$this->_urlencode_rfc3986($concatenatedParams);
// form secret (second key)
$secret = $this->_urlencode_rfc3986($this->secret)."&".$this->_urlencode_rfc3986($tokenSecret);
// make signature
$sig = $this->_urlencode_rfc3986(base64_encode(hash_hmac('sha1', $baseString, $secret, TRUE)));
// BUILD URL
$url = $this->request_statuses_url; // twitter update url
$paramString = $concatenatedParams."&oauth_signature=".$sig;
// Send to cURL
$result = $this->_http($url, $paramString);
if($result['httpCode'] == '200'){
// Return array
return $result;
}
else{
// Error
show_error($result['httpCode'], $result['httpCode']);
return FALSE;
}
}
Is $status your tweet? Take a look at the POST request before you post it, my guess is _urlencode_rfc3986() converts it so that you get "$status=This+is+my+tweet" when you want "$status=This is my tweet"
Twitter is not supporting "+" as escape for spaces, which as far as I know is a violation of the standard.
You have to replace the the + with %20.