Sign-in Back End

Cover Page

DUE Wed, 03/13, 2 pm

Do NOT start work on your back end until you have worked on the front-end spec to the point where you can obtain an ID Token from Google. You’ll need your ID Token to test your back end.

We extend our Chatter back end to (1) receive a Google ID Token from the front end for authentication and (2) to verify users when they post chatts.

Install updates

Remember to install updates available to your Ubuntu back end. If N in the following notice you see when you ssh to your back-end server is not 0,

N updates can be applied immediately.

run the following:

server$ sudo apt update
server$ sudo apt upgrade

Failure to update your packages could lead to the lab back end not performing correctly and also make you vulnerable to security hacks.

If you see *** System restart required *** when you ssh to your server, please run:

server$ sync
server$ sudo reboot

Your ssh session will be ended at the server. Wait a few minutes for the system to reboot before you ssh to your server again.

Modified Chatter API

We’ll add an adduser API and modify the chatt posting API for the new sign-in related functionalities.

adduser API

When a user signs in and submits their ID Token from Google, adduser will receive the token, make sure it hasn’t expired, generate a new chatterID, store it in the database along with the user’s username (obtained from the ID Token) and the chatterID’s expiration time. The adduser API will then return this chatterID, along with its lifetime, to the user.

API:

/adduser/
<- clientID, idToken
-> chatterID, lifetime 200 OK

The data format adduser expects is:

{
    "clientID": "YOUR_APP'S_CLIENT_ID",
    "idToken": "YOUR_GOOGLE_ID_TOKEN"
}

where YOUR_APP'S_CLIENT_ID and YOUR_GOOGLE_ID_TOKEN are both issued by Google to your front end.

Notice how we don’t do anything with user data upon sign out. In a real-world app, we would need to remove the user from the back end and add a button to revoke Google SignIn on the app.

Posting a chatt API

To post a chatt in this lab requires that chatterID be sent along with each chatt. The back end first verifies that the chatterID exists in the database. If the chatterID is found, the new chatt, along with the user’s username (retrieved from the database) will be added to the chatts database. If it isn’t, an error will be returned to the front end.

API for postauth:

/postauth/
<- chatterID, message
-> {} 200 OK

The data format postauth expects is:

{
    "chatterID": "YOUR_CHATTER_ID",
    "message": "Chitt chatts"
}

where YOUR_CHATTER_ID is the chatterID returned by the adduser API above.

Adding a chatters table to chatterdb

You will be using two tables in this lab: the original chatts table from previous labs and a new chatters table to keep track of authorized users. Both tables will be part of your chatterdb database.

Assuming you’re running psql and already connected to chatterdb, create a chatters table and grant PostgreSQL user chatter access to it by:

CREATE TABLE chatters (chatterid char(256) not null, username varchar(255) not null, expiration bigint not null);
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO chatter;

We will use SHA256 to compute chatterID, so it should be of fixed size, 256 bytes.

Web server

We now add new URL paths and their corresponding handlers to your back-end server.

Go with atreugo

Go with atreugo

Editing views.go

Add the following libraries to the import block at the top of views/views.go:

import (
    //...
	"crypto/sha256"
	"fmt"
    "strconv"
    "time"

	"github.com/jackc/pgx/v4"
	"golang.org/x/exp/constraints"
	"google.golang.org/api/idtoken"
)

Next add the following two new structs:

type AuthChatt struct {
    ChatterID  string    `json:"chatterID"`
    Message   string    `json:"message"`
    Timestamp time.Time `json:"timestamp"`
}

type Chatter struct {
    ClientID string `json:"clientID"`
    IDToken  string `json:"idToken"`
}

Now add the AddUser() function, along with the min() helper function (until Go’s stdlib is updated to support generic min() function!):

func min[T constraints.Ordered](a, b T) T {
	if a < b {
		return a
	}
	return b
}

func AddUser(c *atreugo.RequestCtx) error {
	var chatter Chatter

	if err := json.Unmarshal(c.Request.Body(), &chatter); err != nil {
		LogHTTP(http.StatusUnprocessableEntity, c)
		log.Println(err.Error())
		return c.JSONResponse([]byte(err.Error()), http.StatusUnprocessableEntity)
	}

	idinfo, err := idtoken.Validate(ctx, chatter.IDToken, chatter.ClientID)
	if err != nil {
		LogHTTP(http.StatusNetworkAuthenticationRequired, c)
		log.Println(err.Error())
		return c.JSONResponse([]byte(err.Error()), http.StatusNetworkAuthenticationRequired)
	}

	// get username
	username := "Profile NA"
	name := idinfo.Claims["name"]
	if name != nil {
		username = name.(string)
	}

	// Compute chatterID and add to database
	const backendSecret = "ifyougiveamouse"
	now := time.Now().Unix()
	nonce := strconv.FormatInt(now, 10)
	chatterID := fmt.Sprintf("%x", sha256.Sum256([]byte(chatter.IDToken+backendSecret+nonce)))

	exp := idinfo.Expires
	lifetime := min((exp-now)+1, 60) // secs, up to 1800, idToken lifetime

	_, err = chatterDB.Exec(ctx, `DELETE FROM chatters WHERE $1 > expiration`, now)
	if err != nil {
		LogHTTP(http.StatusInternalServerError, c)
		log.Println("DELETE", err.Error())
		return c.JSONResponse([]byte(err.Error()), http.StatusInternalServerError)
	}

	_, err = chatterDB.Exec(ctx,
		`INSERT INTO chatters (chatterid, username, expiration) VALUES ($1, $2, $3)`,
		chatterID, username, now+lifetime)
	if err != nil {
		LogHTTP(http.StatusInternalServerError, c)
		log.Println("INSERT", err.Error())
		return c.JSONResponse([]byte(err.Error()), http.StatusInternalServerError)
	}

	LogHTTP(http.StatusOK, c)
	return c.JSONResponse(map[string]any{"chatterID": chatterID, "lifetime": lifetime}, http.StatusOK)
}

The function AddUser() first receives a POST request containing an ID Token and Client ID from the front end. It uses Google’s idtoken package to verify the user’s ID Token, passing along the Client ID as required by Google. The verification process checks that ID Token hasn’t expired and is valid. If the token is invalid or has expired, a 511, “Network Authentication Required” HTTP error code is returned to the front end.

Next, a new chatterID is computed as a SHA256 one-way hash of the ID Token, a server’s secret, and the current time stamp. A lifetime is assigned to the chatterID. The lifetime should normally be set to be less than the total expected lifetime of the ID Token, and in any case not more than the remaining lifetime of the ID Token. The idea is that during the lifetime of chatterID, the user does not need to check the freshness of their ID Token with Google.

The chatterID, the user’s name obtained from the ID Token, and the chatterID’s lifetime are then entered into the chatters table. At the same time, we do some house keeping and remove all expired chatterIDs from the database.

Finally, the chatterID and its lifetime are returned to the user as a JSON object.

PostAuth()

We now add PostAuth(), which is a modified PostChatt(), to your views.go:

func PostAuth(c *atreugo.RequestCtx) error {
	var chatt AuthChatt

	if err := json.Unmarshal(c.Request.Body(), &chatt); err != nil {
		LogHTTP(http.StatusUnprocessableEntity, c)
		return c.JSONResponse([]byte(err.Error()), http.StatusUnprocessableEntity)
	}

	var username string
	var exp int64
	now := time.Now().Unix()
	err := chatterDB.QueryRow(ctx, `SELECT username, expiration FROM chatters WHERE chatterID = $1`, chatt.ChatterID).Scan(&username, &exp)
	if err == pgx.ErrNoRows || now > exp {
		LogHTTP(http.StatusUnauthorized, c)
		return c.JSONResponse([]byte(err.Error()), http.StatusUnauthorized)
	} else if err != nil {
		LogHTTP(http.StatusInternalServerError, c)
		return c.JSONResponse([]byte(err.Error()), http.StatusInternalServerError)
	}

	_, err = chatterDB.Exec(ctx, `INSERT INTO chatts (username, message) VALUES ($1, $2)`, username, chatt.Message)
	if err != nil {
		LogHTTP(http.StatusInternalServerError, c)
		return c.JSONResponse([]byte(err.Error()), http.StatusInternalServerError)
	}

	LogHTTP(http.StatusOK, c)
	return c.JSONResponse(emptyJSON, http.StatusOK)
}

To post a chatt, the front end sends a POST request containing the user’s chatterID and message. The function postauth() retrieves the record matching chatterID from the chatters table. If chatterID is not found in the chatters table, or if the chatterID has expired, it returns a 401, “Unauthorized” HTTP error code. Otherwise, it retrieves the corresponding username from the table and inserts the chatt into the chatts table with that username. Note: chatterIDs are unique in the chatters table.

We will be using the original GetChatts() from the chatter lab with no changes.

Save and exit views.go.

Routing for new URLs

For the newly added AddUser() and PostAuth() functions, add the following new routes to the routes array in router/router.go:

var routes = []Route {
    // . . .
    {"POST", "/adduser/", views.AddUser},
    {"POST", "/postauth/", views.PostAuth},
}

Save and exit router.go.

:point_right:Go is a compiled language, like C/C++ and unlike Python, which is an interpreted language. This means you must run go build each and every time you made changes to your code, for the changes to show up in your executable.

Since we added new packages, you need to run:

server$ go get

before you can rebuild and restart chatterd:

server$ go build
server$ sudo systemctl restart chatterd

References

Python with Django

Python with Django

Editing views.py

Adding adduser()

To verify Google’s ID Token, we’ll need to install the Google API python client:

server$ cd ~/441/chatterd
server$ source env/bin/activate
(env):chatter$ pip install -U pip
(env):chatter$ pip install -U google-api-python-client

Next edit your views.py file and import the following libraries, including two from Google:

from google.oauth2 import id_token
from google.auth.transport import requests

import hashlib, time

Now add the following adduser() function to your views.py:

@csrf_exempt
def adduser(request):
    if request.method != 'POST':
        return HttpResponse(status=404)

    json_data = json.loads(request.body)
    clientID = json_data['clientID']   # the front end app's OAuth 2.0 Client ID
    idToken = json_data['idToken']     # user's OpenID ID Token, a JSon Web Token (JWT)

    now = time.time()                  # secs since epoch (1/1/70, 00:00:00 UTC)

    try:
        # Collect user info from the Google idToken, verify_oauth2_token checks
        # the integrity of idToken and throws a "ValueError" if idToken or
        # clientID is corrupted or if user has been disconnected from Google
        # OAuth (requiring user to log back in to Google).
        # idToken has a lifetime of about 1 hour
        idinfo = id_token.verify_oauth2_token(idToken, requests.Request(), clientID)
    except ValueError:
        # Invalid or expired token
        return HttpResponse(status=511)  # 511 Network Authentication Required

    # get username
    try:
        username = idinfo['name']
    except:
        username = "Profile NA"

    # Compute chatterID and add to database
    backendSecret = "callmeishmael"   # or server's private key
    nonce = str(now)
    hashable = idToken + backendSecret + nonce
    chatterID = hashlib.sha256(hashable.strip().encode('utf-8')).hexdigest()

    # Lifetime of chatterID is minimum of time to idToken expiration and
    # target lifetime, which should be less than idToken lifetime (~1 hour). 
    # (int()+1 == ceil()) 
    lifetime = min(int(idinfo['exp']-now)+1, 60) # secs, up to idToken's lifetime

    cursor = connection.cursor()
    # clean up db table of expired chatterIDs
    cursor.execute('DELETE FROM chatters WHERE %s > expiration;', (now, ))

    # insert new chatterID
    # Ok for chatterID to expire about 1 sec beyond idToken expiration
    cursor.execute('INSERT INTO chatters (chatterid, username, expiration) VALUES '
                   '(%s, %s, %s);', (chatterID, username, now+lifetime))

    # Return chatterID and its lifetime
    return JsonResponse({'chatterID': chatterID, 'lifetime': lifetime})   

For Python-PostgreSQL interaction, see Passing parameters to SQL queries.

The function adduser() first receives a POST request containing an ID Token and Client ID from the front end. It calls Google’s library to verify the user’s ID Token, passing along the Client ID as required by Google. The verification process checks that ID Token hasn’t expired and is valid. If the token is invalid or has expired, a 511, “Network Authentication Required” HTTP error code is returned to the front end.

Next, a new chatterID is computed as a SHA256 one-way hash of the ID Token, a server’s secret, and the current time stamp. A lifetime is assigned to the chatterID. The lifetime should normally be set to be less than the total expected lifetime of the ID Token, and in any case not more than the remaining lifetime of the ID Token. The idea is that during the lifetime of chatterID, the user does not need to check the freshness of their ID Token with Google.

The chatterID, the user’s name obtained from the ID Token, and the chatterID’s lifetime are then entered into the chatters table. At the same time, we do some house keeping and remove all expired chatterIDs from the database.

Finally, the chatterID and its lifetime are returned to the user as a JSON object.

postauth()

We now add postauth(), which is a modified postchatt(), to your views.py:

@csrf_exempt
def postauth(request):
    if request.method != 'POST':
        return HttpResponse(status=404)
    json_data = json.loads(request.body)

    chatterID = json_data['chatterID']
    message = json_data['message']

    cursor = connection.cursor()
    cursor.execute('SELECT username, expiration FROM chatters WHERE chatterID = %s;', (chatterID,))

    row = cursor.fetchone()
    now = time.time()
    if row is None or now > row[1]:
        # return an error if there is no chatter with that ID
        return HttpResponse(status=401) # 401 Unauthorized

    # Else, insert into the chatts table
    cursor.execute('INSERT INTO chatts (username, message) VALUES (%s, %s);', (row[0], message))
    return JsonResponse({})

To post a chatt, the front end sends a POST request containing the user’s chatterID and message. The function postauth() retrieves the record matching chatterID from the chatters table. If chatterID is not found in the chatters table, or if the chatterID has expired, it returns a 401, “Unauthorized” HTTP error code. Otherwise, it retrieves the corresponding username from the table and inserts the chatt into the chatts table with that username. Note: chatterIDs are unique in the chatters table.

We will be using the original getchatts() from the chatter lab with no changes.

Save and exit views.py.

Routing for new urls

First add the following new routes to the urlpatterns array in urls.py:

    path('postauth/', views.postauth, name='postauth'),
    path('adduser/', views.adduser, name='adduser'),

Save and exit urls.py and restart Gunicorn.

References

Rust with axum

Rust with axum

Editing handlers.rs

First, add the following as additional imported crates/modules:

use openidconnect::{
    core::{CoreClient, CoreIdToken},
    reqwest::async_http_client,
    AuthUrl, ClientId, IdToken, IssuerUrl, JsonWebKeySet, JsonWebKeySetUrl, Nonce,
};
use sha2::{digest::Update, Digest, Sha256};

then add the following crates/modules in the use std::{} block:

use std::{
    // . . .
    cmp::min,
    str::FromStr,
};

You can choose to “merge” the following with the existing set of chrono module or add it as a separate line. Both work:

use chrono::Utc;

Next, add the following two new structs:

#[derive(Debug, Deserialize)]
pub struct Chatter {
    clientID: String,
    idToken: String,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct AuthChatt {
    chatterID: String,
    message: String,
}

Now add the adduser() function:

pub async fn adduser(
    State(pgpool): State<PGPool>,
    ConnectInfo(clientIP): ConnectInfo<SocketAddr>,
    method: Method,
    uri: Uri,
    Json(chatter): Json<Chatter>,
) -> (StatusCode, Json<Value>) {
    let client = CoreClient::new(
        ClientId::new(chatter.clientID),
        None,
        IssuerUrl::new("https://accounts.google.com".to_string()).unwrap(),
        AuthUrl::new("https://accounts.google.com/o/oauth2/v2/auth".to_string()).unwrap(),
        None,
        None,
        JsonWebKeySet::fetch_async(
            &JsonWebKeySetUrl::new("https://www.googleapis.com/oauth2/v3/certs".to_string())
                .unwrap(),
            async_http_client,
        )
        .await
        .unwrap(),
    );

    let idToken: CoreIdToken = IdToken::from_str(&chatter.idToken).unwrap();

    let idInfo = idToken.claims(&client.id_token_verifier().allow_any_alg(), |_: Option<&Nonce>| Ok(()));

    match idInfo {
        Ok(idInfo) => {
            let username = idInfo.name().unwrap().get(None).unwrap().as_str();
            let now = Utc::now().timestamp();
            let hash = Sha256::new()
                .chain(chatter.idToken + "ifyougiveamouse" + &now.to_string())
                .finalize();
            let chatterID = hex::encode(hash);

            let exp = idInfo.expiration().timestamp();
            let lifetime = min((exp - now) + 1, 60);

            let chatterDB = pgpool
                .get()
                .await
                .unwrap();

            chatterDB
                .execute("DELETE FROM chatters WHERE $1 > expiration", &[&now])
                .await
                .unwrap();

            let dbStatus = chatterDB
                .execute(
                    "INSERT INTO chatters (chatterid, username, expiration) VALUES ($1, $2, $3)",
                    &[&chatterID, &username, &(now + lifetime)],
                )
                .await
                .map_or_else(|err| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!(err.to_string()))), |_| (StatusCode::OK, Json(json!({ "chatterID": chatterID, "lifetime": lifetime }))));

            tracing::debug!(
                "{:?} | {:?} | {:?} {:?}",
                dbStatus.0,
                clientIP,
                method,
                uri.path()
            );
            dbStatus
        }
        Err(err) => {
            tracing::debug!(
                "{:?} {:?} | {:?} | {:?} {:?}",
                StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
                &err,
                clientIP,
                method,
                uri.path()
            );
            (StatusCode::NETWORK_AUTHENTICATION_REQUIRED,
             Json(json!(err.to_string())))
        }
    }
}

The function adduser() first receives a POST request containing an ID Token and Client ID from the front end. We use the OpenID Connect crate to verify the user’s ID Token, passing along the Client ID as required by Google. The verification process checks that ID Token hasn’t expired and is valid. If the token is invalid or has expired, a 511, “Network Authentication Required” HTTP error code is returned to the front end.

Next, a new chatterID is computed as a SHA256 one-way hash of the ID Token, a server’s secret, and the current time stamp. A lifetime is assigned to the chatterID. The lifetime should normally be set to be less than the total expected lifetime of the ID Token, and in any case not more than the remaining lifetime of the ID Token. The idea is that during the lifetime of chatterID, the user does not need to check the freshness of their ID Token with Google.

The chatterID, the user’s name obtained from the ID Token, and the chatterID’s lifetime are then entered into the chatters table. At the same time, we do some house keeping and remove all expired chatterIDs from the database.

Finally, the chatterID and its lifetime are returned to the user as a JSON object.

postauth()

We now add postauth(), which is a modified postchatt(), to your handlers.rs:

pub async fn postauth(
    State(pgpool): State<PGPool>,
    ConnectInfo(clientIP): ConnectInfo<SocketAddr>,
    method: Method,
    uri: Uri,
    Json(chatt): Json<AuthChatt>,
) -> (StatusCode, Json<Value>) {
    let chatterDB = pgpool
        .get()
        .await
        .unwrap();

    let row = chatterDB
        .query_one(
            "SELECT username, expiration FROM chatters WHERE chatterID = $1",
            &[&chatt.chatterID],
        )
        .await;

    let dbStatus = match row {
        Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!(err.to_string()))),
        Ok(row) => {
            let username: String = row.get("username");
            let exp: i64 = row.get("expiration");
            let now = Utc::now().timestamp();
            if now > exp {
                (StatusCode::UNAUTHORIZED, Json(json!("Unauthorized")))
            } else {
                chatterDB.execute(
                    "INSERT INTO chatts (username, message) VALUES ($1, $2)",
                    &[&username, &chatt.message],)
                .await
                .map_or_else(|err| (StatusCode::INTERNAL_SERVER_ERROR, Json(json!(err.to_string()))), |_| (StatusCode::OK, Json(json!({}))))
            }
        },
    };

    tracing::debug!(
        "{:?} | {:?} | {:?} {:?}",
        dbStatus.0,
        clientIP,
        method,
        uri.path()
    );
    dbStatus
}

To post a chatt, the front end sends a POST request containing the user’s chatterID and message. The function postauth() retrieves the record matching chatterID from the chatters table. If chatterID is not found in the chatters table, or if the chatterID has expired, it returns a 401, “Unauthorized” HTTP error code. Otherwise, it retrieves the corresponding username from the table and inserts the chatt into the chatts table with that username. Note: chatterIDs are unique in the chatters table.

We will be using the original getchatts() from the chatter lab with no changes.

Save and exit handlers.rs.

Routing for new URLs

For the newly added adduser() and postauth() functions, add the following new routes to the Router instantiation of the router variable in main.rs, before the .with_state(pgpool); line:

        .route("/adduser/", post(handlers::adduser))
        .route("/postauth/", post(handlers::postauth))

Save and exit main.rs.

Finally, edit Cargo.toml to add the following dependencies:

# . . .
hex = "0.4.3"
openidconnect = "3.4.0"
sha2 = "0.11.0-pre.1"

Save and exit Cargo.toml. Rebuild and restart chatterd.

server$ cargo build --release
server$ sudo systemctl restart chatterd

References

Testing Sign-in

To test your implementation with Insomnia, you’d need your idToken. Put a debugging breakpoint at the start of your front end’s ChattStore.addUser() function. The idToken parameter passed to ChattStore.addUser() is your idToken.

ID Token on iOS

For iOS, on Xcode, mouse over the idToken variable inside the addUser(_:) function, then click on the i icon on the extreme right of the box that pops up. The idToken will be displayed as plain text on yet another pop up box. Click in the box, then select all and copy (⌘-A, ⌘-C). This is your idToken to be used with Insomnia below.

Create a new Insomnia HTTP POST request to adduser with your Google ID Token (idToken) and your front end’s Google Client ID (clientID):

{
    "clientID": "YOUR_APP'S_CLIENT_ID",
    "idToken": "YOUR_GOOGLE_ID_TOKEN"
}

Adding users with an invalid token results in a 511 error, while a valid Google ID Token will result in a 200 response status and a payload body with a chatterID and its lifetime. You can use this chatterID in place of YOUR_CHATTER_ID below to POST a message to postauth:

{
    "chatterID": "YOUR_CHATTER_ID",
    "message": "Chitt chatts"
}

You should get a 200 server response in return.

That’s it! You’re now all set up on the back end for Chatter! Be sure to confirm that your back end works with your front end.

Submitting your back end

You can now return to complete the front end: iOS.


Prepared for EECS 441 by Benjamin Brengman, Ollie Elmgren, Wendan Jiang, Alexander Wu, and Sugih Jamin Last updated: February 21st, 2024