bakecost.com
Create recipe Recipes Unit prices About us Privacy policy Terms and conditions Feedback form Bean diet FB login in bash Main

Facebook secure token check with bash

This is a very different recipe compared to the others in bakecost.com. This isn't a recipe for a pancake nor buns, but for linux server side bash scripts. In reference to a facebook login and server-side authentication project, soon available (today: Mon 18th of May, 2020) in github.com, the author Eero Nurkkala from Offcode ltd, Finland will introduce how this is possible.

Although CGI-scripts may look nonexistent these days, they still have a small market share. Some people just love shell scripts as they're quite powerful on what they may do.

Apache2 is used as the reference web server. The ideas apply to any but the shell scripts rely on a few Apache environment variables.

AJAX json data format for the data from a web page

It's expected you have built FB login on your web page one way or another. Facebook developers documentation provides all the information you need for the login steps. For what you can see here, you can find the complete reference HTML+JS source code from the page here. Of course, by viewing its source code. Over here, only the necessary code is introduced.

Apache2 configuration for CGI-BIN

First, let's change the cgi-bin into something shorter:

root@server83:/etc/apache2# cd /etc/apache2
root@server83:/etc/apache2# grep ScriptAlias */*
conf-available/serve-cgi-bin.conf:              ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/
conf-enabled/serve-cgi-bin.conf:                ScriptAlias /cgi-bin/ /usr/lib/cgi-bin/

After the grep results you should be able to find the proper file. Note that the files under conf-enabled/ are just symbolic links to the real files. We use pico (or nano) as a sample text editor, but you can use any you wish. Edit this file:

    <IfDefine ENABLE_USR_LIB_CGI_BIN>
            ScriptAlias /s/ /usr/lib/s/
            <Directory "/usr/lib/s">
                    AllowOverride None
                    Options +ExecCGI -MultiViews +SymLinksIfOwnerMatch
                    Require all granted
            </Directory>
    </IfDefine>

Then create the directory:

root@server83:/etc/apache2# cd /usr/lib/
root@server83:/usr/lib# mkdir s

Oh yes, make sure the CGI module is enabled:

root@server83:/usr/lib# a2enmod cgi
root@server83:/usr/lib# service apache2 restart

Now that you have Apache2 set up for the rest, let's check what the front-end looks like.

Javascript JSON data preparation for the bash scripts

It's expected you have created an FB app in the Facebook developer dashboard. You should be able to get your app ID etc from there. In the following code, we pick the first name and the whole secure token (authResponse):

  var lb_id; // Storing the token like this isn't a good idea

  function statusChangeCallback(response) {
    if (response.status === 'connected') {
      lb_id = response.authResponse;
      console.log("id: ", response.authResponse.userID);
            FB.api('/me', 'GET', {"fields":"id,name,email"}, function(response) {
               var cname = response.name;
               fname = cname.split(' ')[0];  // Pick the first name only
               console.log("fname: ", fname);
            });
      fbLogin = true;

We send the data via the XMLHttpRequest(). Angular JS or whatever else could be used, but to keep this simple, we just use the XMLHttpRequest in the following manner:

function postComment()
{
  if (fbLogin == false) {
    alert("Please login first");
    return;
  }
  if (fname.length < 2) {
    alert("Something went wrong!");
    return;
  }

  var xhr = new XMLHttpRequest();
  var url = "https://juoksukengat.fi/s/fb_cmt";
  url += "?" + window.location.href;
  xhr.open("POST", url, true);
  xhr.setRequestHeader("Content-Type", "application/json");

  xhr.onreadystatechange = function () {
    if (xhr.readyState === 4 && xhr.status === 200) {
        load_comments();
        console.log("Return: ", xhr.responseText);
    }
  };

  if (fbLogin) {
    data = JSON.stringify({"uid": lb_id.userID, "fname": fname, "req": lb_id.signedRequest, "text" : textData });
  } else {
    // This should never happen:
    data = JSON.stringify({"email": "none@mail.com", "word": "101010", "token" : "token"});
  }
  console.log("sending data", data);
  xhr.send(data);
}

Server-side Facebook token handling

Above, we created a json containing the FB user ID, FB user first name, the FB secure token and the text data in the html textarea being sent to the server as a user comment. The page URL information is in the QUERY_STRING Apache2 environment variable. The following script "fb_cmd" referenced from the above JS looks like seen below:

#!/bin/bash

# Common cookie checks:
. ./common.sh

# FB login data dir
FB_HOME="/home/www/fb"

# debug / error pages
HTML_DEBUG_PAGE="/var/www/html/juoksufeed.html"
HTML_ERROR_PAGE="/var/www/html/errors.html"

# Comments base dir
HTML_COMMENTS_PATH="/var/www/html/comments"

# Pick your favorite language. Date will be automated etc. for
# the given language.
export LC_ALL=fi_FI.UTF-8 2>/dev/null >/dev/null

# Example sanity check for the data length
if [ "$CONTENT_LENGTH" -gt 10000 ]; then
  /bin/dd count="$CONTENT_LENGTH" bs=1M 2>/dev/null > /dev/null
  /bin/echo "Status: 413 Payload Too Large"
  /bin/echo ""
  exit
fi

# Make sure you have created this dir.
cd "$FB_HOME" 2>/dev/null > /dev/null
if [ $? -ne 0 ]; then
  /bin/echo "Status: 500 Internal Server Error"
  /bin/echo ""
  exit
fi

# Disallow cross host calls
if [ "$HTTP_HOST" != "juoksukengat.fi" ]; then
  /bin/echo "Status: 405 Method Not Allowed"
  /bin/echo ""
  exit
fi

if [ "$SSL_TLS_SNI" != "juoksukengat.fi" ]; then
  /bin/echo "Status: 405 Method Not Allowed"
  /bin/echo ""
  exit
fi

COMMENT=`/bin/dd count="$CONTENT_LENGTH" bs=1M 2>/dev/null`

echo "$COMMENT" | jq . > "$HTML_DEBUG_PAGE"
echo "query str:" >> "$HTML_DEBUG_PAGE"
echo "$QUERY_STRING" >> "$HTML_DEBUG_PAGE"

PAGE=`echo "$QUERY_STRING" | cut -d '/' -f4-`
echo "$PAGE" | grep -q ".html" >/dev/null 2>/dev/null

if [ $? -ne 0 ]; then
  DT=`date -R`
  echo "$DT: NO Reference page!!" >> "$HTML_ERROR_PAGE"
  /bin/echo "Status: 405 Method Not Allowed"
  /bin/echo ""
  exit
fi

COMMENTSECURE=`echo -n "$COMMENT" | jq .req | tr -d '\"' | tr -d '\n'`
if [ $? -ne 0 ]; then
  DT=`date -R`
  echo "$DT: JQ error#1" >> "$HTML_ERROR_PAGE"
  /bin/echo "Status: 405 Method Not Allowed"
  /bin/echo ""
  exit
fi
USER_ID=`echo -n "$COMMENT" | jq .uid | tr -d '\"' | tr -d '\n' | tr -dc '0-9'`
if [ $? -ne 0 ]; then
  DT=`date -R`
  echo "$DT: JQ error#2" >> "$HTML_ERROR_PAGE"
  /bin/echo "Status: 405 Method Not Allowed"
  /bin/echo ""
  exit
fi

COMMENTTEXT=`echo -n "$COMMENT" | jq .text | sed 's/^\"//' | sed 's/.$//' | recode utf-8..html | sed 's/\\\n/\n/g'`
if [ $? -ne 0 ]; then
  DT=`date -R`
  echo "$DT: JQ error#3" >> "$HTML_ERROR_PAGE"
  /bin/echo "Status: 405 Method Not Allowed"
  /bin/echo ""
  exit
fi

FNAME=`echo -n "$COMMENT" | jq .fname | tr -d '\"' | tr -d '\n'`

echo "CommentText: $COMMENTTEXT" >> "$HTML_DEBUG_PAGE"

# Some sanity checks for the Secure Token and user id:
TRNC=`/bin/echo ${#COMMENTSECURE}`
TRNCID=`/bin/echo ${#USER_ID}`
if [ "$TRNC" -lt 10 ]; then
  /bin/echo "Status: 405 Method Not Allowed"
  /bin/echo ""
  exit
fi
if [ "$TRNCID" -lt 6 ]; then
  /bin/echo "Status: 405 Method Not Allowed"
  /bin/echo ""
  exit
fi


echo "Commentsecure: $TRNC $TRNCID $COMMENTSECURE.$USER_ID" >> "$HTML_DEBUG_PAGE"
echo "nl" >> "$HTML_DEBUG_PAGE"

# Output the jwt decode for debug purposes
echo "$COMMENTSECURE.${USER_ID}" | /usr/lib/s/jwt_decode.sh >> "$HTML_DEBUG_PAGE"

# jwt_decode retuns nonzero in case of error
echo "$COMMENTSECURE.${USER_ID}" | /usr/lib/s/jwt_decode.sh 2>/dev/null >/dev/null
if [ $? -ne 0 ]; then
  DT=`date -R`
  echo "$DT: Auth err" >> "$HTML_ERROR_PAGE"
  /bin/echo "Status: 405 Method Not Allowed"
  /bin/echo ""
  echo "secure check failed" >> "$HTML_DEBUG_PAGE"
  exit
else
  echo "secure check ok" >> "$HTML_DEBUG_PAGE"
fi

D1=`echo -n "$USER_ID" | tail -c 4`
LD1=`/bin/echo ${#D1}`
if [ "$LD1" -ne 4 ]; then
  DT=`date -R`
  echo "$DT: FBID err: Ld1: $LD1" >> "$HTML_ERROR_PAGE"
  /bin/echo "Status: 405 Method Not Allowed"
  /bin/echo ""
  exit
fi

D2=`echo -n "$USER_ID" | tail -c 8 | cut -c1-4`
LD2=`/bin/echo ${#D2}`
if [ "$LD2" -ne 4 ]; then
  /bin/echo "Status: 405 Method Not Allowed"
  /bin/echo ""
  exit
fi

NM=`echo -n "$USER_ID" | head -c10`

if [ -d "$D1" ]; then
  if [ ! -d "$D1"/"$D2" ]; then
    /bin/mkdir "$D1"/"$D2" 2>/dev/null >/dev/null
  fi
else
  /bin/mkdir "$D1" 2>/dev/null >/dev/null
  /bin/mkdir "$D1"/"$D2" 2>/dev/null >/dev/null
fi

cd "$D1"/"$D2" 2>/dev/null > /dev/null

if [ $? -ne 0 ]; then
  /bin/echo "Status: 500 Internal Server Error"
  /bin/echo ""
  exit
fi

mkdir -p "comments" 2>/dev/null > /dev/null
cd comments 2>/dev/null > /dev/null
if [ $? -ne 0 ]; then
  /bin/echo "Status: 500 Internal Server Error"
  /bin/echo ""
  exit
fi
cd .. 2>/dev/null > /dev/null

if [ ! -f _key ]; then
  KTMP=`mktemp XXXX`
  echo -n "$KTMP" > _key
fi

MYLIST=""

# A futex lock
exec 8>/home/www/mylockfile
flock -x -w 20 8
if [ $? -ne 0 ]; then
  echo "Status: 503 Service Unavailable"
  echo ""
  exit
else
  if [ ! -f /var/www/html/"$PAGE" ]; then
    DT=`date -R`
    echo "$DT: Page doesn't exist: $PAGE" >> "$HTML_ERROR_PAGE"
    /bin/echo "Status: 405 Method Not Allowed"
    /bin/echo ""
    exit
  fi

  mkdir -p "$HTML_COMMENTS_PATH"/"$PAGE"
  CNTFILE="$HTML_COMMENTS_PATH"/"$PAGE"/counter
  COUNTER=0
  if [ ! -f "$CNTFILE" ]; then
    echo 1 > "$CNTFILE"
    COUNTER=1
  else
    COUNTER=$[$(cat "$CNTFILE") + 1]
  fi
  echo $COUNTER > "$CNTFILE"

  KEY=`cat _key`
  PTH=`pwd`
  DT=`TZ=UTC-3 date +"%A, %b %d, %Y %H:%M:%S"`
  echo "$COMMENTTEXT" > comments/"$PAGE".$COUNTER
  # 3-line header
  echo "$KEY" > "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$COUNTER.hdr
  echo "$FNAME" >> "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$COUNTER.hdr
  echo "$DT" >> "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$COUNTER.hdr
  # header end
  echo "$COMMENTTEXT" > "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$COUNTER
  echo "$KEY $PTH" >> "$HTML_COMMENTS_PATH"/"$PAGE"/key

  # create page.html
  echo "" > "$HTML_COMMENTS_PATH"/"$PAGE"/page_tmp.html
  counter=1
  mod=0
  while true; do
    if [ "$counter" -gt "$COUNTER" ]; then
      break
    fi

    if [ -f "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$counter ]; then
      COMMENTDATE=`cat "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$counter.hdr | sed -n 3p`
      CMT_TEXT=`cat "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$counter`
      CMT_NAME=`cat "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$counter.hdr | sed -n 2p`
      CMT_KEY=`cat "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$counter.hdr | sed -n 1p`
      if [ "$CMT_KEY" = "$KEY" ]; then
         if [ "$MYLIST" = "" ]; then
           MYLIST="b$counter"
         else
           MYLIST="$MYLIST b$counter"
         fi
      fi
      . /usr/lib/s/fb_comment_form.sh
      mod=1

      stat "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$counter-* 2>/dev/null >/dev/null
      if [ $? -eq 0 ]; then
        replies=1
        REPLIES=`ls -1q "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$counter-* | cut -d '-' -f2 | tr -d '.hdr' | sort -n | tail -1`
        if [ "$REPLIES" = "" ]; then
          REPLIES=0
        fi
        while true; do
          if [ "$replies" -gt "$REPLIES" ]; then
            break
          fi
          if [ ! -f "$HTML_COMMENTS_PATH"/"$PAGE"/comment.$counter-$replies ]; then
            replies=$((replies+1))
            continue
          fi
          . /usr/lib/s/fb_reply_form.sh
          replies=$((replies+1))
        done
      fi

    fi
    counter=$((counter+1))
  done
  if [ "$mod" -ne 0 ]; then
    cp /"$HTML_COMMENTS_PATH"/"$PAGE"/page_tmp.html "$HTML_COMMENTS_PATH"/"$PAGE"/page.html 2>/dev/null >/dev/null
  fi

  cat "$HTML_COMMENTS_PATH"/"$PAGE"/key | uniq -u > "$HTML_COMMENTS_PATH"/"$PAGE"/_key
  cp "$HTML_COMMENTS_PATH"/"$PAGE"/_key "$HTML_COMMENTS_PATH"/"$PAGE"/key 2>/dev/null >/dev/null
fi
echo "$NM" > nm.txt

if [ ! -f "$NM" ]; then
  # Check for duplicates!
  grep -R "$TR" /home/www/fb/ 2>/dev/null >/dev/null
  if [ $? -ne 0 ]; then
    TMP=`mktemp XXXXXXXX`
    TMP1=`echo $TMP | cut -b 2`
    TMP2=`echo $TMP | cut -b 5`
    /bin/echo "$USER_ID" > "$NM"
    /bin/echo "$TMP$TMP1$TMP2" >> "$NM"
  else
    /bin/echo "$USER_ID" > "$NM"
    /bin/echo "$TR" >> "$NM"
  fi
  CLR=`tail -1 "$NM"`
  /bin/echo "Content-type: application/json"
  /bin/echo ""
  /bin/echo "{\"loopback\":\"$CLR\", \"lst\":\"$MYLIST\"}"
else
  CLR=`tail -1 "$NM"`
  /bin/echo "Content-type: application/json"
  /bin/echo ""
  /bin/echo "{\"loopback\":\"$CLR\", \"lst\":\"$MYLIST\"}"
fi

A few important things to note: The common.sh is a script that checks the cookie, where as the fb_comment_form.sh and fb_reply_form.shactually generate a lot of HTML. jwt_decode.sh decodes the facebook secure token. Let's see what it does.

The above script prepares a hook for the cookies as well. It uses a separate file for each comment and contructs a new html page after every comment. The futex lock actually gives exclusive access to print out the comment page so it's impossible to comment / delete in such order that they get out of sync. We'll come with the datails later, as we're planning on publishing the complete FB commenting based on Linux bash shell.

Decrypting Facebook Secure Token

Facebook token is very similar to that of JWT: base64 encoded and signed with HMAC-SHA256. The following script is borrowed from Will Haley. It's been modified a bit for this purpose. Here we go:

#!/usr/bin/env bash

#
# JWT Decoder Bash Script
#

# your facebook app secret
secret='your-facebook-app-secret-goes-here'

base64_encode()
{
	declare input=${1:-$(</dev/stdin)}
	# Use `tr` to URL encode the output from base64.
	printf '%s' "${input}" | base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n'
}

base64_decode()
{
	declare input=${1:-$(</dev/stdin)}
	# A standard base64 string should always be `n % 4 == 0`. We made the base64
	# string URL safe when we created the JWT, which meant removing the `=`
	# signs that are there for padding. Now we must add them back to get the
	# proper length.
	remainder=$((${#input} % 4));
	if [ $remainder -eq 1 ];
	then
		>2& echo "fatal error. base64 string is unexepcted length"
	elif [[ $remainder -eq 2 || $remainder -eq 3 ]];
	then
		input="${input}$(for i in `seq $((4 - $remainder))`; do printf =; done)"
	fi
	printf '%s' "${input}" | tr '_-' '/+' | base64 --decode
}

verify_signature()
{
	declare header_and_payload=${1}
        expected=$(echo -n "${header_and_payload}" | hmacsha256_encode | base64 | tr -d '=' | tr '/+' '_-' | tr -d '\n')
	actual=${2}

	if [ "${expected}" != "${actual}" ]
	then
		echo "Signature is NOT valid"
                echo "sig_not_valid" >> /var/www/html/errors.html
                return 1
	fi
        return 0
}

hmacsha256_encode()
{
	declare input=${1:-$(</dev/stdin)}
	printf '%s' "${input}" | openssl dgst -binary -sha256 -hmac "${secret}"
}

# Read the token from stdin
declare token=${1:-$(</dev/stdin)};

IFS='.' read -ra pieces <<< "$token"

declare signature=${pieces[0]}
declare payload=${pieces[1]}
declare uid=${pieces[2]}

# echo "Header"
# echo "${header}" | base64_decode | jq
echo "Payload: ${signature} AND ${payload}"
DATA=`echo "${payload}" | base64_decode | jq .`
DATA_UID=`echo "$DATA" | jq .user_id | tr -d '\"' | tr -d '\n'`

# echo "$DATA" >> /var/www/html/juoksufeed.html
if [ "$DATA_UID" != "${uid}" ]; then
  echo "data uid mismatch: $DATA_UID not ${uid}" >> /var/www/html/juoksufeed.html
  return 1
fi
echo "done"

verify_signature "${payload}" "${signature}"

if [ $? -ne 0 ]; then
  return 1
fi

We actually pass the user id (facebook user id) into the JSON input for the script and pass it to the JWT script. Why? Because we want to check it matches the one seen in the secure token. If it doesn't, you'd know somebody is most likely trying to hack your service - which is very valuable information.

The script above checks the secure token signature. If it's a success, you'd know you can trust the data. This is very useful as no calls to the FB servers needs to be made and when letting user write a comment, you can check the token every time yourself. This avoids the need for complicated state machines associated with other ways, such as cookie / FB token bundling etc.

Facebook JWT-like token details

The token has only payload and signature, no header like in JWT. Base64 encoded payload may be decoded with Open SSL command line tools. A successful signature check authenticates the token. We added an additional layer with the Facebook user ID known by the JS code. The token payload, decoded from the base64 format, looks like what's seen below:

{ "user_id": "1854617284673116",
  "code": "AQAR76-Yxlwqtuj9M4YLYtNmNErAZFBgFDlHCqSjngtZjNW9X8HyY30c0mhsBUPlWVozrzBae3GVCMIeDesJiPmi4V9qSqgdd6H9I9mdtW5vMX-eJHO2QJm-fUnYIBU_royn9I9hB2-_E6uLHXH94bPrwWB7EKt3Z6w439HLpafF4-VM6cPBgOZ0MujStAlo76xj42ukAaismBR-WsO6jDqozZQxOJw-aHZ_zqZu7d9iSxFPcXqISLzTxu8GrGt6toP0DSCWyZwCatmnFClpHU-ZtDbaRQt7kv9fFwRlPpfSj6qkFb__t-qbMNPC4C1rP3yRSn0tpWTFif-ZHSBIyJsz",
  "oauth_token": "EAAL9nOCt8zwBACyn4QSTOaaA89UM9OpMoTDbot9ZCmXUSymYZBom6or4XdXFKAUsjQWGySNTVMXfcDyusDhSRwuPiZCl2YEuoEL9SpJbrQ2JFFqAQ5IacnweoTgFysx0cBKzT07NT13ba2c7srzOWGkH4cj6ed3NZBbaZBVg91vz7IzUuqsek8DkKZBAwNBRIFzS9A3zXL7QZDZD",
  "algorithm": "HMAC-SHA256",
  "issued_at": 1589955515 }

Shell scripts for FB server-side authentication

Here we presented a simple CGI-based Facebook server-side authentication method without the need to ask requests from the FB infrastructure for verifying the tokens. This may speed up the authentication process and might prove useful in a number of use-cases.

Facebook token verification benefits

It's very beneficial to be able to verify the token yourself. When combined with an online commenting system, the verification process becomes very robust and simple to use. The token is sent every time the user wants to write a comment or delete it. The server-side software may be stateless and thus very simple. Cookies may become even useless although these days it's important to block such users that have declined to the cookie policies etc.

github project

Information will be updated here when the github project is up. Today is Monday the 18th of 2020. Estimate is the end of May, 2020. Stay tuned! It will be a simple shell script based online commenting module with FB login.

2020, bakecost.com. All rights reserved.

bakecost logo At bakecost.com, we provide you robust, useful and simple tools - all for free. We'd like to hear from you! Bring us complaints, new ideas, feedback or whatever you have on your mind. Appreciated! Click here to fill out the feedback form. We can't wait to hear from you!