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 struct
s:
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: chatterID
s 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
.
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: chatterID
s 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
-
Authenticate with a backend server
Once the mobile client obtained an ID Token from an SSO, it presents the ID Token to a backend server. The backend server must authenticate that ID Token.
-
�django.db.utils.ProgrammingError: permission denied for relation django_migrations�
- SQL
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 struct
s:
#[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: chatterID
s 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
- Commit new changes to the local repo with:
server$ cd ~/441/chatterd server$ git commit -am "lab4 back end"
and push new changes to the remote GitHub repo with:
server$ git push
- If
git push
fails due to new changes made to the remote repo, you will need to rungit pull
first. Then you may have to resolve any conflicts before you cangit push
again.
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 |